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,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
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user