From 145f86882d2a692e9049b0c7fd16f8a83303c326 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sun, 12 Jul 2026 22:19:10 -0500 Subject: refactor(gallery): extract meters R43, R44, R53-R56 into GW builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heading-bug servo chase and the chart recorder's day clock are widget-owned animations, so they move into their builders with the reduced-motion gates intact (reduced motion paints the recorder's full day once). This completes the Meters section — all 28 extracted. --- docs/prototypes/widgets.js | 361 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) (limited to 'docs/prototypes/widgets.js') diff --git a/docs/prototypes/widgets.js b/docs/prototypes/widgets.js index 49991bf..89425f1 100644 --- a/docs/prototypes/widgets.js +++ b/docs/prototypes/widgets.js @@ -2443,6 +2443,367 @@ GW.roundCrt = function (host, opts = {}) { return { el: s, tick }; }; +/* R43 attitude indicator — sky/ground roll+shift behind a fixed miniature aircraft; 2D drag */ +GW.attitudeIndicator = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 130, 130); + const cx = 65, cy = 65; + const clipId = uid('aiClip'); + gradDef('aiSky', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#54677d'], ['1', '#3a4c60']]); + gradDef('aiGnd', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#5c4630'], ['1', '#3c2e1e']]); + const defs = svgEl(s, 'defs', {}); + const clip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(clip, 'circle', { cx, cy, r: 48 }); + svgEl(s, 'circle', { cx, cy, r: 56, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 }); + const g = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + const ball = svgEl(g, 'g', {}); + svgEl(ball, 'rect', { x: cx - 90, y: cy - 180, width: 180, height: 180, fill: 'url(#aiSky)' }); + svgEl(ball, 'rect', { x: cx - 90, y: cy, width: 180, height: 180, fill: 'url(#aiGnd)' }); + svgEl(ball, 'line', { x1: cx - 90, y1: cy, x2: cx + 90, y2: cy, stroke: '#f3e7c5', 'stroke-width': 1.4 }); + for (const p of [-20, -10, 10, 20]) { + const y = cy - p * 1.6, w = p % 20 === 0 ? 16 : 10; + svgEl(ball, 'line', { x1: cx - w, y1: y, x2: cx + w, y2: y, stroke: '#f3e7c5', 'stroke-width': .8, opacity: .85 }); + svgEl(ball, 'text', { + x: cx + w + 3, y: y + 2, 'font-size': 4.6, 'font-family': 'var(--mono)', + fill: '#f3e7c5', opacity: .85 + }).textContent = String(Math.abs(p)); + } + /* fixed bank scale + pointer + miniature aircraft */ + for (const b of [-60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60]) { + const [x1, y1] = polar(cx, cy, 47, b), [x2, y2] = polar(cx, cy, b % 30 === 0 ? 42 : 44, b); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': b === 0 ? 1.6 : .9, opacity: .9 }); + } + svgEl(s, 'polygon', { points: `${cx - 3.5},${cy - 38} ${cx + 3.5},${cy - 38} ${cx},${cy - 44}`, fill: 'var(--gold-hi)' }); + svgEl(s, 'path', { + d: `M ${cx - 22} ${cy} L ${cx - 7} ${cy} L ${cx - 4} ${cy + 4} M ${cx + 22} ${cy} L ${cx + 7} ${cy} L ${cx + 4} ${cy + 4}`, + fill: 'none', stroke: 'var(--gold-hi)', 'stroke-width': 2.4, 'stroke-linecap': 'round' + }); + svgEl(s, 'circle', { cx, cy, r: 2, fill: 'var(--gold-hi)' }); + let bank = 0, pitch = 0; + function set(b, p) { + bank = Math.max(-60, Math.min(60, b)); pitch = Math.max(-20, Math.min(20, p)); + ball.setAttribute('transform', `rotate(${(-bank).toFixed(1)},${cx},${cy}) translate(0,${(pitch * 1.6).toFixed(1)})`); + onChange({ bank, pitch }, 'BANK ' + (bank < 0 ? 'L' : bank > 0 ? 'R' : '') + Math.abs(Math.round(bank)) + + ' · PITCH ' + (pitch >= 0 ? '+' : '-') + String(Math.abs(Math.round(pitch))).padStart(2, '0')); + } + s.style.touchAction = 'none'; s.style.cursor = 'move'; + s.addEventListener('pointerdown', e => { + s.setPointerCapture(e.pointerId); + const x0 = e.clientX, y0 = e.clientY, b0 = bank, p0 = pitch; + const mv = ev => set(b0 + (ev.clientX - x0) * 0.4, p0 + (ev.clientY - y0) * 0.25); + const up = () => { s.removeEventListener('pointermove', mv); s.removeEventListener('pointerup', up); s.removeEventListener('pointercancel', up); }; + s.addEventListener('pointermove', mv); s.addEventListener('pointerup', up); s.addEventListener('pointercancel', up); e.preventDefault(); + }); + set(opts.bank ?? 0, opts.pitch ?? 0); + return { el: s, get: () => ({ bank, pitch }), set }; +}; + +/* R44 heading bug + servo needle — drag parks the command; the needle chases + with honest servo lag (widget-owned animation, reduced-motion gated) */ +GW.headingBug = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 130, 130); + const cx = 65, cy = 65; + svgEl(s, 'circle', { cx, cy, r: 56, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 50, fill: '#1a1714', stroke: '#2c2820', 'stroke-width': 1 }); + for (let d = 0; d < 360; d += 10) { + const maj = d % 30 === 0; + const [x1, y1] = polar(cx, cy, 48, d), [x2, y2] = polar(cx, cy, maj ? 42 : 45, d); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': maj ? 1 : .5, opacity: maj ? .9 : .5 }); + } + for (let d = 0; d < 360; d += 30) { + const lbl = d === 0 ? 'N' : d === 90 ? 'E' : d === 180 ? 'S' : d === 270 ? 'W' : String(d / 10); + const [x, y] = polar(cx, cy, 35, d); + svgEl(s, 'text', { + x, y: y + 2.6, 'text-anchor': 'middle', 'font-size': d % 90 === 0 ? 8 : 6.4, 'font-weight': 700, + 'font-family': 'var(--mono)', fill: d % 90 === 0 ? 'var(--cream)' : 'var(--steel)' + }).textContent = lbl; + } + svgEl(s, 'line', { x1: cx, y1: cy - 56, x2: cx, y2: cy - 48, stroke: 'var(--fail)', 'stroke-width': 2 }); + const bug = svgEl(s, 'path', { + d: `M ${cx - 6} ${cy - 49} L ${cx - 6} ${cy - 44} L ${cx - 2.5} ${cy - 44} L ${cx} ${cy - 47} L ${cx + 2.5} ${cy - 44} L ${cx + 6} ${cy - 44} L ${cx + 6} ${cy - 49} Z`, + fill: 'var(--gold-hi)', stroke: '#7d5c16', 'stroke-width': .7 + }); + const needle = svgEl(s, 'g', {}); + svgEl(needle, 'polygon', { + points: `${cx - 2},${cy + 10} ${cx + 2},${cy + 10} ${cx + 1.2},${cy - 40} ${cx},${cy - 44} ${cx - 1.2},${cy - 40}`, + fill: '#bfc4d0', stroke: '#4a4e58', 'stroke-width': .6 + }); + svgEl(s, 'circle', { cx, cy, r: 4.5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 }); + let cmd = opts.value ?? 90, act = cmd; + const draw = () => { + bug.setAttribute('transform', `rotate(${cmd.toFixed(1)},${cx},${cy})`); + needle.setAttribute('transform', `rotate(${act.toFixed(1)},${cx},${cy})`); + onChange({ cmd, act }, 'CMD ' + String(Math.round((cmd % 360 + 360) % 360)).padStart(3, '0') + + ' · ACT ' + String(Math.round((act % 360 + 360) % 360)).padStart(3, '0')); + }; + function set(v) { cmd = (v % 360 + 360) % 360; draw(); } + dragDelta(s, () => cmd, set, { min: -100000, max: 100000, sens: 1.2 }); + draw(); + if (!matchMedia('(prefers-reduced-motion: reduce)').matches) + setInterval(() => { + const diff = ((cmd - act + 540) % 360) - 180; + if (Math.abs(diff) < 0.4) { if (act !== cmd) { act = cmd; draw(); } return; } + act = (act + diff * 0.07 + 360) % 360; draw(); + }, 80); + return { el: s, get: () => ({ cmd, act }), set }; +}; + +/* R53 circular chart recorder — a day per revolution; the pen draws the cycle + (widget-owned clock, reduced-motion paints the full day once); click for fresh paper */ +GW.chartRecorder = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg press', 130, 130); + const cx = 65, cy = 65; + svgEl(s, 'circle', { cx, cy, r: 60, fill: '#17140f', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 54, fill: '#efe9da', stroke: '#8f897b', 'stroke-width': .8 }); + for (const r of [20, 31, 42]) + svgEl(s, 'circle', { cx, cy, r, fill: 'none', stroke: '#b8ae98', 'stroke-width': .5 }); + for (let h = 0; h < 24; h++) { + const a = h * 15; + const [x1, y1] = polar(cx, cy, 54, a), [x2, y2] = polar(cx, cy, h % 6 === 0 ? 12 : 48, a); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#b8ae98', 'stroke-width': h % 6 === 0 ? .6 : .35, opacity: .8 }); + if (h % 6 === 0) { + const [tx, ty] = polar(cx, cy, 50, a + 5); + svgEl(s, 'text', { + x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)', + fill: '#6e6552' + }).textContent = String(h).padStart(2, '0'); + } + } + const trace = svgEl(s, 'path', { d: '', fill: 'none', stroke: '#8f2416', 'stroke-width': 1.1, 'stroke-linejoin': 'round' }); + const pen = svgEl(s, 'line', { x1: cx, y1: cy, x2: cx, y2: cy - 54, stroke: '#4a463e', 'stroke-width': 1 }); + const penDot = svgEl(s, 'circle', { cx, cy: cy - 30, r: 2, fill: '#8f2416' }); + svgEl(s, 'circle', { cx, cy, r: 4, fill: '#4a463e' }); + const val = h => 52 + 30 * Math.sin(h / 24 * 2 * Math.PI - 2.1) + 12 * Math.sin(h / 24 * 6 * Math.PI) + 4 * Math.sin(h * 2.7); + const rOf = v => 12 + Math.max(0, Math.min(100, v)) * 0.36; + let pts = [], hour = 0; + const draw = () => { + const a = hour * 15, r = rOf(val(hour)); + pts.push(polar(cx, cy, r, a)); + if (pts.length > 1) trace.setAttribute('d', 'M ' + pts.map(p => p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' L ')); + pen.setAttribute('transform', `rotate(${a},${cx},${cy})`); + const [px, py] = polar(cx, cy, r, a); + penDot.setAttribute('cx', px); penDot.setAttribute('cy', py); + onChange(hour, String(Math.floor(hour)).padStart(2, '0') + ':' + (hour % 1 >= 0.5 ? '30' : '00') + ' · ' + Math.round(val(hour)) + '%'); + }; + const reset = () => { pts = []; hour = 0; trace.setAttribute('d', ''); draw(); }; + s.style.cursor = 'pointer'; + s.addEventListener('click', reset); + if (matchMedia('(prefers-reduced-motion: reduce)').matches) { + for (hour = 0; hour < 24; hour += 0.25) draw(); + hour = 23.75; + } else { + reset(); + setInterval(() => { hour += 0.25; if (hour >= 24) { pts = []; hour = 0; } draw(); }, 375); + } + return { el: s, get: () => hour, reset }; +}; + +/* R54 vertical tape instrument — the scale scrolls behind a fixed index; drag to drive */ +GW.verticalTape = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 70, 130); + const CX = 38, CY = 65; + const MIN = 5, MAX = 35, PPU = 5.5; /* rpm x100, px per unit */ + const clipId = uid('vtClip'); + const defs = svgEl(s, 'defs', {}); + const clip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(clip, 'rect', { x: 14, y: 8, width: 48, height: 114, rx: 4 }); + svgEl(s, 'rect', { x: 2, y: 2, width: 66, height: 126, rx: 7, fill: '#1c1916', stroke: '#060505', 'stroke-width': 1.5 }); + svgEl(s, 'rect', { x: 14, y: 8, width: 48, height: 114, rx: 4, fill: '#0d0c0a', stroke: '#2c2820', 'stroke-width': 1 }); + const tapeG = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + const tape = svgEl(tapeG, 'g', {}); + for (let u = MIN; u <= MAX; u++) { + const y = -u * PPU; /* higher value further up the tape */ + const maj = u % 5 === 0; + svgEl(tape, 'line', { x1: maj ? 20 : 26, y1: y, x2: 34, y2: y, stroke: 'var(--cream)', 'stroke-width': maj ? 1.1 : .55, opacity: maj ? .95 : .6 }); + if (maj) svgEl(tape, 'text', { + x: 48, y: y + 3, 'text-anchor': 'middle', 'font-size': 9, 'font-weight': 700, + 'font-family': 'var(--mono)', fill: 'var(--cream)' + }).textContent = String(u); + } + /* fixed index: side pointer + line across the window at center height */ + svgEl(s, 'polygon', { points: `8,${CY} 15,${CY - 4} 15,${CY + 4}`, fill: 'var(--gold-hi)' }); + svgEl(s, 'line', { x1: 15, y1: CY, x2: 62, y2: CY, stroke: 'var(--gold-hi)', 'stroke-width': 1, opacity: .85 }); + svgEl(s, 'text', { + x: CX, y: 126, 'text-anchor': 'middle', 'font-size': 5, 'letter-spacing': '.1em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = 'RPM x100'; + let v = opts.value ?? 24; + function set(nv) { + v = Math.max(MIN, Math.min(MAX, nv)); + tape.setAttribute('transform', `translate(0,${(CY + v * PPU).toFixed(1)})`); + onChange(v, 'RPM ' + Math.round(v * 100)); + } + s.style.cursor = 'ns-resize'; + dragDelta(s, () => v, set, { min: MIN, max: MAX, sens: (MAX - MIN) / 140 }); + set(v); + return { el: s, get: () => v, set }; +}; + +/* R55 twin-needle gauge — mirrored half-scales, one hub, two independent needles */ +GW.twinNeedle = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 120, 110); + const cx = 60, cy = 62, R = 44; + svgEl(s, 'circle', { cx, cy, r: 52, fill: '#14110e', stroke: '#060505', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 46, fill: '#1a1714', stroke: '#2c2820', 'stroke-width': 1 }); + /* mirrored arcs: left = FUEL (0 at bottom-left up to 4 at top), right = OIL */ + const angL = v => -170 + v / 4 * 160; /* 0..4 -> -170..-10 (left side, cw from top) */ + const angR = v => 170 - v / 4 * 160; /* 0..4 -> 170..10 (right side) */ + for (let v = 0; v <= 4; v++) { + for (const ang of [angL(v), angR(v)]) { + const [x1, y1] = polar(cx, cy, R, ang), [x2, y2] = polar(cx, cy, R - 5, ang); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': 1, opacity: .9 }); + const [tx, ty] = polar(cx, cy, R - 11, ang); + svgEl(s, 'text', { + x: tx, y: ty + 2.4, 'text-anchor': 'middle', 'font-size': 6, 'font-family': 'var(--mono)', + fill: 'var(--cream)' + }).textContent = String(v); + } + } + for (let v = 0.5; v < 4; v += 0.5) { + if (v % 1 === 0) continue; + for (const ang of [angL(v), angR(v)]) { + const [x1, y1] = polar(cx, cy, R, ang), [x2, y2] = polar(cx, cy, R - 3, ang); + svgEl(s, 'line', { x1, y1, x2, y2, stroke: 'var(--cream)', 'stroke-width': .5, opacity: .55 }); + } + } + svgEl(s, 'text', { + x: cx - 20, y: cy + 34, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.08em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = 'FUEL'; + svgEl(s, 'text', { + x: cx + 20, y: cy + 34, 'text-anchor': 'middle', 'font-size': 5.4, 'letter-spacing': '.08em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = 'OIL'; + svgEl(s, 'text', { + x: cx, y: cy - 26, 'text-anchor': 'middle', 'font-size': 4.6, 'letter-spacing': '.06em', + 'font-family': 'var(--mono)', fill: 'var(--steel)' + }).textContent = 'kg/cm2'; + const needle = color => { + const g = svgEl(s, 'g', {}); + svgEl(g, 'polygon', { + points: `${cx - 1.6},${cy + 8} ${cx + 1.6},${cy + 8} ${cx + 1},${cy - R + 8} ${cx},${cy - R + 4} ${cx - 1},${cy - R + 8}`, + fill: color, stroke: 'rgba(0,0,0,.4)', 'stroke-width': .5 + }); return g; + }; + const nF = needle('#e0523a'), nO = needle('#bfc4d0'); + svgEl(s, 'circle', { cx, cy, r: 5, fill: '#3a3631', stroke: '#060505', 'stroke-width': 1 }); + let vF = opts.fuel ?? 2.4, vO = opts.oil ?? 3.1; + const draw = () => { + nF.setAttribute('transform', `rotate(${angL(vF).toFixed(1)},${cx},${cy})`); + nO.setAttribute('transform', `rotate(${angR(vO).toFixed(1)},${cx},${cy})`); + onChange([vF, vO], 'FUEL ' + vF.toFixed(1) + ' · OIL ' + vO.toFixed(1)); + }; + function set(f, o) { vF = Math.max(0, Math.min(4, f)); vO = Math.max(0, Math.min(4, o)); draw(); } + /* each half is its own drag surface */ + const half = (x, get, setV) => { + const hit = svgEl(s, 'rect', { x, y: 10, width: 60, height: 104, fill: 'transparent' }); + hit.style.cursor = 'ns-resize'; + dragDelta(hit, get, v => { setV(Math.max(0, Math.min(4, v))); draw(); }, { min: 0, max: 4, sens: 4 / 110 }); + }; + half(0, () => vF, v => vF = v); + half(60, () => vO, v => vO = v); + draw(); + return { el: s, get: () => [vF, vO], set }; +}; + +/* R56 comfort-zone crossed needles — temp and humidity cross over printed verdicts */ +GW.comfortMeter = function (host, opts = {}) { + const onChange = opts.onChange || noop; + const s = stageSvg(host, 'rsvg', 130, 122); + const cx = 65, cy = 60; + const clipId = uid('cmClip'); + gradDef('cmBrass', 'linearGradient', { x1: 0, y1: 0, x2: 0, y2: 1 }, [['0', '#d8b25c'], ['.5', '#a07c30'], ['1', '#6e5218']]); + const defs = svgEl(s, 'defs', {}); + svgEl(s, 'circle', { cx, cy, r: 56, fill: 'url(#cmBrass)', stroke: '#4a3610', 'stroke-width': 2 }); + svgEl(s, 'circle', { cx, cy, r: 53, fill: 'none', stroke: '#7a5c1e', 'stroke-width': 2.5, 'stroke-dasharray': '2 1.4' }); + svgEl(s, 'circle', { cx, cy, r: 49, fill: '#efe9da', stroke: '#8f897b', 'stroke-width': 1 }); + const PT = [38, 96], PH = [92, 96]; /* temp pivot (BL), humidity pivot (BR) */ + const angT = t => 105 - (t - 40) / 60 * 70; /* 40..100F -> bearing 105..35 from PT */ + const angH = h => -105 + h / 100 * 70; /* 0..100% -> bearing -105..-35 from PH */ + /* scale ticks along each sweep, near the rim */ + for (let t = 40; t <= 100; t += 10) { + const a = angT(t); + const [x1, y1] = polar(PT[0], PT[1], 86, a), [x2, y2] = polar(PT[0], PT[1], 82, a); + if (Math.hypot(x1 - cx, y1 - cy) < 48) { + svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#3c382f', 'stroke-width': t % 20 === 0 ? 1 : .5 }); + if (t % 20 === 0) { + const [tx, ty] = polar(PT[0], PT[1], 78, a); + svgEl(s, 'text', { + x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)', + fill: '#3c382f' + }).textContent = String(t); + } + } + } + for (let h = 0; h <= 100; h += 10) { + const a = angH(h); + const [x1, y1] = polar(PH[0], PH[1], 86, a), [x2, y2] = polar(PH[0], PH[1], 82, a); + if (Math.hypot(x1 - cx, y1 - cy) < 48) { + svgEl(s, 'line', { x1, y1, x2, y2, stroke: '#3c382f', 'stroke-width': h % 20 === 0 ? 1 : .5 }); + if (h % 20 === 0) { + const [tx, ty] = polar(PH[0], PH[1], 78, a); + svgEl(s, 'text', { + x: tx, y: ty + 2, 'text-anchor': 'middle', 'font-size': 4.6, 'font-family': 'var(--mono)', + fill: '#3c382f' + }).textContent = String(h); + } + } + } + /* printed zone verdicts; the active one goes red */ + const zones = {}; + const zone = (key, txt, x, y, rot, size) => { + zones[key] = svgEl(s, 'text', { + x, y, 'text-anchor': 'middle', + 'font-size': size || 5.2, 'font-weight': 700, 'letter-spacing': '.06em', 'font-family': 'var(--mono)', + fill: '#8f897b', transform: `rotate(${rot},${x},${y})` + }); zones[key].textContent = txt; + }; + zone('humid', 'TOO HUMID', 42, 34, -32); + zone('warm', 'TOO WARM', 89, 34, 32); + zone('right', 'JUST RIGHT', 65, 58, 0, 6.2); + zone('dry', 'TOO DRY', 38, 74, 28); + zone('cold', 'TOO COLD', 93, 74, -28); + svgEl(s, 'text', { + x: cx, y: 92, 'text-anchor': 'middle', 'font-size': 5.6, 'letter-spacing': '.18em', + 'font-family': 'var(--mono)', fill: '#3c382f' + }).textContent = 'WEATHER STATION'; + /* needles: temp from bottom-left, humidity from bottom-right — clipped to the face */ + const nclip = svgEl(defs, 'clipPath', { id: clipId }); + svgEl(nclip, 'circle', { cx, cy, r: 49 }); + const ng = svgEl(s, 'g', { 'clip-path': `url(#${clipId})` }); + const needle = () => { + const g = svgEl(ng, 'g', {}); + svgEl(g, 'line', { x1: 0, y1: 0, x2: 0, y2: -84, stroke: '#c23a28', 'stroke-width': 1.6, 'stroke-linecap': 'round' }); + svgEl(g, 'circle', { cx: 0, cy: 0, r: 2.6, fill: '#8f2416' }); return g; + }; + const nT = needle(), nH = needle(); + nT.setAttribute('transform', `translate(${PT[0]},${PT[1]})`); + nH.setAttribute('transform', `translate(${PH[0]},${PH[1]})`); + let vT = opts.temp ?? 72, vH = opts.humidity ?? 45; + const verdict = () => vT > 78 ? 'warm' : vT < 62 ? 'cold' : vH > 60 ? 'humid' : vH < 30 ? 'dry' : 'right'; + const draw = () => { + nT.setAttribute('transform', `translate(${PT[0]},${PT[1]}) rotate(${angT(vT).toFixed(1)})`); + nH.setAttribute('transform', `translate(${PH[0]},${PH[1]}) rotate(${angH(vH).toFixed(1)})`); + const v = verdict(); + for (const k of Object.keys(zones)) zones[k].setAttribute('fill', k === v ? '#c23a28' : '#8f897b'); + onChange([vT, vH], Math.round(vT) + 'F · ' + Math.round(vH) + '% RH · ' + zones[v].textContent); + }; + function set(t, h) { vT = Math.max(40, Math.min(100, t)); vH = Math.max(0, Math.min(100, h)); draw(); } + const half = (x, get, setV, min, max) => { + const hit = svgEl(s, 'rect', { x, y: 8, width: 65, height: 106, fill: 'transparent' }); + hit.style.cursor = 'ns-resize'; + dragDelta(hit, get, v => { setV(Math.max(min, Math.min(max, v))); draw(); }, { min, max, sens: (max - min) / 110 }); + }; + half(0, () => vT, v => vT = v, 40, 100); + half(65, () => vH, v => vH = v, 0, 100); + draw(); + return { el: s, get: () => [vT, vH], set }; +}; + /* ---- widget CSS: injected once, grows as builders move in ---- */ const GW_CSS = ``; function ensureCss() { -- cgit v1.2.3