mountCodeMap
uses matchMedia
Draw a CodeMap on a canvas as a sky, in the page's colours (a night sky in a dark theme, a light one in a
light theme, following the theme as it changes): each definition a star in its cloud's colour (or where it runs),
sized by how much it's used, the most used glowing; each cloud a nebula round its stars (cloudHues); the
references between clouds arcs of light. It pans and zooms (a drag, a pinch, on a trackpad or a touch screen, a wheel), names more as there's room
(clouds, then their namespaces, then definitions), and shows what a star uses and what uses it once it's pointed
at or chosen. The stars spread out from their namespaces when it opens, and switching the grouping flies each to
its new cloud.
import { cloudHues } from "#codebase/tcmts/share"; import type { CloudLayout, CodeMap, CodeMapView, MapFocus } from "#codebase/tcmts/share"; export function mountCodeMap( canvas: HTMLCanvasElement, map: CodeMap, o: { lens: "clusters" | "namespaces"; color: "cloud" | "place"; tip: HTMLElement; onSelect: (f: MapFocus | null) => void; onZoom: (k: number) => void; }, ): CodeMapView { type RGB = readonly [number, number, number]; const ctx = canvas.getContext("2d")!; const n = map.dots.length; const uses = map.dots.map(() => [] as number[]); const usedBy = map.dots.map(() => [] as number[]); for (let k = 0; k < map.refs.length; k += 2) { uses[map.refs[k]!]!.push(map.refs[k + 1]!); usedBy[map.refs[k + 1]!]!.push(map.refs[k]!); } // a star's size: how much it's used const size = usedBy.map((u) => Math.min(6, 1.1 + 0.42 * Math.sqrt(u.length))); const names = map.dots.map((d) => d.path.slice(d.path.lastIndexOf(".") + 1)); let lens = o.lens; let color = o.color; let L: CloudLayout = map[lens]; const x = Float64Array.from(L.x); const y = Float64Array.from(L.y); let hover: MapFocus | null = null; let selected: MapFocus | null = null; // ---- colours: the page's (its theme's palette for clouds, `--cm0`…, and where code runs), read again when the // theme changes const GREY: RGB = [148, 163, 184]; const parse = (v: string): RGB | undefined => { v = v.trim(); const hex = /^#([0-9a-f]{6})$/i.exec(v)?.[1]; if (hex) return [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16)) as unknown as RGB; const m = /^rgba?\(([\d.]+)[ ,]+([\d.]+)[ ,]+([\d.]+)/.exec(v); return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined; }; const scheme = matchMedia("(prefers-color-scheme: dark)"); const readTheme = () => { const css = getComputedStyle(canvas); const of = (name: string, or: RGB) => parse(css.getPropertyValue(name)) ?? or; const palette: RGB[] = []; for (let i = 0; ; i++) { const c = parse(css.getPropertyValue(`--cm${i}`)); if (!c) break; palette.push(c); } return { dark: scheme.matches, sky: of("--bg", scheme.matches ? [14, 17, 23] : [255, 255, 255]), palette: palette.length ? palette : [GREY], loose: of("--cm-loose", GREY), fg: of("--fg", scheme.matches ? [230, 237, 243] : [31, 35, 40]), dim: of("--dim", GREY), uses: of("--acc", [96, 165, 250]), usedBy: of("--ok", [74, 222, 128]), place: { server: of("--srv", GREY), anywhere: of("--any", GREY), browser: of("--brw", GREY), } as Record<string, RGB>, }; }; let theme = readTheme(); let hues = cloudHues(L, theme.palette.length); const rgb = (c: number): RGB => (hues[c]! < 0 ? theme.loose : (theme.palette[hues[c]!] ?? GREY)); const rgba = ([r, g, b]: RGB, a: number) => `rgba(${r},${g},${b},${a})`; // (a colour taken towards white, or black) const toward = ([r, g, b]: RGB, to: number, by: number): RGB => [r + (to - r) * by, g + (to - g) * by, b + (to - b) * by].map(Math.round) as unknown as RGB; // lighter on a night sky, deeper on a light one: what reads on it const legible = (c: RGB, by: number) => theme.dark ? toward(c, 255, by) : toward(c, 0, by * 0.55); const starRgb = (j: number): RGB => { if (color === "cloud") return rgb(L.cloud[j]!); const place = map.dots[j]!.place; return place ? (theme.place[place] ?? GREY) : GREY; }; // a soft round sprite for each colour: a nebula's share of a star, or a star's glow const glow = document.createElement("canvas"); glow.width = glow.height = 64; { const g = glow.getContext("2d")!; const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32); grad.addColorStop(0, "rgba(255,255,255,1)"); grad.addColorStop(0.35, "rgba(255,255,255,.45)"); grad.addColorStop(1, "rgba(255,255,255,0)"); g.fillStyle = grad; g.fillRect(0, 0, 64, 64); } const sprites = new Map<string, HTMLCanvasElement>(); const sprite = (c: RGB) => { const key = c.join(","); let s = sprites.get(key); if (s) return s; s = document.createElement("canvas"); s.width = s.height = 64; const g = s.getContext("2d")!; g.drawImage(glow, 0, 0); g.globalCompositeOperation = "source-in"; g.fillStyle = `rgb(${key})`; g.fillRect(0, 0, 64, 64); sprites.set(key, s); return s; }; // ---- each cloud's nebula, drawn once into a layer of its own (again when the grouping changes) const NEB = 0.35; let nebulae: { layer: HTMLCanvasElement; x: number; y: number; w: number }[] = []; const buildNebulae = () => { const members = L.clouds.map(() => [] as number[]); for (let j = 0; j < n; j++) members[L.cloud[j]!]!.push(j); nebulae = L.clouds.map((c, ci) => { const bx = c.x - c.r - 60; const by = c.y - c.r - 60; const bw = (c.r + 60) * 2; const layer = document.createElement("canvas"); layer.width = layer.height = Math.max(8, Math.ceil(bw * NEB)); const g = layer.getContext("2d")!; g.globalCompositeOperation = theme.dark ? "lighter" : "source-over"; const haze = sprite(rgb(ci)); // a wide glow round each puff (fainter for a small one), then a little round each star for (const p of c.puffs) { g.globalAlpha = (theme.dark ? 0.14 : 0.1) * Math.min(1, Math.sqrt(p.n / 30)); const r = (p.r + 26) * NEB; g.drawImage(haze, (p.x - bx) * NEB - r, (p.y - by) * NEB - r, r * 2, r * 2); } g.globalAlpha = theme.dark ? 0.07 : 0.035; const r = 30 * NEB; for (const j of members[ci]!) g.drawImage(haze, (L.x[j]! - bx) * NEB - r, (L.y[j]! - by) * NEB - r, r * 2, r * 2); return { layer, x: bx, y: by, w: bw }; }); }; buildNebulae(); // ---- the view: pan, zoom, and flying between views let W = 0; let H = 0; let view = { x: 0, y: 0, k: 1 }; let flight: ((t: number) => void) | null = null; let morph: ((t: number) => void) | null = null; let morphFade = 1; let born = -Infinity; const resize = () => { const r = canvas.getBoundingClientRect(); const dpr = devicePixelRatio || 1; W = r.width; H = r.height; canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }; const box = (c: { x: number; y: number; r: number }) => ({ x: c.x - c.r, y: c.y - c.r - 30, w: c.r * 2, h: c.r * 2 + 30, }); const all = (layout = L) => { let x0 = Infinity; let y0 = Infinity; let x1 = -Infinity; let y1 = -Infinity; for (const c of layout.clouds) { const b = box(c); x0 = Math.min(x0, b.x); y0 = Math.min(y0, b.y); x1 = Math.max(x1, b.x + b.w); y1 = Math.max(y1, b.y + b.h); } return Number.isFinite(x0) ? { x: x0, y: y0, w: x1 - x0, h: y1 - y0 } : { x: -50, y: -50, w: 100, h: 100 }; }; const viewOf = (b: { x: number; y: number; w: number; h: number }, pad = 30) => { const k = Math.min((W - pad * 2) / b.w, (H - pad * 2) / b.h, 4); return { k, x: W / 2 - (b.x + b.w / 2) * k, y: H / 2 - (b.y + b.h / 2) * k }; }; const smooth = (p: number) => (p < 0.5 ? 4 * p ** 3 : 1 - (-2 * p + 2) ** 3 / 2); const fly = (to: { x: number; y: number; k: number }, ms = 550) => { const from = { ...view }; const t0 = performance.now(); flight = (t) => { const p = Math.min(1, (t - t0) / ms); const e = smooth(p); view = { k: from.k * (to.k / from.k) ** e, x: from.x + (to.x - from.x) * e, y: from.y + (to.y - from.y) * e, }; if (p === 1) flight = null; }; kick(); }; const toWorld = (sx: number, sy: number) => [(sx - view.x) / view.k, (sy - view.y) / view.k] as const; const zoomAt = (px: number, py: number, k: number) => { k = Math.min(10, Math.max(0.05, k)); view = { k, x: px - ((px - view.x) * k) / view.k, y: py - ((py - view.y) * k) / view.k }; }; // the stars fly from `from` to `to`'s places; with `fade`, the nebulae fade out and back in as the grouping changes const fan = ( from: { x: Float64Array; y: Float64Array }, to: CloudLayout, ms: number, fade: boolean, ) => { const t0 = performance.now(); morph = (t) => { const p = Math.min(1, (t - t0) / ms); const e = fade ? smooth(p) : 1 - (1 - p) ** 4; morphFade = fade ? Math.abs(1 - 2 * p) ** 1.5 : 1; if (fade && p >= 0.5 && L !== to) { L = to; hues = cloudHues(L, theme.palette.length); buildNebulae(); lit = L.clouds.map(() => 1); } for (let j = 0; j < n; j++) { x[j] = from.x[j]! + (to.x[j]! - from.x[j]!) * e; y[j] = from.y[j]! + (to.y[j]! - from.y[j]!) * e; } if (p === 1) { morph = null; morphFade = 1; regrid(); } }; kick(); }; // ---- what's under the pointer: a star (once they're big enough to aim at), else a puff or a cloud let grid = new Map<string, number[]>(); const regrid = () => { grid = new Map(); for (let j = 0; j < n; j++) { const key = `${Math.floor(x[j]! / 24)},${Math.floor(y[j]! / 24)}`; const there = grid.get(key); if (there) there.push(j); else grid.set(key, [j]); } }; const hit = (sx: number, sy: number): MapFocus | null => { const [wx, wy] = toWorld(sx, sy); if (view.k > 0.55) { let best = -1; let near = Math.max(7 / view.k, 3); const gx = Math.floor(wx / 24); const gy = Math.floor(wy / 24); for (let a = -1; a <= 1; a++) { for (let b = -1; b <= 1; b++) { for (const j of grid.get(`${gx + a},${gy + b}`) ?? []) { const d = Math.hypot(x[j]! - wx, y[j]! - wy) - size[j]!; if (d < near) { near = d; best = j; } } } } if (best >= 0) return { dot: best, cloud: L.cloud[best]! }; } for (const [ci, c] of L.clouds.entries()) { if (Math.hypot(wx - c.x, wy - c.y) > c.r) continue; if (view.k >= 0.9) { for (const [pi, p] of c.puffs.entries()) if (Math.hypot(wx - p.x, wy - p.y) < p.r + 4) return { cloud: ci, puff: pi }; } return { cloud: ci }; } return null; }; const same = (a: MapFocus | null, b: MapFocus | null) => a?.cloud === b?.cloud && a?.puff === b?.puff && a?.dot === b?.dot; // ---- drawing, a frame at a time while anything moves let frame = 0; const kick = () => { if (!frame) frame = requestAnimationFrame(draw); }; // how lit each cloud is: eased towards what the focus says, so a change of focus fades let lit = L.clouds.map(() => 1); const draw = (t: number) => { frame = 0; if (flight) { flight(t); kick(); } if (morph) { morph(t); kick(); } const grown = Math.min(1, (t - born) / 1100); if (grown < 1) kick(); const ease = 1 - (1 - grown) ** 4; const focus = hover ?? selected; const fc = focus?.cloud; const fd = focus?.dot; const cloudOnly = fc !== undefined && fd === undefined; const near = new Set<number>(fd === undefined ? [] : [fd, ...uses[fd]!, ...usedBy[fd]!]); let easing = false; lit = L.clouds.map((_, c) => { const want = fd !== undefined ? (c === fc ? 0.55 : 0.25) : cloudOnly ? (c === fc ? 1.25 : 0.3) : 1; const now = lit[c] ?? 1; if (Math.abs(want - now) < 0.01) return want; easing = true; return now + (want - now) * 0.2; }); if (easing) kick(); ctx.fillStyle = rgba(theme.sky, 1); ctx.fillRect(0, 0, W, H); ctx.save(); ctx.translate(view.x, view.y); ctx.scale(view.k, view.k); const px = 1 / view.k; const [vx0, vy0] = toWorld(0, 0); const [vx1, vy1] = toWorld(W, H); const glowing = theme.dark ? "lighter" : "source-over"; ctx.globalCompositeOperation = glowing; // arcs between clouds, as bright as the references between them, from one's colour to the other's const most = L.links[0]?.[2] ?? 1; ctx.lineCap = "round"; for (const [a, b, w] of L.links.slice(0, 60)) { const on = cloudOnly && (a === fc || b === fc); if (!on && w < most * 0.03) continue; const A = L.clouds[a]!; const B = L.clouds[b]!; const alpha = (on ? 0.55 : fc !== undefined ? 0.04 : (theme.dark ? 0.16 : 0.12) + 0.2 * Math.sqrt(w / most)) * ease * morphFade; const grad = ctx.createLinearGradient(A.x, A.y, B.x, B.y); grad.addColorStop(0, rgba(rgb(a), alpha)); grad.addColorStop(1, rgba(rgb(b), alpha)); ctx.strokeStyle = grad; ctx.lineWidth = (0.8 + 3.2 * Math.sqrt(w / most)) * px; ctx.beginPath(); ctx.moveTo(A.x, A.y); ctx.quadraticCurveTo( (A.x + B.x) / 2 - (B.y - A.y) * 0.18, (A.y + B.y) / 2 + (B.x - A.x) * 0.18, B.x, B.y, ); ctx.stroke(); } // the nebulae for (const [ci, nb] of nebulae.entries()) { if (nb.x > vx1 || nb.y > vy1 || nb.x + nb.w < vx0 || nb.y + nb.w < vy0) continue; ctx.globalAlpha = Math.min(1, lit[ci]! * ease) * morphFade; ctx.drawImage(nb.layer, nb.x, nb.y, nb.w, nb.w); } ctx.globalAlpha = 1; // a focused star's references: light to what it uses (blue) and from what uses it (green) if (fd !== undefined) { ctx.lineWidth = 1.2 * px; for (const [list, c] of [ [uses[fd]!, theme.uses], [usedBy[fd]!, theme.usedBy], ] as const) { ctx.strokeStyle = rgba(c, 0.55); ctx.beginPath(); for (const j of list) { ctx.moveTo(x[fd]!, y[fd]!); ctx.lineTo(x[j]!, y[j]!); } ctx.stroke(); } } // the most used stars glow for (let j = 0; j < n; j++) { if (size[j]! < 2.6 || size[j]! * view.k < 0.8) continue; if (x[j]! < vx0 - 40 || x[j]! > vx1 + 40 || y[j]! < vy0 - 40 || y[j]! > vy1 + 40) continue; ctx.globalAlpha = (fd !== undefined ? (near.has(j) ? 0.9 : 0.08) : Math.min(1, lit[L.cloud[j]!]!)) * (theme.dark ? 0.5 : 0.3) * ease; const r = size[j]! * 5; ctx.drawImage(sprite(starRgb(j)), x[j]! - r, y[j]! - r, r * 2, r * 2); } ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over"; // the stars' cores, brighter than their colour (deeper, on a light sky): a path for each colour and brightness (types as rings) const batches = new Map<string, number[]>(); for (let j = 0; j < n; j++) { if (x[j]! < vx0 - 10 || x[j]! > vx1 + 10 || y[j]! < vy0 - 10 || y[j]! > vy1 + 10) continue; const a = fd !== undefined ? (near.has(j) ? 1 : 0.18) : Math.min(1, lit[L.cloud[j]!]! + 0.15); const key = `${starRgb(j).join(",")}|${a.toFixed(2)}|${map.dots[j]!.type ? 1 : 0}`; const batch = batches.get(key); if (batch) batch.push(j); else batches.set(key, [j]); } const scale = Math.min(1, 0.45 + view.k * 0.9) * (0.35 + 0.65 * ease); for (const [key, batch] of batches) { const [c, a, ring] = key.split("|"); const core = rgba(legible(c!.split(",").map(Number) as unknown as RGB, 0.55), Number(a)); ctx.beginPath(); for (const j of batch) { const r = Math.max(size[j]! * scale, 0.55 * px); ctx.moveTo(x[j]! + r, y[j]!); ctx.arc(x[j]!, y[j]!, r, 0, Math.PI * 2); } if (ring === "1") { ctx.lineWidth = Math.max(0.9 * px, 0.6); ctx.strokeStyle = core; ctx.stroke(); } else { ctx.fillStyle = core; ctx.fill(); } } if (fd !== undefined) { ctx.lineWidth = 1.5 * px; ctx.strokeStyle = rgba(theme.fg, 0.9); ctx.beginPath(); ctx.arc(x[fd]!, y[fd]!, size[fd]! + 4 * px, 0, Math.PI * 2); ctx.stroke(); } if (cloudOnly && focus!.puff !== undefined) { const p = L.clouds[fc!]!.puffs[focus!.puff]!; ctx.setLineDash([3 * px, 4 * px]); ctx.lineWidth = px; ctx.strokeStyle = rgba(rgb(fc!), 0.8); ctx.beginPath(); ctx.arc(p.x, p.y, p.r + 6, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); } ctx.restore(); labels(focus, near, ease); o.onZoom(view.k); }; // ---- names, as on a map of the sky: clouds in spaced capitals, then their namespaces once there's room, then // definitions; none drawn over another const labels = (focus: MapFocus | null, near: Set<number>, ease: number) => { const taken: { x: number; y: number; w: number; h: number }[] = []; const sx = (v: number) => v * view.k + view.x; const sy = (v: number) => v * view.k + view.y; const MONO = "ui-monospace, SFMono-Regular, Menlo, monospace"; const SANS = "system-ui, -apple-system, 'Segoe UI', sans-serif"; ctx.textBaseline = "middle"; ctx.textAlign = "center"; const text = ( s: string, tx: number, ty: number, px: number, font: string, fill: string, alpha = 1, spacing = 0, ) => { ctx.font = `${font} ${px}px ${font.includes("mono") ? MONO : SANS}`.replace(" mono", ""); ctx.letterSpacing = `${spacing}px`; const w = ctx.measureText(s).width; // (kept inside the canvas: a cloud at its edge is named whole) tx = Math.min(Math.max(tx, w / 2 + 6), W - w / 2 - 6); const b = { x: tx - w / 2 - 3, y: ty - px / 2 - 2, w: w + 6, h: px + 4 }; const free = !taken.some( (r) => b.x < r.x + r.w && b.x + b.w > r.x && b.y < r.y + r.h && b.y + b.h > r.y, ); if (free) { taken.push(b); ctx.globalAlpha = alpha * ease; // (a halo of the sky's colour, so a name reads over the stars) ctx.shadowColor = rgba(theme.sky, 0.95); ctx.shadowBlur = theme.dark ? 6 : 4; ctx.fillStyle = fill; ctx.fillText(s, tx, ty); ctx.shadowBlur = 0; ctx.globalAlpha = 1; } ctx.letterSpacing = "0px"; return free; }; if (focus?.dot !== undefined) { const order = [ focus.dot, ...[...near].filter((j) => j !== focus.dot).sort((a, b) => size[b]! - size[a]!), ]; order.forEach((j, i) => text( names[j]!, sx(x[j]!), sy(y[j]!) - Math.max(size[j]! * view.k, 3) - 10, i ? 11 : 13, i ? "500 mono" : "700 mono", rgba(theme.fg, i ? 0.85 : 1), ), ); } const close = view.k >= 0.8; // the biggest clouds first: in the middle of the cloud, else above it, else below; how many it holds beneath for (const ci of [...L.clouds.keys()].sort((a, b) => L.clouds[b]!.n - L.clouds[a]!.n)) { const c = L.clouds[ci]!; const dim = focus && focus.cloud !== ci ? 0.35 : 1; const big = c.n > 400 ? 14 : c.n > 120 ? 12.5 : 11; const tint = rgba(legible(rgb(ci), 0.45), 1); const spots = close ? [sy(c.y - c.r) - 6] : [sy(c.y), sy(c.y - c.r) - 4, sy(c.y + c.r) + 8]; for (const ty of spots) { if (!text(c.label.toUpperCase(), sx(c.x), ty, big, "650", tint, dim, 1.6)) continue; if (c.n > 120 || close) text( c.n.toLocaleString("en"), sx(c.x), ty + big + 2, 10.5, "500", rgba(theme.dim, 0.9), dim, 0.6, ); break; } } if (focus?.dot === undefined) { for (const [ci, c] of L.clouds.entries()) { const mine = focus?.cloud === ci; if (!(view.k >= 1.1 || (mine && view.k >= 0.6))) continue; const tint = rgba(legible(rgb(ci), 0.3), 1); // (the bigger namespaces first, by their last names: their whole names are in the tip) for (const p of c.puffs) { if (p.n < (view.k < 1.6 ? 8 : 3)) continue; const name = p.label.slice(p.label.lastIndexOf(".") + 1); text( name.toUpperCase(), sx(p.x), sy(p.y - p.r) - 2, 10, "600", tint, focus && !mine ? 0.3 : 0.8, 1.2, ); } } } if (view.k >= 1.7) { const [vx0, vy0] = toWorld(-40, -20); const [vx1, vy1] = toWorld(W + 40, H + 20); const seen: number[] = []; for (let j = 0; j < n; j++) if (x[j]! > vx0 && x[j]! < vx1 && y[j]! > vy0 && y[j]! < vy1) seen.push(j); seen.sort((a, b) => size[b]! - size[a]!); for (const j of seen) { const aside = focus && !near.has(j) && (focus.dot !== undefined || focus.cloud !== L.cloud[j]); text( names[j]!, sx(x[j]!), sy(y[j]!) + size[j]! * view.k + 9, 10.5, "400 mono", rgba(theme.dim, 0.95), aside ? 0.3 : 1, ); } } }; // ---- the tip: what's pointed at const tip = (f: MapFocus | null, px = 0, py = 0) => { o.tip.hidden = !f; if (!f) return; const line = (s: string, cls: string) => Object.assign(document.createElement("span"), { className: cls, textContent: s }); const c = L.clouds[f.cloud]!; if (f.dot !== undefined) { const d = map.dots[f.dot]!; o.tip.replaceChildren( line(names[f.dot]!, "name mono"), line(d.path.slice(0, d.path.lastIndexOf(".")), "muted mono"), line( `${d.place ? `${d.place} · ` : ""}${d.type ? "type · " : ""}used by ${usedBy[f.dot]!.length}`, "muted", ), ); } else if (f.puff !== undefined) { const p = c.puffs[f.puff]!; o.tip.replaceChildren( line(p.ns, "name mono"), line(`${p.n} definitions in ${c.label}`, "muted"), ); } else { o.tip.replaceChildren( line(c.label, "name"), line(`${c.n} definitions · ${c.puffs.length} namespaces`, "muted"), ); } o.tip.style.left = `${Math.min(px + 14, W - 260)}px`; o.tip.style.top = `${py + 14}px`; }; // ---- the pointer const at = (e: { clientX: number; clientY: number }) => { const r = canvas.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top] as const; }; // the fingers (or the mouse) down on it, where each is now; and the gesture they make: one drags, two pinch, both // from where it began (the view then, and the fingers' middle and spread) const pointers = new Map<number, readonly [number, number]>(); let gesture: { view: typeof view; mx: number; my: number; spread: number; moved: boolean; many: boolean; } | null = null; const begin = (moved: boolean, many: boolean) => { const ps = [...pointers.values()]; const mx = ps.reduce((s, p) => s + p[0], 0) / ps.length; const my = ps.reduce((s, p) => s + p[1], 0) / ps.length; const spread = ps.length > 1 ? Math.hypot(ps[0]![0] - ps[1]![0], ps[0]![1] - ps[1]![1]) : 0; gesture = { view: { ...view }, mx, my, spread, moved, many }; }; const wheel = (e: WheelEvent) => { e.preventDefault(); flight = null; const [px, py] = at(e); // a pinch (or ctrl/⌘ and a wheel) and a mouse wheel's big steps zoom; a trackpad's two-finger scroll pans if (e.ctrlKey || e.metaKey) zoomAt(px, py, view.k * Math.exp(-e.deltaY * 0.01)); else if (Math.abs(e.deltaX) < 1 && Math.abs(e.deltaY) >= 40) zoomAt(px, py, view.k * Math.exp(-e.deltaY * 0.002)); else view = { ...view, x: view.x - e.deltaX, y: view.y - e.deltaY }; kick(); }; const down = (e: PointerEvent) => { canvas.setPointerCapture(e.pointerId); pointers.set(e.pointerId, at(e)); // (a second finger turns a drag into a pinch, and what began is no tap) begin(gesture?.moved ?? false, pointers.size > 1 || !!gesture?.many); }; const move = (e: PointerEvent) => { if (pointers.has(e.pointerId) && gesture) { pointers.set(e.pointerId, at(e)); const ps = [...pointers.values()]; const mx = ps.reduce((s, p) => s + p[0], 0) / ps.length; const my = ps.reduce((s, p) => s + p[1], 0) / ps.length; const g = gesture; if ( !g.moved && Math.hypot(mx - g.mx, my - g.my) <= (e.pointerType === "touch" ? 8 : 3) && ps.length < 2 ) return; g.moved = true; flight = null; // pinched: zoomed by how much the fingers spread, round the point between them, which stays under them const spread = ps.length > 1 ? Math.hypot(ps[0]![0] - ps[1]![0], ps[0]![1] - ps[1]![1]) : 0; const k = Math.min( 10, Math.max(0.05, g.spread && spread ? g.view.k * (spread / g.spread) : g.view.k), ); const wx = (g.mx - g.view.x) / g.view.k; const wy = (g.my - g.view.y) / g.view.k; view = { k, x: mx - wx * k, y: my - wy * k }; hover = null; tip(null); kick(); return; } // (a finger has no hover: what it touches is chosen when it's lifted) if (e.pointerType === "touch") return; const [px, py] = at(e); const h = hit(px, py); if (!same(h, hover)) { hover = h; canvas.style.cursor = h ? "pointer" : "grab"; kick(); } tip(h, px, py); }; const leave = () => { hover = null; tip(null); kick(); }; const up = (e: PointerEvent) => { if (!pointers.delete(e.pointerId)) return; const was = gesture; // a finger lifted from a pinch: the other drags on from where it is if (pointers.size) return begin(true, true); gesture = null; if (!was || was.moved || was.many || e.type === "pointercancel") return; const h = hit(...at(e)); // a cloud: the first click chooses it and, from afar, flies to it; another flies into it const again = same(h, selected); choose(h); if (h && h.dot === undefined && (again || view.k < 0.9 || h.puff === undefined)) show(h); }; const choose = (f: MapFocus | null) => { selected = f; o.onSelect(f); kick(); }; const show = (f: MapFocus) => { if (f.dot !== undefined) { const k = Math.max(view.k, 2); return fly({ k, x: W / 2 - x[f.dot]! * k, y: H / 2 - y[f.dot]! * k }); } const c = L.clouds[f.cloud]!; fly(viewOf(box(f.puff !== undefined && view.k >= 0.9 ? c.puffs[f.puff]! : c), 50)); }; canvas.addEventListener("wheel", wheel, { passive: false }); canvas.addEventListener("pointerdown", down); canvas.addEventListener("pointermove", move); canvas.addEventListener("pointerleave", leave); canvas.addEventListener("pointerup", up); canvas.addEventListener("pointercancel", up); const observer = new ResizeObserver(() => { const first = !W; resize(); if (first) { view = viewOf(all()); // the stars spread out from the middles of their namespaces (at once, for those who'd rather not see it) if (!matchMedia("(prefers-reduced-motion: reduce)").matches) { born = performance.now(); const from = { x: x.slice(), y: y.slice() }; for (let j = 0; j < n; j++) { const p = L.clouds[L.cloud[j]!]!.puffs[L.puff[j]!]!; from.x[j] = p.x + (x[j]! - p.x) * 0.15; from.y[j] = p.y + (y[j]! - p.y) * 0.15; } fan(from, L, 1300, false); } } kick(); }); observer.observe(canvas); regrid(); const retheme = () => { theme = readTheme(); sprites.clear(); buildNebulae(); kick(); }; scheme.addEventListener("change", retheme); return { setLens(next) { if (next === lens) return; lens = next; hover = null; choose(null); fan({ x: x.slice(), y: y.slice() }, map[lens], 1100, true); fly(viewOf(all(map[lens])), 1100); }, setColor(by) { color = by; kick(); }, select: choose, show, fit: () => fly(viewOf(all())), zoomBy: (f) => { const k = view.k * f; fly( { k, x: W / 2 - ((W / 2 - view.x) * k) / view.k, y: H / 2 - ((H / 2 - view.y) * k) / view.k, }, 250, ); }, destroy() { cancelAnimationFrame(frame); observer.disconnect(); scheme.removeEventListener("change", retheme); canvas.removeEventListener("wheel", wheel); canvas.removeEventListener("pointerdown", down); canvas.removeEventListener("pointermove", move); canvas.removeEventListener("pointerleave", leave); canvas.removeEventListener("pointerup", up); canvas.removeEventListener("pointercancel", up); }, }; }
uses
- tcmts.share.CloudLayout
- tcmts.share.CodeMap
- tcmts.share.CodeMapView
- tcmts.share.MapFocus
- tcmts.share.cloudHues