diff --git a/.changeset/now-ring.md b/.changeset/now-ring.md new file mode 100644 index 0000000..e2a7ddb --- /dev/null +++ b/.changeset/now-ring.md @@ -0,0 +1,5 @@ +--- +"ftw-webapp": patch +--- + +Now draws your home as the phone app does: what the home uses sits in the middle of a ring, and the sun, the battery, the grid and the car sit in the corners as plain text. Each one lights the part of the ring that faces it, longer with more power, and a soft light runs along it toward the home or away from it. Only readings the box confirms live glow or move; old readings show as still grey arcs. Tap a corner or the middle to open it, as before. The first load is about 5 kB smaller. diff --git a/src/lib/state/flow.ts b/src/lib/state/flow.ts index b8891f1..1a6a8b3 100644 --- a/src/lib/state/flow.ts +++ b/src/lib/state/flow.ts @@ -1,17 +1,24 @@ -/* From a site snapshot to the energy-flow component's planets. +/* From a site snapshot to the planets of the box's energy-flow component. * * The box's own dashboard builds this list in web/app.js from /api/status. * Frozen fields on the 1 Hz stream are the fallback: five aggregates, no - * names, no kWh today. Same component, same corners and colours — "same - * views" only holds if the caller speaks to it the same way. + * names, no kWh today. Same corners, colours and words as the box — "same + * views" only holds if both build them the same way. Now folds them into + * the ring's nodes (ring.ts). * - * The component knows nothing about field ids or HTTP. Everything it is - * told is decided here, which is what makes this file worth testing. + * Nothing downstream knows about field ids or HTTP. Everything it is told + * is decided here, which is what makes this file worth testing. */ import { withControlMarks, type ControlMark } from '$vendor/ftw/control-feedback.js' import { FID } from '$lib/format/explanation' -import { FLOW_IDLE_W } from '$vendor/ftw/ftw-energy-flow.js' + +/** + * At or below this a flow is idle: no light, no arc. The box's component + * uses the same 42 W, and a test holds the two equal, so the first frame + * need not load that whole component for one number. + */ +export const FLOW_IDLE_W = 42 export interface FlowDailyPart { text: string @@ -43,7 +50,8 @@ export interface FlowPlanet { } export interface FlowReadings { - load: number + /** The home's use in kW, or null when the box sends no such reading. */ + load: number | null planets: FlowPlanet[] selfPoweredPctToday?: number | null } @@ -192,7 +200,8 @@ export function flowReadings(fields: ReadonlyMap): FlowReadings }) } - return { load: (fields.get(FID.LOAD_W) ?? 0) / 1000, planets } + const loadW = fields.get(FID.LOAD_W) + return { load: loadW === undefined ? null : loadW / 1000, planets } } /** @@ -428,13 +437,14 @@ export function flowReadingsFromStatus(status: SiteStatus, live = true): FlowRea } } + const loadW = num(status.load_w) let selfPoweredPctToday: number | null = null if (loadKwhTotal > 0.001) { selfPoweredPctToday = Math.max(0, Math.min(100, (1 - importKwh / loadKwhTotal) * 100)) } return { - load: (num(status.load_w) ?? 0) / 1000, + load: loadW === null ? null : loadW / 1000, planets: withControlMarks(planets, status.control_feedback, live), selfPoweredPctToday, } diff --git a/src/lib/state/ring-motion.test.ts b/src/lib/state/ring-motion.test.ts new file mode 100644 index 0000000..fe9c1f7 --- /dev/null +++ b/src/lib/state/ring-motion.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { ArcMotion, Follow, wave, waveSpeed, type ArcTarget } from './ring-motion' + +const target = (p: Partial = {}): ArcTarget => ({ + kw: 2, live: true, flowing: true, towardHome: true, fill: 0.5, ...p, +}) + +/** Steps an arc at 60 frames a second from `from` to `to` seconds. */ +function run(m: ArcMotion, t: ArcTarget, from: number, to: number, reduce = false) { + let f = m.step(t, from, reduce) + for (let s = from; s <= to; s += 1 / 60) f = m.step(t, s, reduce) + return f +} + +describe('the light on an arc', () => { + it('never glows on an arc that has never been live', () => { + const t = target({ live: false }) + const f = run(new ArcMotion(t, 0), t, 0, 3) + expect(f.light).toBe(0) + expect(f.phase).toBe(0) + }) + + it('opens live data at full light without a flash', () => { + const f = new ArcMotion(target(), 0).step(target(), 0, false) + expect(f.light).toBe(1) + }) + + it('cools to ink within a second and a half once the box stops confirming it', () => { + const m = new ArcMotion(target(), 0) + run(m, target(), 0, 1) + const quiet = target({ live: false }) + expect(run(m, quiet, 1, 1.2).light).toBeGreaterThan(0.4) + expect(run(m, quiet, 1.2, 2.6).light).toBeLessThan(0.05) + }) + + it('does not cool on screen after going quiet while nothing was drawn', () => { + const m = new ArcMotion(target(), 0) + m.step(target(), 0, false) + expect(m.step(target({ live: false }), 5, false).light).toBe(0) + }) + + it('runs the wave only while live, and not at all under reduced motion', () => { + const live = run(new ArcMotion(target(), 0), target(), 0, 1) + expect(live.phase).toBeGreaterThan(0.5) + const still = run(new ArcMotion(target(), 0), target(), 0, 1, true) + expect(still.phase).toBe(0) + }) + + it('grows the arc to a new reading without overshoot', () => { + const m = new ArcMotion(target({ fill: 0.2 }), 0) + let most = 0 + for (let s = 0; s < 3; s += 1 / 60) most = Math.max(most, m.step(target({ fill: 0.8 }), s, false).fill) + expect(most).toBeLessThanOrEqual(0.8 + 1e-9) + expect(most).toBeGreaterThan(0.79) + }) + + it('snaps to a new reading under reduced motion', () => { + const m = new ArcMotion(target({ fill: 0.2 }), 0) + expect(m.step(target({ fill: 0.8 }), 0.1, true).fill).toBe(0.8) + }) +}) + +describe('the wave', () => { + it('crests at 1 and dips to 0.45', () => { + expect(wave(0, 0)).toBeCloseTo(1) + expect(wave(15, 0)).toBeCloseTo(0.45) + }) + + it('runs faster with power, never racing', () => { + expect(waveSpeed(0.1)).toBeCloseTo(19.2, 0) + expect(waveSpeed(1)).toBeCloseTo(31.4, 0) + expect(waveSpeed(10)).toBeCloseTo(51.6, 0) + }) + + it('follows a step as one long frame lands where many short ones do', () => { + const a = new Follow(0) + const b = new Follow(0) + a.step(1, 1, 0.4) + for (let i = 0; i < 100; i++) b.step(1, 0.01, 0.4) + expect(a.value).toBeCloseTo(b.value, 6) + }) +}) diff --git a/src/lib/state/ring-motion.ts b/src/lib/state/ring-motion.ts new file mode 100644 index 0000000..4960bfa --- /dev/null +++ b/src/lib/state/ring-motion.ts @@ -0,0 +1,181 @@ +/* How the ring's light moves, frame to frame. + * + * Plain values stepped once per frame, so a test can run them without a + * screen. The constants are the iOS app's (Flow.swift): a change belongs in + * both, or the phone and the browser stop showing the same house. + * + * Light means live. An arc glows and its wave runs only while the box + * confirms the reading; when it stops, the glow cools like a CRT and the + * arc goes to ink. An arc that has never been live never glows, so a cache + * opens still. + */ + +/** Pixels from one crest of the light to the next. */ +export const WAVELENGTH = 30 + +/** How fast the light runs from source to sink, in px a second: 19 at + * 0.1 kW, 31 at 1 kW, 52 at 10 kW. Faster with power, never racing. */ +export function waveSpeed(kw: number): number { + return 16 + 22 * Math.log10(1 + 4 * Math.abs(kw)) +} + +/** Brightness along an arc, from 0.45 to 1: soft crests a wavelength apart. + * `s` is px from the source, `phase` is wavelengths the light has run. */ +export function wave(s: number, phase: number): number { + const c = (1 + Math.cos(2 * Math.PI * (s / WAVELENGTH - phase))) / 2 + return 0.45 + 0.55 * c * c * c +} + +/** A value that follows its target as a critically damped spring with time + * constant `tau`: it swells to a new value and never overshoots. Stepped + * exactly, so a long frame lands where many short ones would. */ +export class Follow { + velocity = 0 + constructor(public value: number) {} + + step(target: number, dt: number, tau: number): void { + if (dt <= 0) return + if (tau <= 0) return this.snap(target) + const w = 1 / tau + const e = this.value - target + const k = Math.exp(-w * dt) + const a = this.velocity + w * e + this.value = target + (e + a * dt) * k + this.velocity = (this.velocity - w * a * dt) * k + } + + snap(target: number): void { + this.value = target + this.velocity = 0 + } +} + +/** The phosphor, from 0 (ink, still) to 1 (live). It ignites on a spring + * (response 0.35 s, damping 0.85) and decays exponentially (τ 0.4 s) while + * its wave slows to rest in 0.35 s. Instant under reduced motion. */ +export class Light { + static readonly decay = 0.4 + static readonly rest = 0.35 + static readonly response = 0.35 + static readonly damping = 0.85 + + private since: number + private level0: number + private motion0: number + + constructor(public live: boolean, t: number) { + this.since = t + this.level0 = live ? 1 : 0 + this.motion0 = this.level0 + } + + set(live: boolean, t: number, instant = false): void { + if (live === this.live) return + const target = live ? 1 : 0 + this.level0 = instant ? target : this.level(t) + this.motion0 = instant ? target : this.motion(t) + this.since = t + this.live = live + } + + /** The glow, the wave's depth and the core's strength. */ + level(t: number): number { + const dt = Math.max(0, t - this.since) + return this.live ? 1 - (1 - this.level0) * Light.residual(dt) : this.level0 * Math.exp(-dt / Light.decay) + } + + /** How fast the wave runs, as a share of its speed. */ + motion(t: number): number { + const dt = Math.max(0, t - this.since) + if (this.live) return Math.min(1, 1 - (1 - this.motion0) * Light.residual(dt)) + const k = 1 - Math.min(1, dt / Light.rest) + return this.motion0 * k * k + } + + /** What is left of a step after `t` seconds on the spring. */ + static residual(t: number): number { + const w = (2 * Math.PI) / Light.response + const z = Light.damping + const wd = w * Math.sqrt(1 - z * z) + return Math.exp(-z * w * t) * (Math.cos(wd * t) + ((z * w) / wd) * Math.sin(wd * t)) + } +} + +/** What one arc shows for one node's reading. */ +export interface ArcTarget { + /** Magnitude, never signed. */ + kw: number + live: boolean + flowing: boolean + /** The light runs toward the home: clockwise. */ + towardHome: boolean + /** How much of its quarter the arc lights, 0 to 1. */ + fill: number +} + +/** One arc's look in one frame. */ +export interface ArcFrame { + /** Wavelengths the light has run, source to sink. */ + phase: number + /** The phosphor: the glow's strength and the wave's depth. */ + light: number + /** 0 for a flowing arc, 1 for a node at rest. */ + idle: number + /** The share of its quarter the arc lights. */ + fill: number +} + +export const alpha = (f: ArcFrame) => 0.35 + 0.65 * Math.min(1, f.light) +export const depth = (f: ArcFrame) => Math.min(1, f.light) + +/** One arc over time: its fill and speed follow the readings (τ 0.4 s), its + * light ignites and decays, its wave runs at the speed the power gives. */ +export class ArcMotion { + static readonly tau = 0.4 + /** Longer than this between two frames, nothing was drawn: the page was + * away or the loop rested. */ + static readonly gap = 0.5 + + private speed: Follow + private idle: Follow + private fill: Follow + private light: Light + private phase = 0 + private last: number + + /** The first frame: everything where the reading puts it, with no ignite, + * so a page that opens on live data does not flash. */ + constructor(target: ArcTarget, t: number) { + this.speed = new Follow(waveSpeed(target.kw)) + this.idle = new Follow(target.flowing ? 0 : 1) + this.fill = new Follow(target.fill) + this.light = new Light(target.live, t) + this.last = t + } + + step(target: ArcTarget, t: number, reduceMotion: boolean): ArcFrame { + const dt = Math.max(0, t - this.last) + this.last = Math.max(this.last, t) + // A reading that stopped being live while nothing was drawn went out at + // a moment nobody saw, so it never cools on screen afterwards. + this.light.set(target.live, t, reduceMotion || (!target.live && dt > ArcMotion.gap)) + const v = waveSpeed(target.kw) + const rest = target.flowing ? 0 : 1 + if (reduceMotion) { + this.speed.snap(v) + this.idle.snap(rest) + this.fill.snap(target.fill) + } else { + this.speed.step(v, dt, ArcMotion.tau) + this.idle.step(rest, dt, ArcMotion.tau / 2) + this.fill.step(target.fill, dt, ArcMotion.tau) + this.phase = ((this.phase + (this.speed.value * this.light.motion(t) * dt) / WAVELENGTH) % 1_000) + } + return { + phase: this.phase, + light: this.light.level(t), + idle: Math.min(1, Math.max(0, this.idle.value)), + fill: Math.min(1, Math.max(0, this.fill.value)), + } + } +} diff --git a/src/lib/state/ring.test.ts b/src/lib/state/ring.test.ts new file mode 100644 index 0000000..6805372 --- /dev/null +++ b/src/lib/state/ring.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest' +import { + doing, flowHeight, labelHeight, nodeWidth, quantised, quantisedHolding, ringArc, ringFill, ringGeometry, ringKey, ringReadings, + ringSpot, type RingReading, +} from './ring' +import { FLOW_IDLE_W, flowReadingsFromStatus, type FlowPlanet, type FlowReadings } from './flow' +import { FLOW_IDLE_W as BOX_IDLE_W } from '$vendor/ftw/ftw-energy-flow.js' + +const planet = (p: Partial & Pick): FlowPlanet => ({ + corner: 'top-left', title: '', kw: 0, toHub: true, color: '', sub: '', ...p, +}) + +const node = (rs: RingReading[], n: RingReading['node']) => rs.find((r) => r.node === n) + +describe('the ring’s nodes', () => { + it('calls a flow idle where the box does', () => { + expect(FLOW_IDLE_W).toBe(BOX_IDLE_W) + }) + + it('always draws the grid and the home, and other kinds only when present', () => { + const rs = ringReadings({ load: 0.5, planets: [planet({ id: 'grid', role: 'grid', kw: 1, toHub: true })] }, true) + expect(rs.map((r) => r.node)).toEqual(['home', 'grid']) + }) + + it('folds several batteries into one node by their signed sum', () => { + // Two packs discharging 1 kW each and a third charging 0.5 kW: the home + // gets 1.5 kW. Magnitudes alone would add to 2.5 kW of charge. + const r: FlowReadings = { + load: 2, + planets: [ + planet({ id: 'grid', role: 'grid', kw: 0, toHub: true }), + planet({ id: 'bat-a', role: 'battery', name: 'a', kw: 1, toHub: true, soc: 40 }), + planet({ id: 'bat-b', role: 'battery', name: 'b', kw: 1, toHub: true, soc: 60 }), + planet({ id: 'bat-c', role: 'battery', name: 'c', kw: 0.5, toHub: false, soc: null }), + ], + } + const bat = node(ringReadings(r, true), 'battery')! + expect(bat.watts).toBeCloseTo(1500) + expect(bat.word).toBe('giving power') + expect(bat.towardHome).toBe(true) + expect(bat.color).toBe('var(--energy-gives)') + expect(bat.levelPct).toBe(50) + expect(bat.scope).toEqual({ role: 'battery', name: null, id: 'agg-battery' }) + }) + + it('folds batteries the same way from the box’s snapshot, where watts are signed', () => { + const rs = ringReadings(flowReadingsFromStatus({ + grid_w: 0, load_w: 2_000, + drivers: { a: { status: 'ok', bat_w: -1_000, bat_soc: 0.4 }, b: { status: 'ok', bat_w: 500, bat_soc: 0.6 } }, + }), true) + const bat = node(rs, 'battery')! + expect([bat.watts, bat.word, bat.towardHome, bat.levelPct]).toEqual([500, 'giving power', true, 50]) + }) + + it('does not call a sum live when one device of the kind has no reading', () => { + const rs = ringReadings(flowReadingsFromStatus({ + grid_w: 0, load_w: 500, + drivers: { a: { status: 'ok', bat_w: -1_000 }, b: { status: 'ok', bat_w: 0 } }, + control_feedback: [{ driver: 'b', kind: 'battery', status: 'no_contact', reason: 'readings_lost', severity: 'info', evidence: 'accepted', readings_fresh: false }], + }), true) + const bat = node(rs, 'battery')! + expect(bat.watts).toBe(1_000) + expect(bat.live).toBe(false) + }) + + it('opens nothing from a placeholder the box would not open', () => { + const rs = ringReadings({ + load: 1, + planets: [ + planet({ id: 'grid', role: 'grid', kw: 1 }), + planet({ id: 'control-battery-x', role: 'battery', name: 'x', placeholder: true, sub: 'no data' }), + ], + }, true) + expect(node(rs, 'battery')!.scope).toBeNull() + const marked = ringReadings({ + load: 1, + planets: [planet({ id: 'control-battery-x', role: 'battery', name: 'x', placeholder: true, controlMark: { tone: 'alarm', label: 'Lost' } })], + }, true) + expect(node(marked, 'battery')!.scope).toEqual({ role: 'battery', name: 'x', id: 'control-battery-x' }) + }) + + it('shows the home as unknown when the box sends no use, not as a live 0 W', () => { + const home = node(ringReadings(flowReadingsFromStatus({ grid_w: 100 }), true), 'home')! + expect(home.watts).toBeNull() + expect(home.live).toBe(false) + expect(home.word).toBe('no reading') + }) + + it('treats a reading that is not a number as none', () => { + const rs = ringReadings({ load: 1, planets: [planet({ id: 'grid', role: 'grid' }), planet({ id: 'pv', role: 'pv', kw: Number.NaN })] }, true) + expect(node(rs, 'solar')!.watts).toBeNull() + }) + + it('names the grid buying or selling, one colour both ways, never a sign', () => { + const buy = node(ringReadings({ load: 1, planets: [planet({ id: 'grid', role: 'grid', kw: 2, toHub: true })] }, true), 'grid')! + const sell = node(ringReadings({ load: 1, planets: [planet({ id: 'grid', role: 'grid', kw: 2, toHub: false })] }, true), 'grid')! + expect([buy.word, buy.watts, buy.towardHome]).toEqual(['buying', 2000, true]) + expect([sell.word, sell.watts, sell.towardHome]).toEqual(['selling', 2000, false]) + expect(buy.color).toBe(sell.color) + }) + + it('says a meter it cannot read has no reading, and does not claim it live', () => { + const g = node(ringReadings({ load: 1, planets: [planet({ id: 'grid', role: 'grid', sub: 'no data', clickable: false })] }, true), 'grid')! + expect(g.watts).toBeNull() + expect(g.word).toBe('no reading') + expect(g.live).toBe(false) + expect(g.scope).toBeNull() + }) + + it('carries the cache as not live', () => { + const rs = ringReadings({ load: 1, planets: [planet({ id: 'grid', role: 'grid', kw: 1 })] }, false) + expect(rs.every((r) => !r.live)).toBe(true) + }) + + it('keeps one charger’s name, so a tap opens that charger', () => { + const rs = ringReadings(flowReadingsFromStatus({ grid_w: 0, load_w: 500, drivers: { easee: { status: 'ok', ev_w: 7200 } } }), true) + expect(node(rs, 'car')!.scope).toEqual({ role: 'ev', name: 'easee', id: 'ev-easee' }) + expect(node(rs, 'car')!.word).toBe('charging') + }) + + it('takes the strongest control mark of a kind', () => { + const rs = ringReadings({ + load: 1, + planets: [ + planet({ id: 'grid', role: 'grid' }), + planet({ id: 'bat-a', role: 'battery', controlMark: { tone: 'warning', label: 'a' } }), + planet({ id: 'bat-b', role: 'battery', controlMark: { tone: 'alarm', label: 'b' } }), + ], + }, true) + expect(node(rs, 'battery')!.mark).toEqual({ tone: 'alarm', label: '1 device needs attention' }) + }) + + it('keeps an observe-only battery from opening controls', () => { + const rs = ringReadings(flowReadingsFromStatus({ grid_w: 0, load_w: 500, drivers: { a: { status: 'ok', bat_w: 0, observe_only: true } } }), true) + expect(node(rs, 'battery')!.scope).toBeNull() + }) +}) + +describe('where the nodes sit', () => { + it('puts a node whose partner across the middle is missing on the axis', () => { + const all = ['solar', 'battery', 'home', 'grid', 'car'] as const + expect(all.map((n) => ringSpot(n, all))).toEqual(['topLeading', 'topTrailing', 'center', 'bottomLeading', 'bottomTrailing']) + expect(ringSpot('solar', ['solar', 'home', 'grid', 'car'])).toBe('top') + expect(ringSpot('grid', ['solar', 'battery', 'home', 'grid'])).toBe('bottom') + }) + + it('gives each node the quarter facing it, lit from the middle out', () => { + expect(ringArc('topLeading', 1)).toEqual({ track: [187, 263], lit: [187, 263] }) + expect(ringArc('bottomTrailing', 0.5).lit).toEqual([26, 64]) + expect(ringArc('top', 0).lit).toBeNull() + }) + + it('lights a fifth at 0.6 kW, half at 1.8 kW and nine tenths at 6 kW', () => { + expect(ringFill(0.04)).toBe(0) + expect(ringFill(0.05)).toBe(0.12) + expect(ringFill(0.6)).toBeCloseTo(0.21, 2) + expect(ringFill(1.8)).toBeCloseTo(0.51, 2) + expect(ringFill(6)).toBeCloseTo(0.91, 2) + expect(ringFill(-6)).toBe(ringFill(6)) + }) +}) + +describe('the room it takes', () => { + it('keeps every corner label clear of the ring at phone and desktop widths', () => { + for (const width of [320, 351, 390, 600]) { + const height = flowHeight(width, false) + const label = labelHeight() + const g = ringGeometry(width, height, label, nodeWidth(width), false) + expect(g.labelScale).toBe(1) + // A corner label's inner corner is at least 10 px outside the ring. + const dx = width / 2 - nodeWidth(width) + const dy = g.box / 2 - label + expect(Math.hypot(dx, dy)).toBeGreaterThanOrEqual(g.radius + g.thickness / 2 + 10 - 0.01) + expect(height).toBeGreaterThanOrEqual(232) + expect(height).toBeLessThanOrEqual(420) + } + }) + + it('keeps the whole ring and its glow inside the flow at any width', () => { + for (const width of [320, 560, 580, 600, 700, 900]) { + for (const axis of [false, true]) { + const height = flowHeight(width, axis) + const g = ringGeometry(width, height, labelHeight(), nodeWidth(width), axis) + expect(g.diameter + 3 * g.thickness, `${width} px, axis ${axis}`).toBeLessThanOrEqual(height) + } + } + }) + + it('shrinks the labels rather than let them touch the least ring', () => { + const g = ringGeometry(320, 240, labelHeight(), nodeWidth(320), true) + expect(g.diameter).toBe(120) + expect(g.labelScale).toBeLessThan(1) + expect(g.labelScale).toBeGreaterThanOrEqual(0.7) + }) +}) + +describe('the figures', () => { + it('rolls in steps of 10 W, 0.1 kW and 1 kW', () => { + expect(quantised(634)).toBe(630) + expect(quantised(7_240)).toBe(7_200) + expect(quantised(11_600)).toBe(12_000) + }) + + it('holds still on a rounding edge', () => { + let shown = quantisedHolding(1_450, null) + for (const w of [1_440, 1_460, 1_449]) shown = quantisedHolding(w, shown) + expect(shown).toBe(1_500) + expect(quantisedHolding(1_380, shown)).toBe(1_400) + }) + + it('keys a reading by what it draws, so a 1 W wobble is the same ring', () => { + const at = (w: number) => ringReadings({ load: 1, planets: [planet({ id: 'grid', role: 'grid', kw: w / 1000 })] }, true) + expect(ringKey(at(1_401))).toBe(ringKey(at(1_402))) + expect(ringKey(at(1_401))).not.toBe(ringKey(at(1_480))) + }) + + it('writes what a node does with its level', () => { + const r = { word: 'charging', levelPct: 68 } as RingReading + expect(doing(r)).toBe('Charging · 68%') + expect(doing({ ...r, levelPct: null })).toBe('Charging') + expect(doing({ ...r, word: '', levelPct: null })).toBe(' ') + }) +}) diff --git a/src/lib/state/ring.ts b/src/lib/state/ring.ts new file mode 100644 index 0000000..c620ff9 --- /dev/null +++ b/src/lib/state/ring.ts @@ -0,0 +1,375 @@ +/* The ring on Now, as the iOS app draws it (Ring / Quiet, chosen by Fredrik + * on 24 October 2026). + * + * The home's use sits in the middle of a ring. The sun is top left, the + * battery top right, the grid bottom left and the car bottom right, each as + * text on the ground. Each one owns the quarter of the ring that faces it, + * and its arc lights from that quarter's middle out, longer with more power. + * A node whose partner across the middle is missing moves onto the middle + * axis, so a home with no car is a Y. + * + * Everything here is a plain function of the readings and the room, so the + * drawing and its tests agree. The numbers are the phone's: the iOS app's + * NowFlow.swift and Flow.swift, and a change to one belongs in both. + */ + +import type { ControlMark, ControlScope } from '$vendor/ftw/control-feedback.js' +import { FLOW_IDLE_W, type FlowPlanet, type FlowReadings } from './flow' + +export type RingNode = 'solar' | 'battery' | 'home' | 'grid' | 'car' + +export type RingSpot = 'topLeading' | 'topTrailing' | 'bottomLeading' | 'bottomTrailing' | 'top' | 'bottom' | 'center' + +/** One node as Now draws it. */ +export interface RingReading { + node: RingNode + /** Watts as shown, never signed: the direction is in `word`. Null when + * the box sends no such reading. */ + watts: number | null + /** What it does, lower case: making, charging, buying, using… */ + word: string + /** Its energy's colour, for its name and arc while live. */ + color: string + /** Power flows toward the home. Decides which way the light runs. */ + towardHome: boolean + /** The battery's charge, or the car's level, in percent. */ + levelPct: number | null + /** The box confirms this reading live. Only then does anything glow or move. */ + live: boolean + /** Core's warning or alarm on this node, or null while all is well. */ + mark: ControlMark | null + /** What a tap opens, or null where there is nothing to open. */ + scope: ControlScope | null +} + +/** Above the idle line: worth a beam of light. */ +export function flowing(r: RingReading): boolean { + return (r.watts ?? 0) > FLOW_IDLE_W +} + +export const NODE_TITLE: Record = { + solar: 'Solar', + battery: 'Battery', + home: 'Usage', + grid: 'Grid', + car: 'Car', +} + +/** The order Now draws its nodes in, as the phone does. */ +const ORDER: RingNode[] = ['solar', 'battery', 'home', 'grid', 'car'] + +const ROLE: Record, FlowPlanet['role']> = { + solar: 'pv', + battery: 'battery', + grid: 'grid', + car: 'ev', +} + +const idle = (w: number) => Math.abs(w) <= FLOW_IDLE_W + +/** A planet that only holds a place: no reading behind it. */ +const empty = (p: FlowPlanet) => p.placeholder === true || p.sub === 'no data' || !Number.isFinite(p.kw) + +/** The box's hero's rule for a planet that opens something: a control mark + * always does, a placeholder never does otherwise. */ +const opens = (p: FlowPlanet) => p.clickable !== false && (!!p.controlMark || (!p.placeholder && !!p.role)) + +/** Several devices of one kind fold into one node, as on the phone. */ +function fold(node: Exclude, first: FlowPlanet, group: FlowPlanet[], live: boolean): RingReading { + const real = group.filter((p) => !empty(p)) + const scope: ControlScope | null = !group.some(opens) + ? null + : group.length === 1 + ? { role: first.role, name: first.name ?? null, id: first.id } + : { role: first.role, name: null, id: `agg-${first.role}` } + const mark = strongest(group) + // A sum with a device missing is not the whole reading, so it is not live. + const base = { node, mark, scope, live: live && real.length === group.length } + if (real.length === 0) { + return { ...base, watts: null, word: 'no reading', color: color(node, 0), towardHome: node !== 'car', levelPct: null } + } + // Signed into the home's side of each device: what a battery takes is + // positive, as is what the grid sells to the home. The planets carry a + // magnitude and a direction, so the sign is rebuilt from both. + const signed = real.reduce((sum, p) => { + const kw = Math.abs(p.kw) * 1000 + switch (node) { + case 'solar': return sum + p.kw * 1000 + case 'battery': return sum + (p.toHub ? -kw : kw) + case 'grid': return sum + (p.toHub ? kw : -kw) + case 'car': return sum + kw + } + }, 0) + const socs = real.map((p) => p.soc).filter((s): s is number => typeof s === 'number' && Number.isFinite(s)) + const level = node === 'battery' + ? (socs.length ? Math.round(socs.reduce((a, b) => a + b, 0) / socs.length) : null) + : node === 'car' && socs.length === 1 && socs[0] !== undefined ? Math.round(socs[0]) : null + return { + ...base, + watts: Math.abs(signed), + word: word(node, signed), + color: color(node, signed), + towardHome: node === 'battery' ? signed < 0 : node === 'grid' ? signed >= 0 : node === 'solar', + levelPct: level, + } +} + +function word(node: RingNode, w: number): string { + switch (node) { + case 'solar': return idle(w) ? 'resting' : 'making' + case 'battery': return idle(w) ? 'resting' : w > 0 ? 'charging' : 'giving power' + case 'grid': return idle(w) ? 'balanced' : w > 0 ? 'buying' : 'selling' + case 'car': return idle(w) ? 'resting' : 'charging' + case 'home': return 'using' + } +} + +/** The node's own colour: its kind, and for the battery which way it runs. */ +function color(node: RingNode, w: number): string { + switch (node) { + case 'solar': return 'var(--energy-generation)' + case 'battery': return !idle(w) && w < 0 ? 'var(--energy-gives)' : 'var(--energy-storage)' + case 'grid': return 'var(--energy-import)' + case 'car': return 'var(--energy-mobility)' + case 'home': return 'var(--fg)' + } +} + +function strongest(group: FlowPlanet[]): ControlMark | null { + const marks = group.map((p) => p.controlMark).filter((m): m is ControlMark => !!m) + if (!marks.length) return null + const tone = marks.some((m) => m.tone === 'alarm') ? 'alarm' : 'warning' + const n = marks.filter((m) => m.tone === tone).length + const [only] = marks + return { + tone, + label: only && marks.length === 1 ? only.label : `${n} ${n === 1 ? 'device needs' : 'devices need'} attention`, + } +} + +/** + * Now's nodes from the readings the hero used to take. + * + * The grid and the home are always drawn, so a missing meter reads as + * unknown. Any other kind appears only when the box has a device of it. + */ +export function ringReadings(r: FlowReadings, live: boolean): RingReading[] { + const out: RingReading[] = [] + for (const node of ORDER) { + if (node === 'home') { + const load = r.load + const known = load !== null && Number.isFinite(load) + out.push({ + node, watts: known ? Math.max(0, load * 1000) : null, word: known ? 'using' : 'no reading', + color: color('home', 0), towardHome: false, levelPct: null, live: live && known, mark: null, + scope: { role: 'load' }, + }) + continue + } + const group = r.planets.filter((p) => p.role === ROLE[node]) + const [first] = group + if (!first) continue + out.push(fold(node, first, group, live)) + } + return out +} + +// ---------- where each node sits ---------- + +export function ringSpot(node: RingNode, nodes: readonly RingNode[]): RingSpot { + const has = (n: RingNode) => nodes.includes(n) + switch (node) { + case 'home': return 'center' + case 'solar': return has('battery') ? 'topLeading' : 'top' + case 'battery': return has('solar') ? 'topTrailing' : 'top' + case 'grid': return has('car') ? 'bottomLeading' : 'bottom' + case 'car': return has('grid') ? 'bottomTrailing' : 'bottom' + } +} + +export const onAxis = (s: RingSpot) => s === 'top' || s === 'bottom' + +/** Degrees left open between two quarters of the ring. */ +export const RING_GAP = 14 + +/** + * The middle of a node's arc, in degrees clockwise from three o'clock with y + * down, as a canvas draws: the quarter facing its corner, or straight up or + * down for a node on the axis. + */ +export function ringAngle(spot: RingSpot): number { + switch (spot) { + case 'topLeading': return 225 + case 'topTrailing': return 315 + case 'bottomTrailing': return 45 + case 'bottomLeading': return 135 + case 'top': return 270 + case 'bottom': return 90 + case 'center': return 0 + } +} + +export interface RingArc { + /** The whole quarter the node may light. */ + track: [number, number] + /** The part it lights, centred in it, or null at rest. */ + lit: [number, number] | null +} + +/** A node's arc: a stronger flow grows both ways, so the quarter's middle + * always faces the node. */ +export function ringArc(spot: RingSpot, fill: number): RingArc { + const mid = ringAngle(spot) + const half = (90 - RING_GAP) / 2 + const f = Math.min(1, Math.max(0, fill)) + return { + track: [mid - half, mid + half], + lit: f > 0.001 ? [mid - half * f, mid + half * f] : null, + } +} + +/** + * How much of its quarter a node's arc lights: none at rest, a fifth at + * 0.6 kW, half at 1.8 kW, nine tenths at 6 kW. One curve for every node, so + * arcs compare across the ring, and it saturates, so an 11 kW charger never + * swallows the rest. + */ +export function ringFill(kw: number): number { + const a = Math.abs(kw) + if (!Number.isFinite(a) || a * 1000 <= FLOW_IDLE_W) return 0 + return Math.min(1, Math.max(0.12, 1 - Math.exp(-a / 2.5))) +} + +// ---------- the room it takes ---------- + +/** A corner label's type, in CSS pixels: name, figure, word. */ +export const LABEL_TYPE = { name: 15, figure: 30, unit: 15, word: 14 } as const + +/** A corner label's height for its three rows. */ +export function labelHeight(t: { name: number; figure: number; word: number } = LABEL_TYPE): number { + return Math.min(t.name, 18) * 1.3 + t.figure * 1.2 + t.word * 1.3 + 2 +} + +/** A corner label's width: 38 % of the flow, from 92 px to 140 px. */ +export function nodeWidth(width: number): number { + return Math.min(140, Math.max(92, width * 0.38)) +} + +export interface RingGeometry { + cx: number + cy: number + /** Through the middle of the arcs. */ + diameter: number + radius: number + thickness: number + /** The hub's circle inside it. */ + hub: number + /** The height of the box whose corners hold the labels. */ + box: number + /** What the labels are drawn at, of their size: 1 unless they would touch the least ring. */ + labelScale: number +} + +/** Below it the home's figure has no room. */ +export const LEAST_RING = 120 +const LEAST_SCALE = 0.7 + +const thick = (d: number) => Math.max(7, Math.round(d * 0.042)) + +/** The height the ring itself takes: its stroke and the glow around it, + * 1.4 strokes either side of the middle of the arc. */ +const ringRoom = (d: number) => d + 3 * thick(d) + +/** How far above the middle a corner label's inner edge clears the ring. */ +function clearance(d: number, dx: number): number { + const reach = d / 2 + thick(d) / 2 + 10 + return dx < reach ? Math.sqrt(reach * reach - dx * dx) : 0 +} + +/** + * The largest ring, up to 62 % of the width and 300 px, whose corner labels + * clear it by 10 px, with the labels in the corners of a box just tall + * enough for that. Where even the least ring leaves too little height the + * labels shrink rather than touch it. + */ +export function ringGeometry(width: number, height: number, label: number, labelWidth: number, axis: boolean): RingGeometry { + const dx = axis ? 0 : Math.max(0, width / 2 - labelWidth) + const box = (d: number) => 2 * (clearance(d, dx) + label) + let d = Math.min(width * 0.62, 300) + while (d > LEAST_RING && (box(d) > height || ringRoom(d) > height)) d = Math.max(LEAST_RING, d - 2) + const room = height / 2 - clearance(d, dx) + const labelScale = box(d) > height && label > 0 ? Math.min(1, Math.max(LEAST_SCALE, room / label)) : 1 + return { + cx: width / 2, + cy: height / 2, + diameter: d, + radius: d / 2, + thickness: thick(d), + hub: d * 0.62, + // Where the labels sit beside the ring, the box still spans it, so they + // keep to its corners rather than meet in the middle. + box: Math.min(height, Math.max(2 * (clearance(d, dx) + label * labelScale), ringRoom(d))), + labelScale, + } +} + +/** + * The flow's height for its width: what the largest ring and its labels + * need, or the ring alone where the labels sit beside it, from 232 px to + * 420 px. A function of the width and the home's shape + * alone, so nothing on the page moves when a reading arrives. + */ +export function flowHeight(width: number, axis: boolean, label = labelHeight()): number { + const dx = axis ? 0 : Math.max(0, width / 2 - nodeWidth(width)) + const d = Math.min(width * 0.62, 300) + return Math.min(420, Math.max(232, Math.ceil(Math.max(2 * (clearance(d, dx) + label), ringRoom(d))))) +} + +// ---------- the figures ---------- + +/** The figure's step at a reading: 10 W, 100 W or 1 kW. */ +function step(watts: number): number { + const a = Math.abs(watts) + return a < 995 ? 10 : a < 9_950 ? 100 : 1_000 +} + +/** + * A figure on Now: 10 W steps under 1 kW, 0.1 kW to 10 kW, whole kW past + * that. It changes when the digits on screen change, and not every second. + */ +export function quantised(watts: number): number { + if (!Number.isFinite(watts)) return 0 + return Math.round(watts / step(watts)) * step(watts) +} + +/** + * A figure that holds still on a rounding edge: the one shown stays until + * the reading moves more than 60 % of a step away from it, so 1.45 kW read + * as 1.5, 1.4 and 1.5 again shows 1.5 throughout. + */ +export function quantisedHolding(watts: number, shown: number | null | undefined): number { + const next = quantised(watts) + if (shown == null || !Number.isFinite(shown) || !Number.isFinite(watts)) return next + return Math.abs(watts - shown) > 0.6 * Math.max(step(shown), step(watts)) ? next : shown +} + +/** What a node does, under its figure: "Charging · 68%", or a space when + * there is nothing yet, so the row keeps its height. */ +export function doing(r: RingReading): string { + const w = r.word.charAt(0).toUpperCase() + r.word.slice(1) + if (r.levelPct == null) return w || ' ' + const pct = `${Math.round(r.levelPct)}%` + return w ? `${w} · ${pct}` : pct +} + +/** + * What a reading looks like on screen, as one string: the figures as they + * are drawn, not the watts behind them. Two ticks with the same key draw the + * same ring, so a 1 W wobble repaints nothing. + */ +export function ringKey(readings: readonly RingReading[]): string { + return JSON.stringify(readings.map((r) => [ + r.node, r.watts === null ? null : quantised(r.watts), r.word, r.color, r.towardHome, + r.levelPct === null ? null : Math.round(r.levelPct), r.live, r.mark, r.scope, + ])) +} diff --git a/src/views/EnergyRing.marks.svelte.test.ts b/src/views/EnergyRing.marks.svelte.test.ts new file mode 100644 index 0000000..1538105 --- /dev/null +++ b/src/views/EnergyRing.marks.svelte.test.ts @@ -0,0 +1,45 @@ +import {test,expect,afterEach} from 'vitest' +import {render} from '@testing-library/svelte' +import EnergyRing from './EnergyRing.svelte' +import {flowReadingsFromStatus, type SiteStatus} from '$lib/state/flow' +import {ringReadings} from '$lib/state/ring' + +HTMLCanvasElement.prototype.getContext = (() => null) as never + +const row=(extra:Record)=>({driver:'battery',kind:'battery',status:'following',reason:'power_observed',severity:'info',evidence:'measured',readings_fresh:true,...extra}) + +const battery=(status:SiteStatus,live=true)=>{ + document.body.replaceChildren() + render(EnergyRing,{props:{readings:ringReadings(flowReadingsFromStatus(status,live),live)}}) + return document.querySelector('[data-node="battery"]')! +} + +afterEach(()=>document.body.replaceChildren()) + +test('a folded node cannot hide an offline sibling alarm or claim its old watts',()=>{ + const node=battery({grid_w:1000,load_w:500,drivers:{a:{status:'ok',bat_w:1000},b:{status:'offline',bat_w:5000}},control_feedback:[ + row({driver:'a',evidence:'confirmed'}), + row({driver:'b',status:'no_contact',reason:'readings_lost',severity:'alarm',evidence:'accepted',readings_fresh:false,actual_w:null}), + ]}) + expect(node.querySelector('.mark')?.getAttribute('data-tone')).toBe('alarm') + expect(node.textContent).not.toContain('6.0 kW') + // One pack is missing, so the sum is not the whole reading. + expect(node.getAttribute('aria-label')).toMatch(/not live/) +}) + +test('the ring marks only what needs a look and stays quiet otherwise',()=>{ + const update=(extra:Record,live=true)=> + battery({grid_w:0,load_w:500,drivers:{battery:{status:'ok',bat_w:-500}},control_feedback:[row(extra)]},live) + let node=update({evidence:'confirmed'}) + expect(node.querySelector('.mark')).toBeNull() + node=update({status:'waiting',reason:'waiting_response',evidence:'accepted'}) + expect(node.querySelector('.mark')).toBeNull() + node=update({status:'not_following',reason:'setpoint_changed',severity:'warning'}) + expect(node.querySelector('.mark')?.getAttribute('data-tone')).toBe('warning') + expect(node.getAttribute('aria-label')).toContain('Not following') + node=update({status:'not_following',reason:'device_fault',severity:'alarm'}) + expect(node.querySelector('.mark')?.getAttribute('data-tone')).toBe('alarm') + // A cached snapshot raises no alarm it cannot stand behind. + node=update({status:'no_contact',reason:'readings_lost',severity:'alarm'},false) + expect(node.querySelector('.mark')).toBeNull() +}) diff --git a/src/views/EnergyRing.svelte b/src/views/EnergyRing.svelte new file mode 100644 index 0000000..f95ebf8 --- /dev/null +++ b/src/views/EnergyRing.svelte @@ -0,0 +1,685 @@ + + + +{#snippet symbol(r: RingReading)} + +{/snippet} + +{#snippet mark(r: RingReading)} + {#if r.mark} + + {/if} +{/snippet} + +{#snippet label(r: RingReading, spot: RingSpot)} + {@const f = figure(r)} + + {#if !trailing(spot)}{@render symbol(r)}{/if} + {NODE_TITLE[r.node]} + {#if trailing(spot)}{@render symbol(r)}{/if} + {@render mark(r)} + + + {#if f}{f.text}{' '}{f.unit}{:else}—{/if} + + {doing(r)} + +{/snippet} + +
+ + +
+ {#each corners as { r, spot } (r.node)} + {#if r.scope} + + {:else} +
+ {@render label(r, spot)} +
+ {/if} + {/each} +
+ + {#if home} + {@const f = figure(home)} + + {/if} +
+ + diff --git a/src/views/EnergyRing.svelte.test.ts b/src/views/EnergyRing.svelte.test.ts new file mode 100644 index 0000000..80e62af --- /dev/null +++ b/src/views/EnergyRing.svelte.test.ts @@ -0,0 +1,196 @@ +/* The ring on Now, on what a person can do with it and what it costs. + * + * jsdom has no canvas, so the drawing itself is checked in a browser; here + * the nodes, their doors, the live switch and the frame loop. */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { render } from '@testing-library/svelte' +import { tick } from 'svelte' +import EnergyRing from './EnergyRing.svelte' +import { ringReadings, type RingReading } from '$lib/state/ring' +import { flowReadingsFromStatus } from '$lib/state/flow' + +HTMLCanvasElement.prototype.getContext = (() => null) as never + +const house = (live = true): RingReading[] => + ringReadings( + flowReadingsFromStatus({ + grid_w: 2_000, + load_w: 3_700, + drivers: { sungrow: { status: 'ok', pv_w: -1_100, bat_w: 0, bat_soc: 0.62 }, easee: { status: 'ok', ev_w: 7_300 } }, + }), + live + ) + +describe('the ring', () => { + afterEach(() => { + document.body.replaceChildren() + vi.unstubAllGlobals() + vi.restoreAllMocks() + HTMLCanvasElement.prototype.getContext = (() => null) as never + Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }) + document.documentElement.removeAttribute('style') + }) + + it('draws each node as text on the ground with the home in the middle', () => { + render(EnergyRing, { props: { readings: house() } }) + const text = (n: string) => document.querySelector(`[data-node="${n}"]`)?.textContent?.replace(/\s+/g, ' ').trim() + expect(text('solar')).toBe('Solar 1.1 kW Making') + expect(text('battery')).toBe('Battery 0 W Resting · 62%') + expect(text('grid')).toBe('Grid 2.0 kW Buying') + expect(text('car')).toBe('Car 7.3 kW Charging') + expect(text('home')).toBe('3.7 kW Usage') + }) + + it('opens what a tap on a node names', () => { + const onopen = vi.fn() + render(EnergyRing, { props: { readings: house(), onopen } }) + ;(document.querySelector('[data-node="car"]') as HTMLElement).click() + ;(document.querySelector('[data-node="home"]') as HTMLElement).click() + expect(onopen.mock.calls).toEqual([[{ role: 'ev', name: 'easee', id: 'ev-easee' }], [{ role: 'load' }]]) + }) + + it('says live only for readings the box confirms', () => { + render(EnergyRing, { props: { readings: house(false) } }) + const ring = document.querySelector('.ring')! + expect(ring.getAttribute('data-live')).toBe('false') + expect(document.querySelector('[data-node="solar"]')!.getAttribute('aria-label')).toMatch(/not live$/) + }) + + it('asks for no frame when a tick changes nothing, and for one when it does', async () => { + // A cached house: nothing glows or runs, so the loop rests once a change + // has played. A live flowing arc keeps it running, as on the phone. + const frames = vi.fn((_: FrameRequestCallback) => 1) + vi.stubGlobal('requestAnimationFrame', frames) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + const view = render(EnergyRing, { props: { readings: house(false), active: true } }) + await tick() + expect(frames).toHaveBeenCalledTimes(1) + // Past the moment a change plays for, the frame asks for no other. + vi.spyOn(performance, 'now').mockReturnValue(performance.now() + 10_000) + frames.mock.calls[0]![0](0) + expect(frames).toHaveBeenCalledTimes(1) + frames.mockClear() + + await view.rerender({ readings: house(false), active: true }) + await tick() + expect(frames, 'an unchanged reading started the loop').not.toHaveBeenCalled() + + const next = house(false) + const car = next.findIndex((r) => r.node === 'car') + next[car] = { ...next[car]!, watts: 3_000 } + await view.rerender({ readings: next, active: true }) + await tick() + expect(frames).toHaveBeenCalledTimes(1) + }) + + it('draws nothing while hidden', async () => { + const frames = vi.fn(() => 1) + vi.stubGlobal('requestAnimationFrame', frames) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + const view = render(EnergyRing, { props: { readings: house(), active: false } }) + await tick() + await view.rerender({ readings: house(false), active: false }) + await tick() + expect(frames).not.toHaveBeenCalled() + }) + + it('keeps running while a live arc flows, and rests when the page or the view goes away', async () => { + const frames = stubFrames() + const later = performance.now() + 10_000 + const view = render(EnergyRing, { props: { readings: house(), active: true } }) + await tick() + vi.spyOn(performance, 'now').mockReturnValue(later) + frames.run() + expect(frames.asked(), 'a live flowing arc stopped its light').toBe(1) + + visibility('hidden') + frames.run() + expect(frames.asked(), 'the loop ran on a hidden page').toBe(0) + visibility('visible') + expect(frames.asked()).toBe(1) + + await view.rerender({ readings: house(), active: false }) + frames.run() + expect(frames.asked(), 'the loop ran behind another tab').toBe(0) + }) + + it('glows only while live, and draws a cache in ink', async () => { + // The page's ink and the sun's colour, as the theme would give them. + document.documentElement.style.setProperty('--fg-dim', '#9aa5b8') + document.documentElement.style.setProperty('--energy-generation', '#ffe14d') + const strokes = recordStrokes() + const frames = stubFrames() + const view = render(EnergyRing, { props: { readings: house(), active: true } }) + await tick() + frames.run() + expect(strokes.wide(), 'a live arc did not glow').toBeGreaterThan(0) + expect(strokes.colours.has('rgba(255, 225, 77)'), 'a live sun was not in its colour').toBe(true) + + strokes.clear() + await view.rerender({ readings: house(false), active: true }) + await tick() + // Well past the cooling, the light is out. + vi.spyOn(performance, 'now').mockReturnValue(performance.now() + 10_000) + frames.run() + expect(strokes.list.length, 'nothing was drawn').toBeGreaterThan(0) + expect(strokes.wide(), 'a cached arc glowed').toBe(0) + expect(Math.max(...strokes.stops), 'a cached arc kept its full strength').toBeLessThanOrEqual(0.35 + 1e-6) + expect([...strokes.colours], 'a cached arc kept its colour').toEqual(['rgba(154, 165, 184)']) + }) +}) + +/** A frame queue the test drives by hand. */ +function stubFrames() { + let queue: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => (queue.push(cb), queue.length)) + vi.stubGlobal('cancelAnimationFrame', () => { queue = [] }) + return { + asked: () => queue.length, + run() { + const now = queue + queue = [] + for (const cb of now) cb(0) + }, + } +} + +function visibility(state: DocumentVisibilityState) { + Object.defineProperty(document, 'visibilityState', { value: state, configurable: true }) + document.dispatchEvent(new Event('visibilitychange')) +} + +/** A 2D context that keeps each stroke's width and each colour stop's alpha. */ +function recordStrokes() { + const list: { width: number }[] = [] + const stops: number[] = [] + const colours = new Set() + const alphaOf = (c: string) => Number(/,\s*([\d.]+)\)$/.exec(c)?.[1] ?? 1) + const ctx = { + lineWidth: 1, strokeStyle: '' as unknown, fillStyle: '#000', lineCap: '', lineJoin: '', + setTransform() {}, clearRect() {}, beginPath() {}, arc() {}, moveTo() {}, lineTo() {}, save() {}, restore() {}, + stroke() { list.push({ width: this.lineWidth }) }, + createConicGradient() { + return { + addColorStop: (_: number, c: string) => { + stops.push(alphaOf(c)) + colours.add(c.replace(/,\s*[\d.]+\)$/, ')')) + }, + } + }, + } + HTMLCanvasElement.prototype.getContext = (() => ctx) as never + return { + list, + stops, + /** The colours of the stops, without their alpha. */ + colours, + /** Strokes wider than an arc: the glow. */ + wide: () => list.filter((s) => s.width > 20).length, + clear() { + list.length = 0 + stops.length = 0 + colours.clear() + }, + } +} diff --git a/src/views/EvPanel.svelte.test.ts b/src/views/EvPanel.svelte.test.ts index 21c4d3d..46c03f4 100644 --- a/src/views/EvPanel.svelte.test.ts +++ b/src/views/EvPanel.svelte.test.ts @@ -47,6 +47,13 @@ async function streaming(): Promise { return site } +/** A tap on a node of the ring, as a finger makes it. */ +function tap(node: string) { + const el = document.querySelector(`[data-node="${node}"]`) + expect(el, `no ${node} on the ring`).not.toBeNull() + el!.click() +} + describe('the charger behind its bubble', () => { afterEach(() => { document.body.replaceChildren() @@ -62,11 +69,7 @@ describe('the charger behind its bubble', () => { render(Now, { props: { site } }) await vi.advanceTimersByTimeAsync(50) - const hero = document.querySelector('ftw-energy-flow')! - expect(hero).not.toBeNull() - hero.dispatchEvent( - new CustomEvent('ftw-planet-click', { detail: { role: 'ev', id: 'ev' }, bubbles: true }) - ) + tap('car') await vi.advanceTimersByTimeAsync(500) const sheet = document.querySelector('[role="dialog"]') @@ -87,9 +90,7 @@ describe('the charger behind its bubble', () => { render(Now, { props: { site } }) await vi.advanceTimersByTimeAsync(50) - document - .querySelector('ftw-energy-flow')! - .dispatchEvent(new CustomEvent('ftw-planet-click', { detail: { role: 'battery' } })) + tap('battery') await vi.advanceTimersByTimeAsync(100) // The battery opens its live line, not the charger's controls. @@ -106,9 +107,7 @@ describe('the charger behind its bubble', () => { const api = vi.spyOn(site, 'api') render(Now, { props: { site } }) await vi.advanceTimersByTimeAsync(50) - document.querySelector('ftw-energy-flow')!.dispatchEvent( - new CustomEvent('ftw-planet-click', { detail: { role: 'ev' }, bubbles: true }) - ) + tap('car') await vi.advanceTimersByTimeAsync(50) const sheet = document.querySelector('[role="dialog"]')! expect(sheet.textContent).toContain('Connecting to your box.') @@ -129,9 +128,7 @@ describe('the charger behind its bubble', () => { const api = vi.spyOn(site, 'api') render(Now, { props: { site } }) await vi.advanceTimersByTimeAsync(50) - document.querySelector('ftw-energy-flow')!.dispatchEvent( - new CustomEvent('ftw-planet-click', { detail: { role: 'ev' }, bubbles: true }) - ) + tap('car') await vi.advanceTimersByTimeAsync(50) expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Charging controls are not available from this box yet.') expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Open the box’s own page') @@ -153,9 +150,7 @@ describe('the charger behind its bubble', () => { const command = vi.spyOn(site, 'command') render(Now, { props: { site } }) await vi.advanceTimersByTimeAsync(50) - document.querySelector('ftw-energy-flow')!.dispatchEvent( - new CustomEvent('ftw-planet-click', { detail: { role: 'ev' }, bubbles: true }) - ) + tap('car') await vi.advanceTimersByTimeAsync(500) const sheet = document.querySelector('[role="dialog"]')! expect(sheet.textContent).toContain('Your box is busy') @@ -174,11 +169,7 @@ describe('the charger behind its bubble', () => { render(Now, { props: { site } }) await vi.advanceTimersByTimeAsync(50) - const open = () => { - document - .querySelector('ftw-energy-flow')! - .dispatchEvent(new CustomEvent('ftw-planet-click', { detail: { role: 'ev' } })) - } + const open = () => tap('car') open() await vi.advanceTimersByTimeAsync(100) diff --git a/src/views/LivePanel.svelte.test.ts b/src/views/LivePanel.svelte.test.ts index 3d62bc0..e404069 100644 --- a/src/views/LivePanel.svelte.test.ts +++ b/src/views/LivePanel.svelte.test.ts @@ -37,10 +37,10 @@ async function streaming(): Promise { return site } +/** A tap on the ring's node for this role. */ function openBubble(role: string) { - document - .querySelector('ftw-energy-flow')! - .dispatchEvent(new CustomEvent('ftw-planet-click', { detail: { role }, bubbles: true })) + const node = ({ pv: 'solar', load: 'home' } as Record)[role] ?? role + document.querySelector(`[data-node="${node}"]`)!.click() } describe('a live line behind a bubble', () => { diff --git a/src/views/Now.control-proof.svelte.test.ts b/src/views/Now.control-proof.svelte.test.ts index d46e914..18b4765 100644 --- a/src/views/Now.control-proof.svelte.test.ts +++ b/src/views/Now.control-proof.svelte.test.ts @@ -27,9 +27,10 @@ test('opens the device panel with the answer first and keeps an open receipt thr const view=render(Now,{props:{site}}) for(let i=0;i<200;i++){box.tick(20);await vi.advanceTimersByTimeAsync(20)} expect(view.queryByText('Are we in control?')).toBeNull() - const flow=document.querySelector('ftw-energy-flow')! - const button=flow.shadowRoot!.querySelector('.ef-layer[aria-hidden="false"] [data-role="battery"]')! + const button=document.querySelector('[data-node="battery"]')! expect(button.getAttribute('aria-label')).not.toMatch(/tier/i) + // Core's alarm on the charger reaches the ring from the box's snapshot. + expect(document.querySelector('[data-node="car"] .mark')?.getAttribute('data-tone')).toBe('alarm') button.dispatchEvent(new MouseEvent('click',{bubbles:true})) await vi.advanceTimersByTimeAsync(10) const dialog=document.querySelector('[role="dialog"]')! @@ -60,7 +61,7 @@ test('an EV tap goes straight to charging controls even when status carries evid try { site.connect(new LoopbackCarrier(box,{latencyMs:0}));render(Now,{props:{site}}); for(let i=0;i<200;i++){box.tick(20);await vi.advanceTimersByTimeAsync(20)} - document.querySelector('ftw-energy-flow')!.dispatchEvent(new CustomEvent('ftw-planet-click',{detail:{role:'ev',name:'easee'},bubbles:true})); + document.querySelector('[data-node="car"]')!.click(); for(let i=0;i<200;i++){box.tick(20);await vi.advanceTimersByTimeAsync(20)} const dialog=document.querySelector('[role="dialog"]')!; expect(dialog?.getAttribute('aria-label')).toBe('EV charger'); diff --git a/src/views/Now.svelte b/src/views/Now.svelte index 3bdb0bb..81ce7dd 100644 --- a/src/views/Now.svelte +++ b/src/views/Now.svelte @@ -8,12 +8,10 @@ -->