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)
430 lines
17 KiB
JavaScript
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
|
|
};
|
|
})();
|