Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user