feat(phys8): Phase 0 redesign foundation — CSS + JS infrastructure
Закладывает уникальный визуальный язык и engine'ы для редизайна Физики 8. CSS: - phys8-design-system.css (12 КБ): 3 темы (thermal/electric/spectrum), тематические hero-палитры, watermarks, animations (thermal-shift, electric-pulse, spectrum-drift, wm-breathe/flicker/rotate, noise overlay), staggered fade-in для виджетов, soft elevation на карточках, monospace для физ. величин, topic-aware progress bars, mobile responsive (≤768px), prefers-reduced-motion. - phys8-interactives.css (10 КБ): .p8-draggable + .p8-droptarget с hover-effects, .p8-palette (для circuit-builder), .p8-scrubber, .p8-readout табло, .p8-tooltip, .p8-sandbox canvas wrapper, .p8-thermometer + .p8-compass-needle SVG-композиции, glow-utility. JS: - phys8-anim.js (6 КБ): easing-функции (quad/cubic/expo/back/elastic/ bounce/spring), tween-engine с onUpdate/onComplete, raf-wrapper, oscillate, stagger, onVisible (IntersectionObserver). Экспорт P8Anim. - phys8-drag.js (12 КБ): универсальный drag-engine. P8Drag.attach() для DOM/SVG, P8Drag.attachCanvas() для логических объектов с hit-test, P8Drag.attachPalette() для drag-from-palette-to-drop, constraints (lockX/Y, bounds, snap-to-grid), touch + mouse + pointer. - phys8-helpers.js (18 КБ): тематические хелперы. P8Helpers.thermal (tempColor 0-1, heatFlowArrow, molecule, thermometerSVG, convectionCellParticles), .em (chargeSVG, circuitComponent для battery/resistor/lamp/ammeter/voltmeter/switch, fieldLineFrom), .optics (rayLine, lensSVG converging/diverging, mirrorPlane), .svg utils (el, create, linearGradient, radialGradient, gradientArrow, labeledText). Линковка (redesign_p8_phase0.cjs): - 2 CSS-link после katex CDN - 3 JS-link после phys.js/xp.js - body class p8-theme-thermal/electric/spectrum на ch1/ch2/ch3 - hub и lab — без темы (нейтральный пурпурный brand)
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
// phys8-anim.js — easing-функции и tween-engine для микро-анимаций Физики 8.
|
||||
// Экспорт в window.P8Anim = { ease, tween, raf, lerp, clamp, smoothstep, oscillate }
|
||||
// Без зависимостей.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// === Math utils ===
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
const lerp = (a, b, t) => a + (b - a) * t;
|
||||
const smoothstep = (a, b, x) => {
|
||||
const t = clamp((x - a) / (b - a), 0, 1);
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
|
||||
// === Easing functions (cubic-bezier-style + spring + bounce) ===
|
||||
const ease = {
|
||||
linear: t => t,
|
||||
quadIn: t => t * t,
|
||||
quadOut: t => t * (2 - t),
|
||||
quadInOut: t => t < .5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
|
||||
cubicIn: t => t * t * t,
|
||||
cubicOut: t => (--t) * t * t + 1,
|
||||
cubicInOut: t => t < .5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
|
||||
expoOut: t => t === 1 ? 1 : 1 - Math.pow(2, -10 * t),
|
||||
backOut: t => { const s = 1.70158; return --t * t * ((s + 1) * t + s) + 1; },
|
||||
elasticOut: t => {
|
||||
if (t === 0 || t === 1) return t;
|
||||
const p = 0.3;
|
||||
return Math.pow(2, -10 * t) * Math.sin((t - p/4) * (2 * Math.PI) / p) + 1;
|
||||
},
|
||||
bounceOut: t => {
|
||||
if (t < 1/2.75) return 7.5625 * t * t;
|
||||
if (t < 2/2.75) return 7.5625 * (t -= 1.5/2.75) * t + .75;
|
||||
if (t < 2.5/2.75) return 7.5625 * (t -= 2.25/2.75) * t + .9375;
|
||||
return 7.5625 * (t -= 2.625/2.75) * t + .984375;
|
||||
},
|
||||
// Spring — затухающие колебания
|
||||
spring: (t, mass = 1, stiff = 100, damp = 10) => {
|
||||
if (t >= 1) return 1;
|
||||
const w = Math.sqrt(stiff / mass);
|
||||
const z = damp / (2 * Math.sqrt(stiff * mass));
|
||||
if (z < 1) {
|
||||
const wd = w * Math.sqrt(1 - z*z);
|
||||
return 1 - Math.exp(-z * w * t) * (Math.cos(wd * t) + z * w / wd * Math.sin(wd * t));
|
||||
}
|
||||
return 1 - Math.exp(-w * t) * (1 + w * t);
|
||||
}
|
||||
};
|
||||
|
||||
// === Tween engine ===
|
||||
// Использование:
|
||||
// P8Anim.tween({ from: 0, to: 100, duration: 500, easing: 'quadOut',
|
||||
// onUpdate: v => el.style.opacity = v, onComplete: () => {...} });
|
||||
// Возвращает { cancel() }
|
||||
function tween(opts) {
|
||||
const {
|
||||
from = 0,
|
||||
to = 1,
|
||||
duration = 500,
|
||||
easing = 'cubicOut',
|
||||
onUpdate = () => {},
|
||||
onComplete = () => {},
|
||||
delay = 0
|
||||
} = opts;
|
||||
|
||||
const easeFn = typeof easing === 'function' ? easing : (ease[easing] || ease.cubicOut);
|
||||
|
||||
let startTime = null;
|
||||
let rafId = null;
|
||||
let cancelled = false;
|
||||
|
||||
function step(t) {
|
||||
if (cancelled) return;
|
||||
if (startTime === null) startTime = t + delay;
|
||||
if (t < startTime) {
|
||||
rafId = requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
const elapsed = t - startTime;
|
||||
const k = clamp(elapsed / duration, 0, 1);
|
||||
const eased = easeFn(k);
|
||||
const v = lerp(from, to, eased);
|
||||
onUpdate(v, eased, k);
|
||||
if (k < 1) {
|
||||
rafId = requestAnimationFrame(step);
|
||||
} else {
|
||||
onComplete();
|
||||
}
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(step);
|
||||
|
||||
return {
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// === RAF wrapper — для непрерывных симуляций ===
|
||||
// Использование:
|
||||
// const loop = P8Anim.raf(dt => { /* dt в секундах */ });
|
||||
// loop.start(); loop.stop();
|
||||
function raf(callback) {
|
||||
let id = null, last = 0, running = false;
|
||||
function tick(t) {
|
||||
const dt = last ? (t - last) / 1000 : 0;
|
||||
last = t;
|
||||
if (!running) return;
|
||||
try { callback(dt, t); } catch (e) { console.warn('raf cb:', e.message); }
|
||||
id = requestAnimationFrame(tick);
|
||||
}
|
||||
return {
|
||||
start() { if (running) return; running = true; last = 0; id = requestAnimationFrame(tick); },
|
||||
stop() { running = false; if (id) cancelAnimationFrame(id); id = null; },
|
||||
get running() { return running; }
|
||||
};
|
||||
}
|
||||
|
||||
// === Oscillate (синус-волна, для пульсаций) ===
|
||||
// amplitude * sin(2pi*frequency*t + phase) + offset
|
||||
function oscillate(t, frequency = 1, amplitude = 1, phase = 0, offset = 0) {
|
||||
return offset + amplitude * Math.sin(2 * Math.PI * frequency * t + phase);
|
||||
}
|
||||
|
||||
// === Helper: scheduler — задержка с возможностью отмены ===
|
||||
function after(ms, fn) {
|
||||
const id = setTimeout(fn, ms);
|
||||
return { cancel: () => clearTimeout(id) };
|
||||
}
|
||||
|
||||
// === Helper: animateCSS — добавить класс на duration, потом снять ===
|
||||
function animateCSS(el, className, duration = 350) {
|
||||
if (!el) return;
|
||||
el.classList.add(className);
|
||||
return after(duration, () => el.classList.remove(className));
|
||||
}
|
||||
|
||||
// === Stagger — массив элементов с задержкой между анимациями ===
|
||||
function stagger(elements, callback, perItemDelay = 50) {
|
||||
[...elements].forEach((el, i) => {
|
||||
after(i * perItemDelay, () => callback(el, i));
|
||||
});
|
||||
}
|
||||
|
||||
// === Visibility observer — запуск анимации при появлении в viewport ===
|
||||
function onVisible(el, fn, opts = {}) {
|
||||
if (!('IntersectionObserver' in window) || !el) {
|
||||
fn();
|
||||
return { disconnect: () => {} };
|
||||
}
|
||||
const obs = new IntersectionObserver((entries) => {
|
||||
entries.forEach(e => { if (e.isIntersecting) { fn(); obs.disconnect(); } });
|
||||
}, { threshold: opts.threshold || 0.2, rootMargin: opts.rootMargin || '0px' });
|
||||
obs.observe(el);
|
||||
return obs;
|
||||
}
|
||||
|
||||
// === Export ===
|
||||
window.P8Anim = {
|
||||
ease, tween, raf, oscillate,
|
||||
after, animateCSS, stagger, onVisible,
|
||||
clamp, lerp, smoothstep
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,363 @@
|
||||
// phys8-drag.js — универсальный drag-and-drop движок для Физики 8.
|
||||
// Поддерживает:
|
||||
// - drag SVG-объектов (по data-p8-drag атрибуту или ref)
|
||||
// - drag DOM-элементов (HTML chips, palette items)
|
||||
// - drag «логических» объектов в canvas-симуляциях (через hit-callback)
|
||||
// - constraints: lockX, lockY, bounds, snap, snapToGrid
|
||||
// - touch + mouse + pointer events
|
||||
// Экспорт в window.P8Drag = { attach, attachCanvas, snapToGrid, hitTest }
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
|
||||
// === Конвертация события в координаты внутри контейнера ===
|
||||
function pointerInContainer(ev, container) {
|
||||
const r = container.getBoundingClientRect();
|
||||
// Учитываем масштаб SVG (viewBox vs реальный размер)
|
||||
let scaleX = 1, scaleY = 1;
|
||||
if (container.tagName && container.tagName.toLowerCase() === 'svg') {
|
||||
const vb = container.viewBox && container.viewBox.baseVal;
|
||||
if (vb && vb.width) {
|
||||
scaleX = vb.width / r.width;
|
||||
scaleY = vb.height / r.height;
|
||||
}
|
||||
}
|
||||
return {
|
||||
x: (ev.clientX - r.left) * scaleX,
|
||||
y: (ev.clientY - r.top) * scaleY,
|
||||
clientX: ev.clientX,
|
||||
clientY: ev.clientY
|
||||
};
|
||||
}
|
||||
|
||||
// === Snap-to-grid ===
|
||||
function snapToGrid(x, y, gridSize = 20) {
|
||||
return {
|
||||
x: Math.round(x / gridSize) * gridSize,
|
||||
y: Math.round(y / gridSize) * gridSize
|
||||
};
|
||||
}
|
||||
|
||||
// === Hit-test для логических объектов на canvas ===
|
||||
// objects: [{ x, y, r, ...meta }]
|
||||
// returns: object or null
|
||||
function hitTest(objects, px, py) {
|
||||
for (let i = objects.length - 1; i >= 0; i--) {
|
||||
const o = objects[i];
|
||||
const dx = px - o.x, dy = py - o.y;
|
||||
const r = o.r || 20;
|
||||
if (dx * dx + dy * dy <= r * r) return o;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// === attach: универсальный drag для DOM/SVG элемента ===
|
||||
// Использование:
|
||||
// const handle = P8Drag.attach(svgCircle, {
|
||||
// onStart: (ev, pos) => {...},
|
||||
// onMove: (ev, pos, delta) => {...},
|
||||
// onEnd: (ev, pos) => {...},
|
||||
// lockX: false, lockY: false,
|
||||
// bounds: { minX, maxX, minY, maxY },
|
||||
// snap: 0 // grid size, 0 = no snap
|
||||
// });
|
||||
// handle.destroy()
|
||||
function attach(el, opts = {}) {
|
||||
if (!el) return { destroy: () => {} };
|
||||
const container = opts.container || el.ownerSVGElement || el.parentElement || el;
|
||||
const onStart = opts.onStart || (() => {});
|
||||
const onMove = opts.onMove || (() => {});
|
||||
const onEnd = opts.onEnd || (() => {});
|
||||
const lockX = !!opts.lockX;
|
||||
const lockY = !!opts.lockY;
|
||||
const snap = opts.snap || 0;
|
||||
const bounds = opts.bounds || null;
|
||||
|
||||
let dragging = false;
|
||||
let startPos = null;
|
||||
let pointerId = null;
|
||||
|
||||
el.style.touchAction = 'none';
|
||||
el.classList.add('p8-draggable');
|
||||
|
||||
function applyConstraints(pos) {
|
||||
let x = pos.x, y = pos.y;
|
||||
if (snap > 0) {
|
||||
const s = snapToGrid(x, y, snap);
|
||||
x = s.x; y = s.y;
|
||||
}
|
||||
if (bounds) {
|
||||
if (bounds.minX !== undefined) x = clamp(x, bounds.minX, bounds.maxX || x);
|
||||
if (bounds.minY !== undefined) y = clamp(y, bounds.minY, bounds.maxY || y);
|
||||
}
|
||||
if (lockX) x = startPos.x;
|
||||
if (lockY) y = startPos.y;
|
||||
return { x, y, clientX: pos.clientX, clientY: pos.clientY };
|
||||
}
|
||||
|
||||
function down(ev) {
|
||||
if (ev.button !== undefined && ev.button !== 0) return;
|
||||
ev.preventDefault();
|
||||
dragging = true;
|
||||
pointerId = ev.pointerId;
|
||||
try { el.setPointerCapture(pointerId); } catch (e) {}
|
||||
el.classList.add('is-dragging');
|
||||
const pos = pointerInContainer(ev, container);
|
||||
startPos = pos;
|
||||
onStart(ev, pos);
|
||||
}
|
||||
|
||||
function move(ev) {
|
||||
if (!dragging) return;
|
||||
const pos = applyConstraints(pointerInContainer(ev, container));
|
||||
const delta = { dx: pos.x - startPos.x, dy: pos.y - startPos.y };
|
||||
onMove(ev, pos, delta);
|
||||
}
|
||||
|
||||
function up(ev) {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
el.classList.remove('is-dragging');
|
||||
try { el.releasePointerCapture(pointerId); } catch (e) {}
|
||||
const pos = applyConstraints(pointerInContainer(ev, container));
|
||||
onEnd(ev, pos);
|
||||
startPos = null;
|
||||
pointerId = null;
|
||||
}
|
||||
|
||||
el.addEventListener('pointerdown', down);
|
||||
el.addEventListener('pointermove', move);
|
||||
el.addEventListener('pointerup', up);
|
||||
el.addEventListener('pointercancel', up);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
el.removeEventListener('pointerdown', down);
|
||||
el.removeEventListener('pointermove', move);
|
||||
el.removeEventListener('pointerup', up);
|
||||
el.removeEventListener('pointercancel', up);
|
||||
el.classList.remove('p8-draggable', 'is-dragging');
|
||||
},
|
||||
get isDragging() { return dragging; }
|
||||
};
|
||||
}
|
||||
|
||||
// === attachCanvas: drag «логических» объектов на canvas ===
|
||||
// Использование:
|
||||
// const handle = P8Drag.attachCanvas(canvas, {
|
||||
// objects: [...], // массив объектов с { x, y, r }
|
||||
// onPickup: (obj, pos) => {...},
|
||||
// onDrag: (obj, pos, delta) => {...},
|
||||
// onDrop: (obj, pos) => {...},
|
||||
// onClick: (pos, hitObj) => {...}, // when no drag (mousedown without move)
|
||||
// });
|
||||
function attachCanvas(canvas, opts = {}) {
|
||||
if (!canvas) return { destroy: () => {} };
|
||||
const objects = opts.objects || [];
|
||||
const onPickup = opts.onPickup || (() => {});
|
||||
const onDrag = opts.onDrag || (() => {});
|
||||
const onDrop = opts.onDrop || (() => {});
|
||||
const onClick = opts.onClick || (() => {});
|
||||
|
||||
let active = null;
|
||||
let startPos = null;
|
||||
let lastPos = null;
|
||||
let pointerId = null;
|
||||
let moved = false;
|
||||
const dragThreshold = 4;
|
||||
|
||||
canvas.style.touchAction = 'none';
|
||||
|
||||
function getPos(ev) {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: (ev.clientX - r.left) * canvas.width / r.width,
|
||||
y: (ev.clientY - r.top) * canvas.height / r.height,
|
||||
clientX: ev.clientX, clientY: ev.clientY
|
||||
};
|
||||
}
|
||||
|
||||
function down(ev) {
|
||||
if (ev.button !== undefined && ev.button !== 0) return;
|
||||
ev.preventDefault();
|
||||
const pos = getPos(ev);
|
||||
const hit = hitTest(objects, pos.x, pos.y);
|
||||
if (hit) {
|
||||
active = hit;
|
||||
startPos = pos;
|
||||
lastPos = pos;
|
||||
moved = false;
|
||||
pointerId = ev.pointerId;
|
||||
try { canvas.setPointerCapture(pointerId); } catch (e) {}
|
||||
canvas.style.cursor = 'grabbing';
|
||||
onPickup(active, pos);
|
||||
} else {
|
||||
// start an empty drag — used for "click anywhere" handlers
|
||||
startPos = pos;
|
||||
lastPos = pos;
|
||||
moved = false;
|
||||
pointerId = ev.pointerId;
|
||||
try { canvas.setPointerCapture(pointerId); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
function move(ev) {
|
||||
if (!startPos) return;
|
||||
const pos = getPos(ev);
|
||||
const dx = pos.x - startPos.x, dy = pos.y - startPos.y;
|
||||
if (!moved && (dx * dx + dy * dy) > dragThreshold * dragThreshold) {
|
||||
moved = true;
|
||||
}
|
||||
if (active && moved) {
|
||||
active.x = pos.x;
|
||||
active.y = pos.y;
|
||||
onDrag(active, pos, { dx: pos.x - lastPos.x, dy: pos.y - lastPos.y });
|
||||
lastPos = pos;
|
||||
}
|
||||
}
|
||||
|
||||
function up(ev) {
|
||||
if (!startPos) return;
|
||||
const pos = getPos(ev);
|
||||
if (active) {
|
||||
onDrop(active, pos);
|
||||
} else if (!moved) {
|
||||
onClick(pos, null);
|
||||
}
|
||||
try { canvas.releasePointerCapture(pointerId); } catch (e) {}
|
||||
canvas.style.cursor = '';
|
||||
active = null;
|
||||
startPos = null;
|
||||
lastPos = null;
|
||||
pointerId = null;
|
||||
}
|
||||
|
||||
// Hover cursor change
|
||||
function hover(ev) {
|
||||
if (active) return;
|
||||
const pos = getPos(ev);
|
||||
const hit = hitTest(objects, pos.x, pos.y);
|
||||
canvas.style.cursor = hit ? 'grab' : '';
|
||||
}
|
||||
|
||||
canvas.addEventListener('pointerdown', down);
|
||||
canvas.addEventListener('pointermove', move);
|
||||
canvas.addEventListener('pointerup', up);
|
||||
canvas.addEventListener('pointercancel', up);
|
||||
canvas.addEventListener('mousemove', hover);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
canvas.removeEventListener('pointerdown', down);
|
||||
canvas.removeEventListener('pointermove', move);
|
||||
canvas.removeEventListener('pointerup', up);
|
||||
canvas.removeEventListener('pointercancel', up);
|
||||
canvas.removeEventListener('mousemove', hover);
|
||||
canvas.style.cursor = '';
|
||||
},
|
||||
get active() { return active; },
|
||||
updateObjects(newObjects) { objects.length = 0; objects.push(...newObjects); }
|
||||
};
|
||||
}
|
||||
|
||||
// === Palette: drag items from palette to canvas/svg ===
|
||||
// items: NodeList of .p8-palette-item with data-component attr
|
||||
// dropTarget: HTML element with .p8-droptarget class
|
||||
// onDrop(componentType, pos): callback when dropped on target
|
||||
function attachPalette(paletteItems, dropTarget, opts = {}) {
|
||||
const onDrop = opts.onDrop || (() => {});
|
||||
const onDragStart = opts.onDragStart || (() => {});
|
||||
const onDragEnd = opts.onDragEnd || (() => {});
|
||||
|
||||
const handlers = [];
|
||||
|
||||
[...paletteItems].forEach(item => {
|
||||
let ghost = null;
|
||||
let pointerId = null;
|
||||
|
||||
function down(ev) {
|
||||
if (ev.button !== undefined && ev.button !== 0) return;
|
||||
ev.preventDefault();
|
||||
pointerId = ev.pointerId;
|
||||
try { item.setPointerCapture(pointerId); } catch (e) {}
|
||||
|
||||
const r = item.getBoundingClientRect();
|
||||
ghost = item.cloneNode(true);
|
||||
ghost.style.cssText = `position:fixed;z-index:9999;pointer-events:none;opacity:.85;
|
||||
width:${r.width}px;left:${r.left}px;top:${r.top}px;
|
||||
box-shadow:0 12px 28px rgba(0,0,0,.20);transform:rotate(-2deg);`;
|
||||
document.body.appendChild(ghost);
|
||||
item.style.opacity = '.35';
|
||||
const componentType = item.dataset.component || item.textContent.trim();
|
||||
onDragStart(componentType);
|
||||
}
|
||||
|
||||
function move(ev) {
|
||||
if (!ghost) return;
|
||||
ghost.style.left = (ev.clientX - 30) + 'px';
|
||||
ghost.style.top = (ev.clientY - 20) + 'px';
|
||||
if (dropTarget) {
|
||||
const tr = dropTarget.getBoundingClientRect();
|
||||
const inside = ev.clientX >= tr.left && ev.clientX <= tr.right &&
|
||||
ev.clientY >= tr.top && ev.clientY <= tr.bottom;
|
||||
dropTarget.classList.toggle('p8-drop-over', inside);
|
||||
}
|
||||
}
|
||||
|
||||
function up(ev) {
|
||||
if (!ghost) return;
|
||||
const componentType = item.dataset.component || item.textContent.trim();
|
||||
if (dropTarget) {
|
||||
const tr = dropTarget.getBoundingClientRect();
|
||||
const inside = ev.clientX >= tr.left && ev.clientX <= tr.right &&
|
||||
ev.clientY >= tr.top && ev.clientY <= tr.bottom;
|
||||
dropTarget.classList.remove('p8-drop-over');
|
||||
if (inside) {
|
||||
const pos = {
|
||||
x: ev.clientX - tr.left,
|
||||
y: ev.clientY - tr.top,
|
||||
clientX: ev.clientX, clientY: ev.clientY
|
||||
};
|
||||
onDrop(componentType, pos);
|
||||
}
|
||||
}
|
||||
ghost.remove();
|
||||
ghost = null;
|
||||
item.style.opacity = '';
|
||||
try { item.releasePointerCapture(pointerId); } catch (e) {}
|
||||
pointerId = null;
|
||||
onDragEnd(componentType);
|
||||
}
|
||||
|
||||
item.style.touchAction = 'none';
|
||||
item.addEventListener('pointerdown', down);
|
||||
item.addEventListener('pointermove', move);
|
||||
item.addEventListener('pointerup', up);
|
||||
item.addEventListener('pointercancel', up);
|
||||
handlers.push({ item, down, move, up });
|
||||
});
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
handlers.forEach(({ item, down, move, up }) => {
|
||||
item.removeEventListener('pointerdown', down);
|
||||
item.removeEventListener('pointermove', move);
|
||||
item.removeEventListener('pointerup', up);
|
||||
item.removeEventListener('pointercancel', up);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// === Export ===
|
||||
window.P8Drag = {
|
||||
attach,
|
||||
attachCanvas,
|
||||
attachPalette,
|
||||
snapToGrid,
|
||||
hitTest,
|
||||
pointerInContainer
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,429 @@
|
||||
// phys8-helpers.js — тематические хелперы для Физики 8 (тепловые, электромагнитные, оптические).
|
||||
// Дополняет phys.js (тот не трогаем — он общий для Phys 10/11).
|
||||
// Экспорт в window.P8Helpers = { thermal, em, optics, svg }
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// === SVG-утилиты ===
|
||||
const SVGNS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
function svgEl(tag, attrs) {
|
||||
const e = document.createElementNS(SVGNS, tag);
|
||||
if (attrs) for (const k in attrs) {
|
||||
if (k === 'text') e.textContent = attrs[k];
|
||||
else e.setAttribute(k, attrs[k]);
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
function createSvg(width, height, viewBox) {
|
||||
const svg = svgEl('svg', {
|
||||
xmlns: SVGNS,
|
||||
viewBox: viewBox || `0 0 ${width} ${height}`,
|
||||
width,
|
||||
height,
|
||||
preserveAspectRatio: 'xMidYMid meet'
|
||||
});
|
||||
return svg;
|
||||
}
|
||||
|
||||
// === SVG-defs хелперы (градиенты, шаблоны) ===
|
||||
function linearGradient(id, stops, opts = {}) {
|
||||
const grad = svgEl('linearGradient', {
|
||||
id,
|
||||
x1: opts.x1 || '0%',
|
||||
y1: opts.y1 || '0%',
|
||||
x2: opts.x2 || '100%',
|
||||
y2: opts.y2 || '0%'
|
||||
});
|
||||
stops.forEach(([offset, color, opacity = 1]) => {
|
||||
grad.appendChild(svgEl('stop', {
|
||||
offset: typeof offset === 'number' ? `${offset * 100}%` : offset,
|
||||
'stop-color': color,
|
||||
'stop-opacity': opacity
|
||||
}));
|
||||
});
|
||||
return grad;
|
||||
}
|
||||
|
||||
function radialGradient(id, stops, opts = {}) {
|
||||
const grad = svgEl('radialGradient', {
|
||||
id,
|
||||
cx: opts.cx || '50%',
|
||||
cy: opts.cy || '50%',
|
||||
r: opts.r || '50%'
|
||||
});
|
||||
stops.forEach(([offset, color, opacity = 1]) => {
|
||||
grad.appendChild(svgEl('stop', {
|
||||
offset: typeof offset === 'number' ? `${offset * 100}%` : offset,
|
||||
'stop-color': color,
|
||||
'stop-opacity': opacity
|
||||
}));
|
||||
});
|
||||
return grad;
|
||||
}
|
||||
|
||||
// === Стрелка с градиентом и glow ===
|
||||
function gradientArrow(svg, x1, y1, x2, y2, opts = {}) {
|
||||
const id = opts.id || `arr-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const colorFrom = opts.colorFrom || '#fbbf24';
|
||||
const colorTo = opts.colorTo || '#dc2626';
|
||||
const width = opts.width || 4;
|
||||
const headSize = opts.headSize || 14;
|
||||
|
||||
// defs
|
||||
let defs = svg.querySelector('defs');
|
||||
if (!defs) { defs = svgEl('defs'); svg.appendChild(defs); }
|
||||
defs.appendChild(linearGradient(id, [[0, colorFrom, 1], [1, colorTo, 1]], {
|
||||
x1: '0%', y1: '0%',
|
||||
x2: `${(x2 - x1) > 0 ? 100 : -100}%`,
|
||||
y2: `${(y2 - y1) > 0 ? 100 : -100}%`
|
||||
}));
|
||||
|
||||
const dx = x2 - x1, dy = y2 - y1;
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < 1e-6) return null;
|
||||
const ux = dx / len, uy = dy / len;
|
||||
const px = -uy, py = ux;
|
||||
const bx = x2 - ux * headSize, by = y2 - uy * headSize;
|
||||
const w = headSize * 0.55;
|
||||
const lx = bx + px * w, ly = by + py * w;
|
||||
const rx = bx - px * w, ry = by - py * w;
|
||||
|
||||
const g = svgEl('g', { filter: opts.glow ? `drop-shadow(0 0 4px ${colorTo})` : '' });
|
||||
g.appendChild(svgEl('line', {
|
||||
x1, y1, x2: bx, y2: by,
|
||||
stroke: `url(#${id})`,
|
||||
'stroke-width': width,
|
||||
'stroke-linecap': 'round'
|
||||
}));
|
||||
g.appendChild(svgEl('polygon', {
|
||||
points: `${x2},${y2} ${lx},${ly} ${rx},${ry}`,
|
||||
fill: colorTo
|
||||
}));
|
||||
return g;
|
||||
}
|
||||
|
||||
// === Текст с подложкой (легче читать на анимированном фоне) ===
|
||||
function labeledText(x, y, text, opts = {}) {
|
||||
const g = svgEl('g');
|
||||
const t = svgEl('text', {
|
||||
x, y,
|
||||
'font-family': "'JetBrains Mono', monospace",
|
||||
'font-size': opts.size || 12,
|
||||
'font-weight': opts.weight || 700,
|
||||
fill: opts.color || '#0f172a',
|
||||
'text-anchor': opts.anchor || 'middle',
|
||||
'dominant-baseline': 'middle',
|
||||
text
|
||||
});
|
||||
g.appendChild(t);
|
||||
return g;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// === ТЕРМАЛЬНЫЕ хелперы ===
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const thermal = {
|
||||
// Температурный цвет: 0 (холодный, синий) → 1 (горячий, красный/жёлтый)
|
||||
tempColor(t) {
|
||||
const k = Math.max(0, Math.min(1, t));
|
||||
// Палитра: cool#2563eb → mid#a78bfa → warm#fb923c → hot#facc15
|
||||
const stops = [
|
||||
[0, [37, 99, 235]],
|
||||
[0.33, [167, 139, 250]],
|
||||
[0.66, [251, 146, 60]],
|
||||
[1, [250, 204, 21]]
|
||||
];
|
||||
let from = stops[0], to = stops[stops.length - 1];
|
||||
for (let i = 0; i < stops.length - 1; i++) {
|
||||
if (k >= stops[i][0] && k <= stops[i + 1][0]) { from = stops[i]; to = stops[i + 1]; break; }
|
||||
}
|
||||
const lt = (k - from[0]) / (to[0] - from[0]);
|
||||
const r = Math.round(from[1][0] + (to[1][0] - from[1][0]) * lt);
|
||||
const g = Math.round(from[1][1] + (to[1][1] - from[1][1]) * lt);
|
||||
const b = Math.round(from[1][2] + (to[1][2] - from[1][2]) * lt);
|
||||
return `rgb(${r},${g},${b})`;
|
||||
},
|
||||
|
||||
// Heat-flow стрелка: волнистая, градиентом
|
||||
heatFlowArrow(svg, x1, y1, x2, y2, intensity = 1) {
|
||||
return gradientArrow(svg, x1, y1, x2, y2, {
|
||||
colorFrom: '#fde047',
|
||||
colorTo: '#dc2626',
|
||||
width: 3 + intensity * 2,
|
||||
headSize: 12 + intensity * 4,
|
||||
glow: true
|
||||
});
|
||||
},
|
||||
|
||||
// Молекула (с лёгким glow при горячем состоянии)
|
||||
molecule(x, y, r, temp = 0.5) {
|
||||
const c = thermal.tempColor(temp);
|
||||
const g = svgEl('g');
|
||||
g.appendChild(svgEl('circle', {
|
||||
cx: x, cy: y, r: r * 1.6,
|
||||
fill: c, opacity: 0.18 + temp * 0.30
|
||||
}));
|
||||
g.appendChild(svgEl('circle', {
|
||||
cx: x, cy: y, r,
|
||||
fill: c,
|
||||
stroke: '#0f172a', 'stroke-width': 0.8, opacity: 0.92
|
||||
}));
|
||||
return g;
|
||||
},
|
||||
|
||||
// Термометр (вертикальный, fill уровнем temp 0-1)
|
||||
thermometerSVG(x, y, height = 100, temp = 0.5) {
|
||||
const g = svgEl('g', { transform: `translate(${x},${y})` });
|
||||
const bw = 10, bh = height, bulbR = 14;
|
||||
// Tube
|
||||
g.appendChild(svgEl('rect', {
|
||||
x: -bw / 2, y: 0, width: bw, height: bh,
|
||||
rx: bw / 2, fill: '#f3f4f6', stroke: '#475569', 'stroke-width': 1.5
|
||||
}));
|
||||
// Fill
|
||||
const fillH = bh * temp;
|
||||
g.appendChild(svgEl('rect', {
|
||||
x: -bw / 2 + 2, y: bh - fillH, width: bw - 4, height: fillH,
|
||||
rx: 3, fill: thermal.tempColor(temp)
|
||||
}));
|
||||
// Bulb
|
||||
g.appendChild(svgEl('circle', {
|
||||
cx: 0, cy: bh + bulbR - 3, r: bulbR,
|
||||
fill: thermal.tempColor(temp),
|
||||
stroke: '#475569', 'stroke-width': 1.5
|
||||
}));
|
||||
// Glow
|
||||
g.appendChild(svgEl('circle', {
|
||||
cx: 0, cy: bh + bulbR - 3, r: bulbR + 6,
|
||||
fill: thermal.tempColor(temp), opacity: 0.20 + temp * 0.30
|
||||
}));
|
||||
return g;
|
||||
},
|
||||
|
||||
// Конвекционная ячейка — круговое движение частиц (для §4)
|
||||
// Используется в виджете с canvas, генерирует начальное состояние частиц
|
||||
convectionCellParticles(cx, cy, rx, ry, count = 20) {
|
||||
const arr = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = i / count;
|
||||
const angle = t * 2 * Math.PI;
|
||||
arr.push({
|
||||
x: cx + rx * Math.cos(angle),
|
||||
y: cy + ry * Math.sin(angle),
|
||||
angle,
|
||||
speed: 0.5 + Math.random() * 0.3,
|
||||
r: 3 + Math.random() * 1.5,
|
||||
phase: Math.random() * 2 * Math.PI
|
||||
});
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// === ЭЛЕКТРОМАГНИТНЫЕ хелперы ===
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const em = {
|
||||
// Заряд: круг с +/-
|
||||
chargeSVG(x, y, sign, r = 14, label) {
|
||||
const color = sign > 0 ? '#dc2626' : '#2563eb';
|
||||
const fill = sign > 0 ? '#fecaca' : '#bfdbfe';
|
||||
const g = svgEl('g', { transform: `translate(${x},${y})` });
|
||||
g.appendChild(svgEl('circle', {
|
||||
cx: 0, cy: 0, r: r + 4,
|
||||
fill: color, opacity: 0.15
|
||||
}));
|
||||
g.appendChild(svgEl('circle', {
|
||||
cx: 0, cy: 0, r,
|
||||
fill, stroke: color, 'stroke-width': 2
|
||||
}));
|
||||
const ls = r * 0.5;
|
||||
if (sign > 0) {
|
||||
g.appendChild(svgEl('line', { x1: -ls, y1: 0, x2: ls, y2: 0, stroke: color, 'stroke-width': 2.5 }));
|
||||
g.appendChild(svgEl('line', { x1: 0, y1: -ls, x2: 0, y2: ls, stroke: color, 'stroke-width': 2.5 }));
|
||||
} else {
|
||||
g.appendChild(svgEl('line', { x1: -ls, y1: 0, x2: ls, y2: 0, stroke: color, 'stroke-width': 2.5 }));
|
||||
}
|
||||
if (label) {
|
||||
g.appendChild(svgEl('text', {
|
||||
x: r + 6, y: 4,
|
||||
'font-family': "'JetBrains Mono', monospace",
|
||||
'font-size': 11, 'font-weight': 700,
|
||||
fill: color, text: label
|
||||
}));
|
||||
}
|
||||
return g;
|
||||
},
|
||||
|
||||
// Компонент цепи (battery, resistor, lamp, ammeter, voltmeter, switch)
|
||||
// pos: { x, y }, orient: 'h' | 'v', label
|
||||
circuitComponent(type, x, y, orient = 'h', label) {
|
||||
const g = svgEl('g', { transform: `translate(${x},${y})` });
|
||||
const len = 60;
|
||||
if (type === 'battery') {
|
||||
// 2 параллельные полоски: длинная (+), короткая (-)
|
||||
g.appendChild(svgEl('line', { x1: -len/2, y1: 0, x2: -8, y2: 0, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||
g.appendChild(svgEl('line', { x1: 8, y1: 0, x2: len/2, y2: 0, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||
g.appendChild(svgEl('line', { x1: -8, y1: -12, x2: -8, y2: 12, stroke: '#0f172a', 'stroke-width': 3.5 }));
|
||||
g.appendChild(svgEl('line', { x1: 8, y1: -7, x2: 8, y2: 7, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||
} else if (type === 'resistor') {
|
||||
// Зигзаг
|
||||
g.appendChild(svgEl('line', { x1: -len/2, y1: 0, x2: -22, y2: 0, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||
g.appendChild(svgEl('polyline', {
|
||||
points: '-22,0 -16,-10 -8,10 0,-10 8,10 16,-10 22,0',
|
||||
fill: 'none', stroke: '#d97706', 'stroke-width': 2.5
|
||||
}));
|
||||
g.appendChild(svgEl('line', { x1: 22, y1: 0, x2: len/2, y2: 0, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||
} else if (type === 'lamp') {
|
||||
g.appendChild(svgEl('line', { x1: -len/2, y1: 0, x2: -14, y2: 0, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||
g.appendChild(svgEl('circle', { cx: 0, cy: 0, r: 14, fill: '#fef3c7', stroke: '#0f172a', 'stroke-width': 2 }));
|
||||
g.appendChild(svgEl('line', { x1: -10, y1: -10, x2: 10, y2: 10, stroke: '#0f172a', 'stroke-width': 1.5 }));
|
||||
g.appendChild(svgEl('line', { x1: -10, y1: 10, x2: 10, y2: -10, stroke: '#0f172a', 'stroke-width': 1.5 }));
|
||||
g.appendChild(svgEl('line', { x1: 14, y1: 0, x2: len/2, y2: 0, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||
} else if (type === 'ammeter' || type === 'voltmeter') {
|
||||
g.appendChild(svgEl('line', { x1: -len/2, y1: 0, x2: -14, y2: 0, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||
g.appendChild(svgEl('circle', { cx: 0, cy: 0, r: 14, fill: '#fff', stroke: '#0f172a', 'stroke-width': 2 }));
|
||||
g.appendChild(svgEl('text', {
|
||||
x: 0, y: 0,
|
||||
'font-family': "'JetBrains Mono', monospace",
|
||||
'font-size': 13, 'font-weight': 800,
|
||||
fill: type === 'ammeter' ? '#dc2626' : '#2563eb',
|
||||
'text-anchor': 'middle', 'dominant-baseline': 'middle',
|
||||
text: type === 'ammeter' ? 'A' : 'V'
|
||||
}));
|
||||
g.appendChild(svgEl('line', { x1: 14, y1: 0, x2: len/2, y2: 0, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||
} else if (type === 'switch') {
|
||||
g.appendChild(svgEl('line', { x1: -len/2, y1: 0, x2: -12, y2: 0, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||
g.appendChild(svgEl('circle', { cx: -12, cy: 0, r: 2.5, fill: '#0f172a' }));
|
||||
g.appendChild(svgEl('line', { x1: -12, y1: 0, x2: 8, y2: -10, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||
g.appendChild(svgEl('circle', { cx: 12, cy: 0, r: 2.5, fill: '#0f172a' }));
|
||||
g.appendChild(svgEl('line', { x1: 12, y1: 0, x2: len/2, y2: 0, stroke: '#0f172a', 'stroke-width': 2 }));
|
||||
}
|
||||
if (label) {
|
||||
g.appendChild(svgEl('text', {
|
||||
x: 0, y: -22,
|
||||
'font-family': "'JetBrains Mono', monospace",
|
||||
'font-size': 11, 'font-weight': 700,
|
||||
fill: '#0f172a', 'text-anchor': 'middle',
|
||||
text: label
|
||||
}));
|
||||
}
|
||||
if (orient === 'v') g.setAttribute('transform', `translate(${x},${y}) rotate(90)`);
|
||||
return g;
|
||||
},
|
||||
|
||||
// Силовая линия от точечного заряда (одиночная)
|
||||
fieldLineFrom(cx, cy, angle, length, sign, color) {
|
||||
color = color || (sign > 0 ? '#dc2626' : '#2563eb');
|
||||
const r1 = 18, r2 = length;
|
||||
const x1 = cx + r1 * Math.cos(angle), y1 = cy + r1 * Math.sin(angle);
|
||||
const x2 = cx + r2 * Math.cos(angle), y2 = cy + r2 * Math.sin(angle);
|
||||
return gradientArrow(null, sign > 0 ? x1 : x2, sign > 0 ? y1 : y2, sign > 0 ? x2 : x1, sign > 0 ? y2 : y1, {
|
||||
colorFrom: color, colorTo: color, width: 1.4, headSize: 7
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// === ОПТИКА хелперы ===
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const optics = {
|
||||
// Луч света (стрелка с цветом)
|
||||
rayLine(x1, y1, x2, y2, opts = {}) {
|
||||
const color = opts.color || '#facc15';
|
||||
const width = opts.width || 2.5;
|
||||
const dashed = opts.dashed || false;
|
||||
const arrow = opts.arrow !== false;
|
||||
const g = svgEl('g', { filter: opts.glow ? `drop-shadow(0 0 3px ${color})` : '' });
|
||||
g.appendChild(svgEl('line', {
|
||||
x1, y1, x2, y2,
|
||||
stroke: color, 'stroke-width': width,
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-dasharray': dashed ? '6 5' : ''
|
||||
}));
|
||||
if (arrow) {
|
||||
const dx = x2 - x1, dy = y2 - y1;
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len > 1) {
|
||||
const ux = dx / len, uy = dy / len;
|
||||
const hs = 9;
|
||||
const px = -uy, py = ux;
|
||||
const bx = x2 - ux * hs, by = y2 - uy * hs;
|
||||
const lx = bx + px * hs * 0.45, ly = by + py * hs * 0.45;
|
||||
const rx = bx - px * hs * 0.45, ry = by - py * hs * 0.45;
|
||||
g.appendChild(svgEl('polygon', {
|
||||
points: `${x2},${y2} ${lx},${ly} ${rx},${ry}`, fill: color
|
||||
}));
|
||||
}
|
||||
}
|
||||
return g;
|
||||
},
|
||||
|
||||
// Тонкая линза (двояковыпуклая или двояковогнутая)
|
||||
lensSVG(cx, cy, height, type = 'converging') {
|
||||
const g = svgEl('g', { transform: `translate(${cx},${cy})` });
|
||||
const h = height / 2;
|
||||
const w = type === 'converging' ? 10 : -10;
|
||||
// Эллипс или линза
|
||||
const path = `M 0 ${-h}
|
||||
C ${w} ${-h * 0.5}, ${w} ${h * 0.5}, 0 ${h}
|
||||
C ${-w} ${h * 0.5}, ${-w} ${-h * 0.5}, 0 ${-h} Z`;
|
||||
g.appendChild(svgEl('path', {
|
||||
d: path,
|
||||
fill: 'rgba(125, 211, 252, .35)',
|
||||
stroke: '#0284c7', 'stroke-width': 2
|
||||
}));
|
||||
// Стрелки на концах (вид линзы)
|
||||
const tip = type === 'converging' ? 6 : -6;
|
||||
g.appendChild(svgEl('polygon', {
|
||||
points: `${-tip},${-h} 0,${-h - 8} ${tip},${-h}`,
|
||||
fill: '#0284c7'
|
||||
}));
|
||||
g.appendChild(svgEl('polygon', {
|
||||
points: `${-tip},${h} 0,${h + 8} ${tip},${h}`,
|
||||
fill: '#0284c7'
|
||||
}));
|
||||
return g;
|
||||
},
|
||||
|
||||
// Плоское зеркало (вертикальная штриховка)
|
||||
mirrorPlane(x1, y1, x2, y2) {
|
||||
const g = svgEl('g');
|
||||
g.appendChild(svgEl('line', {
|
||||
x1, y1, x2, y2,
|
||||
stroke: '#0f172a', 'stroke-width': 3
|
||||
}));
|
||||
// Hatch штриховка
|
||||
const dx = x2 - x1, dy = y2 - y1;
|
||||
const len = Math.hypot(dx, dy);
|
||||
const ux = dx / len, uy = dy / len;
|
||||
const px = uy, py = -ux;
|
||||
const step = 8;
|
||||
const segs = Math.floor(len / step);
|
||||
for (let i = 0; i < segs; i++) {
|
||||
const t = i / segs;
|
||||
const bx = x1 + dx * t, by = y1 + dy * t;
|
||||
g.appendChild(svgEl('line', {
|
||||
x1: bx, y1: by,
|
||||
x2: bx + px * 8, y2: by + py * 8,
|
||||
stroke: '#475569', 'stroke-width': 1.5
|
||||
}));
|
||||
}
|
||||
return g;
|
||||
}
|
||||
};
|
||||
|
||||
// === Export ===
|
||||
window.P8Helpers = {
|
||||
svg: { el: svgEl, create: createSvg, linearGradient, radialGradient, gradientArrow, labeledText },
|
||||
thermal,
|
||||
em,
|
||||
optics
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user