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