diff --git a/site/src/components/Header.astro b/site/src/components/Header.astro
index 535aa01..6937fc7 100644
--- a/site/src/components/Header.astro
+++ b/site/src/components/Header.astro
@@ -49,7 +49,61 @@ import { Icon } from 'astro-icon/components'
+
+
diff --git a/site/src/components/ToTop.astro b/site/src/components/ToTop.astro
new file mode 100644
index 0000000..a1bc303
--- /dev/null
+++ b/site/src/components/ToTop.astro
@@ -0,0 +1,80 @@
+---
+import { Icon } from 'astro-icon/components'
+
+/**
+ * ToTop Component
+ *
+ * @description A button in the bottom right that scrolls back to the top once the page is scrolled a screen down
+ */
+---
+
+
+
+
+
+
diff --git a/site/src/layouts/DefaultLayout.astro b/site/src/layouts/DefaultLayout.astro
index 7006022..7395b0b 100644
--- a/site/src/layouts/DefaultLayout.astro
+++ b/site/src/layouts/DefaultLayout.astro
@@ -4,6 +4,7 @@ import '../assets/scss/index.scss'
import SiteMeta from '@components/SiteMeta.astro'
import Header from '@components/Header.astro'
import Footer from '@components/Footer.astro'
+import ToTop from '@components/ToTop.astro'
import { ClientRouter } from 'astro:transitions'
const {
@@ -32,15 +33,20 @@ const {
+
diff --git a/site/src/scripts/hero-story/runtime.ts b/site/src/scripts/hero-story/runtime.ts
new file mode 100644
index 0000000..b3a9e71
--- /dev/null
+++ b/site/src/scripts/hero-story/runtime.ts
@@ -0,0 +1,254 @@
+// Shared runtime for the hero canvas: DPR cap, adaptive render scale, pause when
+// offscreen or in a background tab, and a still frame for reduced motion.
+
+export interface HeroState {
+ time: number
+ dt: number
+ scroll: number
+ px: number
+ w: number
+ h: number
+ scale: number
+ light: boolean
+}
+
+export interface HeroTheme {
+ bg: [number, number, number]
+ ink: [number, number, number]
+ acc: [number, number, number]
+}
+
+const hex = (h: string): [number, number, number] =>
+ [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255) as [number, number, number]
+
+// Mirrors --theme-{dark,light}-bg/text and --color-wire-{90,65} in _root.scss.
+export const themes: Record<'dark' | 'light', HeroTheme> = {
+ dark: { bg: hex('#0a0c14'), ink: hex('#e3e6ee'), acc: hex('#5feaff') },
+ light: { bg: hex('#fdfdff'), ink: hex('#0e1525'), acc: hex('#00b3c4') },
+}
+
+const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v)
+const isLight = () => !document.documentElement.classList.contains('darkmode')
+
+export const reducedMotion = () => matchMedia('(prefers-reduced-motion: reduce)')
+
+interface RunnerOptions {
+ /** Called every frame (or once per still frame) with the current state. */
+ render(st: HeroState): void
+ resize(st: HeroState): void
+ /** Scene time used for the reduced-motion still frame. */
+ stillTime?: number
+}
+
+/** Drives `render` for a pinned (sticky) scroll story. Returns a teardown function. */
+export function runHero(section: HTMLElement, canvas: HTMLCanvasElement, o: RunnerOptions) {
+ const coarse = matchMedia('(pointer: coarse)').matches
+ const reduce = reducedMotion()
+ const st: HeroState = {
+ time: 0,
+ dt: 0,
+ scroll: 0,
+ px: 1,
+ w: 1,
+ h: 1,
+ scale: 1,
+ light: isLight(),
+ }
+ const maxDpr = coarse ? 1.5 : 2
+ let visible = false
+ let raf = 0
+ let last = 0
+ let accT = 0
+ let frames = 0
+ let lastAdapt = 0
+
+ const scrollP = () => {
+ const r = section.getBoundingClientRect()
+ return clamp01(-r.top / Math.max(1, r.height - innerHeight))
+ }
+ const still = () => {
+ if (!visible) return
+ st.scroll = scrollP()
+ st.dt = 0
+ if (reduce.matches) st.time = o.stillTime ?? 9
+ o.render(st)
+ }
+ const measure = () => {
+ const r = canvas.getBoundingClientRect()
+ st.w = Math.max(1, r.width)
+ st.h = Math.max(1, r.height)
+ st.px = Math.min(devicePixelRatio || 1, maxDpr) * st.scale
+ o.resize(st)
+ if (!raf) still()
+ }
+ const frame = (now: number) => {
+ raf = requestAnimationFrame(frame)
+ const dt = Math.min(0.1, (now - (last || now)) / 1000)
+ last = now
+ st.dt = dt
+ st.time += dt
+ st.scroll = scrollP()
+ o.render(st)
+ // Adaptive resolution: drop render scale when frames run long, recover slowly.
+ accT += dt
+ frames++
+ if (frames >= 45) {
+ const avg = accT / frames
+ accT = 0
+ frames = 0
+ if (now - lastAdapt > 1500) {
+ if (avg > 0.024 && st.scale > 0.5) {
+ st.scale = Math.max(0.5, st.scale * 0.8)
+ lastAdapt = now
+ measure()
+ } else if (avg < 0.0175 && st.scale < 1 && now - lastAdapt > 4000) {
+ st.scale = Math.min(1, st.scale / 0.8)
+ lastAdapt = now
+ measure()
+ }
+ }
+ }
+ }
+ const start = () => {
+ if (raf || reduce.matches || document.hidden || !visible) return
+ last = 0
+ accT = 0
+ frames = 0
+ raf = requestAnimationFrame(frame)
+ }
+ const stop = () => {
+ if (raf) cancelAnimationFrame(raf)
+ raf = 0
+ }
+
+ const io = new IntersectionObserver(
+ (es) => {
+ visible = es[0].isIntersecting
+ if (visible) {
+ start()
+ if (!raf) still()
+ } else stop()
+ },
+ { rootMargin: '80px' },
+ )
+ io.observe(section)
+ const onVis = () => (document.hidden ? stop() : start())
+ const onReduce = () => {
+ stop()
+ start()
+ still()
+ }
+ const onTheme = () => {
+ st.light = isLight()
+ if (!raf) still()
+ }
+ const onScroll = () => {
+ if (!raf && visible) still()
+ }
+ const ro = new ResizeObserver(measure)
+ ro.observe(canvas)
+ const mo = new MutationObserver(onTheme)
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
+ document.addEventListener('visibilitychange', onVis)
+ reduce.addEventListener('change', onReduce)
+ addEventListener('scroll', onScroll, { passive: true })
+ measure()
+
+ return {
+ st,
+ /** Render once now, e.g. after an interaction while paused for reduced motion. */
+ still,
+ destroy() {
+ stop()
+ io.disconnect()
+ ro.disconnect()
+ mo.disconnect()
+ document.removeEventListener('visibilitychange', onVis)
+ reduce.removeEventListener('change', onReduce)
+ removeEventListener('scroll', onScroll)
+ },
+ }
+}
+
+const COMMON_UNIFORMS = ['u_res', 'u_time', 'u_light', 'u_px', 'u_bg', 'u_ink', 'u_acc'] as const
+
+/**
+ * Compile a full-screen fragment shader onto `canvas` and run it. `frame` is
+ * called before each draw to set scene uniforms. Returns null without WebGL.
+ */
+export function createShaderHero(
+ section: HTMLElement,
+ canvas: HTMLCanvasElement,
+ frag: string,
+ uniforms: readonly string[],
+ frame: (st: HeroState, set: UniformSetter) => void,
+) {
+ const gl = canvas.getContext('webgl', {
+ antialias: false,
+ alpha: false,
+ depth: false,
+ stencil: false,
+ powerPreference: 'high-performance',
+ })
+ if (!gl) return null
+ const compile = (type: number, src: string) => {
+ const s = gl.createShader(type)!
+ gl.shaderSource(s, src)
+ gl.compileShader(s)
+ if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) console.error(gl.getShaderInfoLog(s))
+ return s
+ }
+ const prog = gl.createProgram()!
+ gl.attachShader(prog, compile(gl.VERTEX_SHADER, 'attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}'))
+ gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, frag))
+ gl.linkProgram(prog)
+ if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
+ console.error(gl.getProgramInfoLog(prog))
+ return null
+ }
+ gl.useProgram(prog)
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer())
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW)
+ gl.enableVertexAttribArray(0)
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)
+
+ const U: Record = {}
+ for (const k of [...COMMON_UNIFORMS, ...uniforms]) U[k] = gl.getUniformLocation(prog, k)
+ const set: UniformSetter = (name, ...v) => {
+ const loc = U[name]
+ if (v.length === 1) gl.uniform1f(loc, v[0])
+ else if (v.length === 2) gl.uniform2f(loc, v[0], v[1])
+ else if (v.length === 3) gl.uniform3f(loc, v[0], v[1], v[2])
+ else gl.uniform4f(loc, v[0], v[1], v[2], v[3])
+ }
+
+ const runner = runHero(section, canvas, {
+ resize(st) {
+ canvas.width = Math.max(1, Math.round(st.w * st.px))
+ canvas.height = Math.max(1, Math.round(st.h * st.px))
+ gl.viewport(0, 0, canvas.width, canvas.height)
+ },
+ render(st) {
+ const T = themes[st.light ? 'light' : 'dark']
+ set('u_res', canvas.width, canvas.height)
+ set('u_time', st.time)
+ set('u_light', st.light ? 1 : 0)
+ set('u_px', canvas.width / st.w)
+ set('u_bg', ...T.bg)
+ set('u_ink', ...T.ink)
+ set('u_acc', ...T.acc)
+ frame(st, set)
+ gl.drawArrays(gl.TRIANGLES, 0, 3)
+ },
+ })
+
+ return {
+ ...runner,
+ destroy() {
+ runner.destroy()
+ gl.getExtension('WEBGL_lose_context')?.loseContext()
+ },
+ }
+}
+
+export type UniformSetter = (name: string, ...v: number[]) => void
diff --git a/site/src/scripts/hero-story/shader.ts b/site/src/scripts/hero-story/shader.ts
new file mode 100644
index 0000000..067d529
--- /dev/null
+++ b/site/src/scripts/hero-story/shader.ts
@@ -0,0 +1,240 @@
+// "The live conduit" fragment shader.
+//
+// One full-screen pass draws the circuit board behind the hero story in world
+// space (CSS px, origin bottom-left), seen through a 2D camera `u_cam` =
+// (x, y, zoom). The server card and browser window are HTML laid over the top;
+// the shader draws everything between and around them:
+// - the pywire chip on the hero, its pins fanning out into the board (u_chip)
+// - the grid and a board of short traces that conduct now and then; scrolling
+// speeds up the clock that drives them (u_flow) and brightens them (u_energy)
+// - the server and browser footprints, which "compile" out of grid cells as
+// they appear (u_srvOn, u_brwOn) and glow when they fire (u_srvHit, u_brwHit)
+// - the conduit between them, drawn in (u_draw), the tangle of old-stack
+// routes that merges into it (u_stack), and the three transport lanes it
+// splits into (u_tx)
+// - arcs: an event or a patch crossing the wire. u_arcUp / u_arcDn are
+// (head distance, intensity, lane, unused); they cross in a few frames and
+// light the whole wire at once, like current, instead of travelling.
+export const storyFrag = `precision highp float;
+uniform vec2 u_res;uniform float u_time;uniform float u_light;uniform float u_px;
+uniform vec3 u_bg;uniform vec3 u_ink;uniform vec3 u_acc;
+uniform vec3 u_cam;uniform float u_out;uniform float u_amb;uniform float u_flow;uniform float u_energy;
+uniform vec4 u_srv;uniform vec4 u_brw;uniform vec2 u_ta;uniform vec2 u_tb;uniform float u_vert;
+uniform float u_srvOn;uniform float u_brwOn;uniform float u_srvHit;uniform float u_brwHit;uniform float u_conv;
+uniform vec4 u_chip;uniform float u_draw;uniform vec2 u_stack;uniform float u_tx;uniform vec4 u_arcUp;uniform vec4 u_arcDn;
+const float C=32.0;
+float aa,W,Z;
+float h1(float n){return fract(sin(n*127.1+311.7)*43758.5453);}
+float h2(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
+float ln(float d,float w){return 1.0-smoothstep(w*W,w*W+aa,d);}
+vec2 segH(vec2 p,float x0,float x1,float y){float lo=min(x0,x1),hi=max(x0,x1);float dx=max(0.0,max(lo-p.x,p.x-hi));return vec2(length(vec2(dx,p.y-y)),clamp(abs(p.x-x0),0.0,hi-lo));}
+// A three-segment routed wire A -> B, shifted sideways by o with its middle
+// run moved by sh. Returns distance to it, distance along it, and its length.
+vec3 lane(vec2 p,vec2 A,vec2 B,float o,float sh){
+ float sx=B.x>=A.x?1.0:-1.0;float sy=B.y>=A.y?1.0:-1.0;
+ float mx=floor((A.x+B.x)*0.5/C+0.5)*C+sh-o*sx*sy;
+ float y0=A.y+o,y1=B.y+o;
+ vec2 a=segH(p,A.x,mx,y0);float l1=abs(mx-A.x);
+ vec2 b=segH(p.yx,y0,y1,mx);float l2=abs(y1-y0);
+ vec2 c=segH(p,mx,B.x,y1);float l3=abs(B.x-mx);
+ vec3 r=vec3(a.x,a.y,0.0);
+ if(b.x0.0?r.y:r.z-r.y;
+ float lit=step(q,a.x)*a.y;
+ float core=exp(-abs(q-a.x)/6.0)*step(a.x,r.z-1.0)*a.y;
+ return max(lit*0.85,core*1.4);
+}
+// A bus in the board block around g, on the layer picked by sd. Returns trace
+// coverage, conduction light, and unused. u_flow is a clock that runs faster
+// while the page scrolls; more buses fire as u_energy rises.
+vec3 bus(vec2 g,float sd){
+ const float BK=10.0;const float SP=7.0;
+ float row=floor(g.y/BK+sd);float sft=floor(h1(row+sd*13.0)*BK);
+ float col=floor((g.x+sft)/BK);vec2 bo=vec2(col*BK-sft,(row-sd)*BK);
+ vec2 bi=vec2(col,row)+sd*37.0;
+ if(h2(bi+0.37)>0.62)return vec3(0.0);
+ vec2 A=bo+vec2(1.0+floor(h2(bi+1.1)*8.0),1.0+floor(h2(bi+2.3)*8.0))+0.5;
+ vec2 B=bo+vec2(1.0+floor(h2(bi+4.7)*8.0),1.0+floor(h2(bi+6.1)*8.0))+0.5;
+ if(abs(B.x-A.x)+abs(B.y-A.y)<4.0)B.x=A.x=ad.y){float k=floor((ad.x-ad.y)*h2(bi+8.9)+0.5);P1=vec2(A.x+sg.x*k,A.y);P2=vec2(P1.x+sg.x*ad.y,B.y);}
+ else{float k=floor((ad.y-ad.x)*h2(bi+8.9)+0.5);P1=vec2(A.x,A.y+sg.y*k);P2=vec2(B.x,P1.y+sg.y*ad.x);}
+ vec2 s1=seg(g,A,P1),s2=seg(g,P1,P2),s3=seg(g,P2,B);
+ float l1=length(P1-A),l2=length(P2-P1),Lt=l1+l2+length(B-P2);
+ vec2 a0=A,a1=P1;vec2 r=s1;
+ if(s2.x=n)break;lines=max(lines,ln(abs(sdist-float(k)*SP),1.1));}
+ lines*=inner*step(-3.0,sdist);
+ vec2 dA=l1>0.01?P1-A:(l2>0.01?P2-P1:B-P2);vec2 dB=length(B-P2)>0.01?B-P2:(l2>0.01?P2-P1:P1-A);
+ vec2 nA=normalize(vec2(-dA.y,dA.x))*SP/C,nB=normalize(vec2(-dB.y,dB.x))*SP/C;
+ float pA=1e3,pB=1e3;
+ for(int k=0;k<3;k++){if(float(k)>=n)break;vec2 q=vec2(float(k));
+ vec2 ea=abs(g-A-nA*q.x)*C;vec2 eb=abs(g-B-nB*q.x)*C;
+ pA=min(pA,max(ea.x,ea.y));pB=min(pB,max(eb.x,eb.y));}
+ float pads=max(ln(max(pA-1.8,0.0),1.0),ln(max(pB-1.8,0.0),1.0));
+ // Conduction: each cycle a bus may fire, from either end.
+ float P=1.8+1.8*h2(bi+3.3);float cyc=u_flow/P+h2(bi+5.5);float cn=floor(cyc);
+ float age=fract(cyc)*P;
+ float fire=step(h2(bi+cn*1.7),0.3+0.55*u_energy);
+ float fwd=step(0.5,h2(bi+cn*2.9));
+ float q=(fwd>0.5?r.y:Lt-r.y)*C;float L=Lt*C;
+ float head=age*1400.0;float done=L/1400.0;
+ float lit=step(q,head)*exp(-max(age-done,0.0)*2.2);
+ float core=exp(-abs(q-head)/5.0)*step(head,L);
+ float land=step(done,age)*exp(-(age-done)*3.0)*(fwd>0.5?ln(max(pB-1.8,0.0),1.0):ln(max(pA-1.8,0.0),1.0));
+ float cur=fire*max(max(lit,core*1.4)*lines,land);
+ return vec3(max(lines,pads),cur,0.0);
+}
+// The pywire chip on the hero: pins on all four sides, each fanning out into
+// a trace that ends in a via. u_chip is (center, half size, visibility); as
+// visibility drops the traces retract into the chip. Returns (traces and
+// pins, conduction light).
+vec2 chip(vec2 css){
+ vec2 q=css-u_chip.xy;float hs=u_chip.z;float on=u_chip.w;
+ // Fold into the frame of the nearest side, with that side facing +x.
+ bool sx=abs(q.x)>=abs(q.y);
+ vec2 f=sx?vec2(abs(q.x),q.y*sign(q.x)):vec2(abs(q.y),-q.x*sign(q.y));
+ float side=sx?(q.x>0.0?0.0:2.0):(q.y>0.0?1.0:3.0);
+ float pitch=hs*1.6/7.0;
+ float lines=0.0,cur=0.0;
+ for(int i=0;i<8;i++){
+ float fi=float(i);float id=side*8.0+fi;
+ float oy=(fi-3.5)*pitch;
+ float sg=sign(oy);float k=abs(oy)*0.9;
+ vec2 A=vec2(hs+8.0,oy);vec2 P1=vec2(hs+22.0,oy);vec2 P2=vec2(P1.x+k,oy+sg*k);
+ float len=(70.0+170.0*h1(id+4.0))*clamp(hs/80.0,0.6,1.0);
+ vec2 B=vec2(P2.x+len,P2.y);
+ vec2 s1=seg(f,A,P1),s2=seg(f,P1,P2),s3=seg(f,P2,B);
+ float l1=14.0,l2=length(P2-P1),Lt=l1+l2+len;
+ vec2 r=s1;if(s2.x0.5?Lt-r.y:r.y;float head=age*1100.0;
+ float lit=step(qd,head)*exp(-max(age-Lt/1100.0,0.0)*2.4);
+ float core=exp(-abs(qd-head)/5.0)*step(head,Lt);
+ cur=max(cur,fire*max(lit,core*1.4)*max(line,pin*step(0.5,inw)*lit));
+ }
+ return vec2(lines,cur)*step(0.001,on);
+}
+void main(){
+ vec2 size=u_res/u_px;Z=u_cam.z;aa=1.0/(u_px*Z);W=0.5/Z;
+ vec2 scr=gl_FragCoord.xy/u_px;
+ vec2 css=(scr-size*0.5)/Z+u_cam.xy;
+ vec2 g=css/C;vec2 gi=floor(g);vec2 gf=fract(g);
+ vec2 dl=min(gf,1.0-gf)*C;
+ float gx=ln(dl.x,1.0),gy=ln(dl.y,1.0);float gridL=max(gx,gy);vec2 li=floor(g+0.5);
+ float major=max(gx*step(mod(li.x,4.0),0.5),gy*step(mod(li.y,4.0),0.5));
+ vec2 inset=step(vec2(3.0),gf*C)*step(gf*C,vec2(C-3.0));float insq=inset.x*inset.y;
+ vec2 cc=(gi+0.5)*C;
+
+ // Server and browser footprints.
+ float sdS=sdBox(css,u_srv);float sdB=sdBox(css,u_brw);
+ float inS=step(sdBox(cc,u_srv),0.0);float inB=step(sdBox(cc,u_brw),0.0);
+ float tS=h2(gi)*0.55;float onS=smoothstep(tS,tS+0.25,u_srvOn);
+ float fS=inS*insq*onS*(1.0-smoothstep(tS+0.25,tS+0.55,u_srvOn));
+ vec2 dpB=abs(cc-u_tb);float tB=clamp((dpB.x+dpB.y)/max(1.0,2.0*(u_brw.z+u_brw.w)),0.0,1.0)*0.6+h2(gi+3.0)*0.12;
+ float onB=smoothstep(tB,tB+0.2,u_brwOn);
+ float fB=inB*insq*onB*(1.0-smoothstep(tB+0.2,tB+0.5,u_brwOn));
+ float haloS=exp(-max(sdS,0.0)/(26.0+30.0*u_srvHit))*u_srvOn*(0.25+0.9*u_srvHit)*step(0.0,sdS);
+ float haloB=exp(-max(sdB,0.0)/(26.0+30.0*u_brwHit))*u_brwOn*(0.2+0.9*u_brwHit)*step(0.0,sdB);
+ // Signals converging into the server while it compiles.
+ float Ls=max(abs(css.x-u_srv.x)-u_srv.z,0.0)+max(abs(css.y-u_srv.y)-u_srv.w,0.0);
+ float sel=max(gx*step(0.4,h1(li.y+5.0)),gy*step(0.4,h1(li.x+9.0)));
+ float inW=pow(1.0-fract(Ls/224.0+u_time*0.8),9.0)*sel*u_conv*(0.3+0.7*exp(-Ls/700.0))*step(4.0,sdS);
+
+ // The board: buses of one to three traces between pads, routed with 45 degree
+ // jogs like a circuit board, on two layers (the lower one dimmer). Now and
+ // then a bus conducts and lights end to end almost at once.
+ vec3 top=bus(g,0.0),bot=bus(g,0.5);
+ float clr=mix(1.0,smoothstep(8.0,56.0,sdS),u_srvOn)*mix(1.0,smoothstep(8.0,56.0,sdB),u_brwOn);
+ vec2 ch=vec2(0.0);float sdCh=1e4;
+ if(u_chip.w>0.001){
+ sdCh=sdBox(css,vec4(u_chip.xy,u_chip.zz));
+ if(sdCh<460.0)ch=chip(css);
+ clr*=mix(1.0,smoothstep(160.0,300.0,sdCh),u_chip.w);
+ }
+ float trace=max(max(top.x,bot.x*0.5)*clr,ch.x);
+ float cur=max(max(top.y,bot.y*0.55)*clr,ch.y)*(0.8+0.5*u_energy);
+ // The chip's own corners are rounded (22% in CSS); keep the halo round too.
+ float rc=u_chip.z*0.44;float sdChR=sdBox(css,vec4(u_chip.xy,u_chip.zz-rc))-rc;
+ float haloC=exp(-max(sdChR,0.0)/40.0)*u_chip.w*step(0.0,sdChR);
+
+ // Wires.
+ float wire=0.0,spark=0.0,glow=0.0,stack=0.0;
+ if(u_draw>0.001){
+ vec2 P=css,A=u_ta,B=u_tb;if(u_vert>0.5){P=P.yx;A=A.yx;B=B.yx;}
+ for(int k=-1;k<=1;k++){
+ float fk=float(k);
+ if(k!=0&&u_tx<0.001)continue;
+ vec3 r=lane(P,A,B,fk*C*u_tx,0.0);
+ float head=r.z*u_draw;
+ float vis=step(r.y,head+0.5)*(k==0?1.0:smoothstep(0.0,0.4,u_tx));
+ wire=max(wire,ln(r.x,1.3)*vis);
+ float tip=exp(-abs(r.y-head)/16.0)*(1.0-step(0.999,u_draw))*vis;
+ float up=arc(r,u_arcUp,-1.0)*step(abs(u_arcUp.z-fk),0.5);
+ float dn=arc(r,u_arcDn,1.0)*step(abs(u_arcDn.z-fk),0.5);
+ float a=max(max(up,dn),tip)*vis;
+ spark=max(spark,a*ln(r.x,2.2));
+ glow=max(glow,a*exp(-r.x*0.2*Z));
+ }
+ // The old stack: alternate routes that merge into the one conduit.
+ if(u_stack.x>0.001){
+ float m=u_stack.y;
+ for(int k=0;k<3;k++){
+ float fk=float(k);
+ float o=(fk==0.0?-48.0:fk==1.0?40.0:88.0)*(1.0-m);
+ float sh=(fk==0.0?-128.0:fk==1.0?96.0:192.0)*(1.0-m);
+ vec3 r=lane(P,A,B,o,sh);
+ float dash=step(0.45,fract(r.y/12.0-u_time*(0.6+0.3*fk)));
+ stack=max(stack,ln(r.x,1.2)*dash);
+ }
+ stack*=u_stack.x*(1.0-m);
+ }
+ }
+
+ float ph=u_time*0.5+h2(gi)*20.0;
+ float fl=step(0.975,h2(gi+floor(ph)*1.37))*pow(1.0-fract(ph),2.0)*insq*(1.0-inS*u_srvOn)*(1.0-inB*u_brwOn);
+ vec3 col=u_bg;
+ col=mix(col,u_ink,gridL*mix(0.06,0.12,major));
+ col=mix(col,u_acc,fl*0.16*u_amb);
+ col=mix(col,u_ink,clamp(trace*0.2*u_amb,0.0,1.0));
+ col=mix(col,u_acc,clamp(cur*u_amb,0.0,1.0));
+ col=mix(col,u_ink,clamp(stack*0.45,0.0,1.0));
+ col=mix(col,u_acc,clamp(inW+fS*0.7+fB*0.7,0.0,1.0));
+ col=mix(col,u_acc,clamp(wire*0.45+spark,0.0,1.0));
+ float halo=haloS+haloB+haloC*0.6;
+ col+=(u_acc*(halo*0.22+glow*0.35+cur*u_amb*0.18))*(1.0-u_light);
+ col=mix(col,u_acc,clamp((halo*0.12+glow*0.25)*u_light,0.0,1.0));
+ vec2 uv=gl_FragCoord.xy/u_res;
+ float vg=smoothstep(1.25,0.4,length((uv-0.5)*vec2(1.3,1.0)));
+ col=mix(u_bg,col,mix(0.55,1.0,vg));
+ gl_FragColor=vec4(mix(col,u_bg,u_out),1.0);
+}`
diff --git a/site/src/scripts/hero-story/snap.ts b/site/src/scripts/hero-story/snap.ts
new file mode 100644
index 0000000..f650259
--- /dev/null
+++ b/site/src/scripts/hero-story/snap.ts
@@ -0,0 +1,190 @@
+// Snap scrolling for a pinned scroll story, one step per gesture. Between the
+// first and last stop, a scroll gesture (a wheel or trackpad swipe, a touch
+// swipe, an arrow key) glides to the next stop on a critically damped spring.
+// For a moment after a step starts, input is ignored, and the rest of the
+// gesture that took it (a trackpad's momentum, a spun wheel) is swallowed, so
+// however fast you scroll you see every step, one at a time; a new gesture
+// after that carries on from the stop the glide is heading to. Past the ends the page scrolls
+// natively; a gesture that starts at the last stop heading out simply scrolls
+// the page. If the page comes to rest between stops (a scrollbar drag, a
+// native gesture that ran into the story), it settles onto the next stop.
+
+const RATE = 9 // spring rate: a glide settles in about 0.7 s
+const HOLD = 560 // ms after a step starts before another can
+const QUIET = 120 // ms without wheel events that ends a gesture
+const KEYS: Record = { ArrowDown: 1, PageDown: 1, ' ': 1, ArrowUp: -1, PageUp: -1 }
+
+export interface Snap {
+ /** Glide one stop in `dir` (1 down, -1 up). False when there is no stop that way. */
+ go(dir: number): boolean
+ destroy(): void
+}
+
+export function snapScroll(stops: () => number[], enabled: () => boolean): Snap {
+ let target: number | null = null
+ let pos = 0
+ let vel = 0
+ let raf = 0
+ let last = 0
+ let stepAt = -Infinity // when the latest step started
+ let settle = 0
+ let lastY = scrollY
+ let lastDir = 1
+ // The wheel gesture in progress: when its latest event came, whether it
+ // already did its one thing (took a step, or ran into a step playing), and
+ // whether it's the page's to scroll.
+ let wheelAt = -Infinity
+ let spent = false
+ let native = false
+ let acc = 0
+
+ // 'instant': the site sets scroll-behavior: smooth, which would lag every frame.
+ const put = (y: number) => scrollTo({ top: y, behavior: 'instant' })
+ const tick = (now: number) => {
+ const dt = Math.min(1 / 30, Math.max(0, (now - last) / 1000))
+ last = now
+ vel += (RATE * RATE * (target! - pos) - 2 * RATE * vel) * dt
+ pos += vel * dt
+ if (Math.abs(target! - pos) < 0.5 && Math.abs(vel) < 20) {
+ put(target!)
+ target = null
+ vel = 0
+ raf = 0
+ return
+ }
+ put(pos)
+ raf = requestAnimationFrame(tick)
+ }
+ const inside = (y: number) => {
+ const st = stops()
+ return y >= st[0] - 2 && y <= st[st.length - 1] + 2
+ }
+ const beyond = (from: number, d: number) => {
+ const st = stops()
+ return (d > 0 ? st.filter((v) => v > from + 2) : st.filter((v) => v < from - 2).reverse())[0] ?? null
+ }
+ const busy = () => performance.now() - stepAt < HOLD
+
+ const go = (d: number) => {
+ // A step is playing: this input is spent on it.
+ if (busy()) return true
+ // The last glide is nearly home: carry on from its stop.
+ if (target !== null) {
+ const t = beyond(target, d)
+ if (t === null) return true
+ target = t
+ stepAt = performance.now()
+ return true
+ }
+ if (!inside(scrollY)) return false
+ const t = beyond(scrollY, d)
+ if (t === null) return false
+ pos = scrollY
+ vel = 0
+ target = t
+ stepAt = last = performance.now()
+ raf = requestAnimationFrame(tick)
+ return true
+ }
+
+ const onWheel = (e: WheelEvent) => {
+ if (!enabled() || e.ctrlKey || Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return
+ const now = performance.now()
+ const d = Math.sign(e.deltaY)
+ if (now - wheelAt > QUIET) {
+ spent = false
+ native = false
+ acc = 0
+ }
+ wheelAt = now
+ if (native) return
+ if (spent || busy()) {
+ spent = true
+ if (inside(scrollY) || target !== null) e.preventDefault()
+ return
+ }
+ // Outside the stops, or at the end heading out: the page scrolls, for
+ // the whole of this gesture.
+ if (!inside(scrollY) || beyond(scrollY, d) === null) {
+ native = true
+ return
+ }
+ e.preventDefault()
+ if (Math.sign(acc) !== d) acc = 0
+ acc += Math.abs(e.deltaY) * (e.deltaMode === 1 ? 32 : 1) * d
+ if (Math.abs(acc) > 30) {
+ spent = true
+ go(d)
+ }
+ }
+
+ let touchY: number | null = null
+ const onTouchStart = (e: TouchEvent) => {
+ touchY = e.touches.length === 1 ? e.touches[0].clientY : null
+ }
+ const onTouchMove = (e: TouchEvent) => {
+ if (!enabled() || touchY === null) return
+ const dy = touchY - e.touches[0].clientY
+ if (target !== null || (Math.abs(dy) > 6 && inside(scrollY) && beyond(scrollY, Math.sign(dy)) !== null))
+ e.preventDefault()
+ }
+ const onTouchEnd = (e: TouchEvent) => {
+ if (!enabled() || touchY === null) return
+ const dy = touchY - e.changedTouches[0].clientY
+ touchY = null
+ if (Math.abs(dy) >= 30) go(Math.sign(dy))
+ }
+
+ const onKey = (e: KeyboardEvent) => {
+ const d = KEYS[e.key]
+ const el = e.target as HTMLElement
+ if (
+ !enabled() ||
+ !d ||
+ e.altKey ||
+ e.ctrlKey ||
+ e.metaKey ||
+ el.closest('input, textarea, select, [contenteditable]') ||
+ (e.key === ' ' && el.closest('button, a'))
+ )
+ return
+ // A held key doesn't run through the steps.
+ if (e.repeat ? inside(scrollY) : go(e.shiftKey && e.key === ' ' ? -1 : d)) e.preventDefault()
+ }
+
+ // Coming to rest between stops settles onto the next stop the way you were going.
+ const onScroll = () => {
+ const y = scrollY
+ if (y !== lastY) lastDir = Math.sign(y - lastY)
+ lastY = y
+ clearTimeout(settle)
+ if (target !== null || !enabled()) return
+ settle = window.setTimeout(() => {
+ if (target !== null || !enabled() || !inside(scrollY)) return
+ if (stops().some((v) => Math.abs(v - scrollY) <= 2)) return
+ stepAt = -Infinity
+ go(lastDir)
+ }, 250)
+ }
+
+ addEventListener('wheel', onWheel, { passive: false })
+ addEventListener('touchstart', onTouchStart, { passive: true })
+ addEventListener('touchmove', onTouchMove, { passive: false })
+ addEventListener('touchend', onTouchEnd, { passive: true })
+ addEventListener('keydown', onKey)
+ addEventListener('scroll', onScroll, { passive: true })
+
+ return {
+ go,
+ destroy() {
+ cancelAnimationFrame(raf)
+ clearTimeout(settle)
+ removeEventListener('wheel', onWheel)
+ removeEventListener('touchstart', onTouchStart)
+ removeEventListener('touchmove', onTouchMove)
+ removeEventListener('touchend', onTouchEnd)
+ removeEventListener('keydown', onKey)
+ removeEventListener('scroll', onScroll)
+ },
+ }
+}
diff --git a/site/src/scripts/hero-story/story.ts b/site/src/scripts/hero-story/story.ts
new file mode 100644
index 0000000..82a5abc
--- /dev/null
+++ b/site/src/scripts/hero-story/story.ts
@@ -0,0 +1,764 @@
+// "The live conduit" hero: a pinned scroll story over a circuit-board grid.
+// 0 hero the pywire chip sits on a live circuit board
+// 1 python the server card compiles out of the grid (a .wire component)
+// 2 conduit the old stack's tangle of routes merges into one live wire
+// 3 patch a click crosses the wire, the server bumps state, and only the
+// changed text node patches; three scripted clicks, then yours
+// 4 transport the conduit splits into WebSocket, WebTransport, long polling
+// 5 edge stateless: the server and its wire go, a world map of edge
+// locations comes up, and you travel around it; every click
+// lands on whichever edge is nearest
+// The camera frames each beat; HTML cards ride the same camera transform.
+import { createShaderHero, reducedMotion, type HeroState } from './runtime'
+import { storyFrag } from './shader'
+import { snapScroll } from './snap'
+import { EDGES, MAP_H, MAP_W, TRIP, project } from './world'
+
+type V2 = [number, number]
+/** Center x, y (world, origin bottom-left) and half width, half height. */
+type Rect = [number, number, number, number]
+interface Layout {
+ srv: Rect
+ brw: Rect
+ /** The hero's chip: center x, y and half size. */
+ chip: [number, number, number]
+ /** The edge beat's world map, and where the browser moves for it. */
+ map: Rect
+ brwEdge: V2
+ /** Base zoom: the diagram scaled to fit the screen, and the screen band
+ * (top-down) it has to fit in, above the step copy. */
+ z0: number
+ band: V2
+ ta: V2
+ tb: V2
+ vert: boolean
+}
+
+const C = 32
+const sn = (v: number) => Math.round(v / C) * C
+const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v)
+const ss = (t: number) => {
+ const x = clamp01(t)
+ return x * x * (3 - 2 * x)
+}
+const ramp = (s: number, a: number, d: number) => ss((s - a) / d)
+const TAIL = 1 / 6.4 // share of the scroll spent pulling the camera out
+// Beats 1 to 4 run on their own clock `s` over the first CORE of the story;
+// the edge beat takes the rest.
+const CORE = 0.82
+const EDGE = 0.84
+const BEATS = [...[0.14, 0.36, 0.58, 0.8].map((b) => b * CORE), EDGE]
+const stepOf = (s: number) => BEATS.filter((b) => s >= b).length
+// Where the next button lands for each step (story progress), and what it says.
+// These are also the stops that scrolling snaps between.
+const NEXT: [number, string][] = [
+ [0.27 * CORE, 'Write Python, not a frontend'],
+ [0.555 * CORE, 'One conduit, no API layer'],
+ [0.67 * CORE, 'Click. Patch. Done.'],
+ [0.9 * CORE, "Any wire you've got"],
+ [0.93, 'Stateless, at the edge'],
+ [-1, 'Why pywire'],
+]
+const ARC = 0.07 // seconds for an arc to cross the wire
+const HOP = 0.16 // seconds for a packet to cross to an edge on the map
+const GLIDE = 1.3 // seconds travelling between two cities
+const STAY = 1.5 // seconds in each city
+const MAP_AR = MAP_W / MAP_H
+const LANES = [-1, 0, 1]
+
+/** Where the page's copy sits (screen px, top-down), measured from the DOM. */
+interface Fit {
+ /** Top of the tallest step copy. */
+ copyTop: number
+ /** Bottom of the hero copy, and top of the next button on the hero. */
+ heroBottom: number
+ nextTop: number
+}
+
+function layout(w: number, h: number, fit: Fit): Layout {
+ const portrait = w < 800 || w / h < 1.05
+ // Positions are picked top-down, then flipped into world space.
+ const y = (top: number) => h - top
+ if (portrait) {
+ // The server card above the browser, fitted into the band above the step
+ // copy: the base camera (w/2, h/2, z0) scales the pair up on tablets and
+ // down on short phones (Safari with its toolbars showing).
+ const top = Math.max(20, h * 0.05)
+ const bottom = Math.max(top + 120, fit.copyTop - 18)
+ const room = bottom - top
+ const bw = 150
+ const bh = 88
+ const gap = 64 + 40 * clamp01((room - 300) / 200)
+ const need = 128 + gap + 2 * bh
+ const z0 = Math.min(1.5, (0.88 * w) / (2 * bw), room / need)
+ // Screen top-down to world top-down, through the base camera.
+ const wy = (v: number) => h / 2 + (v - h / 2) / z0
+ const t0 = wy(top) + (room / z0 - need) / 2
+ const srv: Rect = [sn(w * 0.5), sn(y(t0 + 64)), 136, 64]
+ const brw: Rect = [sn(w * 0.5), sn(y(t0 + 128 + gap + bh)), bw, bh]
+ // The hero's chip sits between the hero copy and the next button, if it fits.
+ const hg = fit.nextTop - fit.heroBottom
+ const ch = Math.round(Math.min(52, w * 0.13, hg / 2 - 30))
+ // The map takes the server card's place, over the browser.
+ const brwTop = h / 2 + (y(brw[1] + brw[3]) - h / 2) * z0
+ const mh = Math.min(Math.min(w * 0.96, 720) / MAP_AR, brwTop - top - 24)
+ const mc = (top + brwTop - 12) / 2
+ return {
+ srv,
+ brw,
+ map: [sn(w * 0.5), y(wy(mc)), (mh * MAP_AR) / 2 / z0, mh / 2 / z0],
+ brwEdge: [brw[0], brw[1]],
+ chip: ch >= 22 ? [sn(w * 0.5), sn(y((fit.heroBottom + fit.nextTop) / 2)), ch] : [0, 0, 0],
+ ta: [srv[0] - C, srv[1] - srv[3]],
+ tb: [brw[0] + C, brw[1] + brw[3]],
+ vert: true,
+ z0,
+ band: [top, bottom],
+ }
+ }
+ // Landscape: the diagram is laid out for a W x H screen, at least 1440 x 900,
+ // and the base camera (w/2, h/2, z0) scales it down to fit, so smaller
+ // screens (tablets, Safari with its toolbars) get the same composition.
+ const top = Math.max(24, h * 0.06)
+ const bottom = fit.copyTop - 16
+ const z0 = Math.min(1, w / 1440, (bottom - top) / 300)
+ const W = w / z0
+ const H = h / z0
+ const bw = Math.min(210, W * 0.17)
+ const bh = Math.min(136, H * 0.17)
+ // Virtual to world, top-down: the virtual screen's center lands on the
+ // camera's, unless that runs the browser into the copy; then the pair
+ // centers in the band above the copy instead.
+ const wy = (v: number) => h / 2 + (v - h / 2) / z0
+ const [st, bb] = [H * 0.33 - 80, H * 0.4 + bh]
+ let shift = (h - H) / 2
+ if (wy(bottom) < bb + shift) shift = wy((top + bottom) / 2) - (st + bb) / 2
+ const dx = sn((w - W) / 2)
+ const dy = -sn(shift)
+ const srv: Rect = [sn(W * 0.2) + dx, sn(h - H * 0.33) + dy, 148, 80]
+ const brw: Rect = [sn(W * 0.7) + dx, sn(h - H * 0.4) + dy, bw, bh]
+ const ch = Math.round(Math.min(88, Math.max(56, w * 0.055)))
+ // The map spans the top; the browser tucks under it, right of the copy.
+ // Picked on screen, then taken through the base camera into the world.
+ const wx = (v: number) => w / 2 + (v - w / 2) / z0
+ const mh = Math.min(h * 0.21, (w * 0.46) / MAP_AR)
+ const mt = h * 0.09
+ const bt = Math.max(mt + 2 * mh + bh * z0 + 28, h * 0.62)
+ return {
+ srv,
+ brw,
+ map: [w / 2, y(wy(mt + mh)), (mh * MAP_AR) / z0, mh / z0],
+ brwEdge: [wx(Math.min(w * 0.78, w - bw * z0 - 48)), y(wy(bt))],
+ chip: [sn(w * 0.72), sn(h * 0.5), ch],
+ ta: [srv[0] + srv[2], srv[1]],
+ tb: [brw[0] - brw[2], brw[1]],
+ vert: false,
+ z0,
+ band: [0, fit.copyTop],
+ }
+}
+
+/** A lane as polyline points, matching `lane()` in the shader. */
+function lanePath(L: Layout, o: number, sh: number): V2[] {
+ const sw = (p: V2): V2 => (L.vert ? [p[1], p[0]] : p)
+ const A = sw(L.ta)
+ const B = sw(L.tb)
+ const sx = B[0] >= A[0] ? 1 : -1
+ const sy = B[1] >= A[1] ? 1 : -1
+ const mx = Math.floor(((A[0] + B[0]) * 0.5) / C + 0.5) * C + sh - o * sx * sy
+ const pts: V2[] = [
+ [A[0], A[1] + o],
+ [mx, A[1] + o],
+ [mx, B[1] + o],
+ [B[0], B[1] + o],
+ ]
+ return pts.map(sw)
+}
+const pathLength = (pts: V2[]) =>
+ pts.slice(1).reduce((n, p, i) => n + Math.abs(p[0] - pts[i][0]) + Math.abs(p[1] - pts[i][1]), 0)
+function pointAt(pts: V2[], d: number): V2 {
+ for (let i = 1; i < pts.length; i++) {
+ const a = pts[i - 1]
+ const b = pts[i]
+ const l = Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1])
+ if (d <= l || i === pts.length - 1) {
+ const t = l ? clamp01(d / l) : 0
+ return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
+ }
+ d -= l
+ }
+ return pts[pts.length - 1]
+}
+
+type Key = [number, number, number, number]
+function keyed(s: number, keys: Key[]): [number, number, number] {
+ if (s <= keys[0][0]) return [keys[0][1], keys[0][2], keys[0][3]]
+ for (let i = 1; i < keys.length; i++) {
+ if (s <= keys[i][0]) {
+ const a = keys[i - 1]
+ const b = keys[i]
+ const t = ss((s - a[0]) / (b[0] - a[0]))
+ return [
+ a[1] + (b[1] - a[1]) * t,
+ a[2] + (b[2] - a[2]) * t,
+ Math.exp(Math.log(a[3]) + (Math.log(b[3]) - Math.log(a[3])) * t),
+ ]
+ }
+ }
+ const k = keys[keys.length - 1]
+ return [k[1], k[2], k[3]]
+}
+
+function camera(s: number, out: number, L: Layout, w: number, h: number): [number, number, number] {
+ const c: V2 = [w / 2, h / 2]
+ const { srv, brw, z0 } = L
+ // Frame both cards; on landscape keep the pair in the top of the screen so
+ // the step copy has the bottom. Portrait centers on the cards (on the grid).
+ const both: V2 = [(srv[0] + brw[0]) / 2, h / 2]
+ let patch: V2 = [(srv[0] + brw[0]) / 2, (srv[1] + brw[1]) / 2 - 90]
+ let zs = 1.7 * z0
+ let zp = 1.18 * z0
+ let focus: V2 = [srv[0], srv[1] - 30]
+ if (L.vert) {
+ // Portrait: zoom about the middle of the band, so what's in the band stays in it.
+ const mid = (L.band[0] + L.band[1]) / 2
+ const room = L.band[1] - L.band[0]
+ const aim = (p: number, z: number) => p + (mid - h / 2) / z // world y seen at `mid`
+ zs = Math.min(1.35 * Math.max(1, z0), (0.94 * w) / (2 * srv[2]), room / 150)
+ zp = Math.min(1.06 * z0, (0.94 * w) / (2 * brw[2]))
+ focus = [srv[0], aim(srv[1], zs)]
+ patch = [srv[0], aim(h / 2 - (mid - h / 2) / z0, zp)]
+ }
+ const k = keyed(s, [
+ [0.06, ...c, 1],
+ [0.2, ...focus, zs],
+ [0.32, ...focus, zs],
+ [0.44, ...both, z0],
+ [0.56, ...both, z0],
+ [0.64, ...patch, zp],
+ [0.76, ...patch, zp],
+ [0.86, ...both, z0],
+ ])
+ k[2] *= 1 - 0.3 * ss(out)
+ return k
+}
+
+const UNIFORMS = [
+ 'u_cam',
+ 'u_out',
+ 'u_amb',
+ 'u_flow',
+ 'u_energy',
+ 'u_srv',
+ 'u_brw',
+ 'u_ta',
+ 'u_tb',
+ 'u_vert',
+ 'u_srvOn',
+ 'u_brwOn',
+ 'u_srvHit',
+ 'u_brwHit',
+ 'u_conv',
+ 'u_draw',
+ 'u_stack',
+ 'u_tx',
+ 'u_arcUp',
+ 'u_arcDn',
+ 'u_chip',
+]
+
+interface Arc {
+ t: number // seconds since it left
+ lane: number
+ i: number // peak intensity
+}
+
+export function mountStory(root: HTMLElement) {
+ const canvas = root.querySelector('canvas')!
+ const world = root.querySelector('[data-world]')!
+ const steps = [...root.querySelectorAll('[data-step]')]
+ const bar = root.querySelector('[data-progress]')
+ const q = (sel: string) => root.querySelector(sel)!
+ const srvEl = q('[data-ov="server"]')
+ const brwEl = q('[data-ov="browser"]')
+ const chip = q('[data-ov="chip"]')
+ const chipText = q('[data-chip-text]')
+ const ghost = q('[data-ghost]')
+ const stackEls = [...root.querySelectorAll('[data-ov="stack"]')]
+ const txEls = [...root.querySelectorAll('[data-ov="tx"]')]
+ const serverCount = q('[data-count="server"]')
+ const browserCount = q('[data-count="browser"]')
+ const button = q('[data-add]')
+ const reduce = reducedMotion()
+ const chipEl = q('[data-ov="core"]')
+ const mapEl = q('[data-ov="map"]')
+ const beacons = [...root.querySelectorAll('[data-beacon]')]
+ const link = root.querySelector('[data-link]')!
+ const packet = root.querySelector('[data-packet]')!
+ const you = root.querySelector('[data-you]')!
+ const youMark = you.firstElementChild as SVGGElement
+ const edgeAt = EDGES.map(([, lon, lat]) => project(lon, lat))
+ const cities = TRIP.map(([lon, lat]) => project(lon, lat))
+ const next = q('[data-next]')
+ const nextLabel = q('[data-next-label]')
+
+ let count = 0
+ let geom = ''
+ let L = layout(1, 1, { copyTop: 1, heroBottom: 0, nextTop: 1 })
+ let main = lanePath(L, 0, 0)
+ let len = 1
+ let cur = -2
+ let live = false
+ // Round trip: the event arc goes up, the server bumps state, the patch comes down.
+ let up: Arc | null = null
+ let dn: Arc | null = null
+ let queued = 0
+ let srvHit = 0
+ let handling = -1 // seconds the server has been handling an event, -1 = idle
+ let brwHit = 0
+ let chipT = 9
+ let lane = 0
+ let ping = 0
+ // Scripted demo: three clicks a second apart the first time beat 3 is reached.
+ let demo = -1 // seconds into the demo, -1 = not started
+ let demoDone = false
+ let clicks = 0
+ let flow = 0
+ let energy = 0
+ let lastScroll = 0
+ let edge = 0
+ // The edge beat: where you are on the trip, the edge serving you, and the
+ // edge your latest event went to.
+ let leg = 0
+ let legT = GLIDE
+ let near = -1
+ let sentTo = 0
+ let youAt: V2 = cities[0]
+ let edgeClick = 9 // seconds since the trip last clicked
+ let brwAt = ''
+ let chipAt: V2 = [0, 0]
+ const paced: number[] = []
+ // Move toward `to`, forward no faster than 1/`secs` per second. Backward is
+ // quick, and without a frame delta (a still frame) it snaps.
+ const pace = (k: number, to: number, secs: number, dt: number) => {
+ const v = paced[k] ?? to
+ paced[k] = dt > 0 && to > v ? Math.min(to, v + dt / secs) : dt > 0 ? Math.max(to, v - dt * 3) : to
+ return paced[k]
+ }
+ let scrollRange = 1
+
+ const setText = (n: HTMLElement, v: string) => {
+ if (n.textContent !== v) n.textContent = v
+ }
+ const flash = (el: Element, cls: string) => {
+ el.classList.remove(cls)
+ void el.getBoundingClientRect()
+ el.classList.add(cls)
+ }
+ const send = () => {
+ up = { t: 0, lane, i: 0.75 }
+ // Stateless: the event carries its signed state along to the nearest edge.
+ setText(chipText, edge > 0.5 ? '@click + state' : '@click')
+ sentTo = Math.max(0, near)
+ chipT = 0
+ }
+ // The server takes the event: on the server card the @click line lights
+ // first, then (handled) the state line changes and the patch goes down.
+ // At the edge it's handled at once.
+ const onServer = () => {
+ srvHit = 0.35
+ if (edge < 0.5) {
+ flash(srvEl, 'click')
+ handling = 0
+ } else handled()
+ }
+ const handled = () => {
+ count++
+ setText(serverCount, String(count))
+ if (edge < 0.5) flash(srvEl, 'hit')
+ else flash(beacons[sentTo], 'hit')
+ dn = { t: -0.02, lane: up?.lane ?? lane, i: 0.75 }
+ }
+ const onBrowser = () => {
+ setText(browserCount, String(count))
+ flash(browserCount, 'patched')
+ setText(chipText, `patch "${count}"`)
+ chipT = 0
+ // Quieter at the edge: the count patches without the window lighting up.
+ if (edge < 0.5) brwHit = 0.35
+ }
+
+ // The edge beat on the map (in map units): you, the edge nearest you, and
+ // an event's packet crossing to its edge and the patch coming back.
+ const trip = (on: number) => {
+ const a = cities[leg]
+ const b = cities[(leg + 1) % cities.length]
+ // The last leg heads east over the Pacific and wraps around.
+ const bx = b[0] < a[0] ? b[0] + MAP_W : b[0]
+ const t = ss(Math.min(1, legT / GLIDE))
+ youAt = [(a[0] + (bx - a[0]) * t) % MAP_W, a[1] + (b[1] - a[1]) * t]
+ let n = 0
+ edgeAt.forEach((e, i) => {
+ const d = (p: V2) => Math.hypot(p[0] - youAt[0], p[1] - youAt[1])
+ if (d(e) < d(edgeAt[n])) n = i
+ })
+ if (n !== near) {
+ beacons[near]?.classList.remove('on')
+ beacons[n].classList.add('on')
+ near = n
+ }
+ you.setAttribute('transform', `translate(${youAt[0].toFixed(2)} ${youAt[1].toFixed(2)})`)
+ you.style.opacity = String(on)
+ const e = edgeAt[near]
+ link.setAttribute('x1', youAt[0].toFixed(2))
+ link.setAttribute('y1', youAt[1].toFixed(2))
+ link.setAttribute('x2', String(e[0]))
+ link.setAttribute('y2', String(e[1]))
+ link.style.opacity = String(on * 0.9)
+ // Packet: out along the link, then the patch back.
+ let f = -1
+ let o = 0
+ if (dn && dn.t >= 0 && dn.i > 0.6) {
+ f = 1 - Math.min(1, dn.t / HOP)
+ o = clamp01(1 - (dn.t - HOP) * 8)
+ } else if (up) {
+ f = Math.min(1, up.t / HOP)
+ o = 1
+ }
+ const to = edgeAt[sentTo]
+ if (f >= 0) {
+ packet.setAttribute('cx', (youAt[0] + (to[0] - youAt[0]) * f).toFixed(2))
+ packet.setAttribute('cy', (youAt[1] + (to[1] - youAt[1]) * f).toFixed(2))
+ }
+ packet.style.opacity = String(o * on)
+ link.classList.toggle('busy', f >= 0 && o > 0.5)
+ }
+
+ const place = (el: HTMLElement, x: number, y: number, h: number) => {
+ el.style.transform = `translate(${x}px, ${h - y}px)`
+ }
+ const size = (el: HTMLElement, r: Rect, h: number) => {
+ Object.assign(el.style, {
+ left: `${r[0] - r[2]}px`,
+ top: `${h - r[1] - r[3]}px`,
+ width: `${2 * r[2]}px`,
+ height: `${2 * r[3]}px`,
+ })
+ }
+
+ const split = (p: number) => ({ s: Math.min(1, p / (1 - TAIL)), out: Math.max(0, (p - (1 - TAIL)) / TAIL) })
+ const updateSteps = (s: number, out: number) => {
+ let i = stepOf(s)
+ // Hysteresis, so hovering around the hand-off doesn't flicker the copy.
+ if (out > (cur === -1 ? 0.14 : 0.22)) i = -1
+ if (i !== cur) {
+ cur = i
+ steps.forEach((node, j) => node.classList.toggle('on', j === i))
+ root.dataset.step = String(i)
+ if (i >= 0) setText(nextLabel, NEXT[i][1])
+ next.classList.toggle('gone', i < 0)
+ next.disabled = i < 0
+ }
+ if (bar) bar.style.transform = `scaleX(${s})`
+ }
+
+ const stackShift = [
+ [-48, -128],
+ [40, 96],
+ [88, 192],
+ ]
+
+ const frame = (st: HeroState, set: (n: string, ...v: number[]) => void) => {
+ const { w, h, dt } = st
+ const key = `${w}x${h}`
+ if (key !== geom) {
+ geom = key
+ L = layout(w, h, {
+ copyTop: Math.min(...steps.slice(1).map((el) => el.offsetTop)),
+ heroBottom: steps[0].offsetTop + steps[0].offsetHeight,
+ // On the hero the next button sits 80px up, plus what's kept clear (see .next).
+ nextTop: h - 80 - clear() - next.offsetHeight,
+ })
+ main = lanePath(L, 0, 0)
+ len = pathLength(main)
+ size(srvEl, L.srv, h)
+ size(brwEl, L.brw, h)
+ size(chipEl, [L.chip[0], L.chip[1], L.chip[2], L.chip[2]], h)
+ size(mapEl, L.map, h)
+ // Keep the map's markers and labels legible when the map is small.
+ const k = Math.min(2.4, Math.max(1, (11 * MAP_W) / (3.6 * 2 * L.map[2] * L.z0))).toFixed(2)
+ beacons.forEach((b) => b.setAttribute('transform', `scale(${k})`))
+ youMark.setAttribute('transform', `scale(${k})`)
+ packet.setAttribute('r', String(1.3 * +k))
+ mapEl.style.setProperty('--k', k)
+ brwAt = ''
+ root.dataset.orient = L.vert ? 'portrait' : 'landscape'
+ scrollRange = Math.max(1, root.offsetHeight - innerHeight)
+ chipAt = pointAt(main, len / 2)
+ stackEls.forEach((el, k) => {
+ const pts = lanePath(L, stackShift[k][0], stackShift[k][1])
+ const p = pointAt(pts, pathLength(pts) * 0.5)
+ place(el, p[0], p[1], h)
+ })
+ const lanes = LANES.map((o) => lanePath(L, o * C, 0))
+ // Portrait: the labels stack beside the lanes, where there's room.
+ const right = Math.max(...lanes.flat().map((p) => p[0])) + 10
+ const mid = (L.ta[1] + L.tb[1]) / 2
+ // Keep them on screen: portrait frames this beat at (srv x, h/2, z0).
+ const rim = L.srv[0] + (w / 2 - 8) / L.z0
+ txEls.forEach((el, k) => {
+ const x = Math.min(right, rim - el.offsetWidth)
+ const p = L.vert ? [x, mid + (1 - k) * 20] : pointAt(lanes[k], 96)
+ place(el, p[0], p[1], h)
+ })
+ }
+ const { s: sa, out } = split(st.scroll)
+ const s = Math.min(1, sa / CORE)
+ const cam = camera(s, out, L, w, h)
+
+ // Reveals follow the scroll but never faster than their own pace, so a
+ // quick scroll can't rush the server compiling or the wire drawing in.
+ const srvOn = pace(0, ramp(s, 0.14, 0.14), 1.1, dt)
+ const brwOn = pace(1, ramp(s, 0.36, 0.1), 1.0, dt)
+ const draw = pace(2, ramp(s, 0.4, 0.08), 0.7, dt)
+ const stackVis = ramp(s, 0.4, 0.04)
+ // The old stack's routes hold a moment so their labels can be read, then
+ // ease into the one conduit.
+ const merge = ss(clamp01((pace(3, ramp(s, 0.47, 0.07), 2.2, dt) - 0.5) / 0.5))
+ const tx = pace(4, ramp(s, 0.8, 0.06), 0.7, dt)
+ const chipOn = 1 - ramp(s, 0.02, 0.09)
+ edge = pace(5, ramp(sa, EDGE, 0.06), 1.2, dt)
+ // In the edge beat the server and its wire go, and the browser makes room
+ // for the map.
+ const ee = ss(edge)
+ const gone = 1 - ramp(edge, 0, 0.4)
+ const bx = (L.brwEdge[0] - L.brw[0]) * ee
+ const by = (L.brwEdge[1] - L.brw[1]) * ee
+ const brwR: Rect = [L.brw[0] + bx, L.brw[1] + by, L.brw[2], L.brw[3]]
+ const tb: V2 = [L.tb[0] + bx, L.tb[1] + by]
+ // Landscape recenters on the map; portrait is framed for it already.
+ const cx = L.vert ? cam[0] : cam[0] + (w / 2 - cam[0]) * ee
+ const cy = L.vert ? cam[1] : cam[1] + (h / 2 - cam[1]) * ee
+ const z = cam[2]
+ const bk = `${bx.toFixed(1)} ${by.toFixed(1)}`
+ if (bk !== brwAt) {
+ brwAt = bk
+ brwEl.style.translate = `${bx}px ${-by}px`
+ }
+ const conv = ramp(s, 0.12, 0.06) * (1 - ramp(s, 0.3, 0.06))
+ live = s >= 0.56 && out < 0.9
+
+ if (dt > 0) {
+ // Scripted demo clicks, then it's the visitor's turn.
+ if (live && !demoDone && s >= 0.6 && s < 0.8) {
+ demo = Math.max(demo, 0) + dt
+ const at = 0.6 + clicks
+ if (clicks < 3 && demo >= at) {
+ clicks++
+ flash(button, 'pressed')
+ queued++
+ }
+ if (demo >= 3.4) {
+ demoDone = true
+ button.classList.add('your-turn')
+ }
+ }
+ if (live) {
+ // In the transport beat, keepalive pings walk the three lanes.
+ // Stateless holds no connection open, so no keepalives in the edge beat.
+ if (tx > 0.5 && edge < 0.5) {
+ ping -= dt
+ if (ping <= 0 && !up && !dn && queued === 0) {
+ lane = LANES[(LANES.indexOf(lane) + 1) % 3]
+ dn = { t: 0, lane, i: 0.4 }
+ ping = 0.8
+ }
+ } else lane = 0
+ if (queued > 0 && !up && handling < 0 && !(dn && dn.i > 0.6)) {
+ queued--
+ send()
+ }
+ } else {
+ up = dn = null
+ queued = 0
+ }
+ // The trip: glide to the next city, stay a moment, click on arrival.
+ if (edge > 0.9) {
+ legT += dt
+ if (legT >= GLIDE + STAY) {
+ legT -= GLIDE + STAY
+ leg = (leg + 1) % cities.length
+ }
+ // One click in each city, on arrival.
+ if (live && legT - dt < GLIDE && legT >= GLIDE) {
+ flash(button, 'pressed')
+ edgeClick = 0
+ queued = Math.min(queued + 1, 4)
+ }
+ }
+ edgeClick += dt
+ // The cursor that presses Add one: the scripted demo in beat 3, then
+ // every click on the trip in the edge beat.
+ if (demo >= 0 && !demoDone) {
+ const t = demo - (0.6 + clicks - 1)
+ ghost.style.opacity = String(clamp01(demo * 4) * (s >= 0.6 && s < 0.8 ? 1 : 0))
+ ghost.classList.toggle('down', clicks > 0 && t >= 0 && t < 0.15)
+ } else if (edge > 0.9 && live) {
+ ghost.style.opacity = String(clamp01((edge - 0.9) * 10))
+ ghost.classList.toggle('down', edgeClick < 0.15)
+ } else ghost.style.opacity = '0'
+ const hop = edge > 0.5 ? HOP : ARC
+ if (up) {
+ const before = up.t
+ up.t += dt
+ if (before < hop && up.t >= hop) onServer()
+ if (up.t > 0.4) up = null
+ }
+ if (dn) {
+ const before = dn.t
+ dn.t += dt
+ if (before < hop && dn.t >= hop && dn.i > 0.6) onBrowser()
+ if (dn.t > 0.4) dn = null
+ }
+ if (handling >= 0) {
+ handling += dt
+ if (handling >= 0.16) {
+ handling = -1
+ handled()
+ }
+ }
+ srvHit = Math.max(0, srvHit - dt * 2.2)
+ brwHit = Math.max(0, brwHit - dt * 2.2)
+ chipT += dt
+ }
+ const arcU = (a: Arc | null): [number, number, number, number] =>
+ a && a.t >= 0
+ ? [(len * Math.min(a.t, ARC)) / ARC, a.i * Math.exp(-Math.max(0, a.t - ARC) * 6), a.lane, 0]
+ : [0, 0, 0, 0]
+
+ set('u_cam', cx, cy, z)
+ // Scrolling energizes the board: its clock runs up to 3.5x faster and it glows.
+ const sv = dt > 0 ? (Math.abs(st.scroll - lastScroll) * scrollRange) / dt : 0
+ lastScroll = st.scroll
+ energy += (Math.min(1, sv / 1400) - energy) * (1 - Math.exp(-dt * (sv > 0 ? 6 : 1.5)))
+ flow += dt * (1 + 2.5 * energy)
+ set('u_flow', flow)
+ set('u_energy', energy)
+ set('u_out', out * 0.8)
+ set('u_amb', (1 - 0.6 * srvOn) * (1 - 0.5 * ee))
+ set('u_srv', ...L.srv)
+ set('u_brw', ...brwR)
+ set('u_ta', ...L.ta)
+ set('u_tb', ...tb)
+ set('u_vert', L.vert ? 1 : 0)
+ set('u_srvOn', srvOn * gone)
+ set('u_brwOn', brwOn)
+ set('u_srvHit', srvHit)
+ set('u_brwHit', brwHit)
+ set('u_conv', conv)
+ set('u_draw', draw * gone)
+ set('u_stack', stackVis, merge)
+ set('u_tx', tx * gone)
+ set('u_arcUp', ...arcU(up))
+ set('u_arcDn', ...arcU(dn))
+ set('u_chip', ...L.chip, L.chip[2] ? chipOn : 0)
+
+ // HTML overlays share the camera: one transform maps world to screen.
+ world.style.transform = `translate(${w / 2 - cx * z}px, ${h / 2 - (h - cy) * z}px) scale(${z})`
+ const fade = clamp01(1 - out * 2.2)
+ chipEl.style.opacity = String(L.chip[2] ? ramp(chipOn, 0.4, 0.6) : 0)
+ const mapOn = ramp(edge, 0.25, 0.5) * fade
+ mapEl.style.opacity = String(mapOn)
+ if (mapOn > 0) trip(ramp(edge, 0.75, 0.25))
+ // Stateless: no one server holds the app any more; it runs on every node.
+ srvEl.style.opacity = String(ramp(srvOn, 0.55, 0.45) * fade * gone)
+ brwEl.style.opacity = String(ramp(brwOn, 0.5, 0.5) * fade)
+ const cardOn = live && brwOn > 0.9 && fade > 0.5
+ if (button.disabled === cardOn) {
+ button.disabled = !cardOn
+ brwEl.toggleAttribute('inert', !cardOn)
+ }
+ if (edge > 0.5) {
+ // In the edge beat the event label rides along with you.
+ const u = L.map[2] / MAP_W
+ place(chip, L.map[0] - L.map[2] + youAt[0] * 2 * u, L.map[1] + L.map[3] - youAt[1] * 2 * u, h)
+ } else place(chip, chipAt[0], chipAt[1], h)
+ chip.style.opacity = String(live ? clamp01(1 - (chipT - 0.5) / 0.4) * fade : 0)
+ stackEls.forEach((el) => (el.style.opacity = String(stackVis * (1 - merge) * fade)))
+ txEls.forEach((el, k) => {
+ el.style.opacity = String(ramp(tx, 0.4, 0.6) * fade * gone)
+ el.classList.toggle('on', tx > 0.5 && (up?.lane ?? dn?.lane) === LANES[k])
+ })
+ updateSteps(sa, out)
+ }
+
+ // The stage is 100svh, but iPhone Safari's svh runs under its floating
+ // toolbar; the CSS keeps 100svh - 100dvh clear (--over), and the layout
+ // measures the same thing to fit the diagram above it.
+ const dvh = q('[data-dvh]')
+ const clear = () => Math.max(0, canvas.clientHeight - dvh.offsetHeight)
+ // The visible height changes with the toolbar: refit.
+ const fitOver = () => (geom = '')
+ // ?viewport shows what this browser reports about its viewport.
+ if (new URLSearchParams(location.search).has('viewport'))
+ void import('./viewport-probe').then((m) => m.probe(canvas, next))
+ visualViewport?.addEventListener('resize', fitOver)
+ addEventListener('resize', fitOver)
+
+ const hero = createShaderHero(root, canvas, storyFrag, UNIFORMS, frame)
+ // The layout measures the copy, which moves once the web fonts load.
+ void document.fonts?.ready.then(() => (geom = ''))
+ // Without WebGL the copy still steps through on scroll over a static grid.
+ const onScroll = () => {
+ const r = root.getBoundingClientRect()
+ const { s, out } = split(clamp01(-r.top / Math.max(1, r.height - innerHeight)))
+ updateSteps(s, out)
+ }
+ if (!hero) {
+ root.classList.add('no-gl')
+ addEventListener('scroll', onScroll, { passive: true })
+ onScroll()
+ }
+
+ const onAdd = () => {
+ button.classList.remove('your-turn')
+ if (reduce.matches || !hero) {
+ count++
+ setText(serverCount, String(count))
+ setText(browserCount, String(count))
+ hero?.still()
+ return
+ }
+ queued = Math.min(queued + 1, 4)
+ }
+ button.addEventListener('click', onAdd)
+
+ // Stops, as page scroll offsets: the hero, each beat, and the end of the
+ // story. Scrolling snaps between them (see snap.ts); reduced motion keeps
+ // native scrolling.
+ const stops = () => {
+ const r = root.getBoundingClientRect()
+ const range = r.height - innerHeight
+ const top = scrollY + r.top
+ const st = [0, ...NEXT.map(([t]) => (t < 0 ? 1 : t * (1 - TAIL)))].map((f) => Math.round(top + f * range))
+ // The story starts just under the nav: the hero stop is the page top.
+ if (top < innerHeight) st[0] = 0
+ return st
+ }
+ const snap = snapScroll(stops, () => !!hero && !reduce.matches)
+
+ const onNext = () => {
+ if (!snap.go(1)) scrollTo(0, stops()[Math.max(0, cur) + 1] ?? stops().at(-1)!)
+ }
+ next.addEventListener('click', onNext)
+
+ return () => {
+ button.removeEventListener('click', onAdd)
+ next.removeEventListener('click', onNext)
+ removeEventListener('scroll', onScroll)
+ visualViewport?.removeEventListener('resize', fitOver)
+ removeEventListener('resize', fitOver)
+ snap.destroy()
+ hero?.destroy()
+ }
+}
diff --git a/site/src/scripts/hero-story/viewport-probe.ts b/site/src/scripts/hero-story/viewport-probe.ts
new file mode 100644
index 0000000..d19c7a1
--- /dev/null
+++ b/site/src/scripts/hero-story/viewport-probe.ts
@@ -0,0 +1,49 @@
+// A readout of what the browser reports about its viewport, for tuning the
+// story on devices we can't run here (iPhone Safari's floating toolbar).
+// Open the page with ?viewport and screenshot it.
+export function probe(stage: HTMLElement, next: HTMLElement) {
+ const box = document.createElement('pre')
+ Object.assign(box.style, {
+ position: 'fixed',
+ top: '8px',
+ right: '8px',
+ zIndex: '9999',
+ margin: '0',
+ padding: '6px 8px',
+ borderRadius: '6px',
+ background: 'rgba(0,0,0,0.78)',
+ color: '#7ff',
+ font: '11px/1.35 ui-monospace, monospace',
+ pointerEvents: 'none',
+ })
+ // Elements sized in each viewport unit, and one padded by the safe area.
+ const unit = (css: string) => {
+ const el = document.createElement('div')
+ el.style.cssText = `position:absolute;left:0;top:0;width:0;visibility:hidden;${css}`
+ document.body.append(el)
+ return el
+ }
+ const svh = unit('height:100svh')
+ const dvh = unit('height:100dvh')
+ const lvh = unit('height:100lvh')
+ const safe = unit('height:0;padding-bottom:env(safe-area-inset-bottom)')
+ document.body.append(box)
+ const r = (n: number) => Math.round(n)
+ const show = () => {
+ const vv = visualViewport
+ box.textContent = [
+ `screen ${screen.width}x${screen.height}`,
+ `inner ${innerWidth}x${innerHeight}`,
+ `client ${document.documentElement.clientHeight}`,
+ `vv h${r(vv?.height ?? 0)} top${r(vv?.offsetTop ?? 0)} x${vv?.scale.toFixed(2)}`,
+ `svh/dvh/lvh ${svh.offsetHeight}/${dvh.offsetHeight}/${lvh.offsetHeight}`,
+ `safe-bot ${safe.offsetHeight}`,
+ `stage top${r(stage.getBoundingClientRect().top)} h${stage.clientHeight}`,
+ `clear ${Math.max(0, stage.clientHeight - dvh.offsetHeight)}`,
+ `next bottom${r(next.getBoundingClientRect().bottom)}`,
+ `scrollY ${r(scrollY)}`,
+ ].join('\n')
+ }
+ show()
+ setInterval(show, 400)
+}
diff --git a/site/src/scripts/hero-story/world.ts b/site/src/scripts/hero-story/world.ts
new file mode 100644
index 0000000..7556d14
--- /dev/null
+++ b/site/src/scripts/hero-story/world.ts
@@ -0,0 +1,75 @@
+// Dot-matrix world map for the edge beat, one character per 3 degree cell
+// ('#' is land), from Natural Earth 1:110m land (public domain) via
+// world-atlas. Longitude runs from LON0 east, latitude from LAT0 south; in
+// map units one unit is one degree, so a place at (lon, lat) sits at
+// (lon - LON0, LAT0 - lat).
+export const LON0 = -168
+export const LAT0 = 66
+export const STEP = 3
+export const WORLD = [
+ '..###########################.####.....###.......##.........###.###################################################..',
+ '.########################.....##........##................####.#############################################.#.###...',
+ '....#.....###############.....###.#...................#...#.##..#######################################......#.......',
+ '............###############...######..................#.....#..######################################.......##.......',
+ '.............################.#######................#.#################################################....#........',
+ '...............###################.....................#################################################.............',
+ '...............###################.....................###########.#.####.############################.#.............',
+ '...............##################....................#.###.######....###.############################..#.............',
+ '...............################......................###.....###..#######.######################.##..................',
+ '...............################......................###....#..#.########.#######################.#...#..............',
+ '................##############........................#####.........############################....##...............',
+ '.................############........................########..##...#############################..#.................',
+ '...................#####....#.......................###############.####.########################....................',
+ '....................####...........................######################...####################.....................',
+ '.....................##......#.....................#################.#######...#######.#######.......................',
+ '.....................###..#.....#..................#################..#####.....####...####.#........................',
+ '.......................####.......................###################.####......###....#####....#....................',
+ '..........................##......................######################.........##......###.........................',
+ '...........................#...###.#...............######################........##........#.........................',
+ '..............................######................#####################........................#...................',
+ '..............................#########..............#....##############................#.....##.....................',
+ '..............................#########....................############..................##.###......................',
+ '.............................############..................###########....................#..##.#...#................',
+ '.............................##############.................#########.....................#..........###.............',
+ '.............................###############................#########.......................##........###............',
+ '..............................##############................#########................................................',
+ '...............................############.................##########..#..........................##..#.............',
+ '................................###########.................#########..##........................#####.##............',
+ '.................................##########.................########...#........................#########............',
+ '.................................#########...................#######..##......................############...........',
+ '................................########.....................######...........................#############..........',
+ '................................########.....................######...........................#############..........',
+ '................................#######.......................####.............................############..........',
+ '................................######........................#................................#......####...........',
+ '...............................######..................................................................###........#..',
+ '...............................####...............................................................................#..',
+ '................................##...............................................................................#...',
+ '...............................###..............................................................................#....',
+]
+export const MAP_W = WORLD[0].length * STEP
+export const MAP_H = WORLD.length * STEP
+export const project = (lon: number, lat: number): [number, number] => [lon - LON0, LAT0 - lat]
+
+/** Edge locations: code and lon, lat. */
+export const EDGES: [string, number, number][] = [
+ ['sfo', -122.4, 37.8],
+ ['iad', -77.5, 39],
+ ['gru', -46.5, -23.4],
+ ['fra', 8.7, 50.1],
+ ['bom', 72.9, 19.1],
+ ['sin', 103.8, 1.4],
+ ['nrt', 140.4, 35.8],
+ ['syd', 151.2, -33.9],
+]
+
+/** Where the visitor goes, eastward: lon, lat. The last leg wraps over the Pacific. */
+export const TRIP: [number, number][] = [
+ [-122.3, 47.6], // Seattle
+ [-79.4, 43.7], // Toronto
+ [-58.4, -34.6], // Buenos Aires
+ [2.4, 48.9], // Paris
+ [55.3, 25.3], // Dubai
+ [106.8, -6.2], // Jakarta
+ [127, 37.6], // Seoul
+ [145, -37.8], // Melbourne
+]