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