diff --git a/.changeset/ring-steady-flow.md b/.changeset/ring-steady-flow.md new file mode 100644 index 0000000..8230baa --- /dev/null +++ b/.changeset/ring-steady-flow.md @@ -0,0 +1,5 @@ +--- +"ftw-webapp": patch +--- + +Now shows which way your power flows, as the phone app does: thin lines of light run between each part of the ring and the home in the middle, in toward the home for power coming into it and out for power leaving it, faster with more power. Each new reading from your box sends one soft swell along the arcs that are live. With reduced motion turned on, the lines hold still and a small arrow shows the direction instead. diff --git a/src/lib/state/ring-motion.test.ts b/src/lib/state/ring-motion.test.ts index fe9c1f7..4be8c52 100644 --- a/src/lib/state/ring-motion.test.ts +++ b/src/lib/state/ring-motion.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { ArcMotion, Follow, wave, waveSpeed, type ArcTarget } from './ring-motion' +import { ArcMotion, Follow, WAVELENGTH, ringEase, ringLines, ringStrength, ringSwell, waveSpeed, type ArcTarget } from './ring-motion' const target = (p: Partial = {}): ArcTarget => ({ kw: 2, live: true, flowing: true, towardHome: true, fill: 0.5, ...p, @@ -39,7 +39,7 @@ describe('the light on an arc', () => { 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', () => { + it('runs the lines 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) @@ -60,10 +60,67 @@ describe('the light on an arc', () => { }) }) -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) +describe('a swell per reading', () => { + it('starts with a new reading from the box and ends before the next', () => { + const m = new ArcMotion(target(), 0, 0) + expect(m.step(target(), 0.5, false, 0).swell).toBeNull() + expect(m.step(target(), 1, false, 1000).swell).toBe(0) + expect(m.step(target(), 1.4, false, 1000).swell).toBeGreaterThan(0.3) + expect(m.step(target(), 2, false, 1000).swell).toBeNull() + }) + + it('never swells on the first reading, a quiet arc, a frame drawn at rest or under reduced motion', () => { + expect(new ArcMotion(target(), 0).step(target(), 0.1, false, 1000).swell).toBeNull() + expect(new ArcMotion(target({ live: false }), 0, 0).step(target({ live: false }), 0.1, false, 1000).swell).toBeNull() + expect(new ArcMotion(target({ flowing: false }), 0, 0).step(target({ flowing: false }), 0.1, false, 1000).swell).toBeNull() + expect(new ArcMotion(target(), 0, 0).step(target(), 0.1, false, 1000, false).swell).toBeNull() + expect(new ArcMotion(target(), 0, 0).step(target(), 0.1, true, 1000).swell).toBeNull() + }) + + it('runs from the middle out past both ends and fades', () => { + const half = 60 + expect(ringSwell(0, 0, half)).toBeCloseTo(1, 12) + const peak = (k: number) => { + let best = 0 + for (let s = 0; s <= 140; s += 0.5) if (ringSwell(s, k, half) > ringSwell(best, k, half)) best = s + return best + } + expect(peak(0.2)).toBeLessThan(peak(0.5)) + expect(peak(0.9)).toBeGreaterThan(half) + expect(ringSwell(peak(0.8), 0.8, half)).toBeLessThan(ringSwell(peak(0.3), 0.3, half)) + expect(ringSwell(10, 0.4, half)).toBe(ringSwell(-10, 0.4, half)) + expect(ringSwell(0, 1.01, half)).toBe(0) + }) +}) + +describe('the lines and the arc', () => { + it('sit a wavelength apart and run toward the home for power coming in', () => { + const length = 36.75 + for (const toward of [true, false]) { + for (let phase = 0; phase < 3; phase += 0.07) { + const ps = ringLines(phase, length, toward) + expect(ps.every((p) => p >= 0 && p <= 1)).toBe(true) + expect([2, 3]).toContain(ps.length) + } + const a = ringLines(0.2, length, toward)[0] ?? 0 + const b = ringLines(0.3, length, toward)[0] ?? 0 + expect((b - a) * length).toBeCloseTo((toward ? 1 : -1) * 0.1 * WAVELENGTH, 9) + } + expect(ringLines(0.4, 0, true)).toEqual([]) + expect(ringLines(Number.NaN, length, true)).toEqual([]) + }) + + it('is brightest over its middle third, with no hot ends', () => { + expect(ringEase(0, 0.14)).toBe(1) + expect(ringEase(0.3, 0.14)).toBe(1) + expect(ringEase(1, 0.14)).toBeCloseTo(0.14, 12) + expect(ringEase(-1, 0)).toBeCloseTo(0, 12) + }) + + it('grows in strength with power and stops at full', () => { + expect(ringStrength(0)).toBe(0.65) + expect(ringStrength(5)).toBe(1) + expect(ringStrength(-1)).toBe(ringStrength(1)) }) it('runs faster with power, never racing', () => { diff --git a/src/lib/state/ring-motion.ts b/src/lib/state/ring-motion.ts index 4960bfa..4783117 100644 --- a/src/lib/state/ring-motion.ts +++ b/src/lib/state/ring-motion.ts @@ -4,26 +4,73 @@ * 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 + * Steady flow (J, chosen by Fredrik on 30 October 2026): an arc is brightest + * over its middle third, each reading from the box sends one swell along it, + * and thin lines of light run all the time between the arc and the home's + * circle, in toward the home for power coming into it and out for power + * leaving it. + * + * Light means live. An arc glows and its lines run 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 +/** Pixels from one line of light to the next on its way to the home or + * away from it. */ +export const WAVELENGTH = 15 -/** 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. */ +/** How fast the light runs, 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 +/** How strong an arc's swell and its lines are: 0.65 at a trickle, full + * from about 4.7 kW. */ +export function ringStrength(kw: number): number { + const a = Math.abs(kw) + if (!Number.isFinite(a)) return 0.65 + return 0.65 + 0.35 * Math.min(1, Math.log10(1 + 4 * a) / 1.3) +} + +/** Brightness along an arc or a line, `u` from -1 at one end to 1 at the + * other: full over the middle third, easing to `end` at the ends. The + * light has no hot ends. */ +export function ringEase(u: number, end: number): number { + const x = Math.min(1, Math.max(0, (Math.abs(u) - 0.3) / 0.7)) + return 1 - (1 - end) * x * x * (3 - 2 * x) +} + +/** The swell a reading sends along its arc, `s` px from the arc's middle, + * `k` of the way through it, on an arc `half` px each side of its middle: + * it starts in the middle, runs out past both ends and fades as it goes. */ +export function ringSwell(s: number, k: number, half: number): number { + if (!(k >= 0 && k <= 1)) return 0 + const sigma = Math.max(12, 0.32 * half) + const at = (1 - (1 - k) * (1 - k)) * (half + sigma) + const d = Math.abs(s) - at + return Math.pow(1 - k, 1.3) * Math.exp(-(d * d) / (2 * sigma * sigma)) +} + +/** How long a reading's swell runs: 0.95 of the time since the one before, + * from 0.48 s to 1.5 s, so at a steady cadence a swell has ended when the + * next reading comes. */ +export function ringSwellDuration(gap: number): number { + return Math.min(1.5, 0.95 * Math.max(0.5, Number.isFinite(gap) ? gap : 1)) +} + +/** Where the lines of light sit on a way `length` px long between an arc + * and the home's circle, as shares from the arc (0) to the circle (1). As + * `phase`, in wavelengths, grows they move toward the home when the power + * flows into it and toward the arc when it flows out. */ +export function ringLines(phase: number, length: number, towardHome: boolean): number[] { + if (!(length > 0) || !Number.isFinite(phase)) return [] + const out: number[] = [] + for (let s = (phase - Math.floor(phase)) * WAVELENGTH; s <= length; s += WAVELENGTH) { + out.push(towardHome ? s / length : 1 - s / length) + } + return out } /** A value that follows its target as a critically damped spring with time @@ -52,7 +99,7 @@ export class Follow { /** 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. */ + * its lines slow to rest in 0.35 s. Instant under reduced motion. */ export class Light { static readonly decay = 0.4 static readonly rest = 0.35 @@ -78,13 +125,13 @@ export class Light { this.live = live } - /** The glow, the wave's depth and the core's strength. */ + /** The glow, the lines' light 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. */ + /** How fast the lines run, as a share of their 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)) @@ -107,7 +154,7 @@ export interface ArcTarget { kw: number live: boolean flowing: boolean - /** The light runs toward the home: clockwise. */ + /** The power flows into the home: the lines run toward it. */ towardHome: boolean /** How much of its quarter the arc lights, 0 to 1. */ fill: number @@ -115,21 +162,23 @@ export interface ArcTarget { /** One arc's look in one frame. */ export interface ArcFrame { - /** Wavelengths the light has run, source to sink. */ + /** Wavelengths the lines have run. */ phase: number - /** The phosphor: the glow's strength and the wave's depth. */ + /** The phosphor: the glow's strength and the lines' light. */ 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 + /** How far the newest reading's swell has run, 0 to 1; null when none runs. */ + swell: number | null } 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. */ + * light ignites and decays, its lines run at the speed the power gives, and + * each new reading from the box sends one swell along it. */ export class ArcMotion { static readonly tau = 0.4 /** Longer than this between two frames, nothing was drawn: the page was @@ -142,18 +191,27 @@ export class ArcMotion { private light: Light private phase = 0 private last: number + /** The newest reading's time, in ms. */ + private reading: number | null + private swell: { start: number; duration: number } | null = null /** 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) { + constructor(target: ArcTarget, t: number, reading: number | null = null) { 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 + this.reading = reading } - step(target: ArcTarget, t: number, reduceMotion: boolean): ArcFrame { + /** The arc at `t`, with `reading` the time of the newest reading from the + * box. A new one starts a swell while the arc is live and flowing and the + * ring is `animating`. The first frame, a reading that came while nothing + * was drawn, a frame drawn once at rest, a cache and reduced motion never + * swell: a swell that cannot run would stand still at its brightest. */ + step(target: ArcTarget, t: number, reduceMotion: boolean, reading: number | null = null, animating = true): 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 @@ -171,11 +229,24 @@ export class ArcMotion { this.fill.step(target.fill, dt, ArcMotion.tau) this.phase = ((this.phase + (this.speed.value * this.light.motion(t) * dt) / WAVELENGTH) % 1_000) } + if (reading !== null && reading !== this.reading) { + if (this.reading !== null && target.live && target.flowing && !reduceMotion && animating && dt <= ArcMotion.gap) { + this.swell = { start: t, duration: ringSwellDuration((reading - this.reading) / 1000) } + } + this.reading = reading + } + let swell: number | null = null + if (this.swell) { + const p = (t - this.swell.start) / this.swell.duration + if (reduceMotion || !animating || p > 1) this.swell = null + else swell = Math.max(0, p) + } 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)), + swell, } } } diff --git a/src/lib/state/ring.test.ts b/src/lib/state/ring.test.ts index 6805372..b168a41 100644 --- a/src/lib/state/ring.test.ts +++ b/src/lib/state/ring.test.ts @@ -150,10 +150,11 @@ describe('where the nodes sit', () => { 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', () => { + it('lights at least a quarter, 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(0.05)).toBe(0.25) + expect(ringFill(0.7)).toBe(0.25) + expect(ringFill(0.8)).toBeGreaterThan(0.25) expect(ringFill(1.8)).toBeCloseTo(0.51, 2) expect(ringFill(6)).toBeCloseTo(0.91, 2) expect(ringFill(-6)).toBe(ringFill(6)) diff --git a/src/lib/state/ring.ts b/src/lib/state/ring.ts index c620ff9..946afeb 100644 --- a/src/lib/state/ring.ts +++ b/src/lib/state/ring.ts @@ -1,5 +1,5 @@ /* The ring on Now, as the iOS app draws it (Ring / Quiet, chosen by Fredrik - * on 24 October 2026). + * on 24 October 2026, with steady flow, J, on 30 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 @@ -229,15 +229,15 @@ export function ringArc(spot: RingSpot, fill: number): RingArc { } /** - * 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 + * How much of its quarter a node's arc lights: none at rest, at least a + * quarter, 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))) + return Math.min(1, Math.max(0.25, 1 - Math.exp(-a / 2.5))) } // ---------- the room it takes ---------- diff --git a/src/views/EnergyRing.svelte b/src/views/EnergyRing.svelte index f95ebf8..677eb02 100644 --- a/src/views/EnergyRing.svelte +++ b/src/views/EnergyRing.svelte @@ -3,15 +3,19 @@ Quiet, chosen by Fredrik on 24 October 2026): the home's use in the middle of a ring, each other node as text on the ground in its corner, and the quarter of the ring that faces it lit from its middle out, longer with - more power. A soft wave of light runs along a lit arc, clockwise for power - coming into the home and the other way for power leaving it. + more power. Steady flow (J, chosen on 30 October 2026): the arc is + brightest over its middle third, each reading from the box sends one swell + along it, and thin lines of light run all the time between the arc and the + home's circle, in toward the home for power coming into it and out for + power leaving it. In the dark the arcs and lines glow in two blurred + passes for the whole ring. Light means live. Only a reading the box confirms live glows or moves; a cached or quiet arc is a faint still arc in ink, and a node at rest shows only its quarter's track. The loop runs only while an arc is live and flowing, or for a moment after a change so an arc can grow or cool, and - only while this view is on screen. Under reduced motion the light holds - still and a chevron on the arc says which way it flows. + only while this view is on screen. Under reduced motion nothing runs or + swells, and a still chevron on the way to the home says which way it flows. -->