mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
668 lines
25 KiB
TypeScript
668 lines
25 KiB
TypeScript
/**
|
|
* THE DOT-MATRIX GRAMMAR — what the lattice is doing, and how a page says it.
|
|
*
|
|
* `components/backdrop/DotMatrix` draws one field of dots under every route.
|
|
* Left alone it draws the ambient one — two slow plane waves, saying nothing.
|
|
* A page that has something to say pushes a field here and the backdrop blends
|
|
* to it, so the lattice shows what the software is doing instead of decorating
|
|
* the page while it happens.
|
|
*
|
|
* DESIGN.md §11 fixes the vocabulary and it is not negotiable:
|
|
*
|
|
* dot information, or a unit of compute
|
|
* cluster a machine, an application, a logical component
|
|
* bright active work movement data or control flowing
|
|
* split parallelism, agents join synthesis
|
|
* boundary security, locality density scale of compute
|
|
* pulse an event
|
|
*
|
|
* ── THE SHAPE OF THE API ──────────────────────────────────────────────────
|
|
*
|
|
* Everything is a `Field`: brightness 0..1 at a point, given CSS pixels and a
|
|
* clock. That is the whole interface, which is why there is no scheduler, no
|
|
* state machine and no per-page machinery in the backdrop — one loop over one
|
|
* grid, and composition is arithmetic.
|
|
*
|
|
* A verb is a `Field` factory. Every verb takes a `Beat` — `at`, when it
|
|
* starts, and `span`, how long it takes, both in seconds from the moment the
|
|
* field was shown. Before `at` a verb is zero. After `at + span` it HOLDS ITS
|
|
* END STATE. That last rule is what makes a topology assemble and stay drawn
|
|
* rather than flash past, and it is what lets `sequence` be a plain maximum:
|
|
*
|
|
* show(sequence(
|
|
* route(prompt, enso, { at: 0.0, span: 0.7 }),
|
|
* fork(enso, [agent, model], { at: 0.7, span: 0.9 }),
|
|
* join([agent, model], infra, { at: 1.5, span: 0.9 }),
|
|
* ))
|
|
*
|
|
* Positions are `Point`s in fractions of the viewport, so a scene is written
|
|
* once and is correct at every window size. Distances are measured in WIDTH
|
|
* units, so a circle is round rather than an ellipse on a phone.
|
|
*
|
|
* A page shows a scene with `show(field)` and returns the lattice to ambient
|
|
* with `show(null)` on the way out.
|
|
*
|
|
* ── THE SIX VERBS ─────────────────────────────────────────────────────────
|
|
*
|
|
* materialize(where, radius, beat) the system forms around a request
|
|
* route(from, to, beat) one path, traced dot by dot
|
|
* fork(from, [to…], beat) one becomes many — parallelism
|
|
* join([from…], to, beat) many become one — synthesis
|
|
* handoff(from, to, beat) the same work, somewhere else
|
|
* policy(gate, from, to, beat) authority travels; the secret does not
|
|
*
|
|
* And one thing the verbs operate ON:
|
|
*
|
|
* work([{ where, at }…]) the bright dot IS the work
|
|
*
|
|
* There are no others. §11.4: a field that does not correspond to something
|
|
* the software actually does is decoration, and decoration here is a bug. If a
|
|
* page cannot say which verb it is using and what its dots stand for, it does
|
|
* not get a field.
|
|
*
|
|
* `policy` carries the one claim the architecture has to make in five seconds:
|
|
* THE SECRET NEVER MOVES. It draws the credential stationary inside the
|
|
* boundary and the authorization travelling past it. An animation that moves
|
|
* the key is wrong about the product, so this verb is the one that must not be
|
|
* reimplemented per page.
|
|
*
|
|
* ── GRAVITY ───────────────────────────────────────────────────────────────
|
|
*
|
|
* Idle long enough and the loose dots fall back into the Hanzo mark, which
|
|
* this module holds as a gravity source (§11.3). Any interaction dissolves it
|
|
* back into computation. That arbitration lives here rather than in the
|
|
* backdrop or in a page, because it is the same question the rest of this file
|
|
* answers — what the lattice is doing — and two places deciding it would
|
|
* disagree the first time a page showed a field while the mark was forming.
|
|
*
|
|
* ── WHY MODULE STATE AND NOT REACT ────────────────────────────────────────
|
|
*
|
|
* The backdrop is mounted outside the providers (it reads nothing from them)
|
|
* and must survive navigation — §11.3 asks the field to REORGANISE between
|
|
* routes rather than tear down. So this is a module-level store with a
|
|
* subscription, and the canvas never remounts.
|
|
*/
|
|
|
|
/** Brightness at a point, 0..1. `x`/`y` are CSS pixels; `t` is seconds since the field was shown. */
|
|
export type Field = (x: number, y: number, t: number, w: number, h: number) => number
|
|
|
|
/** A place on the viewport, in fractions of its width and height. */
|
|
export type Point = { x: number; y: number }
|
|
|
|
/** When a verb starts and how long it takes, in seconds from the field's own zero. */
|
|
export type Beat = { at?: number; span?: number }
|
|
|
|
/* ------------------------------------------------------------------ timing */
|
|
|
|
const clock = (): number => performance.now() / 1000
|
|
|
|
const clamp = (u: number): number => (u < 0 ? 0 : u > 1 ? 1 : u)
|
|
|
|
/** Smoothstep. Verbs start and stop without a visible edge on either end. */
|
|
const ease = (u: number): number => u * u * (3 - 2 * u)
|
|
|
|
/** A verb's own progress, 0 before it starts and 1 forever after it finishes. */
|
|
const beat = (t: number, at: number, span: number): number =>
|
|
span <= 0 ? (t >= at ? 1 : 0) : ease(clamp((t - at) / span))
|
|
|
|
/* ------------------------------------------------------------------- shape */
|
|
|
|
/**
|
|
* Constants, in WIDTH units, so every scene is the same physical size at every
|
|
* window. A node is about a finger-width on a laptop; a wire is one to two
|
|
* lattice cells across, which is what makes a route read as traced rather than
|
|
* as a drawn line.
|
|
*/
|
|
|
|
/**
|
|
* Half-width of a route.
|
|
*
|
|
* It has to be wide enough that the FLAT part of its cross-section covers a row
|
|
* of cells, or an axis-aligned run falls between two rows and all but vanishes.
|
|
* Measured at 0.009: a horizontal route lit nothing on an 18px grid. At 0.013
|
|
* the half-width is 19px and the plateau is 9.4px, so the nearest row — never
|
|
* more than 9px off a horizontal line at this pitch — always gets full ink.
|
|
*/
|
|
const WIRE = 0.013
|
|
/** How much of a route the travelling head occupies. */
|
|
const HEAD = 0.22
|
|
/**
|
|
* The ink ladder, and it is aimed at the backdrop's buckets on purpose.
|
|
*
|
|
* `DotMatrix` quantises a field into eight steps and only thickens the dot in
|
|
* the top three — that is how §11.1's `·· → ▒▒ → ██` is drawn. So these are not
|
|
* free numbers: a route has to land a rung below a hub, a hub has to land where
|
|
* the dot grows, and the work has to top out. Measured with everything a rung
|
|
* too low: the whole topology sat in the flat middle of the ramp, nothing grew,
|
|
* and a drawing made of hubs and routes read as one texture.
|
|
*
|
|
* What a route holds once the head has passed. Step 4 of 8: visible, flat.
|
|
*/
|
|
const TRAIL = 0.55
|
|
/** Radius of the cluster a `fork` or `join` puts at its hub. */
|
|
export const NODE = 0.035
|
|
/** Radius of the cluster `handoff` carries. */
|
|
const CARRY = 0.05
|
|
/** How far past the front edge a `materialize` stays hot. */
|
|
const SKIN = 0.05
|
|
/** What a materialised region holds at its centre. Step 6: the dot thickens. */
|
|
const SETTLED = 0.82
|
|
|
|
/**
|
|
* The one profile every solid shape here uses: flat out to `flat` of the
|
|
* radius, then off.
|
|
*
|
|
* NOT A FALLOFF FROM THE CENTRE. That was the first version and it was wrong
|
|
* for the same reason at three sizes: the lattice samples on an 18px grid, so
|
|
* with a squared falloff whether a cluster looked solid depended on where its
|
|
* centre happened to land between cells, and a node the spec draws as `██` came
|
|
* out as a smudge that dimmed toward its own middle. A plateau reads as an
|
|
* object at every position, and the ramp on the outside is what keeps it from
|
|
* aliasing into a rectangle.
|
|
*/
|
|
const plate = (d: number, r: number, flat: number): number => {
|
|
if (d >= r) return 0
|
|
const hold = r * flat
|
|
return d <= hold ? 1 : 1 - (d - hold) / (r - hold)
|
|
}
|
|
|
|
/** `plate`, from a vector. */
|
|
const blot = (dx: number, dy: number, r: number, flat: number): number =>
|
|
plate(Math.sqrt(dx * dx + dy * dy), r, flat)
|
|
|
|
/**
|
|
* A radius in width units, with a floor in CSS PIXELS.
|
|
*
|
|
* Every size here is a fraction of the viewport width, so a scene is the same
|
|
* picture at every window — and that breaks at the small end. On a 390px phone
|
|
* a node is 14px across while the backdrop opens its pitch from 18px to 26px to
|
|
* pay for the fill, so a cluster lands on one cell and stops being a cluster and
|
|
* a route lands between cells and stops being a route. The floors are stated in
|
|
* pixels because the thing they have to beat — the cell pitch — is.
|
|
*/
|
|
const enough = (r: number, w: number, floor: number): number => Math.max(r * w, floor) / w
|
|
|
|
/** Pixel floors, one per shape, all a small multiple of the coarse 26px pitch. */
|
|
const FLOOR = { node: 40, wire: 17, seed: 21, carry: 48, wall: 15, reach: 72 } as const
|
|
|
|
/* ------------------------------------------------------------------- verbs */
|
|
|
|
/**
|
|
* MATERIALIZE — the system forms around a request.
|
|
*
|
|
* ········ ··▒▒·· ▒████▒
|
|
* ········ → ·▒██▒· → ██████
|
|
*
|
|
* A front leaves `where` and the lattice behind it STAYS LIT, so the picture is
|
|
* something being built rather than a ripple passing through. Small radii make
|
|
* a cluster; large ones make a region waking up.
|
|
*/
|
|
export function materialize(where: Point, radius = NODE, when: Beat = {}): Field {
|
|
const at = when.at ?? 0
|
|
const span = when.span ?? 0.6
|
|
return (x, y, t, w, h) => {
|
|
if (t < at) return 0
|
|
const p = beat(t, at, span)
|
|
const r = enough(radius, w, FLOOR.node)
|
|
const dx = (x - where.x * w) / w
|
|
const dy = (y - where.y * h) / w
|
|
const d = Math.sqrt(dx * dx + dy * dy)
|
|
const front = p * r
|
|
if (d > front) return 0
|
|
const lit = SETTLED * plate(d, r, 0.45)
|
|
// The hot front is what makes this read as growth, and it has to GO when
|
|
// the growing stops — held to the end it leaves a bright rim around a
|
|
// duller middle, so every cluster on the page is a ring instead of a
|
|
// cluster. It fades out over the last third of the verb.
|
|
const skin = (1 - (front - d) / SKIN) * Math.min(1, (1 - p) / 0.35)
|
|
return skin > 0 ? Math.max(lit, skin * skin) : lit
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ROUTE — a request takes one path of several.
|
|
*
|
|
* ● · · · · · ○
|
|
*
|
|
* A head is traced dot by dot from `from` to `to` and the wire behind it holds.
|
|
* Nothing about the path is drawn before the head reaches it, which is the
|
|
* difference between a route and a diagram.
|
|
*/
|
|
export function route(from: Point, to: Point, when: Beat = {}): Field {
|
|
const at = when.at ?? 0
|
|
const span = when.span ?? 0.8
|
|
return (x, y, t, w, h) => {
|
|
if (t < at) return 0
|
|
const p = beat(t, at, span)
|
|
const ax = from.x * w
|
|
const ay = from.y * h
|
|
const vx = to.x * w - ax
|
|
const vy = to.y * h - ay
|
|
const len2 = vx * vx + vy * vy
|
|
if (len2 === 0) return 0
|
|
let u = ((x - ax) * vx + (y - ay) * vy) / len2
|
|
if (u < 0) u = 0
|
|
else if (u > 1) u = 1
|
|
if (u > p) return 0
|
|
const near = blot((x - (ax + vx * u)) / w, (y - (ay + vy * u)) / w, enough(WIRE, w, FLOOR.wire), 0.5)
|
|
if (near <= 0) return 0
|
|
const lead = 1 - (p - u) / HEAD
|
|
const ink = lead > 0 ? TRAIL + (1 - TRAIL) * lead * lead : TRAIL
|
|
return near * ink
|
|
}
|
|
}
|
|
|
|
/**
|
|
* FORK — one becomes many. Parallelism, agents.
|
|
*
|
|
* ●
|
|
* ╱│╲
|
|
* ● ● ●
|
|
*
|
|
* The hub materialises and the branches leave it together, because they are
|
|
* concurrent — staggering them would say pipeline, which is a different claim.
|
|
*/
|
|
export function fork(from: Point, to: Point[], when: Beat = {}): Field {
|
|
const at = when.at ?? 0
|
|
const span = when.span ?? 0.9
|
|
return sequence(
|
|
materialize(from, NODE, { at, span: span * 0.4 }),
|
|
...to.map((end) => route(from, end, { at, span })),
|
|
...to.map((end) => materialize(end, NODE, { at: at + span * 0.7, span: span * 0.4 })),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* JOIN — many become one. Synthesis.
|
|
*
|
|
* ● ● ●
|
|
* ╲│╱
|
|
* ●
|
|
*
|
|
* The destination forms LAST, on the tail of the arrivals, so the reader sees
|
|
* the branches produce it rather than flow into something already there.
|
|
*/
|
|
export function join(from: Point[], to: Point, when: Beat = {}): Field {
|
|
const at = when.at ?? 0
|
|
const span = when.span ?? 0.9
|
|
return sequence(
|
|
...from.map((start) => route(start, to, { at, span })),
|
|
materialize(to, NODE, { at: at + span * 0.65, span: span * 0.5 }),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* HANDOFF — the same work, somewhere else.
|
|
*
|
|
* LOCAL CLOUD
|
|
* █████ · · · → █████
|
|
*
|
|
* The cluster TRANSLATES. It is not torn down at `from` and rebuilt at `to`,
|
|
* and it leaves no trail, because the claim is that this is the same software
|
|
* in a different place. A version of this that dissolves and re-forms is
|
|
* saying something the product does not do.
|
|
*/
|
|
export function handoff(from: Point, to: Point, when: Beat = {}): Field {
|
|
const at = when.at ?? 0
|
|
const span = when.span ?? 1.4
|
|
return (x, y, t, w, h) => {
|
|
if (t < at) return 0
|
|
const p = beat(t, at, span)
|
|
const cx = (from.x + (to.x - from.x) * p) * w
|
|
const cy = (from.y + (to.y - from.y) * p) * h
|
|
return blot((x - cx) / w, (y - cy) / w, enough(CARRY, w, FLOOR.carry), 0.5) * 0.86
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POLICY — authority, not secrets.
|
|
*
|
|
* agent → │ boundary │ → API
|
|
* ↑ policy
|
|
*
|
|
* A stationary bar at `gate` is the boundary. The bright dot at its centre is
|
|
* the credential, and it is drawn ONCE, at a fixed point, for the whole life of
|
|
* the field: it does not travel, fade or follow. What travels is the request,
|
|
* in on one side and out on the other.
|
|
*
|
|
* This is the verb that must not be reimplemented locally. The moment a page
|
|
* animates the key moving, the page is wrong about the architecture.
|
|
*/
|
|
export function policy(gate: Point, from: Point, to: Point, when: Beat = {}): Field {
|
|
const at = when.at ?? 0
|
|
const span = when.span ?? 1.4
|
|
const half = span * 0.45
|
|
return sequence(
|
|
boundary(gate, { at, span: span * 0.3 }),
|
|
route(from, gate, { at, span: half }),
|
|
route(gate, to, { at: at + span - half, span: half }),
|
|
// What was authorised. Without it the request crosses a wall and arrives
|
|
// nowhere, which is a picture of a boundary rather than of getting past one.
|
|
materialize(to, NODE, { at: at + span - half * 0.4, span: half }),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The wall and the thing behind it. Not a verb — the stationary half of
|
|
* `policy`, factored out so there is one place that knows a boundary is a bar
|
|
* and a secret is a dot that never moves.
|
|
*/
|
|
function boundary(gate: Point, when: Beat = {}): Field {
|
|
const at = when.at ?? 0
|
|
const span = when.span ?? 0.5
|
|
const REACH = 0.075
|
|
const THICK = 0.006
|
|
return (x, y, t, w, h) => {
|
|
if (t < at) return 0
|
|
const p = beat(t, at, span)
|
|
const gx = gate.x * w
|
|
const gy = gate.y * h
|
|
const dx = (x - gx) / w
|
|
const dy = (y - gy) / w
|
|
// The credential. Full brightness, at one fixed point, for the whole life
|
|
// of the field — it is the thing that does not move.
|
|
const secret = blot(dx, dy, enough(0.015, w, FLOOR.seed), 0.55)
|
|
const reach = enough(REACH, w, FLOOR.reach) * p
|
|
const thick = enough(THICK, w, FLOOR.wall)
|
|
const wall = Math.abs(dy) <= reach && Math.abs(dx) <= thick ? 0.45 * p : 0
|
|
return Math.max(wall, secret)
|
|
}
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- the work */
|
|
|
|
/**
|
|
* THE BRIGHT DOT IS THE WORK.
|
|
*
|
|
* Not the agent, not the model, not the machine — those are clusters, and they
|
|
* are all replaceable. The single brightest dot in any Hanzo field is the piece
|
|
* of work itself, and it is the SAME dot from the first frame to the last. That
|
|
* is the one thing this site has to say: your work survives the surface.
|
|
*
|
|
* So the work is a value, not a drawing. It is an itinerary — where it is and
|
|
* when — and the verbs operate on it rather than owning it. `handoff` carries
|
|
* it between surfaces; `route` shows a request reaching it; `fork` splits the
|
|
* work it is doing without splitting the work itself. A page that draws a
|
|
* second bright dot has said there are two pieces of work, and had better mean
|
|
* it.
|
|
*
|
|
* const task = work([
|
|
* { where: APP, at: 0 },
|
|
* { where: CLI, at: 3 },
|
|
* { where: CLOUD, at: 6 },
|
|
* { where: EDITOR, at: 9 },
|
|
* ], { loop: 12 })
|
|
*
|
|
* show(sequence(surfaces, task.legs, task.seed))
|
|
*
|
|
* `task.seed` goes LAST in the sequence because `sequence` takes a maximum and
|
|
* the seed must win everywhere it lands — that is what makes it the same dot
|
|
* rather than a bright patch that happens to be nearby.
|
|
*/
|
|
|
|
/** Where the work is, and when it got there. `at` is seconds on the field's clock. */
|
|
export type Stop = { where: Point; at: number }
|
|
|
|
export type Work = {
|
|
/** Where the work is at `t`, in viewport fractions. */
|
|
where: (t: number) => Point
|
|
/** The dot. One, bright, continuous. */
|
|
seed: Field
|
|
/** The surfaces carrying it between stops — HANDOFF, once per leg. */
|
|
legs: Field
|
|
}
|
|
|
|
/** Radius of the work itself. Small: it is one dot, not a cluster. */
|
|
const SEED = 0.015
|
|
|
|
/** Seconds a leg of the journey takes, unless a caller says otherwise. */
|
|
const TRAVEL = 1.1
|
|
|
|
export function work(stops: Stop[], opts: { travel?: number; loop?: number } = {}): Work {
|
|
const travel = opts.travel ?? TRAVEL
|
|
const loop = opts.loop ?? 0
|
|
|
|
if (stops.length === 0) throw new Error('work() needs somewhere to be')
|
|
|
|
/** Fold the clock back on itself when the itinerary repeats. */
|
|
const wrap = (t: number): number => (loop > 0 ? ((t % loop) + loop) % loop : t)
|
|
|
|
const where = (raw: number): Point => {
|
|
const t = wrap(raw)
|
|
let here = stops[0]
|
|
for (let i = 1; i < stops.length; i++) {
|
|
const next = stops[i]
|
|
if (t < next.at) {
|
|
// Departure is `travel` before arrival, so a stop's own time is the
|
|
// moment the work LANDS there — which is what a caller means by "the
|
|
// work is in the cloud at t=6" and the only reading that survives a
|
|
// leg being retimed.
|
|
const p = beat(t, next.at - travel, travel)
|
|
if (p <= 0) return here.where
|
|
return {
|
|
x: here.where.x + (next.where.x - here.where.x) * p,
|
|
y: here.where.y + (next.where.y - here.where.y) * p,
|
|
}
|
|
}
|
|
here = next
|
|
}
|
|
return here.where
|
|
}
|
|
|
|
// Where the work is, worked out ONCE PER FRAME and not once per cell. A
|
|
// `Field` is called for every cell in the lattice — six thousand times at
|
|
// 30fps — and `where` returns a fresh Point, so the naive spelling allocated a
|
|
// couple of hundred thousand objects a second to answer the same question.
|
|
// The clock is the cache key because within a frame it does not move.
|
|
let seen = NaN
|
|
let px = 0
|
|
let py = 0
|
|
|
|
const seed: Field = (x, y, t, w, h) => {
|
|
if (t !== seen) {
|
|
seen = t
|
|
const at = where(t)
|
|
px = at.x
|
|
py = at.y
|
|
}
|
|
return blot((x - px * w) / w, (y - py * h) / w, enough(SEED, w, FLOOR.seed), 0.55)
|
|
}
|
|
|
|
const legs = sequence(
|
|
...stops.slice(1).map((next, i) =>
|
|
handoff(stops[i].where, next.where, { at: next.at - travel, span: travel }),
|
|
),
|
|
)
|
|
|
|
// A looping itinerary's legs have to fold too, or the last handoff holds its
|
|
// end state over the top of the first one on the second pass.
|
|
const folded: Field = loop > 0 ? (x, y, t, w, h) => legs(x, y, wrap(t), w, h) : legs
|
|
|
|
return { where, seed, legs: folded }
|
|
}
|
|
|
|
/**
|
|
* The work in flight, kept across a navigation.
|
|
*
|
|
* The lattice survives route changes (§11.3) and the work is the thing that is
|
|
* supposed to survive hardest, so the store holds it: a page that picks it up
|
|
* continues the same journey instead of starting a second one. `carry(null)`
|
|
* puts it down.
|
|
*/
|
|
let inflight: Work | null = null
|
|
|
|
export function carry(w: Work | null) {
|
|
inflight = w
|
|
}
|
|
|
|
export function carried(): Work | null {
|
|
return inflight
|
|
}
|
|
|
|
/* --------------------------------------------------------------- the glyph */
|
|
|
|
/**
|
|
* The Hanzo mark, as a mask.
|
|
*
|
|
* Four corner squares and one diagonal band, in the mark's own 67-unit box —
|
|
* the same five paths `public/favicon.svg` draws, stated as arithmetic rather
|
|
* than rasterised. A field is evaluated per cell per frame and must not touch
|
|
* an image; the mark is five rectangles, so it does not have to.
|
|
*/
|
|
const A = 22.21 / 67
|
|
const B = 44.51 / 67
|
|
const C = 22.318 / 67
|
|
const D = 44.637 / 67
|
|
const BAND_TOP_LEFT = 22.253 / 67
|
|
const BAND_TOP_RIGHT = 66.704 / 67
|
|
const BAND_LOW_LEFT = 0.0879 / 67
|
|
const BAND_LOW_RIGHT = 44.463 / 67
|
|
|
|
/** Is a point in the mark's unit box inside the mark? */
|
|
const inMark = (gx: number, gy: number): boolean => {
|
|
if (gy <= C || gy >= D) return gx <= A || gx >= B
|
|
const s = (gy - C) / (D - C)
|
|
const lo = BAND_TOP_LEFT + (BAND_LOW_LEFT - BAND_TOP_LEFT) * s
|
|
const hi = BAND_TOP_RIGHT + (BAND_LOW_RIGHT - BAND_TOP_RIGHT) * s
|
|
return gx >= lo && gx <= hi
|
|
}
|
|
|
|
/**
|
|
* GRAVITY — the mark, pulling the loose dots back in.
|
|
*
|
|
* Not one of the verbs, and deliberately: the verbs say what the software is
|
|
* doing, and this says that it is not doing anything. It forms over `SETTLE`
|
|
* seconds, which is slower than anything else on the site, because a mark that
|
|
* snaps into place reads as a logo animation rather than as the field coming
|
|
* to rest.
|
|
*/
|
|
const SETTLE = 8
|
|
|
|
export function gravity(where: Point = { x: 0.83, y: 0.72 }, size = 0.16): Field {
|
|
return (x, y, t, w, h) => {
|
|
const side = size * w
|
|
const gx = (x - (where.x * w - side / 2)) / side
|
|
const gy = (y - (where.y * h - side / 2)) / side
|
|
if (gx < 0 || gx > 1 || gy < 0 || gy > 1) return 0
|
|
return inMark(gx, gy) ? ease(clamp(t / SETTLE)) : 0
|
|
}
|
|
}
|
|
|
|
/* --------------------------------------------------------------- composing */
|
|
|
|
/**
|
|
* Several verbs as one field.
|
|
*
|
|
* The brightest wins. A sum would blow out wherever two routes cross — and
|
|
* they cross at every hub, which is exactly where the picture needs to stay
|
|
* readable — so this is a maximum, and nothing has to be normalised.
|
|
*/
|
|
export function sequence(...parts: Field[]): Field {
|
|
return (x, y, t, w, h) => {
|
|
let top = 0
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const v = parts[i](x, y, t, w, h)
|
|
if (v > top) top = v
|
|
}
|
|
return top > 1 ? 1 : top
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------- store */
|
|
|
|
/** How long the backdrop takes to cross-fade in or out, in seconds. */
|
|
export const FADE = 0.9
|
|
|
|
/** Silence, in seconds, before the mark starts pulling the dots back in. */
|
|
const IDLE = 24
|
|
|
|
type State = { field: Field | null; since: number }
|
|
|
|
/** What the backdrop reads. Resolved — the arbitration below has already run. */
|
|
const state: State = { field: null, since: 0 }
|
|
|
|
/** What a page asked for. Beats gravity whenever it is set. */
|
|
let asked: Field | null = null
|
|
|
|
/** When the reader last did anything. */
|
|
let touched = 0
|
|
|
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
const listeners = new Set<() => void>()
|
|
|
|
const announce = () => {
|
|
for (const fn of listeners) fn()
|
|
}
|
|
|
|
const hold = (field: Field | null) => {
|
|
if (state.field === field) return
|
|
state.field = field
|
|
state.since = clock()
|
|
announce()
|
|
}
|
|
|
|
/**
|
|
* Decide what the lattice is doing, and say so once.
|
|
*
|
|
* A page's field wins. With none, the mark forms after `IDLE` seconds of
|
|
* silence and dissolves the moment anything happens. One function owns the
|
|
* choice, so there is no ordering between "a page showed something" and "the
|
|
* reader went quiet" for the two of them to get wrong.
|
|
*/
|
|
const settle = () => {
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
timer = null
|
|
}
|
|
if (asked) {
|
|
hold(asked)
|
|
return
|
|
}
|
|
const quiet = clock() - touched
|
|
if (quiet >= IDLE) {
|
|
if (!state.field) hold(gravity())
|
|
return
|
|
}
|
|
hold(null)
|
|
timer = setTimeout(settle, (IDLE - quiet) * 1000)
|
|
}
|
|
|
|
/** The reader did something. Dissolves the mark; restarts the idle count. */
|
|
export function stir() {
|
|
touched = clock()
|
|
settle()
|
|
}
|
|
|
|
export function subscribe(fn: () => void) {
|
|
listeners.add(fn)
|
|
return () => listeners.delete(fn)
|
|
}
|
|
|
|
export function current(): State {
|
|
return state
|
|
}
|
|
|
|
/** Show a field. Passing null returns the lattice to ambient — or, in time, to the mark. */
|
|
export function show(field: Field | null) {
|
|
asked = field
|
|
settle()
|
|
}
|
|
|
|
/**
|
|
* Start watching for silence. Called once, by the backdrop.
|
|
*
|
|
* The events are passive and on the window, and none of them does work — each
|
|
* writes one timestamp. `stir` is exported for the rare caller that changes
|
|
* something without an input event.
|
|
*/
|
|
export function watch(): () => void {
|
|
const kinds = ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart'] as const
|
|
for (const kind of kinds) addEventListener(kind, stir, { passive: true })
|
|
stir()
|
|
return () => {
|
|
for (const kind of kinds) removeEventListener(kind, stir)
|
|
if (timer) clearTimeout(timer)
|
|
timer = null
|
|
}
|
|
}
|