Files
aiturk-hermes-ide/apps/desktop/src/app/starmap/star-map.tsx
T

984 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { type Simulation } from 'd3-force'
import { atom, type WritableAtom } from 'nanostores'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useThemeEpoch } from '@/hooks/use-theme-epoch'
import { useI18n } from '@/i18n'
import { createDoubleTapDetector, isSmartZoomWheel } from '@/lib/trackpad-gestures'
import type { StarmapGraph } from '@/types/hermes'
import { computePalette, memoryInkFor, resolveRgb, rgba } from './color'
import { RING_OUTER, TILT, ZOOM_MAX, ZOOM_MIN } from './constants'
import { clamp, distToSegmentSq, fitScale, fitViewport, nodeRadius } from './geometry'
import { NodeContextMenu, type NodeMenuTarget } from './node-context-menu'
import { drawScene, drawScramble } from './render'
import { decodeShareCode, encodeShareCode, shareCodeErrorText } from './share-code'
import { ShareControls } from './share-controls'
import { buildSimulation } from './simulation'
import { formatDate } from './text'
import { buildTimeAxis, dateAtReveal, type TimeAxis } from './time-axis'
import { Timeline } from './timeline'
import type { FadeBuckets, MemoryCard, Palette, Ring, RingLabelRect, SimLink, SimNode, Viewport } from './types'
// How long a full play-through sweep takes (ms), reveal 0 → 1. Longer = the
// build-up breathes; the eased middle no longer rushes past in a blink.
const SWEEP_MS = 15000
// How far to relax the ease toward a flat linear march. The bare smoothstep
// spikes to 1.5× linear speed mid-sweep, which reads as a "snap" through the
// middle; blending it back toward linear flattens that peak (≈1.3× at GENTLE
// = 0.45) so playback glides instead of lurching, while still keeping a soft
// ease-in / ease-out at the very start and end.
const GENTLE = 0.45
// Cinematic timing: cubic smoothstep (gentle ease-in / ease-out) relaxed toward
// linear by GENTLE, so the middle never rushes. Monotonic on [0,1], so the
// numeric inverse below stays valid.
function cineEase(t: number): number {
const u = t < 0 ? 0 : t > 1 ? 1 : t
const smooth = u * u * (3 - 2 * u)
return GENTLE * u + (1 - GENTLE) * smooth
}
// Numeric inverse (monotonic) so a resume maps the current reveal back to clock
// progress without a closed-form solution.
function invCineEase(y: number): number {
let lo = 0
let hi = 1
for (let i = 0; i < 24; i += 1) {
const mid = (lo + hi) / 2
if (cineEase(mid) < y) {
lo = mid
} else {
hi = mid
}
}
return (lo + hi) / 2
}
function revealText(axis: TimeAxis, reveal: number): string {
const date = dateAtReveal(axis, reveal)
return date !== null ? formatDate(date) : `${Math.round(reveal * axis.size)} / ${axis.size}`
}
function RevealLabel({ axis, revealStore }: { axis: TimeAxis; revealStore: WritableAtom<number> }) {
const labelRef = useRef<HTMLSpanElement | null>(null)
const sync = useCallback(
(reveal: number) => {
const el = labelRef.current
if (el) {
el.textContent = revealText(axis, reveal)
}
},
[axis]
)
useEffect(() => revealStore.subscribe(sync), [revealStore, sync])
useEffect(() => {
sync(revealStore.get())
}, [revealStore, sync])
return (
<span className="tabular-nums text-foreground/75" ref={labelRef}>
{revealText(axis, revealStore.get())}
</span>
)
}
// A tilted, top-down star map of what Hermes has learned. Time is RADIAL: oldest
// at the core, newest on the outer rings. This component owns the refs, effects
// and pointer wiring; layout lives in simulation.ts and painting in render.ts.
export function StarMap({
graph,
imported = false,
onImport,
onResetMap
}: {
graph: StarmapGraph
imported?: boolean
onImport?: (graph: StarmapGraph) => void
onResetMap?: () => void
}) {
const { t } = useI18n()
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const wrapRef = useRef<HTMLDivElement | null>(null)
const simRef = useRef<null | Simulation<SimNode, SimLink>>(null)
const nodesRef = useRef<SimNode[]>([])
const linksRef = useRef<SimLink[]>([])
const byIdRef = useRef(new Map<string, SimNode>())
const adjacencyRef = useRef(new Map<string, Set<string>>())
const memByIdRef = useRef(new Map<string, MemoryCard>())
const ringsRef = useRef<Ring[]>([])
const ringLabelRectsRef = useRef<RingLabelRect[]>([])
const fadeRef = useRef<FadeBuckets>({
appear: new Map(),
labels: new Map(),
links: new Map(),
nodes: new Map(),
rings: new Map()
})
const doubleTapRef = useRef(createDoubleTapDetector())
const paletteRef = useRef<null | Palette>(null)
const themeDirtyRef = useRef(true)
const invalidateRef = useRef<() => void>(() => {})
const viewportRef = useRef<Viewport>({ k: 1, x: 0, y: 0 })
const hoverRef = useRef<null | string>(null)
const hoveredLinkRef = useRef<null | string>(null)
const hoveredRingRef = useRef<null | number>(null)
const selectedRingRef = useRef<null | number>(null)
const selectedIdRef = useRef<null | string>(null)
const sizeRef = useRef({ h: 0, w: 0 })
const dprRef = useRef(1)
const dirtyRef = useRef(true)
// Scrub = direct manipulation (snap the fades to the pointer); Play = the
// cinematic birth/fade easing. One frame's worth of state, never re-rendered.
const snapMotionRef = useRef(false)
const dragRef = useRef<{
id: null | string
mode: 'none' | 'pan'
moved: boolean
ring: null | number
sx: number
sy: number
vp: Viewport
}>({ id: null, mode: 'none', moved: false, ring: null, sx: 0, sy: 0, vp: { k: 1, x: 0, y: 0 } })
const [selectedId, setSelectedId] = useState<null | string>(null)
const [menuTarget, setMenuTarget] = useState<NodeMenuTarget | null>(null)
const [size, setSize] = useState({ h: 0, w: 0 })
// Increments on every theme repaint (shared hook) so the legend swatch and the
// canvas palette re-resolve against the freshly-painted CSS custom properties.
const themeEpoch = useThemeEpoch()
// Memory's swatch color — the same complementary-of-primary the canvas uses,
// so the legend matches the rendered diamonds exactly.
const [memoryColor, setMemoryColor] = useState('var(--theme-secondary)')
// Time scrubber: reveal 1 = the whole map (idle default); lower values hide
// not-yet-reached nodes so playing/scrubbing "builds it up". revealRef feeds
// the canvas loop and revealStore feeds the timeline + legend label — so a
// play-through / scrub never re-renders StarMap (the perf win). `playing`
// stays React state since it flips rarely and drives the play effect + button.
const revealStore = useMemo(() => atom(1), [])
const [playing, setPlaying] = useState(false)
// Reveal positions where each dated ring spawns (its inner neighbor's ratio —
// ringSeen reveals one band ahead), surfaced as markers on the timeline.
const [ringStops, setRingStops] = useState<number[]>([])
const revealRef = useRef(1)
// Spore-style zoom: the camera fits the *leading ring's* radius, a step
// function of reveal. It holds steady while a band fills, then eases out to the
// next shell when a new ring is reached — growth in discrete jumps, not a
// constant creep. This ref is the camera's current (eased) fit radius.
const camRadiusRef = useRef(RING_OUTER)
const timeAxis = useMemo(() => buildTimeAxis(graph, 72), [graph])
// The current map as a WoW-style share code, recomputed only when the graph
// changes (encode walks every node/edge/card, so don't redo it per render).
const shareCode = useMemo(() => encodeShareCode(graph), [graph])
// Decode a pasted code and hand the resulting graph up to the StarmapView,
// which swaps it in for the live profile scan. Returns an error string for the
// Timeline to surface inline, or null on success.
const importCode = useCallback(
(code: string): null | string => {
try {
const next = decodeShareCode(code)
onImport?.(next)
return null
} catch (err) {
return shareCodeErrorText(err, t.surfaces.mapErrors)
}
},
[onImport, t.surfaces.mapErrors]
)
// Mark the canvas dirty and wake the (otherwise-idle) render loop.
const invalidate = useCallback(() => invalidateRef.current(), [])
// Single writer for the scrubber position: feeds the canvas (ref), the
// timeline + legend label (store subscribers), and wakes the paint loop —
// no React re-render, so playback/scrubbing stays off the render path.
const setRevealValue = useCallback(
(value: number) => {
const next = clamp(value, 0, 1)
revealRef.current = next
revealStore.set(next)
invalidate()
},
[invalidate, revealStore]
)
// Drop every in-flight ease so the next frame snaps to its targets.
const resetFades = useCallback(() => {
for (const bucket of Object.values(fadeRef.current)) {
bucket.clear()
}
}, [])
const memById = useMemo(() => {
const m = new Map<string, MemoryCard>()
graph.memory.forEach((card, i) => m.set(`memory:${card.source}:${i}`, card))
return m
}, [graph.memory])
const adjacency = useMemo(() => {
const m = new Map<string, Set<string>>()
for (const n of graph.nodes) {
m.set(n.id, new Set())
}
for (const e of graph.edges) {
m.get(e.source)?.add(e.target)
m.get(e.target)?.add(e.source)
}
return m
}, [graph.edges, graph.nodes])
// Track the wrapper size.
useEffect(() => {
const el = wrapRef.current
if (!el) {
return
}
const sync = () => setSize({ h: el.clientHeight, w: el.clientWidth })
const ro = new ResizeObserver(sync)
ro.observe(el)
sync()
return () => ro.disconnect()
}, [])
// (Re)build the radial simulation whenever the graph or size changes.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
sizeRef.current = size
if (size.w === 0 || size.h === 0) {
return
}
const { byId, links, nodes, rings, sim } = buildSimulation(graph, invalidate)
simRef.current = sim
nodesRef.current = nodes
linksRef.current = links
byIdRef.current = byId
ringsRef.current = rings
// Markers fire when a ring spawns: ringSeen(i) flips at rings[i-1].ratio.
setRingStops(rings.map((rg, i) => (rg.label != null ? (rings[i - 1]?.ratio ?? 0) : -1)).filter(v => v >= 0))
resetFades()
// Fit the actual disk (outermost ring), so a 3-ring map frames like a 12-ring
// one — count changes the disk size, not the framing.
viewportRef.current = fitViewport(size.w, size.h, rings[rings.length - 1]?.r ?? RING_OUTER)
invalidate()
if (selectedIdRef.current && !byId.has(selectedIdRef.current)) {
selectedIdRef.current = null
setSelectedId(null)
}
return () => {
sim.stop()
if (simRef.current === sim) {
simRef.current = null
}
}
}, [graph, invalidate, resetFades, size])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
adjacencyRef.current = adjacency
memByIdRef.current = memById
invalidate()
}, [adjacency, invalidate, memById])
// The empty-core ASCII scramble uses the bundled JetBrains Mono face. Canvas
// text doesn't reflow when a webfont loads, so repaint once it's ready.
useEffect(() => {
document.fonts?.load('1em "JetBrains Mono"').then(invalidate, () => {})
}, [invalidate])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
selectedIdRef.current = selectedId
invalidate()
}, [invalidate, selectedId])
// A fresh graph resets the scrubber to "fully built" (the idle default).
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
camRadiusRef.current = RING_OUTER
snapMotionRef.current = false
setRevealValue(1)
setPlaying(false)
}, [graph, setRevealValue])
// The stepped fit radius for a reveal: fit ONE ring BEYOND the leading shell
// (the first not-yet-passed ring) so the ring currently igniting its nodes
// sits comfortably inside the frame with a band of headroom, instead of jammed
// against the edge. Steps only when reveal crosses a boundary — the Spore step.
const targetRadius = useCallback((rev: number): number => {
const rings = ringsRef.current
if (!rings.length) {
return RING_OUTER
}
const lead = rings.findIndex(rg => rg.ratio > rev + 1e-3)
const i = lead === -1 ? rings.length - 1 : lead
const band = (rings[1]?.r ?? RING_OUTER) - (rings[0]?.r ?? 0)
// Small headroom (a third of a band) so the igniting ring isn't jammed at the
// frame edge during playback, without zooming the resting view out.
return rings[i]!.r + band * 0.35
}, [])
const applyFit = useCallback((radius: number) => {
const { h, w } = sizeRef.current
if (w > 0 && h > 0) {
viewportRef.current = fitViewport(w, h, radius)
}
}, [])
// Snap the camera to a reveal's stepped target (scrubbing / reset — no glide).
const fitForReveal = useCallback(
(rev: number) => {
camRadiusRef.current = targetRadius(rev)
applyFit(camRadiusRef.current)
},
[applyFit, targetRadius]
)
// Playback: sweep reveal 0 → 1 over SWEEP_MS, then stop (play once).
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
if (!playing) {
return
}
let raf = 0
let start = 0
const step = (now: number) => {
if (!start) {
// Anchor (in clock-space) so a resume continues from the current reveal.
start = now - invCineEase(revealRef.current) * SWEEP_MS
}
const progress = Math.min(1, (now - start) / SWEEP_MS)
const next = cineEase(progress)
// Ease the camera toward the leading ring's radius (a step target): it
// holds while a band fills, then pushes out when the next shell is reached.
const target = targetRadius(next)
camRadiusRef.current += (target - camRadiusRef.current) * 0.1
applyFit(camRadiusRef.current)
setRevealValue(next)
// End once the reveal is complete AND the camera has settled on the final
// shell, so the last push-out finishes instead of cutting off.
if (progress >= 1 && Math.abs(target - camRadiusRef.current) < 0.5) {
camRadiusRef.current = target
applyFit(target)
setPlaying(false)
return
}
raf = requestAnimationFrame(step)
}
raf = requestAnimationFrame(step)
return () => cancelAnimationFrame(raf)
}, [applyFit, playing, setRevealValue, targetRadius])
const onTogglePlay = useCallback(() => {
if (playing) {
setPlaying(false)
return
}
// Leaving scrub: play eases (cinematic) rather than holding the snapped view.
snapMotionRef.current = false
// Replay from the start when parked at the end. Snap straight to the empty
// state (no fade-out) before playing in.
if (revealRef.current >= 1) {
resetFades()
fitForReveal(0)
setRevealValue(0)
}
setPlaying(true)
}, [fitForReveal, playing, resetFades, setRevealValue])
const onScrub = useCallback(
(value: number) => {
const next = clamp(value, 0, 1)
setPlaying(false)
// Scrub is direct manipulation: snap fades + camera to the pointer so a
// fast drag jumps there instead of replaying the birth-in from a stale spot.
snapMotionRef.current = true
fitForReveal(next)
setRevealValue(next)
},
[fitForReveal, setRevealValue]
)
// Spacebar toggles playback (unless typing, or the play button itself is
// focused — that already handles Space natively, so skip to avoid a double).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.code !== 'Space' && e.key !== ' ') {
return
}
const el = document.activeElement
const tag = el?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'BUTTON' || (el as HTMLElement | null)?.isContentEditable) {
return
}
e.preventDefault()
onTogglePlay()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onTogglePlay])
// Recompute the legend's memory swatch from the live --theme-primary (matches
// the canvas), re-running on theme change and once the canvas is mounted.
useEffect(() => {
const el = canvasRef.current ?? wrapRef.current
if (!el) {
return
}
const style = getComputedStyle(el)
const val = style.getPropertyValue('--theme-primary').trim()
if (val) {
const bgVal =
style.getPropertyValue('--background').trim() || style.getPropertyValue('--dt-background').trim() || '#000'
setMemoryColor(rgba(memoryInkFor(resolveRgb(val), resolveRgb(bgVal)), 0.9))
}
}, [size, themeEpoch])
// Repaint + repalette when the theme/mode repaints (the shared observer fires
// after applyTheme rewrites the class + inline vars on <html>).
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
themeDirtyRef.current = true
invalidate()
}, [invalidate, themeEpoch])
// Render loop. The core scramble animates continuously, so the loop runs while
// the window is focused — but each frame is cheap (live scramble + a blit of the
// cached static layer). The expensive scene only re-renders when invalidate()
// marks it dirty. Capped to ~30fps; interaction (force) bypasses the cap.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
let raf = 0
const ANIM_MS = 1000 / 30
let lastAnimTs = 0
let force = true
// The scramble keeps the loop perpetually "animating", so a fully-built,
// untouched map still repaints 30×/s for as long as the panel is open. That's
// wasted CPU/GPU (WindowServer compositing) when the window isn't even the one
// you're looking at. Freeze the loop while the window is hidden or unfocused;
// a frozen core next to other work is fine, and it resumes instantly on focus.
const isPaused = () =>
(typeof document !== 'undefined' && document.hidden) ||
(typeof document.hasFocus === 'function' && !document.hasFocus())
let paused = isPaused()
const schedule = () => {
if (!paused && !raf) {
raf = requestAnimationFrame(frame)
}
}
// The static scene (rings, bands, links, nodes, labels) is cached in an
// offscreen layer and only re-rendered when something actually changes —
// dirtyRef flags that. The animated core scramble is the ONLY per-frame work:
// each frame we just clear, draw the live scramble, and blit the cached layer
// on top. So an idle map costs a scramble + one drawImage, not a full redraw.
let staticCanvas: HTMLCanvasElement | null = null
const paint = () => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (!canvas || !ctx) {
return
}
if (!staticCanvas) {
staticCanvas = document.createElement('canvas')
}
// Keep the offscreen layer matched to the backing store; a resize wipes it,
// so force a static rebuild.
if (staticCanvas.width !== canvas.width || staticCanvas.height !== canvas.height) {
staticCanvas.width = canvas.width
staticCanvas.height = canvas.height
dirtyRef.current = true
}
const offCtx = staticCanvas.getContext('2d')
if (!offCtx) {
return
}
if (themeDirtyRef.current || !paletteRef.current) {
paletteRef.current = computePalette(canvas)
themeDirtyRef.current = false
dirtyRef.current = true
}
const palette = paletteRef.current
if (!palette) {
return
}
// Rebuild the cached static layer only when the scene changed; keep
// rebuilding while fades are mid-ease (drawScene returns `animating`).
if (dirtyRef.current) {
const { animating, ringLabelRects } = drawScene({
adjacency: adjacencyRef.current,
byId: byIdRef.current,
ctx: offCtx,
dpr: dprRef.current,
fades: fadeRef.current,
focusId: selectedIdRef.current ?? hoverRef.current,
hoverId: hoverRef.current,
hoverLink: hoveredLinkRef.current,
hoverRing: hoveredRingRef.current,
links: linksRef.current,
memById: memByIdRef.current,
nodes: nodesRef.current,
palette,
reveal: revealRef.current,
rings: ringsRef.current,
selectedRing: selectedRingRef.current,
size: sizeRef.current,
snapMotion: snapMotionRef.current,
vp: viewportRef.current
})
// One-shot: a scrub snaps this frame; hover/focus afterward eases as usual
// (buckets are already at target, so the next eased frames don't move).
snapMotionRef.current = false
ringLabelRectsRef.current = ringLabelRects
dirtyRef.current = animating
}
// Composite order flips on focus/hover. Idle: scene first, sphere on top —
// its backdrop wash dims the busy centre. Focused/hovered: sphere first,
// scene on top — so the active node's tooltip + lit lines lift ABOVE the
// sphere instead of being covered by it.
const focused = selectedIdRef.current ?? hoverRef.current
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, canvas.width, canvas.height)
if (focused) {
drawScramble({ ctx, dpr: dprRef.current, palette, rings: ringsRef.current, vp: viewportRef.current })
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.drawImage(staticCanvas, 0, 0)
} else {
ctx.drawImage(staticCanvas, 0, 0)
drawScramble({ ctx, dpr: dprRef.current, palette, rings: ringsRef.current, vp: viewportRef.current })
}
}
const frame = (ts: number) => {
raf = 0
// The scramble animates every frame; throttle to ANIM_MS unless an
// interaction (force) needs an immediate repaint.
if (!force && ts - lastAnimTs < ANIM_MS) {
schedule()
return
}
force = false
lastAnimTs = ts
paint()
schedule()
}
invalidateRef.current = () => {
dirtyRef.current = true
force = true
schedule()
}
// Suspend the loop when the window drops out of view/focus; wake + force a
// fresh frame the moment it returns so the resume is seamless.
const onActivity = () => {
const next = isPaused()
if (next === paused) {
return
}
paused = next
if (paused) {
if (raf) {
cancelAnimationFrame(raf)
raf = 0
}
} else {
dirtyRef.current = true
force = true
schedule()
}
}
document.addEventListener('visibilitychange', onActivity)
window.addEventListener('blur', onActivity)
window.addEventListener('focus', onActivity)
schedule()
return () => {
cancelAnimationFrame(raf)
document.removeEventListener('visibilitychange', onActivity)
window.removeEventListener('blur', onActivity)
window.removeEventListener('focus', onActivity)
invalidateRef.current = () => {}
}
}, [])
// Size the backing canvas (DPR-aware).
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
sizeRef.current = size
dprRef.current = Math.min(2, window.devicePixelRatio || 1)
const canvas = canvasRef.current
if (canvas && size.w > 0 && size.h > 0) {
canvas.width = Math.round(size.w * dprRef.current)
canvas.height = Math.round(size.h * dprRef.current)
canvas.style.width = `${size.w}px`
canvas.style.height = `${size.h}px`
}
invalidate()
}, [invalidate, size])
// ── Pointer interactions (invert the tilted projection for hit-testing) ─────
const pickNode = (cssX: number, cssY: number): null | SimNode => {
const vp = viewportRef.current
// Hit radius mirrors the billboarded draw: rested fit scale, screen space.
const nodeK = fitScale(sizeRef.current.w, sizeRef.current.h, ringsRef.current)
let best: null | SimNode = null
let bestD = Infinity
for (const n of nodesRef.current) {
const r = nodeRadius(n) * nodeK + 6
const sx = n.x * vp.k + vp.x
const sy = n.y * vp.k * TILT + vp.y
const d = (sx - cssX) ** 2 + (sy - cssY) ** 2
if (d < r * r && d < bestD) {
bestD = d
best = n
}
}
return best
}
// Nearest link within ~5px of the cursor (screen space), or null.
const pickLink = (cssX: number, cssY: number): null | string => {
const vp = viewportRef.current
let best: null | string = null
let bestD = 25
for (const link of linksRef.current) {
const s = typeof link.source === 'object' ? link.source : byIdRef.current.get(String(link.source))
const t = typeof link.target === 'object' ? link.target : byIdRef.current.get(String(link.target))
if (!s || !t) {
continue
}
const d = distToSegmentSq(
cssX,
cssY,
s.x * vp.k + vp.x,
s.y * vp.k * TILT + vp.y,
t.x * vp.k + vp.x,
t.y * vp.k * TILT + vp.y
)
if (d < bestD) {
bestD = d
best = `${s.id}->${t.id}`
}
}
return best
}
const pickRingLabel = (cssX: number, cssY: number): null | number => {
for (const r of ringLabelRectsRef.current) {
if (cssX >= r.x && cssX <= r.x + r.w && cssY >= r.y && cssY <= r.y + r.h) {
return r.i
}
}
return null
}
const localXY = (e: React.MouseEvent): { x: number; y: number } => {
const rect = canvasRef.current?.getBoundingClientRect()
return { x: e.clientX - (rect?.left ?? 0), y: e.clientY - (rect?.top ?? 0) }
}
const resetView = () => {
setPlaying(false)
viewportRef.current = fitViewport(
sizeRef.current.w,
sizeRef.current.h,
ringsRef.current[ringsRef.current.length - 1]?.r ?? RING_OUTER
)
selectedRingRef.current = null
invalidate()
setSelectedId(null)
}
const onMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (e.button !== 0) {
return
}
const { x, y } = localXY(e)
const ringHit = pickRingLabel(x, y)
hoveredRingRef.current = null
// Nodes aren't draggable (static map) — remember which was pressed so a click
// (press without movement) can select it; any drag just pans.
const nodeId = ringHit == null ? (pickNode(x, y)?.id ?? null) : null
dragRef.current = {
id: nodeId,
mode: 'pan',
moved: false,
ring: ringHit,
sx: e.clientX,
sy: e.clientY,
vp: viewportRef.current
}
}
const onMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
const drag = dragRef.current
if (drag.mode === 'none') {
const { x, y } = localXY(e)
const ringHit = pickRingLabel(x, y)
const id = ringHit == null ? (pickNode(x, y)?.id ?? null) : null
// Links are the last fallback (only when not over a node/date).
const linkKey = ringHit == null && id == null ? pickLink(x, y) : null
if (id !== hoverRef.current || ringHit !== hoveredRingRef.current || linkKey !== hoveredLinkRef.current) {
hoverRef.current = id
hoveredRingRef.current = ringHit
hoveredLinkRef.current = linkKey
invalidate()
}
return
}
const dx = e.clientX - drag.sx
const dy = e.clientY - drag.sy
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
drag.moved = true
}
if (drag.mode === 'pan') {
// Taking manual control of the camera ends an auto-fit play-through.
if (drag.moved) {
setPlaying(false)
}
viewportRef.current = { ...drag.vp, x: drag.vp.x + dx, y: drag.vp.y + dy }
invalidate()
}
}
const endDrag = () => {
const drag = dragRef.current
// A click (press without movement) toggles a ring date, a node, or clears.
if (drag.mode === 'pan' && !drag.moved) {
// Double tap (trackpad tap-to-click may never emit a dblclick) resets view.
if (doubleTapRef.current()) {
resetView()
dragRef.current = { id: null, mode: 'none', moved: false, ring: null, sx: 0, sy: 0, vp: viewportRef.current }
return
}
// Independent toggles: a date and a node can both be selected.
if (drag.ring != null) {
selectedRingRef.current = selectedRingRef.current === drag.ring ? null : drag.ring
} else if (drag.id) {
setSelectedId(prev => (prev === drag.id ? null : drag.id))
} else {
selectedRingRef.current = null
setSelectedId(null)
}
invalidate()
}
dragRef.current = { id: null, mode: 'none', moved: false, ring: null, sx: 0, sy: 0, vp: viewportRef.current }
}
const onMouseLeave = () => {
hoverRef.current = null
hoveredRingRef.current = null
hoveredLinkRef.current = null
invalidate()
endDrag()
}
const onContextMenu = (e: React.MouseEvent<HTMLCanvasElement>) => {
e.preventDefault()
const { x, y } = localXY(e)
const node = pickNode(x, y)
if (!node) {
return setMenuTarget(null)
}
setSelectedId(node.id)
setMenuTarget({
id: node.id,
kind: node.kind === 'memory' ? 'memory' : 'skill',
label: node.label,
x: e.clientX,
y: e.clientY
})
}
const onWheel = (e: React.WheelEvent<HTMLCanvasElement>) => {
const rect = canvasRef.current?.getBoundingClientRect()
if (!rect) {
return
}
// macOS smart zoom (two-finger double-tap) → reset (see lib/trackpad-gestures).
if (isSmartZoomWheel(e)) {
resetView()
return
}
// Manual zoom takes over the camera from any auto-fit play-through.
setPlaying(false)
const px = e.clientX - rect.left
const py = e.clientY - rect.top
const vp = viewportRef.current
const k = clamp(vp.k * (e.deltaY > 0 ? 0.9 : 1.1), ZOOM_MIN, ZOOM_MAX)
viewportRef.current = { k, x: px - ((px - vp.x) / vp.k) * k, y: py - ((py - vp.y) / vp.k) * k }
invalidate()
}
return (
<div className="relative min-h-0 flex-1 overflow-hidden" ref={wrapRef}>
<canvas
className="block touch-none select-none text-foreground"
onContextMenu={onContextMenu}
onDoubleClick={resetView}
onMouseDown={onMouseDown}
onMouseLeave={onMouseLeave}
onMouseMove={onMouseMove}
onMouseUp={endDrag}
onWheel={onWheel}
ref={canvasRef}
/>
<NodeContextMenu
onClose={() => setMenuTarget(null)}
onNodeRemoved={() => {
setMenuTarget(null)
setSelectedId(null)
}}
target={menuTarget}
/>
{/* Timeline scrubber — centered along the top, clear of the close button.
z-20 lifts it above the titlebar's app-region drag layer (z-10) so the
scrubber receives pointer events instead of dragging the window. */}
<div className="pointer-events-none absolute inset-x-0 top-6 z-20 flex justify-center px-12">
<Timeline
axis={timeAxis}
memoryColor={memoryColor}
onScrub={onScrub}
onTogglePlay={onTogglePlay}
playing={playing}
revealStore={revealStore}
ringStops={ringStops}
/>
</div>
{/* Share / import (WoW-talent-style code) — bottom-right, mirroring the legend. */}
<div className="pointer-events-auto absolute bottom-2 right-2 z-20 [-webkit-app-region:no-drag]">
<ShareControls imported={imported} onImport={importCode} onResetMap={onResetMap} shareCode={shareCode} />
</div>
{/* Legend — bottom-left, one entry per line like a conventional key. */}
<div className="pointer-events-none absolute bottom-2 left-2 flex flex-col gap-1 text-[0.62rem] text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="inline-block size-2 rounded-full bg-[var(--theme-primary)]/80" /> {t.surfaces.skill}
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block size-2 rotate-45" style={{ backgroundColor: memoryColor }} />{' '}
{t.surfaces.memory}
</span>
<span className="text-[0.58rem] text-muted-foreground/65">{t.surfaces.mapAge}</span>
<RevealLabel axis={timeAxis} revealStore={revealStore} />
</div>
</div>
)
}