Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ring-steady-flow.md
Original file line number Diff line number Diff line change
@@ -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.
69 changes: 63 additions & 6 deletions src/lib/state/ring-motion.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): ArcTarget => ({
kw: 2, live: true, flowing: true, towardHome: true, fill: 0.5, ...p,
Expand Down Expand Up @@ -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)
Expand All @@ -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', () => {
Expand Down
111 changes: 91 additions & 20 deletions src/lib/state/ring-motion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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))
Expand All @@ -107,29 +154,31 @@ 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
}

/** 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
Expand All @@ -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
Expand All @@ -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,
}
}
}
7 changes: 4 additions & 3 deletions src/lib/state/ring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
8 changes: 4 additions & 4 deletions src/lib/state/ring.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 ----------
Expand Down
Loading
Loading