Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
yilsem committed 2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 files changed
+3442369

No files matched your search

+980
View File
@@ -0,0 +1,980 @@
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 { 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, ShareCodeError } 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 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 err instanceof ShareCodeError ? err.message : 'Could not read that map code.'
}
},
[onImport]
)
// 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" /> skill
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block size-2 rotate-45" style={{ backgroundColor: memoryColor }} /> memory
</span>
<span className="text-[0.58rem] text-muted-foreground/65">core = oldest · outer = newer</span>
<RevealLabel axis={timeAxis} revealStore={revealStore} />
</div>
</div>
)
}