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:
Maxim Dolgolyov
2026-05-30 09:55:00 +03:00
parent 70aad6a423
commit 77e4dffb43
11 changed files with 1827 additions and 3 deletions
+109
View File
@@ -0,0 +1,109 @@
// Phase 0 — подключает design system + interactives CSS + новые JS-модули
// (phys8-anim.js, phys8-drag.js, phys8-helpers.js) во все 5 файлов учебника Физики 8:
// physics_8_hub.html, physics_8_ch1.html, physics_8_ch2.html, physics_8_ch3.html, physics_8_lab.html.
// Также добавляет body class (p8-theme-thermal/electric/spectrum) на каждой странице.
'use strict';
const fs = require('fs');
const path = require('path');
const TBOOKS = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
const FILES = [
{ name: 'physics_8_hub.html', theme: null },
{ name: 'physics_8_ch1.html', theme: 'thermal' },
{ name: 'physics_8_ch2.html', theme: 'electric' },
{ name: 'physics_8_ch3.html', theme: 'spectrum' },
{ name: 'physics_8_lab.html', theme: null },
];
const CSS_LINKS = [
'<link rel="stylesheet" href="/css/phys8-design-system.css">',
'<link rel="stylesheet" href="/css/phys8-interactives.css">',
];
const JS_LINKS = [
'<script src="/js/phys8-anim.js" defer></script>',
'<script src="/js/phys8-drag.js" defer></script>',
'<script src="/js/phys8-helpers.js" defer></script>',
];
// Anchor: после katex link мы добавляем design-system css
const CSS_ANCHOR = '<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">';
// Anchor: после phys.js / xp.js мы добавляем новые JS
const JS_ANCHOR_OPTIONS = [
'<script src="/js/phys.js" defer></script>',
'<script src="/js/xp.js" defer></script>',
];
let totalPatched = 0;
for (const { name, theme } of FILES) {
const fp = path.join(TBOOKS, name);
if (!fs.existsSync(fp)) { console.warn('miss:', name); continue; }
let h = fs.readFileSync(fp, 'utf8');
const before = h.length;
let changes = [];
// 1. CSS links
for (const link of CSS_LINKS) {
if (h.includes(link)) continue;
if (h.includes(CSS_ANCHOR)) {
h = h.replace(CSS_ANCHOR, CSS_ANCHOR + '\n' + link);
changes.push('+css: ' + link.match(/href="([^"]+)"/)[1]);
}
}
// 2. JS links — место подключения: после любого из якорей
for (const link of JS_LINKS) {
if (h.includes(link)) continue;
let placed = false;
for (const anchor of JS_ANCHOR_OPTIONS) {
if (h.includes(anchor)) {
h = h.replace(anchor, anchor + '\n' + link);
placed = true;
changes.push('+js: ' + link.match(/src="([^"]+)"/)[1]);
break;
}
}
if (!placed) {
// Fallback: перед </head>
h = h.replace('</head>', link + '\n</head>');
changes.push('+js (head): ' + link.match(/src="([^"]+)"/)[1]);
}
}
// 3. Theme class на body
if (theme) {
const themeClass = 'p8-theme-' + theme;
if (!h.includes(themeClass)) {
// Найти <body ...> и добавить класс
h = h.replace(/<body([^>]*)>/, (match, attrs) => {
if (/class="([^"]*)"/.test(attrs)) {
return '<body' + attrs.replace(/class="([^"]*)"/, (m, cls) =>
`class="${cls} ${themeClass}"`) + '>';
}
return `<body${attrs} class="${themeClass}">`;
});
changes.push('+body class: ' + themeClass);
}
}
fs.writeFileSync(fp, h);
if (changes.length) {
console.log(`${name}: ${before}${h.length} bytes`);
changes.forEach(c => console.log(' ' + c));
totalPatched++;
} else {
console.log(`${name}: no changes (already patched)`);
}
// Sanity parse inline scripts
const scripts = [...h.matchAll(/<script>([\s\S]*?)<\/script>/g)];
for (const m of scripts) {
try { new Function(m[1]); }
catch (e) { console.error(`JS PARSE FAIL in ${name}:`, e.message.slice(0, 100)); process.exit(1); }
}
}
console.log('Total patched:', totalPatched, '/', FILES.length);
+359
View File
@@ -0,0 +1,359 @@
/* phys8-design-system.css
* Уникальный визуальный язык «Физики 8» — 3 тематических темы.
* Применяется на physics_8_hub.html, physics_8_ch1/ch2/ch3.html, physics_8_lab.html.
*
* Тематика:
* Ch1 «жар» — угольный/оранжевый/жёлтый, дышащий градиент
* Ch2 «искра» — индиго/бирюза/голубой, electric pulse
* Ch3 «спектр» — глубокий синий/радуга/персик, dispersion shift
*/
/* === Глобальные тематические переменные === */
:root {
--p8-brand: #7c3aed;
--p8-brand-d: #5b21b6;
--p8-brand-l: #c4b5fd;
--p8-brand-soft: #ede9fe;
--p8-ink: #0f172a;
--p8-text: #1e293b;
--p8-muted: #64748b;
--p8-mono: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
--p8-display: 'Unbounded', 'Outfit', system-ui, sans-serif;
--p8-body: 'Inter', system-ui, sans-serif;
}
html.dark {
--p8-ink: #f1f5f9;
--p8-text: #e2e8f0;
--p8-muted: #94a3b8;
}
/* === Тема Ch1 «жар» — Тепловые явления === */
body.p8-theme-thermal {
--th-deep: #0f172a;
--th-mid: #f97316;
--th-warm: #facc15;
--th-glow: rgba(249, 115, 22, .35);
--th-soft: #fff7ed;
--th-card: #fffbf5;
}
html.dark body.p8-theme-thermal {
--th-soft: #1a0f05;
--th-card: #1f1408;
}
/* === Тема Ch2 «искра» — Электромагнитные явления === */
body.p8-theme-electric {
--el-deep: #1e1b4b;
--el-mid: #06b6d4;
--el-cool: #7dd3fc;
--el-glow: rgba(6, 182, 212, .35);
--el-soft: #ecfeff;
--el-card: #f0fbff;
}
html.dark body.p8-theme-electric {
--el-soft: #061226;
--el-card: #081530;
}
/* === Тема Ch3 «спектр» — Световые явления === */
body.p8-theme-spectrum {
--sp-deep: #0c0a3a;
--sp-mid-r: #ef4444;
--sp-mid-y: #facc15;
--sp-mid-g: #22c55e;
--sp-mid-b: #3b82f6;
--sp-mid-v: #a855f7;
--sp-warm: #fb7185;
--sp-glow: rgba(168, 85, 247, .30);
--sp-soft: #fdf2f8;
--sp-card: #fff5fa;
}
html.dark body.p8-theme-spectrum {
--sp-soft: #0d0a1f;
--sp-card: #110d27;
}
/* === Hero (заменяет .hdr на главах) === */
.p8-hero {
position: relative;
padding: 48px 28px 38px;
color: #fff;
overflow: hidden;
font-family: var(--p8-display);
border-bottom: 2px solid rgba(255,255,255,.16);
}
.p8-theme-thermal .p8-hero {
background:
radial-gradient(circle at 78% 18%, var(--th-glow), transparent 55%),
linear-gradient(125deg, var(--th-deep) 0%, var(--th-mid) 60%, var(--th-warm) 100%);
background-size: 220% 220%, 100% 100%;
animation: p8-thermal-shift 14s ease-in-out infinite;
}
.p8-theme-electric .p8-hero {
background:
radial-gradient(circle at 22% 30%, var(--el-glow), transparent 50%),
linear-gradient(115deg, var(--el-deep) 0%, var(--el-mid) 65%, var(--el-cool) 100%);
animation: p8-electric-pulse 5s ease-in-out infinite;
}
.p8-theme-spectrum .p8-hero {
background:
linear-gradient(115deg,
var(--sp-deep) 0%,
var(--sp-mid-r) 20%,
var(--sp-mid-y) 38%,
var(--sp-mid-g) 55%,
var(--sp-mid-b) 72%,
var(--sp-mid-v) 88%,
var(--sp-warm) 100%);
background-size: 240% 100%;
animation: p8-spectrum-drift 18s linear infinite;
}
@keyframes p8-thermal-shift {
0%, 100% { background-position: 0% 50%, 0 0; }
50% { background-position: 100% 50%, 0 0; }
}
@keyframes p8-electric-pulse {
0%, 100% { filter: brightness(1) saturate(1); }
35% { filter: brightness(1.12) saturate(1.18); }
60% { filter: brightness(0.96) saturate(1.06); }
}
@keyframes p8-spectrum-drift {
0% { background-position: 0% 50%; }
100% { background-position: 200% 50%; }
}
/* === Hero watermark (тематический SVG-символ справа) === */
.p8-hero-wm {
position: absolute;
right: -24px;
top: 50%;
transform: translateY(-50%);
width: clamp(180px, 32vw, 360px);
aspect-ratio: 1;
opacity: .18;
color: #fff;
pointer-events: none;
z-index: 0;
}
.p8-hero-wm svg { width: 100%; height: 100%; fill: currentColor; }
/* Анимация дыхания watermark */
.p8-theme-thermal .p8-hero-wm { animation: p8-wm-breathe 6s ease-in-out infinite; }
.p8-theme-electric .p8-hero-wm { animation: p8-wm-flicker 3.2s ease-in-out infinite; }
.p8-theme-spectrum .p8-hero-wm { animation: p8-wm-rotate 40s linear infinite; }
@keyframes p8-wm-breathe { 0%,100% { opacity: .15; transform: translateY(-50%) scale(1); } 50% { opacity: .22; transform: translateY(-50%) scale(1.06); } }
@keyframes p8-wm-flicker { 0%,100% { opacity: .18; } 30% { opacity: .26; } 33% { opacity: .12; } 36% { opacity: .24; } 50% { opacity: .18; } }
@keyframes p8-wm-rotate { 0% { transform: translateY(-50%) rotate(0); } 100% { transform: translateY(-50%) rotate(360deg); } }
/* === Hero content === */
.p8-hero-inner {
position: relative;
z-index: 1;
max-width: 1100px;
margin: 0 auto;
}
.p8-hero-eyebrow {
display: inline-block;
padding: 5px 12px;
background: rgba(255,255,255,.18);
border-radius: 99px;
font-size: .72rem;
font-weight: 700;
letter-spacing: .12em;
text-transform: uppercase;
margin-bottom: 14px;
backdrop-filter: blur(6px);
}
.p8-hero-title {
font-family: var(--p8-display);
font-size: clamp(1.8rem, 4vw, 2.6rem);
font-weight: 900;
letter-spacing: -.02em;
line-height: 1.05;
margin: 0 0 10px;
text-shadow: 0 2px 16px rgba(0,0,0,.18);
}
.p8-hero-sub {
font-size: clamp(.92rem, 1.4vw, 1.05rem);
opacity: .92;
max-width: 640px;
font-family: var(--p8-body);
font-weight: 500;
line-height: 1.55;
}
/* === Live header indicator (маячок в углу хедера) === */
.p8-hero-meter {
position: absolute;
top: 22px;
right: 28px;
z-index: 2;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
background: rgba(0,0,0,.28);
backdrop-filter: blur(8px);
border-radius: 99px;
font-family: var(--p8-mono);
font-size: .76rem;
font-weight: 700;
color: #fff;
letter-spacing: .04em;
}
.p8-hero-meter::before {
content: '';
width: 8px; height: 8px;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 0 0 currentColor;
animation: p8-meter-pulse 1.4s ease-out infinite;
}
@keyframes p8-meter-pulse {
0% { box-shadow: 0 0 0 0 rgba(255,255,255,.65); }
70% { box-shadow: 0 0 0 12px rgba(255,255,255,0); }
100% { box-shadow: 0 0 0 0 rgba(255,255,255,0); }
}
/* === Section watermarks (тематические SVG на правой стороне каждой секции) === */
.p8-sec-wm {
position: absolute;
right: -28px;
top: 8%;
width: clamp(140px, 22vw, 240px);
aspect-ratio: 1;
color: var(--p8-brand);
opacity: .07;
pointer-events: none;
user-select: none;
z-index: 0;
}
.p8-theme-thermal .p8-sec-wm { color: var(--th-mid); }
.p8-theme-electric .p8-sec-wm { color: var(--el-mid); }
.p8-theme-spectrum .p8-sec-wm { color: var(--sp-mid-v); }
.p8-sec-wm svg { width: 100%; height: 100%; fill: currentColor; }
/* === Hover-resonance — SVG/canvas в виджетах === */
.wg svg, .wg canvas {
transition: filter .35s cubic-bezier(.16,1,.3,1), transform .35s cubic-bezier(.16,1,.3,1);
}
.wg:hover svg,
.wg:hover canvas {
filter: drop-shadow(0 6px 20px rgba(0,0,0,.10));
}
.p8-theme-thermal .wg:hover svg { filter: drop-shadow(0 6px 20px var(--th-glow)); }
.p8-theme-electric .wg:hover svg { filter: drop-shadow(0 6px 20px var(--el-glow)); }
.p8-theme-spectrum .wg:hover svg { filter: drop-shadow(0 6px 20px var(--sp-glow)); }
/* === Staggered fade-in для виджетов === */
@keyframes p8-wg-fade-in {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: none; }
}
.sec.active .wg {
animation: p8-wg-fade-in .45s cubic-bezier(.16,1,.3,1) backwards;
}
.sec.active .wg:nth-of-type(1) { animation-delay: .03s; }
.sec.active .wg:nth-of-type(2) { animation-delay: .10s; }
.sec.active .wg:nth-of-type(3) { animation-delay: .17s; }
.sec.active .wg:nth-of-type(4) { animation-delay: .24s; }
.sec.active .wg:nth-of-type(5) { animation-delay: .31s; }
.sec.active .wg:nth-of-type(6) { animation-delay: .38s; }
.sec.active .wg:nth-of-type(7) { animation-delay: .45s; }
/* === Soft elevation на карточках (2 уровня) === */
.wg {
box-shadow: 0 1px 3px rgba(15,23,42,.05), 0 8px 20px rgba(15,23,42,.04);
transition: box-shadow 350ms cubic-bezier(.16,1,.3,1), transform 350ms cubic-bezier(.16,1,.3,1);
}
.wg:hover {
box-shadow: 0 4px 10px rgba(15,23,42,.07), 0 18px 36px rgba(15,23,42,.10);
transform: translateY(-2px);
}
/* === Typography rhythm: моноширинный для физических величин === */
.p8-num, .p8-unit, .p8-mono, var.p8-var,
.tinp[type="number"], input[type="number"].tinp {
font-family: var(--p8-mono);
letter-spacing: -.01em;
}
.p8-num { font-weight: 700; color: var(--p8-ink); }
.p8-unit { color: var(--p8-muted); font-weight: 600; font-size: .92em; }
/* === Topic-aware progress bars === */
.p8-theme-thermal .hp-fill,
.p8-theme-thermal .po-fill,
.p8-theme-thermal .ch-prog-fill {
background: linear-gradient(90deg, var(--th-mid), var(--th-warm));
}
.p8-theme-electric .hp-fill,
.p8-theme-electric .po-fill,
.p8-theme-electric .ch-prog-fill {
background: linear-gradient(90deg, var(--el-mid), var(--el-cool));
}
.p8-theme-spectrum .hp-fill,
.p8-theme-spectrum .po-fill,
.p8-theme-spectrum .ch-prog-fill {
background: linear-gradient(90deg, var(--sp-mid-b), var(--sp-mid-v), var(--sp-warm));
background-size: 200% 100%;
animation: p8-spectrum-drift 8s linear infinite;
}
/* === Mobile responsiveness === */
@media (max-width: 768px) {
.p8-hero { padding: 34px 18px 26px; }
.p8-hero-wm { width: clamp(120px, 38vw, 200px); right: -16px; opacity: .12; }
.p8-hero-meter { top: 14px; right: 16px; font-size: .68rem; padding: 5px 9px; }
.p8-sec-wm { width: clamp(80px, 28vw, 140px); right: -16px; opacity: .05; }
}
/* === Animated noise overlay (микро-шум на hero) === */
.p8-hero::after {
content: '';
position: absolute;
inset: 0;
background-image:
radial-gradient(circle 1px at 25% 30%, rgba(255,255,255,.10) 0, transparent 1px),
radial-gradient(circle 1px at 75% 60%, rgba(255,255,255,.08) 0, transparent 1px),
radial-gradient(circle 1px at 50% 80%, rgba(255,255,255,.06) 0, transparent 1px),
radial-gradient(circle 1px at 15% 75%, rgba(255,255,255,.09) 0, transparent 1px),
radial-gradient(circle 1px at 85% 20%, rgba(255,255,255,.07) 0, transparent 1px);
background-size: 80px 80px;
opacity: .6;
pointer-events: none;
z-index: 0;
}
.p8-theme-thermal .p8-hero::after { animation: p8-noise-thermal 8s linear infinite; }
.p8-theme-electric .p8-hero::after { animation: p8-noise-electric 4s linear infinite; }
.p8-theme-spectrum .p8-hero::after { animation: p8-noise-spectrum 12s linear infinite; }
@keyframes p8-noise-thermal { 0% { background-position: 0 0; } 100% { background-position: 80px -160px; } }
@keyframes p8-noise-electric { 0% { background-position: 0 0; } 100% { background-position: -120px 80px; } }
@keyframes p8-noise-spectrum { 0% { background-position: 0 0; } 100% { background-position: 80px 80px; } }
/* === Focus-visible для accessibility === */
.btn:focus-visible,
button:focus-visible,
input:focus-visible,
.wg [tabindex]:focus-visible {
outline: 2px solid var(--p8-brand);
outline-offset: 2px;
border-radius: 9px;
}
.p8-theme-thermal :focus-visible { outline-color: var(--th-mid); }
.p8-theme-electric :focus-visible { outline-color: var(--el-mid); }
.p8-theme-spectrum :focus-visible { outline-color: var(--sp-mid-v); }
/* === Reduced motion === */
@media (prefers-reduced-motion: reduce) {
.p8-hero,
.p8-hero-wm,
.p8-hero-meter::before,
.p8-hero::after,
.sec.active .wg,
.p8-theme-spectrum .ch-prog-fill {
animation: none !important;
}
.wg, .wg svg, .wg canvas { transition: none !important; }
}
+372
View File
@@ -0,0 +1,372 @@
/* phys8-interactives.css
* Стили для drag-and-drop виджетов, scrubbers, tooltips, badges,
* палитр компонентов (для circuit builder), readout-табло.
* Расширяет phys-textbook-widgets.css.
*/
/* === Drag handle (cursor + микро-эффект) === */
.p8-draggable {
cursor: grab;
touch-action: none;
user-select: none;
transition: filter .18s, transform .18s;
}
.p8-draggable:hover {
filter: brightness(1.05);
}
.p8-draggable:active,
.p8-draggable.is-dragging {
cursor: grabbing;
filter: brightness(1.12) drop-shadow(0 6px 12px rgba(0,0,0,.18));
z-index: 10;
}
/* === Drop target (зона приёма) === */
.p8-droptarget {
border: 2px dashed var(--p8-brand-l, #c4b5fd);
border-radius: 12px;
padding: 14px;
background: rgba(124, 58, 237, .04);
transition: border-color .2s, background .2s, transform .2s;
min-height: 60px;
position: relative;
}
.p8-droptarget.p8-drop-over {
border-style: solid;
border-color: var(--p8-brand, #7c3aed);
background: rgba(124, 58, 237, .10);
transform: scale(1.02);
}
.p8-theme-thermal .p8-droptarget.p8-drop-over { border-color: var(--th-mid); background: rgba(249,115,22,.10); }
.p8-theme-electric .p8-droptarget.p8-drop-over { border-color: var(--el-mid); background: rgba(6,182,212,.10); }
.p8-theme-spectrum .p8-droptarget.p8-drop-over { border-color: var(--sp-mid-v); background: rgba(168,85,247,.10); }
/* === Component palette (для circuit builder и т.д.) === */
.p8-palette {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 12px;
background: rgba(0,0,0,.04);
border-radius: 12px;
margin-bottom: 14px;
}
.p8-palette-item {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--p8-brand-soft, #ede9fe);
border: 1.5px solid var(--p8-brand-l, #c4b5fd);
border-radius: 10px;
cursor: grab;
font-size: .86rem;
font-weight: 600;
color: var(--p8-brand-d, #5b21b6);
transition: transform .15s, box-shadow .15s, background .15s;
touch-action: none;
}
.p8-palette-item:hover {
transform: translateY(-2px);
box-shadow: 0 6px 14px rgba(0,0,0,.10);
background: #fff;
}
.p8-palette-item:active { cursor: grabbing; }
.p8-palette-item svg { width: 22px; height: 22px; }
/* === Scrubber (диапазонный ползунок с подписью) === */
.p8-scrubber {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: rgba(0,0,0,.03);
border-radius: 10px;
margin-bottom: 10px;
}
.p8-scrubber-label {
font-size: .82rem;
font-weight: 700;
color: var(--p8-text);
min-width: 80px;
}
.p8-scrubber input[type="range"] {
flex: 1;
accent-color: var(--p8-brand, #7c3aed);
height: 6px;
}
.p8-theme-thermal .p8-scrubber input[type="range"] { accent-color: var(--th-mid); }
.p8-theme-electric .p8-scrubber input[type="range"] { accent-color: var(--el-mid); }
.p8-theme-spectrum .p8-scrubber input[type="range"] { accent-color: var(--sp-mid-v); }
.p8-scrubber-value {
font-family: var(--p8-mono);
font-weight: 700;
font-size: .92rem;
color: var(--p8-ink);
min-width: 70px;
text-align: right;
}
.p8-scrubber-value .p8-unit {
margin-left: 3px;
}
/* === Readout табло (для live-данных симуляции) === */
.p8-readout {
display: inline-flex;
align-items: baseline;
gap: 4px;
padding: 6px 12px;
background: var(--p8-ink, #0f172a);
color: #f1f5f9;
border-radius: 8px;
font-family: var(--p8-mono);
font-weight: 700;
font-size: 1rem;
letter-spacing: -.01em;
box-shadow: inset 0 1px 0 rgba(255,255,255,.10);
}
.p8-readout-label {
font-size: .68rem;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: #94a3b8;
margin-right: 6px;
}
.p8-readout-value {
color: #fff;
font-size: 1.1em;
min-width: 1em;
display: inline-block;
transition: color .2s;
}
.p8-readout-value.is-changing {
color: #fde047;
}
.p8-readout-unit {
color: #94a3b8;
font-weight: 600;
font-size: .82em;
margin-left: 3px;
}
/* === Tooltip (всплывающие подсказки на drag-объектах) === */
.p8-tooltip {
position: absolute;
pointer-events: none;
padding: 6px 10px;
background: rgba(15, 23, 42, .94);
color: #fff;
font-size: .76rem;
font-weight: 600;
border-radius: 8px;
z-index: 100;
white-space: nowrap;
box-shadow: 0 6px 14px rgba(0,0,0,.20);
opacity: 0;
transform: translateY(4px);
transition: opacity .15s, transform .15s;
}
.p8-tooltip.is-visible {
opacity: 1;
transform: translateY(0);
}
.p8-tooltip::after {
content: '';
position: absolute;
bottom: -4px; left: 50%;
transform: translateX(-50%) rotate(45deg);
width: 8px; height: 8px;
background: inherit;
}
/* === Badge (тематический значок IV-N, статус) === */
.p8-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 99px;
font-size: .68rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: .08em;
font-family: var(--p8-display);
}
.p8-badge.p8-badge-thermal { background: var(--th-soft, #fff7ed); color: var(--th-mid, #f97316); }
.p8-badge.p8-badge-electric { background: var(--el-soft, #ecfeff); color: var(--el-mid, #06b6d4); }
.p8-badge.p8-badge-spectrum { background: var(--sp-soft, #fdf2f8); color: var(--sp-mid-v, #a855f7); }
/* === Sandbox canvas (для drag-сцен) === */
.p8-sandbox {
position: relative;
width: 100%;
background:
linear-gradient(135deg, #fafafa 0%, #f3f4f6 100%);
border: 1.5px solid #e5e7eb;
border-radius: 12px;
overflow: hidden;
cursor: default;
}
html.dark .p8-sandbox {
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
border-color: #334155;
}
.p8-sandbox canvas,
.p8-sandbox svg {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.p8-sandbox-toolbar {
position: absolute;
top: 10px; left: 10px;
display: flex;
gap: 6px;
z-index: 5;
}
.p8-sandbox-btn {
padding: 5px 10px;
background: rgba(255,255,255,.92);
border: 1px solid #e5e7eb;
border-radius: 7px;
font-size: .76rem;
font-weight: 600;
cursor: pointer;
font-family: var(--p8-body);
transition: background .15s;
}
.p8-sandbox-btn:hover { background: #fff; }
html.dark .p8-sandbox-btn { background: rgba(15,23,42,.92); border-color: #334155; color: #f1f5f9; }
html.dark .p8-sandbox-btn:hover { background: #1e293b; }
/* === Snap-to-grid индикатор === */
.p8-snap-line {
position: absolute;
pointer-events: none;
background: var(--p8-brand, #7c3aed);
opacity: .35;
z-index: 4;
transition: opacity .12s;
}
.p8-snap-line.horizontal { left: 0; right: 0; height: 1px; }
.p8-snap-line.vertical { top: 0; bottom: 0; width: 1px; }
/* === Thermometer (вертикальный термометр для thermal sandbox) === */
.p8-thermometer {
display: inline-flex;
flex-direction: column;
align-items: center;
position: relative;
}
.p8-thermometer-bulb {
width: 28px; height: 28px;
border-radius: 50%;
background: linear-gradient(135deg, #ef4444, #dc2626);
border: 2px solid #991b1b;
box-shadow: 0 0 12px rgba(239,68,68,.45);
}
.p8-thermometer-tube {
width: 10px;
height: 90px;
margin-bottom: -2px;
background: linear-gradient(to top, #ef4444 var(--p8-temp, 50%), #f3f4f6 var(--p8-temp, 50%));
border: 2px solid #475569;
border-bottom: none;
border-radius: 5px 5px 0 0;
}
.p8-thermometer-scale {
position: absolute;
left: 100%; top: 10px;
margin-left: 4px;
font-family: var(--p8-mono);
font-size: .68rem;
color: var(--p8-muted);
white-space: nowrap;
}
/* === Compass needle (для magnetic compass) === */
.p8-compass {
width: 120px; height: 120px;
position: relative;
border-radius: 50%;
background: radial-gradient(circle, #fff 0%, #f1f5f9 100%);
border: 3px solid #475569;
box-shadow: inset 0 2px 4px rgba(0,0,0,.10), 0 4px 10px rgba(0,0,0,.10);
}
.p8-compass::before,
.p8-compass::after {
content: ''; position: absolute;
left: 50%; top: 50%;
background: #475569;
transform-origin: center;
}
.p8-compass::before { /* N marker */
content: 'N';
top: 6px; left: 50%;
transform: translateX(-50%);
background: transparent;
font-family: var(--p8-display);
font-weight: 900;
font-size: .82rem;
color: #dc2626;
}
.p8-compass-needle {
position: absolute;
left: 50%; top: 50%;
width: 6px;
height: 84px;
transform: translate(-50%, -50%) rotate(var(--p8-needle-angle, 0deg));
transform-origin: center;
transition: transform .35s cubic-bezier(.34, 1.56, .64, 1);
z-index: 2;
pointer-events: none;
}
.p8-compass-needle::before,
.p8-compass-needle::after {
content: ''; position: absolute;
left: 0; width: 100%;
height: 50%;
}
.p8-compass-needle::before {
top: 0;
background: linear-gradient(to bottom, #dc2626, #b91c1c);
clip-path: polygon(50% 0, 100% 100%, 0 100%);
}
.p8-compass-needle::after {
bottom: 0;
background: linear-gradient(to top, #475569, #1e293b);
clip-path: polygon(50% 100%, 100% 0, 0 0);
}
/* === Particle Glow (для частиц газа / искр / лучей) === */
.p8-glow {
filter: drop-shadow(0 0 6px currentColor);
}
/* === Animation utility classes === */
.p8-anim-jitter { animation: p8-jitter .35s ease infinite alternate; }
@keyframes p8-jitter {
0% { transform: translate(0, 0); }
100% { transform: translate(1px, -1px); }
}
.p8-anim-shimmer {
background-image: linear-gradient(105deg, transparent 40%, rgba(255,255,255,.4) 50%, transparent 60%);
background-size: 250% 100%;
animation: p8-shimmer 1.4s linear infinite;
}
@keyframes p8-shimmer {
0% { background-position: 250% 0; }
100% { background-position: -150% 0; }
}
/* === Reduced motion === */
@media (prefers-reduced-motion: reduce) {
.p8-draggable, .p8-droptarget, .p8-tooltip,
.p8-compass-needle, .p8-readout-value,
.p8-anim-jitter, .p8-anim-shimmer {
transition: none !important;
animation: none !important;
}
}
+167
View File
@@ -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
};
})();
+363
View File
@@ -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
};
})();
+429
View File
@@ -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
};
})();
+6 -1
View File
@@ -8,6 +8,8 @@
<title>Физика 8 · Глава 1 · «Тепловые явления»</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<link rel="stylesheet" href="/css/phys8-interactives.css">
<link rel="stylesheet" href="/css/phys8-design-system.css">
<link rel="stylesheet" href="/css/phys-textbook-widgets.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
@@ -16,6 +18,9 @@
<script src="/js/xp.js" defer></script>
<script src="/js/g3d.js" defer></script>
<script src="/js/phys.js" defer></script>
<script src="/js/phys8-helpers.js" defer></script>
<script src="/js/phys8-drag.js" defer></script>
<script src="/js/phys8-anim.js" defer></script>
<script src="/js/optics.js" defer></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@600;700;800;900&family=Unbounded:wght@700;800;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
<style>
@@ -163,7 +168,7 @@ a{color:inherit;text-decoration:none}
.sec{transition:opacity .25s}
</style>
</head>
<body>
<body class="p8-theme-thermal">
<header class="hdr">
<div class="hdr-row">
+6 -1
View File
@@ -459,6 +459,8 @@ doctype html>
<title>Физика 8 · Глава 2 · «Электромагнитные явления»</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<link rel="stylesheet" href="/css/phys8-interactives.css">
<link rel="stylesheet" href="/css/phys8-design-system.css">
<link rel="stylesheet" href="/css/phys-textbook-widgets.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
@@ -467,6 +469,9 @@ doctype html>
<script src="/js/xp.js" defer></script>
<script src="/js/g3d.js" defer></script>
<script src="/js/phys.js" defer></script>
<script src="/js/phys8-helpers.js" defer></script>
<script src="/js/phys8-drag.js" defer></script>
<script src="/js/phys8-anim.js" defer></script>
<script src="/js/optics.js" defer></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@600;700;800;900&family=Unbounded:wght@700;800;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
<style>
@@ -623,7 +628,7 @@ a{color:inherit;text-decoration:none}
.sec{transition:opacity .25s}
</style>
</head>
<body>
<body class="p8-theme-electric">
<header class="hdr">
<div class="hdr-row">
+6 -1
View File
@@ -90,6 +90,8 @@ doctype html>
<title>Физика 8 · Глава 3 · «Световые явления»</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<link rel="stylesheet" href="/css/phys8-interactives.css">
<link rel="stylesheet" href="/css/phys8-design-system.css">
<link rel="stylesheet" href="/css/phys-textbook-widgets.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
@@ -98,6 +100,9 @@ doctype html>
<script src="/js/xp.js" defer></script>
<script src="/js/g3d.js" defer></script>
<script src="/js/phys.js" defer></script>
<script src="/js/phys8-helpers.js" defer></script>
<script src="/js/phys8-drag.js" defer></script>
<script src="/js/phys8-anim.js" defer></script>
<script src="/js/optics.js" defer></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@600;700;800;900&family=Unbounded:wght@700;800;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
<style>
@@ -243,7 +248,7 @@ a{color:inherit;text-decoration:none}
.sec{transition:opacity .25s}
</style>
</head>
<body>
<body class="p8-theme-spectrum">
<header class="hdr">
<div class="hdr-row">
+5
View File
@@ -9,11 +9,16 @@
<title>Физика 8 класс — учебник</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800;900&family=Inter:wght@400;500;600;700&family=Unbounded:wght@400;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<link rel="stylesheet" href="/css/phys8-interactives.css">
<link rel="stylesheet" href="/css/phys8-design-system.css">
<link rel="stylesheet" href="/css/phys-textbook-widgets.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
<script src="/js/api.js" defer></script>
<script src="/js/xp.js" defer></script>
<script src="/js/phys8-helpers.js" defer></script>
<script src="/js/phys8-drag.js" defer></script>
<script src="/js/phys8-anim.js" defer></script>
<style>
:root{
--bg:#faf5ff; --card:#fff;
+5
View File
@@ -8,6 +8,8 @@
<title>Физика 8 · Лабораторный практикум</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<link rel="stylesheet" href="/css/phys8-interactives.css">
<link rel="stylesheet" href="/css/phys8-design-system.css">
<link rel="stylesheet" href="/css/phys-textbook-widgets.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
@@ -16,6 +18,9 @@
<script src="/js/xp.js" defer></script>
<script src="/js/g3d.js" defer></script>
<script src="/js/phys.js" defer></script>
<script src="/js/phys8-helpers.js" defer></script>
<script src="/js/phys8-drag.js" defer></script>
<script src="/js/phys8-anim.js" defer></script>
<script src="/js/optics.js" defer></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Manrope:wght@600;700;800;900&family=Unbounded:wght@700;800;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
<style>