Files
Learn_System/frontend/js/phys8-helpers.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

430 lines
17 KiB
JavaScript

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