77e4dffb43
Закладывает уникальный визуальный язык и 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)
168 lines
5.6 KiB
JavaScript
168 lines
5.6 KiB
JavaScript
// 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
|
|
};
|
|
})();
|