Files
Learn_System/frontend/js/phys8-anim.js
Maxim Dolgolyov 77e4dffb43 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)
2026-05-30 09:55:00 +03:00

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
};
})();