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

+138
View File
@@ -0,0 +1,138 @@
import { BLACK, MODE_DEFAULTS } from './constants'
import { clamp } from './geometry'
import type { Palette, Rgb } from './types'
// Theme tokens come through `color-mix()`/oklch, so getComputedStyle returns a
// non-rgb() string. Rasterize through a 1x1 canvas to get real sRGB bytes —
// naive string parsing of oklab()/color(srgb …) silently yields black.
let _probe: CanvasRenderingContext2D | null = null
export function resolveRgb(color: string): Rgb {
if (!_probe) {
const c = document.createElement('canvas')
c.width = 1
c.height = 1
_probe = c.getContext('2d', { willReadFrequently: true })
}
if (!_probe) {
return { b: 184, g: 163, r: 148 }
}
_probe.clearRect(0, 0, 1, 1)
_probe.fillStyle = '#888888'
_probe.fillStyle = color
_probe.fillRect(0, 0, 1, 1)
const d = _probe.getImageData(0, 0, 1, 1).data
return { b: d[2], g: d[1], r: d[0] }
}
export function rgba(c: Rgb, a: number): string {
return `rgba(${c.r},${c.g},${c.b},${a})`
}
export function mixRgb(a: Rgb, b: Rgb, t: number): Rgb {
const p = clamp(t, 0, 1)
return {
b: Math.round(a.b + (b.b - a.b) * p),
g: Math.round(a.g + (b.g - a.g) * p),
r: Math.round(a.r + (b.r - a.r) * p)
}
}
export function darken(c: Rgb, amount: number): Rgb {
return mixRgb(c, BLACK, amount)
}
export function luminance(r: number, g: number, b: number): number {
return (0.2126 * r + 0.7152 * g + 0.114 * b) / 255
}
function rgbToHsl(c: Rgb): [number, number, number] {
const r = c.r / 255
const g = c.g / 255
const b = c.b / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const l = (max + min) / 2
const d = max - min
let h = 0
let s = 0
if (d) {
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
h = (max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4) * 60
}
return [h, s, l]
}
function hslToRgb(h: number, s: number, l: number): Rgb {
const hue = ((h % 360) + 360) % 360
const c = (1 - Math.abs(2 * l - 1)) * s
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1))
const m = l - c / 2
const [r, g, b] =
hue < 60
? [c, x, 0]
: hue < 120
? [x, c, 0]
: hue < 180
? [0, c, x]
: hue < 240
? [0, x, c]
: hue < 300
? [x, 0, c]
: [c, 0, x]
return { b: Math.round((b + m) * 255), g: Math.round((g + m) * 255), r: Math.round((r + m) * 255) }
}
// Complementary ink: rotate the source hue (the theme primary) and keep it vivid
// so memories read as a distinct color from skills, in any theme.
function complementaryInk(c: Rgb): Rgb {
const [h, s, l] = rgbToHsl(c)
return hslToRgb(h + 165, Math.max(s, 0.5), clamp(l, 0.5, 0.7))
}
// Memory ink: the complementary hue muted toward the overlay background so it
// reads as a distinct-but-quiet color (fake alpha), not a loud full-sat pop.
export function memoryInkFor(primary: Rgb, bg: Rgb): Rgb {
return mixRgb(complementaryInk(primary), bg, 0.45)
}
// Resolve the theme-derived palette once per theme change — the resolveRgb probe
// does a getImageData readback, so this stays out of the per-frame path. Node
// groups borrow restrained tint from the theme; structure stays foreground ink.
export function computePalette(canvas: HTMLCanvasElement): Palette {
const style = getComputedStyle(canvas)
const fg = resolveRgb(style.color)
const darkTheme = luminance(fg.r, fg.g, fg.b) > 0.55
const base: Rgb = darkTheme ? { b: 255, g: 255, r: 255 } : { b: 0, g: 0, r: 0 }
const primary = resolveRgb(style.getPropertyValue('--theme-primary').trim() || style.color)
const bg = resolveRgb(
style.getPropertyValue('--background').trim() ||
style.getPropertyValue('--dt-background').trim() ||
(darkTheme ? '#000' : '#fff')
)
return {
// Band tint derives from the theme primary so rings read consistently in
// both modes (foreground ink would go white on dark / black on light).
bandInk: mixRgb(primary, base, darkTheme ? 0.3 : 0),
base,
bg,
c: MODE_DEFAULTS[darkTheme ? 'dark' : 'light'],
chipBg: darkTheme ? 'rgba(0,0,0,0.72)' : 'rgba(255,255,255,0.85)',
darkTheme,
inkInv: darkTheme ? 'rgba(0,0,0,1)' : 'rgba(255,255,255,1)',
memoryInk: memoryInkFor(primary, bg),
primary,
skillInk: mixRgb(primary, base, darkTheme ? 0.12 : 0.18)
}
}
+62
View File
@@ -0,0 +1,62 @@
import type { StarmapNode } from '@/types/hermes'
import type { GraphParams, Rgb, RingParams, Shape } from './types'
// ── Disk geometry ────────────────────────────────────────────────────────────
export const RING_INNER = 58
export const RING_OUTER = 340
export const ZOOM_MIN = 0.3
export const ZOOM_MAX = 5
export const FIT_PADDING = 80
export const TILT = 1 // vertical squash → "looking down at a tilted disk"
export const RING_STEPS = 4
export const WHITE: Rgb = { b: 255, g: 255, r: 255 }
export const BLACK: Rgb = { b: 0, g: 0, r: 0 }
// Fixed recency (age) gradient — old content quiet, recent content bright.
export const AGE_GRADIENT = { mid: 0.52, midInk: 0.74, newInk: 0.95, oldInk: 0.42, reach: 1 }
// Node glyph per kind — pure path geometry (the seam a future sprite/instanced
// renderer would bake from).
export const NODE_SHAPE: Record<StarmapNode['kind'], Shape> = { memory: 'diamond', skill: 'circle' }
// Darken the orb body so a bright primary doesn't swallow the sheen (the
// highlight is computed from the original ink, so it still reads).
export const ORB_DARKEN = 0.3
// Sheen forced this high when the orb ink is near-white (a white body needs a
// pure-white core to read as a sphere at all).
export const WHITEISH_SHEEN = 0.95
// Flat wash alpha for a lit (hovered/selected) date's band. The focused ring
// outline derives from this (×2).
export const LIT_BAND_ALPHA = 0.04
export const MODE_DEFAULTS: Record<'dark' | 'light', GraphParams> = {
dark: {
lineAlpha: 0.24,
lineDash: 1.5,
lineDashed: true,
lineWidth: 0.5,
ringAlpha: 0.1,
ringDash: 4,
ringDashed: false,
ringWidth: 1.5
},
light: {
lineAlpha: 0.18,
lineDash: 1.5,
lineDashed: true,
lineWidth: 0.5,
ringAlpha: 0.06,
ringDash: 4,
ringDashed: false,
ringWidth: 2
}
}
export const RING_PARAMS: Record<'dark' | 'light', RingParams> = {
dark: { bandAlpha: 0.01, lightSize: 0.64, ringAlpha: 0.06, sheen: 0.12 },
light: { bandAlpha: 0.03, lightSize: 0.27, ringAlpha: 0.028, sheen: 0.1 }
}
+132
View File
@@ -0,0 +1,132 @@
import type { StarmapNode } from '@/types/hermes'
import { AGE_GRADIENT, FIT_PADDING, RING_INNER, RING_OUTER, TILT, ZOOM_MAX, ZOOM_MIN } from './constants'
import type { Ring, Shape, Viewport } from './types'
export function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v))
}
// FNV-1a — stable per-id seed for layout angle / starfield.
export function hash(input: string): number {
let h = 2166136261
for (let i = 0; i < input.length; i += 1) {
h ^= input.charCodeAt(i)
h = Math.imul(h, 16777619)
}
return h >>> 0
}
export function nodeRadius(n: StarmapNode): number {
if (n.kind === 'memory') {
return 4.4
}
const base = n.state === 'archived' || n.state === 'stale' ? 2.4 : 3
return base + Math.sqrt(Math.max(0, n.useCount)) * 0.55 + (n.pinned ? 0.8 : 0)
}
// Smoothstep recency → ink alpha along the age gradient.
export function recencyInk(rec: number): number {
const reach = Math.max(0.01, AGE_GRADIENT.reach)
const mid = clamp(AGE_GRADIENT.mid, 0.01, 0.99)
const t = clamp(rec / reach, 0, 1)
if (t <= mid) {
const p = t / mid
return AGE_GRADIENT.oldInk + (AGE_GRADIENT.midInk - AGE_GRADIENT.oldInk) * (p * p * (3 - 2 * p))
}
const p = (t - mid) / (1 - mid)
return AGE_GRADIENT.midInk + (AGE_GRADIENT.newInk - AGE_GRADIENT.midInk) * (p * p * (3 - 2 * p))
}
// Trace a centred geometric shape of radius r into the current path.
export function shapePath(ctx: CanvasRenderingContext2D, shape: Shape, x: number, y: number, r: number): void {
ctx.beginPath()
if (shape === 'square') {
ctx.rect(x - r, y - r, r * 2, r * 2)
return
}
if (shape === 'circle') {
ctx.arc(x, y, r, 0, Math.PI * 2)
return
}
const pts = shape === 'diamond' ? 4 : shape === 'triangle' ? 3 : 6
// Diamond/triangle point up; hexagon is flat-topped.
const rot = shape === 'hexagon' ? Math.PI / 6 : -Math.PI / 2
for (let i = 0; i < pts; i += 1) {
const a = rot + (i / pts) * Math.PI * 2
const px = x + Math.cos(a) * r
const py = y + Math.sin(a) * r
if (i === 0) {
ctx.moveTo(px, py)
} else {
ctx.lineTo(px, py)
}
}
ctx.closePath()
}
// Center the tilted disk in the viewport at a fit zoom. `outer` is the radius to
// fit (defaults to the full disk); the scrubber passes the revealed extent so the
// camera tightens at the core and zooms out as the rings grow.
export function fitViewport(w: number, h: number, outer: number = RING_OUTER): Viewport {
if (w <= 0 || h <= 0) {
return { k: 1, x: w / 2, y: h / 2 }
}
// Fit zoom for a disk of radius r into this viewport (capped at 2.2× zoom-in).
const kFor = (r: number): number => {
const spanX = (r + 30) * 2
return Math.min((w - FIT_PADDING * 2) / spanX, (h - FIT_PADDING * 2) / (spanX * TILT), 2.2)
}
// Never zoom out past the reference (RING_OUTER / 5-ring) extent: a bigger map
// renders at that constant scale and overflows — you pan it — instead of
// shrinking every node to fit. Smaller extents (few rings, or the playback
// core) still fit tightly / zoom in.
const k = clamp(Math.max(kFor(outer), kFor(RING_OUTER)), ZOOM_MIN, ZOOM_MAX)
// Bias the center down a touch — the timeline along the top adds visual weight
// up there, so true-center reads as sitting high.
return { k, x: w / 2, y: h / 2 + h * 0.05 }
}
// Target radius for a node at recency `rec` (oldest at the core), scaled to a
// disk of the given outer radius.
export function radiusForRecency(rec: number, outer: number = RING_OUTER): number {
return RING_INNER + rec * (outer - RING_INNER)
}
// Screen-space scale at the graph's fully-rested fit. Nodes size against THIS,
// not the live (playback) camera — so a spore-zoom moves WHERE they sit, not how
// big they read (billboarded), while a full-map view keeps its honest density.
export const fitScale = (w: number, h: number, rings: Ring[]): number =>
fitViewport(w, h, rings.at(-1)?.r ?? RING_OUTER).k
// Squared distance from point (px,py) to segment a→b — for cheap link hit-tests.
export function distToSegmentSq(px: number, py: number, ax: number, ay: number, bx: number, by: number): number {
const dx = bx - ax
const dy = by - ay
const len = dx * dx + dy * dy
const t = len ? clamp(((px - ax) * dx + (py - ay) * dy) / len, 0, 1) : 0
const cx = ax + dx * t
const cy = ay + dy * t
return (px - cx) ** 2 + (py - cy) ** 2
}
+58
View File
@@ -0,0 +1,58 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { useI18n } from '@/i18n'
import { $starmapError, $starmapGraph, $starmapLoading, loadStarmapGraph } from '@/store/starmap'
import type { StarmapGraph } from '@/types/hermes'
import { Panel, PanelEmpty } from '../overlays/panel'
import { StarMap } from './star-map'
// Star map overlay: a top-down map of what Hermes has learned for a profile,
// over a radial time axis. Data is fetched on demand into the $starmap* atoms;
// the map itself lives in ./star-map. The chrome is owned by the map itself
// (timeline scrubber + legend float over the canvas), so there's no panel
// header here.
export function StarmapView({ onClose }: { onClose: () => void }) {
const { t } = useI18n()
const graph = useStore($starmapGraph)
const loading = useStore($starmapLoading)
const error = useStore($starmapError)
// A pasted share code populates the map with someone else's (or an exported)
// graph, overriding the live profile scan. Cleared by "back to my map" and
// whenever a fresh profile graph loads in.
const [imported, setImported] = useState<StarmapGraph | null>(null)
useEffect(() => {
void loadStarmapGraph()
}, [])
// Drop a stale import when the underlying profile graph changes out from under it.
useEffect(() => {
setImported(null)
}, [graph])
const shown = imported ?? graph
return (
<Panel closeLabel={t.starmap.close} onClose={onClose}>
{error ? (
<PanelEmpty description={error} icon="warning" title={t.starmap.loadFailed} />
) : !shown && loading ? (
<PageLoader aria-label={t.starmap.loading} className="min-h-0 flex-1" />
) : shown && shown.nodes.length === 0 && !imported ? (
<PanelEmpty description={t.starmap.emptyDesc} icon="lightbulb" title={t.starmap.emptyTitle} />
) : shown ? (
<StarMap
graph={shown}
imported={imported !== null}
onImport={setImported}
onResetMap={() => setImported(null)}
/>
) : null}
</Panel>
)
}
@@ -0,0 +1,219 @@
import { useRef, useState } from 'react'
import { ArchiveSkillConfirmDialog, fireOptimistic } from '@/app/learning/archive-skill-confirm-dialog'
import { CodeEditor } from '@/components/chat/code-editor'
import { Button } from '@/components/ui/button'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { deleteLearningNode, editLearningNode, getLearningNode } from '@/hermes'
import { notifyError } from '@/store/notifications'
import { evictStarmapNode, loadStarmapGraph } from '@/store/starmap'
import { useOnProfileSwitch } from '../hooks/use-on-profile-switch'
export interface NodeMenuTarget {
id: string
kind: 'memory' | 'skill'
label: string
x: number
y: number
}
interface NodeContextMenuProps {
onClose: () => void
onNodeRemoved: () => void
target: NodeMenuTarget | null
}
interface EditState {
content: string
id: string
label: string
}
/** Right-click actions for a star-map node: edit (modal) or delete (confirm). */
export function NodeContextMenu({ onClose, onNodeRemoved, target }: NodeContextMenuProps) {
const [editing, setEditing] = useState<EditState | null>(null)
const [deleting, setDeleting] = useState<Omit<NodeMenuTarget, 'x' | 'y'> | null>(null)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
// Bumped on profile switch so an in-flight openEdit fetch from profile A can't
// reopen the editor with A's node content after switching to B.
const editEpoch = useRef(0)
// A profile switch swaps the backend under an open edit/delete dialog — its
// node id belongs to the previous profile, so a Save/Delete after the switch
// would hit the newly active profile. Close everything on switch.
useOnProfileSwitch(() => {
editEpoch.current += 1
setEditing(null)
setDeleting(null)
setError(null)
})
const noun = target?.kind === 'memory' ? 'memory' : 'skill'
const openEdit = async () => {
if (!target) {
return
}
const epoch = editEpoch.current
setLoading(true)
setError(null)
try {
const detail = await getLearningNode(target.id)
if (editEpoch.current !== epoch) {
return
}
setEditing({ content: detail.content, id: target.id, label: target.label })
onClose()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}
const save = async () => {
if (!editing) {
return
}
setSaving(true)
setError(null)
try {
const res = await editLearningNode(editing.id, editing.content)
if (!res.ok) {
throw new Error(res.message)
}
setEditing(null)
void loadStarmapGraph(true)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
}
const menuOpen = target && !editing && !deleting
return (
<>
{menuOpen ? (
<>
<div className="fixed inset-0 z-50" onClick={onClose} onContextMenu={e => e.preventDefault()} />
{/* Styled to DropdownMenuContent/Item scale (rounded-lg card, p-1,
text-xs rows) — the hand-rolled fixed positioning stays because
the target is a canvas point, not a DOM anchor. */}
<div
className="fixed z-50 min-w-36 rounded-lg border border-(--ui-stroke-secondary) bg-[color-mix(in_srgb,var(--ui-bg-elevated)_96%,transparent)] p-1 shadow-md backdrop-blur-md"
style={{ left: target.x, top: target.y }}
>
<div className="truncate px-2 py-1 text-[0.68rem] text-muted-foreground">{target.label}</div>
<button
className="block w-full cursor-pointer rounded-md px-2 py-1 text-left text-xs hover:bg-(--ui-control-active-background) hover:text-foreground disabled:opacity-50"
disabled={loading}
onClick={() => void openEdit()}
type="button"
>
Edit {noun}…
</button>
<button
className="block w-full cursor-pointer rounded-md px-2 py-1 text-left text-xs text-destructive hover:bg-destructive/10"
onClick={() => {
setDeleting({ id: target.id, kind: target.kind, label: target.label })
onClose()
}}
type="button"
>
{target.kind === 'skill' ? 'Archive skill' : 'Delete memory'}
</button>
</div>
</>
) : null}
<Dialog onOpenChange={value => !value && !saving && setEditing(null)} open={Boolean(editing)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit {editing?.label}</DialogTitle>
</DialogHeader>
<div className="h-80">
{editing && (
<CodeEditor
filePath={noun === 'skill' ? 'SKILL.md' : 'memory.md'}
framed
initialValue={editing.content}
key={editing.id}
onCancel={() => !saving && setEditing(null)}
onChange={content => setEditing(prev => (prev ? { ...prev, content } : prev))}
onSave={() => void save()}
/>
)}
</div>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
<DialogFooter>
<Button disabled={saving} onClick={() => setEditing(null)} type="button" variant="ghost">
Cancel
</Button>
<Button disabled={saving} onClick={() => void save()}>
{saving ? 'Saving…' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{deleting?.kind === 'skill' ? (
<ArchiveSkillConfirmDialog
onApply={() => {
onNodeRemoved()
return evictStarmapNode(deleting.id)
}}
onClose={() => setDeleting(null)}
onFailure={(err, name) => notifyError(err, name)}
open
skillId={deleting.id}
skillName={deleting.label}
/>
) : (
<ConfirmDialog
confirmLabel="Delete"
description="This memory is removed permanently."
destructive
dismissOnConfirm
onClose={() => setDeleting(null)}
onConfirm={() => {
if (!deleting) {
return
}
const { id, label } = deleting
const rollback = evictStarmapNode(id)
onNodeRemoved()
fireOptimistic(
deleteLearningNode(id).then(res => {
if (!res.ok) {
throw new Error(res.message)
}
}),
rollback,
err => notifyError(err, label)
)
}}
open={Boolean(deleting)}
title={`Delete ${deleting?.label ?? ''}?`}
/>
)}
</>
)
}
+864
View File
@@ -0,0 +1,864 @@
import { darken, luminance, mixRgb, rgba } from './color'
import {
LIT_BAND_ALPHA,
NODE_SHAPE,
ORB_DARKEN,
RING_INNER,
RING_PARAMS,
TILT,
WHITE,
WHITEISH_SHEEN
} from './constants'
import { clamp, fitScale, nodeRadius, recencyInk, shapePath } from './geometry'
import { countLabel, ellipsize, metaBadges, nodeFooter, wrapText } from './text'
import type {
FadeBuckets,
MemoryCard,
Palette,
Rect,
Rgb,
Ring,
RingLabelRect,
SimLink,
SimNode,
Viewport
} from './types'
export interface Scene {
adjacency: Map<string, Set<string>>
byId: Map<string, SimNode>
ctx: CanvasRenderingContext2D
dpr: number
fades: FadeBuckets
focusId: null | string
hoverId: null | string
hoverLink: null | string
hoverRing: null | number
links: SimLink[]
memById: Map<string, MemoryCard>
nodes: SimNode[]
palette: Palette
// Time scrubber: only paint nodes/links whose recency has been reached. 1 =
// everything (the default, idle state); lower values "build up" the map.
reveal: number
rings: Ring[]
selectedRing: null | number
size: { h: number; w: number }
// Scrub jumps: snap every ease to its target this frame (no birth/fade replay).
snapMotion?: boolean
vp: Viewport
}
export interface DrawResult {
animating: boolean
ringLabelRects: RingLabelRect[]
}
// Smoothstep — eases the birth animations (position grow-out) in and out.
const ease = (t: number): number => {
const u = t < 0 ? 0 : t > 1 ? 1 : t
return u * u * (3 - 2 * u)
}
// EVE-style warp arrival for node births: the star streaks outward fast, then
// decelerates hard (exponential ease-out) and drops onto its ring — like a ship
// dropping out of warp. WARP_FROM is how deep toward the core it launches from.
const WARP_FROM = 0.32
const warpIn = (t: number): number => {
const u = t < 0 ? 0 : t > 1 ? 1 : t
return u >= 1 ? 1 : 1 - 2 ** (-9 * u)
}
// Layered birth speeds for the scrubber's parallax: rings expand slowly and
// grandly in the background, stars pop in quicker up front — both well below the
// default hover/focus speeds so the build-up reads as a cinematic settle.
const RING_BIRTH = { down: 0.055, up: 0.032 }
const NODE_BIRTH = { down: 0.11, up: 0.075 }
// Glyph pool for the empty-core scramble: Matrix-style half-width katakana plus
// a few digits/symbols for the "digital rain / decoding" look.
const SCRAMBLE_CHARS = 'ハヒフヘホマミムメモヤユヨラリルレワンヲアウエオカキケコサシスセタチツテナニヌネ0123456789:.=*+<>Ξ╳'
// Sphere-sprite atlas: a lit orb is the same picture at every size, so we render
// each distinct (ink, sheen, darken) appearance ONCE into an offscreen sprite and
// blit it (scaled) per node — instead of allocating a fresh radial gradient for
// every star on every frame. Keyed by appearance, not size; drawImage scales it.
// Reference radius the sprite is rendered at — larger than the usual billboarded
// screen-space orb, so sprites scale down in normal use and stay crisp.
const SPRITE_R = 96
const spriteCache = new Map<string, HTMLCanvasElement>()
// Build (or fetch) the orb sprite for one appearance: an offset radial gradient
// from a hot core → darkened body → translucent rim, clipped to the disk, so a
// flat circle reads with volume. `strength` is how white the core is; `bodyDarken`
// darkens the body (0 for active/hover nodes so they pop full bright). Near-white
// inks skip the darken and force a near-full sheen so the white core still reads.
function sphereSprite(ink: Rgb, strength: number, bodyDarken: number): HTMLCanvasElement {
const key = `${ink.r},${ink.g},${ink.b}|${strength}|${bodyDarken}`
const cached = spriteCache.get(key)
if (cached) {
return cached
}
const R = SPRITE_R
// Margin for the gradient's rim (extends to 1.15·R) so it isn't clipped.
const pad = Math.ceil(R * 0.15) + 1
const size = (R + pad) * 2
const c = R + pad
const cv = document.createElement('canvas')
cv.width = size
cv.height = size
const g2 = cv.getContext('2d')
if (!g2) {
return cv
}
const mx = Math.max(ink.r, ink.g, ink.b)
const mn = Math.min(ink.r, ink.g, ink.b)
const sat = mx ? (mx - mn) / mx : 0
const whiteness = clamp((luminance(ink.r, ink.g, ink.b) - 0.7) / 0.3, 0, 1) * (1 - sat)
const eff = strength + (WHITEISH_SHEEN - strength) * whiteness
const hi = mixRgb(ink, WHITE, 0.7 * eff)
const body = darken(ink, bodyDarken * (1 - whiteness))
const grad = g2.createRadialGradient(c - R * 0.35, c - R * 0.4, R * 0.05, c, c, R * 1.15)
grad.addColorStop(0, rgba(hi, 1))
grad.addColorStop(0.5, rgba(body, 1))
grad.addColorStop(1, rgba(body, 0.85))
g2.fillStyle = grad
g2.beginPath()
g2.arc(c, c, R, 0, Math.PI * 2)
g2.fill()
spriteCache.set(key, cv)
return cv
}
// Paint a lit orb of radius `r` centered at (x, y) by blitting its cached sprite.
// Honors the caller's globalAlpha (drawImage multiplies it), matching the old
// gradient fill. No path needed — the sprite already carries the disk + AA rim.
function sphereFill(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
r: number,
ink: Rgb,
strength: number,
bodyDarken: number
): void {
const sprite = sphereSprite(ink, strength, bodyDarken)
const scale = r / SPRITE_R
const drawSize = sprite.width * scale
ctx.drawImage(sprite, x - drawSize / 2, y - drawSize / 2, drawSize, drawSize)
}
const rectsOverlap = (a: Rect, b: Rect) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y
// Paint a full frame of the star map. Pure given its inputs (draws to the
// canvas + advances the fade buckets); returns whether it's still animating and
// the ring-label hit rects for pointer picking.
export function drawScene(scene: Scene): DrawResult {
const {
adjacency,
byId,
ctx,
dpr,
fades,
focusId,
hoverId,
hoverLink,
hoverRing,
links,
memById,
nodes,
palette,
reveal,
rings,
selectedRing,
size,
snapMotion = false,
vp
} = scene
// Small epsilon so a node exactly at the playhead counts as revealed.
const seen = (rec: number) => rec <= reveal + 1e-3
// Recency for styling is RELATIVE to the newest revealed node — the current
// "present" — not the bare playhead. So a lone frontier node still reads as
// fresh (bright/full size) even with empty space between it and the scrubber.
// At reveal = 1 the frontier is the newest node, collapsing back to raw recency.
let frontier = 0
for (const fn of nodes) {
if (fn.rec <= reveal + 1e-3 && fn.rec > frontier) {
frontier = fn.rec
}
}
const erec = (rec: number) => (frontier > 0 ? clamp(rec / frontier, 0, 1) : 1)
const { h, w } = size
const { bandInk, base, bg, c, chipBg, darkTheme, inkInv, memoryInk, skillInk } = palette
const { bandAlpha, lightSize, ringAlpha, sheen } = RING_PARAMS[darkTheme ? 'dark' : 'light']
let animating = false
const ringLabelRects: RingLabelRect[] = []
// Eased opacity per element: snaps up when newly highlighted, eases otherwise.
// `rates` overrides the default in/out lerp speed (the slow births pass their
// own gentler pair so the build-up reads as a graceful settle, not a flash).
const fadeAlpha = (
bucket: Map<string, number>,
key: string,
target: number,
snapUp = false,
rates?: { down: number; up: number }
) => {
const targetAlpha = clamp(target, 0, 1)
const prev = bucket.get(key)
// Scrub: jump straight to the target so a fast drag doesn't replay easing.
if (snapMotion) {
bucket.set(key, targetAlpha)
return targetAlpha
}
if (prev == null || (snapUp && targetAlpha > prev)) {
bucket.set(key, targetAlpha)
return targetAlpha
}
const up = rates?.up ?? 0.22
const down = rates?.down ?? 0.32
const rate = targetAlpha > prev ? up : down
const next = prev + (targetAlpha - prev) * rate
if (Math.abs(next - targetAlpha) < 0.01) {
bucket.set(key, targetAlpha)
return targetAlpha
}
animating = true
bucket.set(key, next)
return next
}
const shade = (a: number) => `rgba(${base.r},${base.g},${base.b},${a})`
const projX = (wx: number) => wx * vp.k + vp.x
const projY = (wy: number) => wy * vp.k * TILT + vp.y
// Baseline node scale: the rested fit, held stable while the playback camera
// dives into the core — so t≈0 nodes don't balloon (see fitScale).
const nodeK = fitScale(w, h, rings)
// Two composable layers: node highlight (selected ?? hovered) in full ink, and
// a selection-only ring/date filter that only shifts alpha.
const focusSet = focusId ? (adjacency.get(focusId) ?? new Set<string>()) : null
const ringIdx = selectedRing
const ring = ringIdx != null ? (rings[ringIdx] ?? null) : null
// A selected ring owns the band it caps: previous ring → this ring. Ring 0 is
// visual-only/unlabeled, so the first selectable date naturally owns shell 0→1.
const ringLo = ring && ringIdx != null ? (rings[ringIdx - 1]?.ratio ?? 0) - 1e-3 : 0
const ringHi = ring ? ring.ratio + 1e-3 : 1
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, w, h)
ctx.globalAlpha = 1
// Tilted world transform for the disk structure.
ctx.setTransform(vp.k * dpr, 0, 0, vp.k * TILT * dpr, vp.x * dpr, vp.y * dpr)
// The "lit" date = hovered (preview) or selected (locked) — drives the band
// flatten only; the ring outline reacts to selection.
const litRingIdx = hoverRing ?? ringIdx
// A ring is "laid" one band AHEAD of the playhead — it appears the moment the
// scrubber enters the band beneath it (its inner neighbor's date), so the date
// gridline that caps a region is always drawn before any node in that region.
// Ring 0 is just the visual core; the first real shell still needs non-zero
// playback progress so replay starts empty instead of showing it pre-laid.
const ringSeen = (i: number) => {
const threshold = rings[i - 1]?.ratio ?? 0
return i === 0 || (threshold <= 0 ? reveal > 1e-3 : reveal + 1e-3 >= threshold)
}
// Per-ring "grow out" progress (advanced once per frame, reused by bands /
// outlines / labels): a revealed ring eases its radius from its inner neighbor
// outward to its resting radius, so it expands into place instead of popping.
const ringAppear = rings.map((rg, i) =>
ease(fadeAlpha(fades.appear, `ring:${i}`, ringSeen(i) ? 1 : 0, false, RING_BIRTH))
)
// Direction-based origin (the sign of the reveal): a ring growing IN expands
// outward from its inner neighbour — never from the dead centre — while a ring
// fading OUT collapses all the way to the core. ringSeen is the direction tell:
// true = revealing/at rest, false = receding.
const ringDrawR = rings.map((rg, i) => {
const startR = ringSeen(i) ? (rings[i - 1]?.r ?? rg.r) : RING_INNER
return startR + (rg.r - startR) * (ringAppear[i] ?? 1)
})
// Opacity envelope that stays near-full through most of the grow/shrink and
// only fades in the final stretch — so the radius TRAVEL is visible (the ring
// shrinks back into place) instead of just dimming out where it stands.
const ringVis = ringAppear.map(a => clamp(a / 0.55, 0, 1))
// Inter-ring bands: a theme-tinted wash sliver at the outer edge; the lit
// date's band flattens to an even wash.
if (bandAlpha > 0 || litRingIdx != null) {
for (let i = 0; i < rings.length - 1; i += 1) {
const lit = litRingIdx != null && i + 1 === litRingIdx
if (!lit && bandAlpha <= 0) {
continue
}
// The band tracks its outer ring's grow-in.
if ((ringAppear[i + 1] ?? 1) <= 0.01) {
continue
}
const inner = ringDrawR[i] ?? 0
const outer = ringDrawR[i + 1] ?? 0
if (lit) {
ctx.fillStyle = rgba(bandInk, LIT_BAND_ALPHA)
} else {
const grad = ctx.createRadialGradient(0, 0, inner, 0, 0, outer)
if (darkTheme) {
// Dark: a light wash on each band's OUTER rim — reads as light catching
// a raised edge → depth.
grad.addColorStop(0, rgba(bandInk, 0))
grad.addColorStop(clamp(1 - lightSize, 0.01, 0.99), rgba(bandInk, 0))
grad.addColorStop(1, rgba(bandInk, bandAlpha))
} else {
// Light: flip it — the (darker) wash sits on the INNER edge and fades
// outward, so each shell reads as recessed toward the core (depth),
// not a raised mound.
grad.addColorStop(0, rgba(bandInk, bandAlpha))
grad.addColorStop(clamp(lightSize, 0.01, 0.99), rgba(bandInk, 0))
grad.addColorStop(1, rgba(bandInk, 0))
}
ctx.fillStyle = grad
}
ctx.beginPath()
ctx.arc(0, 0, outer, 0, Math.PI * 2)
ctx.arc(0, 0, inner, 0, Math.PI * 2, true)
ctx.fill()
}
}
// Ring outline: brightens only on selection — the selected ring + its inner
// neighbor (the two bounding the lit band).
ctx.lineWidth = c.ringWidth / vp.k
ctx.setLineDash(c.ringDashed ? [c.ringDash / vp.k, c.ringDash / vp.k] : [])
rings.forEach((rg, i) => {
const emphasized = ringIdx != null && (i === ringIdx || i === ringIdx - 1)
// Reveal in/out rides the smooth (slow) ringAppear envelope so a ring fades
// out as gracefully as it grew in; the alpha bucket only carries the snappy
// selection emphasis.
const emphasisAlpha = emphasized ? clamp(LIT_BAND_ALPHA * 2, 0, 1) : ringAlpha
// The core ring (i 0) fades in from reveal 0 so the scramble orb starts
// un-enclosed (no outline boxing it in) and the shell appears as it plays.
const coreFade = i === 0 ? clamp(reveal / 0.08, 0, 1) : 1
const ringAlphaNow = fadeAlpha(fades.rings, String(i), emphasisAlpha, emphasized) * (ringVis[i] ?? 1) * coreFade
if (ringAlphaNow < 0.004) {
return
}
ctx.strokeStyle = shade(ringAlphaNow)
ctx.beginPath()
ctx.arc(0, 0, ringDrawR[i] ?? rg.r, 0, Math.PI * 2)
ctx.stroke()
})
ctx.setLineDash([])
// Screen space for the jump routes and glyphs (crisp, easy to trim). The empty
// core's animated scramble is NOT painted here — it's the only perpetually
// moving layer, so it's drawn live each frame by drawScramble() on top of the
// (cached) static scene. Everything else in this function is static until an
// input changes, which is why `animating` now reflects only in-flight fades.
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
// Jump routes — a focused node's links stop at its selection ring.
const focusNode = focusId ? (byId.get(focusId) ?? null) : null
const focusRingR = focusNode ? nodeRadius(focusNode) * nodeK + 4 : 0
for (const link of links) {
const s = typeof link.source === 'object' ? link.source : byId.get(String(link.source))
const t = typeof link.target === 'object' ? link.target : byId.get(String(link.target))
if (!s || !t) {
continue
}
// A jump route only exists once both of its endpoints have ignited.
const revealed = seen(s.rec) && seen(t.rec)
const lit =
revealed &&
!!focusId &&
(s.id === focusId || t.id === focusId || (!!focusSet && focusSet.has(s.id) && focusSet.has(t.id)))
let x1 = projX(s.x)
let y1 = projY(s.y)
let x2 = projX(t.x)
let y2 = projY(t.y)
if (s.id === focusId) {
const d = Math.hypot(x2 - x1, y2 - y1) || 1
x1 += ((x2 - x1) / d) * focusRingR
y1 += ((y2 - y1) / d) * focusRingR
}
if (t.id === focusId) {
const d = Math.hypot(x1 - x2, y1 - y2) || 1
x2 += ((x1 - x2) / d) * focusRingR
y2 += ((y1 - y2) / d) * focusRingR
}
const key = `${s.id}->${t.id}`
const ambient = recencyInk(erec((s.rec + t.rec) / 2)) * c.lineAlpha
// Hovering a line fades it in a bit (×2, capped — never full white).
const targetAlpha = !revealed
? 0
: lit
? 1
: key === hoverLink
? clamp(ambient * 2, 0, 0.7)
: focusId || ring
? 0.025
: ambient
const linkAlpha = fadeAlpha(fades.links, key, targetAlpha, lit)
if (linkAlpha < 0.004) {
continue
}
ctx.strokeStyle = shade(linkAlpha)
ctx.setLineDash(lit || !c.lineDashed ? [] : [c.lineDash, c.lineDash])
ctx.lineWidth = lit ? 1.5 : c.lineWidth
ctx.beginPath()
ctx.moveTo(x1, y1)
ctx.lineTo(x2, y2)
ctx.stroke()
}
ctx.setLineDash([])
// Nodes: the node layer paints pure ink (focused node + neighbors); the date
// filter is alpha-only, so the two states compose. Track which rings have at
// least one revealed node so a ring's date only shows once it has content.
const revealedRings = new Set<number>()
for (const n of nodes) {
// The land comes first: a node waits for the ring that CAPS its region (its
// outer date gridline) to grow in before it ignites — so the ring is always
// drawn before any star inside it, not after.
const landLaid = (ringAppear[n.outerRingIndex] ?? 1) >= 0.5
const revealed = seen(n.rec) && landLaid
if (revealed) {
revealedRings.add(n.outerRingIndex)
}
const isFocus = revealed && n.id === focusId
const isNeighbor = revealed && !!focusSet && focusSet.has(n.id)
const inRing = !!ring && n.rec >= ringLo && n.rec < ringHi
const nodeHigh = isFocus || isNeighbor
const er = erec(n.rec)
const ageScale = nodeHigh || inRing ? 1 : 0.34 + Math.min(1, er / 0.4) * 0.66
// Stable screen-space radius: use the graph's resting fit zoom, not the
// current playback camera zoom. Full-map views keep their original density,
// while t≈0 spore-zoom no longer inflates nodes into bubbles.
const r = nodeRadius(n) * nodeK * ageScale
const baseAlpha = nodeHigh ? 1 : ring ? (inRing ? (focusId ? 0.55 : 1) : 0.16) : focusId ? 0.16 : recencyInk(er)
const alpha = fadeAlpha(fades.nodes, n.id, revealed ? baseAlpha : 0, nodeHigh || inRing)
// Birth fade + warp rise are coupled (slow rates) so a star grows in instead
// of flashing. Focus snaps (no drift).
const rawBorn = fadeAlpha(fades.appear, n.id, revealed ? 1 : 0, nodeHigh || inRing, NODE_BIRTH)
const born = ease(rawBorn)
const vis = alpha * born
if (vis < 0.004) {
continue
}
// Warp-in: streak outward from WARP_FROM·radius and decelerate hard onto the
// ring (origin = disk core), echoing an EVE ship dropping out of warp.
const posScale = WARP_FROM + (1 - WARP_FROM) * warpIn(rawBorn)
const sx = projX(n.x * posScale)
const sy = projY(n.y * posScale)
ctx.globalAlpha = vis
const nodeInk = nodeHigh ? base : n.kind === 'memory' ? memoryInk : skillInk
const shape = NODE_SHAPE[n.kind]
if (shape === 'circle') {
// Highlighted orbs pop full bright; others darken so the sheen reads. The
// sprite carries the disk, so no path is built for circles.
sphereFill(ctx, sx, sy, r, nodeInk, sheen, nodeHigh ? 0 : ORB_DARKEN)
} else {
shapePath(ctx, shape, sx, sy, r)
ctx.fillStyle = rgba(nodeInk, 1)
ctx.fill()
}
if (isFocus) {
ctx.globalAlpha = 1
ctx.strokeStyle = rgba(nodeInk, 1)
ctx.lineWidth = 1.4
shapePath(ctx, shape, sx, sy, r + 4)
ctx.stroke()
}
}
ctx.globalAlpha = 1
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
// Ring date labels (top of each ellipse) — hoverable to focus the ring. Many
// adaptive rings can crowd the top, so labels thin out: skip any that would
// land within LABEL_GAP of the last one drawn (the gridline still shows).
ctx.font = '10px ui-sans-serif, system-ui, sans-serif'
ctx.textAlign = 'center'
const LABEL_GAP = 15
let lastLabelY = Number.POSITIVE_INFINITY
// A ring's date only shows once it actually has a revealed node — no floating
// date over a blank disk (t=0) or a lone empty ring.
rings.forEach((rg, i) => {
if (!rg.label || !revealedRings.has(i)) {
return
}
const sx = projX(0)
// Track the growing radius so the date rides the ring as it expands out.
const sy = projY(-(ringDrawR[i] ?? rg.r))
if (sy < 8 || sy > h - 8 || lastLabelY - sy < LABEL_GAP) {
return
}
lastLabelY = sy
const tw = ctx.measureText(rg.label).width
const boxW = tw + 6
const isThis = ringIdx === i || hoverRing === i
const faded = (focusId != null || ringIdx != null) && !isThis
// The date rides the same smooth ringAppear envelope, so it recedes as
// gently as it appears; the bucket carries only the snappy focus/selection dim.
const emphasisAlpha = faded ? 0.33 : 1
const labelAlpha = fadeAlpha(fades.labels, String(i), emphasisAlpha, isThis) * (ringVis[i] ?? 1)
if (labelAlpha < 0.01) {
return
}
ctx.globalAlpha = labelAlpha
ctx.fillStyle = rgba(bg, 1)
ctx.fillRect(sx - boxW / 2, sy - 6, boxW, 13)
ctx.fillStyle = shade(isThis ? 1 : 0.2)
ctx.fillText(rg.label, sx, sy + 3)
ctx.globalAlpha = 1
// Hidden labels (mid fade-out / not yet reached) drop out of hit-testing.
ringLabelRects.push({ h: 18, i, w: boxW + 6, x: sx - boxW / 2 - 3, y: sy - 10 })
})
// Tooltip on focus — measured first so its rect joins the avoidance set and
// neighbor labels route around it.
const tipNode = focusId ? byId.get(focusId) : null
const tip = tipNode && seen(tipNode.rec) ? tipNode : null
let tipRect: null | Rect = null
if (tip) {
const PADX = 6
const PADY = 4
const BADGE_H = 14
const ROW_GAP = 3
const LINE_H = 16
const ITEM_GAP = 8
const badgeFont = '9px ui-sans-serif, system-ui, sans-serif'
const monoFont = '9px ui-monospace, SFMono-Regular, Menlo, monospace'
const titleFont = '600 11px ui-sans-serif, system-ui, sans-serif'
const footerFont = '9px ui-sans-serif, system-ui, sans-serif'
const FOOTER_H = 13
// The date (index 0) stays sans; the rest of the tags are monospace.
const badgeFontFor = (i: number) => (i === 0 ? badgeFont : monoFont)
const badges = metaBadges(tip)
const use = countLabel(tip)
const titleText = tip.kind === 'memory' ? memById.get(tip.id)?.body.split('\n')[0]?.trim() || tip.label : tip.label
const badgeW = badges.map((b, i) => {
ctx.font = badgeFontFor(i)
return ctx.measureText(b).width
})
const rowW = badgeW.reduce((a, b) => a + b, 0) + ITEM_GAP * Math.max(0, badges.length - 1)
ctx.font = monoFont
const useW = use ? ctx.measureText(use).width : 0
const metaW = rowW + (use ? ITEM_GAP + useW : 0)
ctx.font = titleFont
const maxTitleW = Math.min(380, w - 16) - PADX * 2
const titleLines = wrapText(ctx, titleText, maxTitleW)
const titleW = Math.max(0, ...titleLines.map(l => ctx.measureText(l).width))
const titleBgW = titleW + PADX * 2
const titleBgH = titleLines.length * LINE_H + PADY * 2
const footerText = nodeFooter(tip)
ctx.font = footerFont
const footerW = footerText ? ctx.measureText(footerText).width : 0
const totalW = Math.max(metaW, footerW, titleBgW)
const totalH = BADGE_H + ROW_GAP + titleBgH + (footerText ? ROW_GAP + FOOTER_H : 0)
const bx = clamp(projX(tip.x) - totalW / 2, 4, Math.max(4, w - totalW - 4))
const by = clamp(projY(tip.y) - (nodeRadius(tip) * nodeK + 8) - totalH, 4, Math.max(4, h - totalH - 4))
tipRect = { h: totalH, w: totalW, x: bx, y: by }
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
const badgeMidY = by + BADGE_H / 2
// Metadata row, flush at the left edge.
ctx.fillStyle = shade(0.7)
let cx = bx
badges.forEach((label, i) => {
ctx.font = badgeFontFor(i)
ctx.fillText(label, cx, badgeMidY)
cx += badgeW[i] + ITEM_GAP
})
if (use) {
ctx.font = monoFont
ctx.fillStyle = shade(0.5)
ctx.fillText(use, cx, badgeMidY)
}
// Title: inverted (fg/bg flipped) so the focused tooltip pops.
const ty = by + BADGE_H + ROW_GAP
ctx.fillStyle = shade(1)
ctx.fillRect(bx, ty, titleBgW, titleBgH)
ctx.font = titleFont
ctx.fillStyle = inkInv
titleLines.forEach((line, i) => {
ctx.fillText(line, bx + PADX, ty + PADY + LINE_H * i + LINE_H / 2)
})
if (footerText) {
ctx.font = footerFont
ctx.fillStyle = shade(0.45)
ctx.fillText(footerText, bx, ty + titleBgH + ROW_GAP + FOOTER_H / 2)
}
ctx.textBaseline = 'alphabetic'
}
// Neighbor constellation labels — greedy placement that clamps to the overlay
// and dodges placed labels (date labels + tooltip) so nothing overlaps/clips.
ctx.font = '11px ui-sans-serif, system-ui, sans-serif'
ctx.textAlign = 'center'
const LBL_M = 6
const LBL_H = 15
const placed: Rect[] = ringLabelRects.map(r => ({ h: r.h, w: r.w, x: r.x, y: r.y }))
if (tipRect) {
placed.push(tipRect)
}
for (const id of focusSet ?? []) {
if (id === hoverId) {
continue
}
const n = byId.get(id)
if (!n || !seen(n.rec)) {
continue
}
const label = ellipsize(ctx, n.label, Math.min(180, w * 0.32))
const bw = ctx.measureText(label).width + 8
const x = clamp(projX(n.x) - bw / 2, LBL_M, Math.max(LBL_M, w - bw - LBL_M))
const top = projY(n.y) - (nodeRadius(n) * nodeK + 7) - LBL_H + 4
const clampY = (v: number) => clamp(v, LBL_M, Math.max(LBL_M, h - LBL_H - LBL_M))
const step = LBL_H + 3
let y: null | number = null
// Prefer above the node, then fan outward; skip if nothing stays clear (a
// label on the tooltip reads worse than no label).
for (let k = 0; k <= 7 && y == null; k += 1) {
for (const dy of k === 0 ? [0] : [-k * step, k * step]) {
const cand = { h: LBL_H, w: bw, x, y: clampY(top + dy) }
if (!placed.some(p => rectsOverlap(cand, p))) {
y = cand.y
break
}
}
}
if (y == null) {
continue
}
placed.push({ h: LBL_H, w: bw, x, y })
ctx.fillStyle = chipBg
ctx.fillRect(x, y, bw, LBL_H)
ctx.fillStyle = shade(0.85)
ctx.fillText(label, x + bw / 2, y + 11)
}
return { animating, ringLabelRects }
}
// Glyph cells from the core's center to its rim — the target density. In the mid
// range the field is this many cells across (constant "amount of text"), and the
// glyph size tracks the camera. Bump for denser, drop for sparser.
const SCRAMBLE_RADIUS = 6
// Glyph size (px) is clamped to this band: the font grows with the camera but
// never balloons on a big/zoomed-in core — past the ceiling the core fills with
// MORE, smaller glyphs instead of fewer huge ones — and stays legible when tiny.
const SCRAMBLE_CELL_MIN = 5
const SCRAMBLE_CELL_MAX = 13
// The empty-core scramble: a tilted, Matrix-style decoding-glyph field laid on
// the disk plane (rows squashed by TILT, clipped to the core ellipse) so the
// empty center reads as "computing", not missing. PURELY decorative — the glyphs
// are a seeded PRNG field, never derived from nodes/memories. Drawn live each
// frame on top of the cached static scene, since it's the only animated layer.
export function drawScramble({
ctx,
dpr,
palette,
rings,
vp
}: {
ctx: CanvasRenderingContext2D
dpr: number
palette: Palette
rings: Ring[]
vp: Viewport
}): void {
const { bg, darkTheme, primary } = palette
const projX = (wx: number) => wx * vp.k + vp.x
const projY = (wy: number) => wy * vp.k * TILT + vp.y
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
const coreX = projX(0)
const coreY = projY(0)
// Scale with the world (like the rings), but ~1.25× bigger than the bare inner
// shell so the core reads prominently at the rested fit.
const coreRx = (rings[0]?.r ?? RING_INNER) * vp.k * 1.25
if (coreRx <= 0) {
return
}
// Backdrop wash: a background-colour radial dimming the core ellipse, so on a
// busy map the nodes/links crowding through the centre recede behind the orb.
// Self-masking — bg over empty bg is invisible, so a sparse map shows no disc.
const washR = coreRx * 1.15
ctx.save()
ctx.translate(coreX, coreY)
ctx.scale(1, TILT)
const wash = ctx.createRadialGradient(0, 0, 0, 0, 0, washR)
// Near-opaque across the core (busy graph effectively vanishes behind the orb)
// with a soft falloff only at the rim so there's no hard disc edge.
wash.addColorStop(0, rgba(bg, darkTheme ? 0.9 : 0.93))
wash.addColorStop(0.62, rgba(bg, darkTheme ? 0.84 : 0.88))
wash.addColorStop(1, rgba(bg, 0))
ctx.fillStyle = wash
ctx.beginPath()
ctx.arc(0, 0, washR, 0, Math.PI * 2)
ctx.fill()
ctx.restore()
// Target ~SCRAMBLE_RADIUS cells to the rim (camera-scaled glyphs), but clamp the
// glyph SIZE so a big/zoomed-in core scales the font DOWN — packing in more,
// smaller glyphs rather than a few giant ones — and stays legible when tiny.
const cell = clamp(coreRx / SCRAMBLE_RADIUS, SCRAMBLE_CELL_MIN, SCRAMBLE_CELL_MAX)
// Aspect-correct on the tilt: rows are spaced by the full glyph height (square
// cells, no vertical squish), but the field is clipped to the disk's ELLIPSE
// (vertical extent = coreRx * TILT), so it sits on the tilted plane while the
// glyphs themselves stay un-squished. Fewer rows fit vertically — that's it.
const coreRy = coreRx * TILT
const half = Math.max(3, Math.round(coreRx / cell))
const now = performance.now()
const t = now / 1000 // seconds, for the travelling-glow highlight
ctx.save()
ctx.font = `${cell}px "JetBrains Mono", "Hiragino Sans", "Noto Sans JP", ui-monospace, monospace`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
for (let r = -half; r <= half; r += 1) {
// Per-row flow: half the rows drift left, half right, each at its own speed.
// The drift is a continuous pixel scroll (not a per-cell swap), and each
// glyph's identity is tied to its slot index — so a character visibly slides
// across instead of the whole row flickering in place. Combined with the
// TILT squash + opposite directions, the field reads as a turning surface.
const rowSeed = (r * 19349663) >>> 0 || 1
const dir = rowSeed & 1 ? 1 : -1
const speed = 8 + (rowSeed % 16) // px/sec
const scroll = (now / 1000) * speed * dir
const ny = (r * cell) / coreRy
// Latitude dimming: rows away from the equator fade, selling the sphere read.
const rowDim = 1 - 0.5 * Math.min(1, Math.abs(ny))
const kMin = Math.floor((-coreRx - scroll) / cell) - 1
const kMax = Math.ceil((coreRx - scroll) / cell) + 1
for (let k = kMin; k <= kMax; k += 1) {
const sx = k * cell + scroll // screen-space x relative to the core center
const nx = sx / coreRx
const d2 = nx * nx + ny * ny
if (d2 > 1) {
continue
}
const seed = (rowSeed ^ ((k >>> 0) * 73856093)) >>> 0
const ch = SCRAMBLE_CHARS[seed % SCRAMBLE_CHARS.length] ?? '0'
// Mostly flat brightness, fading only near the rim (reduced gradient).
const edge = clamp((1 - Math.sqrt(d2)) / 0.4, 0, 1)
const flick = 0.7 + 0.3 * (((seed >>> 5) % 100) / 100)
// Travelling glow: two crossing sine waves (drifting in time) light a
// lattice of bright spots that ripple ACROSS the orb — so the highlight
// moves and twinkles instead of being a fixed random set. A per-glyph
// phase keeps neighbours from pulsing in lockstep.
const phase = (seed & 7) * 0.35
const glow = Math.sin(nx * 4.5 + t * 1.3 + phase) * Math.sin(ny * 4.5 - t * 0.9 + phase)
const pop = 1 + clamp((glow - 0.25) / 0.75, 0, 1) * 2.6
const a = clamp((darkTheme ? 0.22 : 0.3) * edge * flick * rowDim * pop, 0, 0.9)
if (a < 0.02) {
continue
}
ctx.fillStyle = rgba(primary, a)
ctx.fillText(ch, coreX + sx, coreY + r * cell)
}
}
ctx.restore()
ctx.globalAlpha = 1
}
@@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest'
import type { StarmapGraph } from '@/types/hermes'
import { decodeShareCode, encodeShareCode, ShareCodeError } from './share-code'
function sampleGraph(): StarmapGraph {
return {
clusters: [],
edges: [
{ source: 'skill-a', target: 'skill-b' },
{ source: 'skill-b', target: 'memory:profile:0' }
],
memory: [
{ body: 'Prefers concise answers.', source: 'profile', timestamp: 1_700_000_000, title: 'Tone' },
{ body: 'Uses a worktree.', source: 'memory', timestamp: null, title: 'Env' }
],
nodes: [
{
category: 'devops',
createdBy: 'agent',
id: 'skill-a',
kind: 'skill',
label: 'skill-a',
pinned: true,
state: 'active',
timestamp: 1_699_900_000,
useCount: 7
},
{
category: 'devops',
createdBy: null,
id: 'skill-b',
kind: 'skill',
label: 'skill-b',
pinned: false,
state: 'draft',
timestamp: 1_699_950_000,
useCount: 0
},
{
category: 'memory',
createdBy: null,
id: 'memory:profile:0',
kind: 'memory',
label: 'A fact',
memorySource: 'profile',
pinned: false,
state: 'active',
timestamp: 1_700_000_000,
useCount: 0
}
],
stats: {}
}
}
// Decoded edges compared by node POSITION (ids are synthesized), so topology is
// the invariant, not the literal id strings.
const topology = (g: StarmapGraph): [number, number][] => {
const idx = new Map(g.nodes.map((n, i) => [n.id, i]))
return g.edges.map(e => [idx.get(e.source)!, idx.get(e.target)!])
}
describe('share-code', () => {
// The viz contract: everything the star map RENDERS survives — kinds, radius
// inputs, time position, edge topology — while text is dropped (it's a loadout,
// not a backup).
it('preserves the visualization', () => {
const g = sampleGraph()
const decoded = decodeShareCode(encodeShareCode(g))
const span = 1_700_000_000 - 1_699_900_000
const tol = Math.ceil(span / 4095) + 1
expect(decoded.nodes).toHaveLength(g.nodes.length)
decoded.nodes.forEach((d, i) => {
const o = g.nodes[i]!
expect(d.kind).toBe(o.kind)
expect(d.label).toBe(o.label)
expect(d.useCount).toBe(o.useCount)
expect(d.state).toBe(o.state)
expect(d.pinned).toBe(o.pinned)
expect(d.category).toBe(o.category)
expect(d.memorySource).toBe(o.memorySource)
expect(d.createdBy).toBe(o.createdBy)
if (o.timestamp == null) {
expect(d.timestamp).toBeNull()
} else {
expect(Math.abs((d.timestamp ?? 0) - o.timestamp)).toBeLessThanOrEqual(tol)
}
})
expect(topology(decoded)).toEqual(topology(g))
})
it('drops memory prose (loadout is viz-only)', () => {
expect(decodeShareCode(encodeShareCode(sampleGraph())).memory).toHaveLength(0)
})
it('rebuilds clusters from node categories', () => {
const decoded = decodeShareCode(encodeShareCode(sampleGraph()))
expect(decoded.clusters.find(c => c.category === 'devops')?.count).toBe(2)
})
it('produces a short, opaque, prefixed code', () => {
const code = encodeShareCode(sampleGraph())
expect(code.startsWith('HML')).toBe(true)
expect(code.slice(3)).toMatch(/^[A-Za-z0-9_-]+$/)
// Strictly smaller than the naive JSON it replaces — the whole point.
expect(code.length).toBeLessThan(JSON.stringify(sampleGraph()).length)
})
it('stays compact on a large graph (no string bloat)', () => {
const nodes = Array.from({ length: 500 }, (_, i) => ({
category: `cat-${i % 8}`,
createdBy: 'agent' as const,
id: `s${i}`,
kind: 'skill' as const,
label: `A fairly verbose skill label number ${i}`,
pinned: false,
state: 'active',
timestamp: 1_700_000_000 + i * 3600,
useCount: i % 50
}))
const graph: StarmapGraph = { clusters: [], edges: [], memory: [], nodes, stats: {} }
const code = encodeShareCode(graph)
// Deflate keeps even verbose, repetitive labels far below the naive JSON.
expect(code.length).toBeLessThan(JSON.stringify(graph).length / 5)
})
it('handles an empty graph', () => {
const decoded = decodeShareCode(encodeShareCode({ clusters: [], edges: [], memory: [], nodes: [], stats: {} }))
expect(decoded.nodes).toHaveLength(0)
expect(decoded.edges).toHaveLength(0)
})
it('drops edges whose endpoints are missing', () => {
const g = sampleGraph()
g.edges.push({ source: 'skill-a', target: 'does-not-exist' })
expect(decodeShareCode(encodeShareCode(g)).edges).toHaveLength(2)
})
it('rejects garbage with a ShareCodeError', () => {
expect(() => decodeShareCode('not a real code !!!')).toThrow(ShareCodeError)
expect(() => decodeShareCode('')).toThrow(ShareCodeError)
})
it('rejects a corrupted (bit-flipped) code', () => {
const code = encodeShareCode(sampleGraph())
// Flip a mid-payload char (trailing base64 bits can be dropped on decode).
const i = Math.floor(code.length / 2)
const corrupted = code.slice(0, i) + (code[i] === 'A' ? 'B' : 'A') + code.slice(i + 1)
expect(() => decodeShareCode(corrupted)).toThrow(ShareCodeError)
})
it('tolerates whitespace, including internal wraps', () => {
const code = encodeShareCode(sampleGraph())
const wrapped = ` ${code.slice(0, 20)}\n${code.slice(20)}\t`
expect(() => decodeShareCode(wrapped)).not.toThrow()
expect(decodeShareCode(wrapped).nodes).toHaveLength(sampleGraph().nodes.length)
})
})
+188
View File
@@ -0,0 +1,188 @@
import { type BitReader, type BitWriter, createLoadout, Dict, idxOf, indexBits, LoadoutError } from '@/lib/loadout'
import type { StarmapEdge, StarmapGraph, StarmapNode } from '@/types/hermes'
// ── Star-map share code ───────────────────────────────────────────────────────
//
// The body schema for a star map, riding the generic loadout codec (@/lib/loadout
// owns the bitstream, DEFLATE, version+checksum frame, and base64url). We encode
// what the map RENDERS — each node's kind, its time POSITION (12-bit quantized,
// not an absolute epoch), radius inputs (useCount/state/pinned), and an interned
// label + category — plus edges as fixed-width node indices. Memory prose is
// dropped; labels are trimmed. DEFLATE then makes the repetitive label/category
// text almost free. A 60-skill map is a few hundred chars.
const VERSION = 3
const PREFIX = 'HML' // "Hermes Memory Loadout" — namespaces our codes like WoW's leading bytes.
const MAX_LABEL = 64 // trim runaway memory titles so one card can't bloat the code.
const trim = (s: string): string => (s.length > MAX_LABEL ? s.slice(0, MAX_LABEL) : s)
const KINDS = ['skill', 'memory'] as const
const STATES = ['active', 'archived', 'disabled', 'draft'] as const
const MEM_SOURCES = ['none', 'memory', 'profile'] as const
const CREATED_BY = ['none', 'agent', 'user'] as const
const REC_BITS = 12 // time position resolution: 1/4096 of the span — sub-pixel here.
const REC_MAX = (1 << REC_BITS) - 1
const finiteTs = (v?: null | number): null | number =>
typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.round(v)) : null
function writeNode(w: BitWriter, n: StarmapNode, dict: Dict, minTs: number, span: number): void {
w.uint(idxOf(KINDS, n.kind), 1)
w.varint(dict.id(trim(n.label || '')))
w.varint(dict.id(n.category || ''))
w.varint(Math.max(0, n.useCount | 0))
w.uint(idxOf(STATES, n.state), 2)
w.uint(idxOf(MEM_SOURCES, n.memorySource ?? 'none'), 2)
w.uint(idxOf(CREATED_BY, n.createdBy ?? 'none'), 2)
w.bit(n.pinned)
// Time as a 12-bit POSITION within [minTs, maxTs] — not an absolute epoch.
const ts = finiteTs(n.timestamp)
if (ts === null) {
w.bit(0)
} else {
w.bit(1)
w.uint(span > 0 ? Math.round(((ts - minTs) / span) * REC_MAX) : 0, REC_BITS)
}
}
function readNode(r: BitReader, dict: string[], i: number, minTs: number, span: number): StarmapNode {
const kind = KINDS[r.uint(1)] ?? 'skill'
const label = dict[r.varint()] ?? ''
const category = dict[r.varint()] ?? ''
const useCount = r.varint()
const state = STATES[r.uint(2)] ?? 'active'
const memSrc = MEM_SOURCES[r.uint(2)] ?? 'none'
const createdBy = CREATED_BY[r.uint(2)] ?? 'none'
const pinned = r.bit() === 1
const timestamp = r.bit() === 1 ? minTs + (span > 0 ? Math.round((r.uint(REC_BITS) / REC_MAX) * span) : 0) : null
// Ids are synthesized (they're never displayed); memory ids mirror the scan's
// `memory:<source>:<index>` shape so the rest of the UI is none the wiser.
const isMemory = kind === 'memory'
const source = memSrc === 'none' ? 'memory' : memSrc
return {
category,
createdBy: createdBy === 'none' ? null : createdBy,
id: isMemory ? `memory:${source}:${i}` : `s${i}`,
kind,
label,
memorySource: isMemory ? source : undefined,
pinned,
state,
timestamp,
useCount
}
}
function writeGraph(w: BitWriter, graph: StarmapGraph): void {
const dict = new Dict()
// Intern labels + categories; deflate later squeezes the inevitable repetition.
for (const n of graph.nodes) {
dict.id(trim(n.label || ''))
dict.id(n.category || '')
}
const stamps = graph.nodes.map(n => finiteTs(n.timestamp)).filter((v): v is number => v !== null)
const minTs = stamps.length ? Math.min(...stamps) : 0
const maxTs = stamps.length ? Math.max(...stamps) : 0
const span = maxTs - minTs
w.varint(minTs)
w.varint(maxTs)
w.varint(dict.list.length)
for (const s of dict.list) {
w.str(s)
}
w.varint(graph.nodes.length)
for (const n of graph.nodes) {
writeNode(w, n, dict, minTs, span)
}
// Edges reference nodes by position; drop any whose endpoints aren't both nodes.
const order = new Map(graph.nodes.map((n, i) => [n.id, i]))
const edges = graph.edges.filter(e => order.has(e.source) && order.has(e.target))
const bits = indexBits(graph.nodes.length)
w.varint(edges.length)
for (const e of edges) {
w.uint(order.get(e.source)!, bits)
w.uint(order.get(e.target)!, bits)
}
}
function readGraph(r: BitReader): StarmapGraph {
const minTs = r.varint()
const maxTs = r.varint()
const span = maxTs - minTs
const dictLen = r.varint()
const dict: string[] = []
for (let i = 0; i < dictLen; i += 1) {
dict.push(r.str())
}
const nodeCount = r.varint()
const nodes: StarmapNode[] = []
for (let i = 0; i < nodeCount; i += 1) {
nodes.push(readNode(r, dict, i, minTs, span))
}
const bits = indexBits(nodeCount)
const edgeCount = r.varint()
const edges: StarmapEdge[] = []
for (let i = 0; i < edgeCount; i += 1) {
const src = nodes[r.uint(bits)]
const dst = nodes[r.uint(bits)]
if (src && dst) {
edges.push({ source: src.id, target: dst.id })
}
}
const counts = new Map<string, number>()
for (const n of nodes) {
counts.set(n.category, (counts.get(n.category) ?? 0) + 1)
}
const clusters = [...counts.entries()]
.map(([category, count]) => ({ category, count }))
.sort((a, b) => b.count - a.count)
// Memory cards are dropped (viz-only); a marker lets the UI tell a decoded map
// apart from a freshly-scanned one.
return { clusters, edges, memory: [], nodes, stats: { imported: true } }
}
export class ShareCodeError extends LoadoutError {}
const codec = createLoadout<StarmapGraph>({
error: ShareCodeError,
noun: 'map code',
prefix: PREFIX,
read: readGraph,
version: VERSION,
write: writeGraph
})
// Serialize a star-map graph to a short, opaque, clipboard-safe loadout string.
export function encodeShareCode(graph: StarmapGraph): string {
return codec.encode(graph)
}
// Parse a loadout string back into a (viz-complete, text-synthesized) graph.
export function decodeShareCode(code: string): StarmapGraph {
return codec.decode(code)
}
@@ -0,0 +1,24 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { ShareControls } from './share-controls'
describe('ShareControls', () => {
afterEach(() => {
cleanup()
})
it('opens its dialog when the trigger has a tooltip', async () => {
render(<ShareControls shareCode="map-code" />)
const trigger = screen.getByRole('button', { name: 'Import / export map' })
fireEvent.pointerMove(trigger, { pointerType: 'mouse' })
expect((await screen.findByRole('tooltip')).textContent).toContain('Import / export map')
fireEvent.click(trigger)
const dialog = await screen.findByRole('dialog')
expect(within(dialog).getByRole('heading', { name: 'Import / export map' })).toBeTruthy()
})
})
@@ -0,0 +1,134 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { CopyButton } from '@/components/ui/copy-button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@/components/ui/dialog'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { Upload } from '@/lib/icons'
interface ShareControlsProps {
// True when the shown map was loaded from a pasted code (not the live scan).
imported?: boolean
// Decode + apply a pasted code. Returns an error string to show inline, or null.
onImport?: (code: string) => null | string
onResetMap?: () => void
// The current map serialized as a WoW-style share code (the copy target).
shareCode?: string
}
// Share / import a map as a single code. The textarea shows the current map's
// code (copy it to share); edit/replace it and hit Load to view someone else's.
// One field, one button — a standard Dialog matching rename/create.
export function ShareControls({ imported = false, onImport, onResetMap, shareCode }: ShareControlsProps) {
const { t } = useI18n()
const [open, setOpen] = useState(false)
const [value, setValue] = useState('')
const [error, setError] = useState<null | string>(null)
const own = (shareCode ?? '').trim()
const code = value.trim()
const canLoad = code !== '' && code !== own
const load = () => {
if (!code) {
setError(t.starmap.importEmpty)
return
}
const err = onImport?.(code) ?? null
setError(err)
if (err === null) {
setOpen(false)
}
}
return (
<div className="flex items-center gap-1">
{imported && (
<Button
className="text-muted-foreground hover:text-foreground"
onClick={() => onResetMap?.()}
size="xs"
variant="text"
>
{t.starmap.resetToMine}
</Button>
)}
<Dialog
onOpenChange={next => {
setOpen(next)
setError(null)
if (next) {
setValue(shareCode ?? '')
}
}}
open={open}
>
<Tip label={t.starmap.shareTitle}>
<DialogTrigger asChild>
<Button
aria-label={t.starmap.shareTitle}
className="text-muted-foreground hover:text-foreground"
size="icon"
variant="ghost"
>
<Upload className="size-3.5" />
</Button>
</DialogTrigger>
</Tip>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t.starmap.shareTitle}</DialogTitle>
<DialogDescription>{t.starmap.shareHint}</DialogDescription>
</DialogHeader>
{/* One code field: pre-filled with this map's code (copy to share); edit
or paste another and Load. Copy button floats in on hover, like a
thread code block. */}
<div className="group/code relative">
<textarea
aria-label={t.starmap.shareTitle}
className="h-24 w-full resize-none rounded-md bg-foreground/5 p-2.5 pr-9 font-mono text-xs leading-relaxed break-all text-muted-foreground/90 outline-none transition placeholder:text-muted-foreground/50 focus-visible:text-foreground focus-visible:ring-1 focus-visible:ring-ring/40"
onChange={e => {
setValue(e.target.value)
setError(null)
}}
placeholder={t.starmap.sharePlaceholder}
spellCheck={false}
value={value}
/>
{code !== '' && (
<CopyButton
appearance="inline"
className="absolute right-1.5 top-1.5 h-5 gap-0 rounded-md px-1 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/code:opacity-100 hover:opacity-100"
iconClassName="size-3"
label={t.starmap.copy}
showLabel={false}
text={value}
/>
)}
</div>
{error && <p className="text-[0.7rem] text-destructive">{error}</p>}
<Button className="w-full" disabled={!canLoad} onClick={load} type="button">
{t.starmap.importBtn}
</Button>
</DialogContent>
</Dialog>
</div>
)
}
+298
View File
@@ -0,0 +1,298 @@
import { forceCollide, forceLink, forceManyBody, forceRadial, forceSimulation, type Simulation } from 'd3-force'
import type { StarmapGraph, StarmapNode } from '@/types/hermes'
import { RING_STEPS } from './constants'
import { clamp, hash, nodeRadius, radiusForRecency } from './geometry'
import { formatDate } from './text'
import { computeRecency, recForRatio } from './time-axis'
import type { Ring, SimLink, SimNode } from './types'
export interface BuiltSim {
byId: Map<string, SimNode>
links: SimLink[]
nodes: SimNode[]
rings: Ring[]
sim: Simulation<SimNode, SimLink>
}
const DAY = 86_400
// Roughly how many nodes share one ignite burst within a ring band — the build
// reads as clustered pops, not a 1-by-1 trickle or an all-at-once flood.
const CLUSTER_SIZE = 5
// Constant ring SCALE: the core radius and the per-ring band are pinned to the
// canonical 5-ring layout, so the empty core and every band are ALWAYS that
// size on the disk — more data grows the disk OUTWARD (more rings) instead of
// stretching a fixed disk thinner. The camera caps its zoom at the 5-ring
// extent (see fitViewport), so this world size is also a constant screen size.
const RING_CORE = radiusForRecency(recForRatio(0))
const RING_BAND = (radiusForRecency(recForRatio(1)) - RING_CORE) / RING_STEPS
const ringRadius = (i: number): number => RING_CORE + i * RING_BAND
// Place a node INSIDE its ring's band (the annulus the ring caps), biased toward
// mid-band so it reads as "within the ring", only occasionally grazing an edge —
// never sitting on the outline like a bead.
const placeRadius = (i: number, id: string): number => {
const outer = ringRadius(i)
const inner = i > 0 ? ringRadius(i - 1) : RING_CORE - RING_BAND * 0.5
const h = (hash(id) % 1000) / 1000
return outer - (0.15 + 0.7 * h) * (outer - inner)
}
interface Unit {
kind: 'day' | 'month'
step: number
}
// "Nice" calendar intervals, fine → coarse, WITH intermediate rungs (2-day,
// bi-weekly, 2/3/6-month) so the bucketer can land NEAR the target count instead
// of jumping straight from weekly (≈9) to monthly (≈3) and missing the ~5 sweet spot.
const UNITS: Unit[] = [
{ kind: 'day', step: 1 },
{ kind: 'day', step: 2 },
{ kind: 'day', step: 7 },
{ kind: 'day', step: 14 },
{ kind: 'month', step: 1 },
{ kind: 'month', step: 2 },
{ kind: 'month', step: 3 },
{ kind: 'month', step: 6 },
{ kind: 'month', step: 12 }
]
// Floor a timestamp to the start of its calendar bucket — the key nodes group by.
function bucketStart(ts: number, { kind, step }: Unit): number {
if (kind === 'day') {
const period = step * DAY
return Math.floor(ts / period) * period
}
const d = new Date(ts * 1000)
d.setUTCHours(0, 0, 0, 0)
// Floor to a step-month boundary in ABSOLUTE months so steps align across
// years (3-month → Jan/Apr/Jul/Oct, 12-month → Jan).
const absMonth = Math.floor((d.getUTCFullYear() * 12 + d.getUTCMonth()) / step) * step
d.setUTCFullYear(Math.floor(absMonth / 12), absMonth % 12, 1)
return Math.floor(d.getTime() / 1000)
}
const populatedStarts = (stamps: number[], u: Unit): number[] =>
[...new Set(stamps.map(t => bucketStart(t, u)))].sort((a, b) => a - b)
// "Nice ticks" for time (à la D3/Heckbert): aim for a target ring count that
// grows ~log2 with the span, then snap to the calendar interval whose POPULATED
// count lands nearest it (ties + overshoot break toward fewer/finer). The floor
// is 5 — fewer than that and the play-through "steps" between rings get big and
// abrupt; ~5+ evenly-paced rings give the smooth Spore-style build-up.
function chooseUnit(stamps: number[], spanDays: number): Unit {
const target = clamp(Math.round(4 + Math.log2(Math.max(1, spanDays / 60))), 5, 12)
let best = UNITS[0]!
let bestScore = Infinity
for (const u of UNITS) {
const count = populatedStarts(stamps, u).length
if (!count) {
continue
}
const score = Math.abs(count - target) + (count > target ? 0.5 : 0)
if (score < bestScore) {
bestScore = score
best = u
}
}
return best
}
function bucketLabel(ts: number, { kind, step }: Unit): string {
if (kind === 'day') {
return formatDate(ts)
}
try {
const d = new Date(ts * 1000)
return step >= 12
? String(d.getUTCFullYear())
: d.toLocaleDateString(undefined, { month: 'short', timeZone: 'UTC', year: 'numeric' })
} catch {
return formatDate(ts)
}
}
interface Layout {
// bucket/cap ring a node belongs to (the ring it ignites behind)
index: (n: StarmapNode) => number
// reveal coordinate (0–1) a node ignites at — staggered within its band
rec: (n: StarmapNode) => number
rings: Ring[]
// world radius a node is drawn at (inside its band)
tr: (n: StarmapNode) => number
}
// Even, unlabeled-ish fallback when there's no usable time span (undated graph
// or one instant): keep the legacy continuous mapping so nothing regresses.
function evenLayout(recById: Map<string, number>, minTs: null | number, maxTs: null | number, timed: boolean): Layout {
const rings: Ring[] = Array.from({ length: RING_STEPS + 1 }, (_, i) => ({
label:
timed && minTs !== null && maxTs !== null
? formatDate(Math.round(minTs + (maxTs - minTs) * (i / RING_STEPS)))
: null,
r: ringRadius(i),
ratio: recForRatio(i / RING_STEPS)
}))
const capRing = (rec: number): number => {
for (let i = 0; i < rings.length; i += 1) {
if ((rings[i]?.ratio ?? 1) >= rec - 1e-3) {
return i
}
}
return rings.length - 1
}
return {
index: n => capRing(recById.get(n.id) ?? 0),
rec: n => recById.get(n.id) ?? 0,
rings,
tr: n => radiusForRecency(recById.get(n.id) ?? 0)
}
}
// One equal-width ring per POPULATED calendar bucket; a bucket's nodes fill the
// band INSIDE their ring (fanned by angle) and ignite staggered across it.
function buildLayout(
graph: StarmapGraph,
recById: Map<string, number>,
minTs: null | number,
maxTs: null | number,
timed: boolean
): Layout {
const stamps = graph.nodes.map(n => Number(n.timestamp)).filter(Number.isFinite)
if (!(timed && minTs !== null && maxTs !== null && maxTs > minTs && stamps.length)) {
return evenLayout(recById, minTs, maxTs, timed)
}
const span = maxTs - minTs
const unit = chooseUnit(stamps, span / DAY)
const starts = populatedStarts(stamps, unit)
if (starts.length < 2) {
return evenLayout(recById, minTs, maxTs, timed)
}
const indexOfStart = new Map(starts.map((s, i) => [s, i]))
// Reveal pacing is per-BUCKET (uniform), matching the equal-width bands: each
// ring is one even step. (Radius is already index-based.) Using raw time here
// decouples a ring's ignite moment from its position — a bursty gap makes a
// ring appear bands ahead of the nodes that belong to it. Labels stay real dates.
const last = Math.max(1, starts.length - 1)
const rings: Ring[] = starts.map((s, i) => ({
label: bucketLabel(s, unit),
r: ringRadius(i),
ratio: recForRatio(i / last)
}))
// A node's bucket is its ring; undated nodes (rare, in an otherwise-timed
// graph) fall to the newest ring so they still appear.
const indexFor = (n: StarmapNode): number => {
const ts = Number(n.timestamp)
return Number.isFinite(ts) ? (indexOfStart.get(bucketStart(ts, unit)) ?? starts.length - 1) : starts.length - 1
}
// Node POSITION fills the band inside its bucket ring (placeRadius); its IGNITE
// time is staggered ACROSS that band, ordered by real timestamp, so a busy
// bucket trickles in over its whole slice instead of every node popping at
// once (the "everything floods in at the end" bug).
const buckets: StarmapNode[][] = starts.map(() => [])
for (const n of graph.nodes) {
buckets[indexFor(n)]!.push(n)
}
const tsOf = (n: StarmapNode): number => (Number.isFinite(Number(n.timestamp)) ? Number(n.timestamp) : Infinity)
const recByNode = new Map<string, number>()
buckets.forEach((bucket, i) => {
bucket.sort((a, b) => (tsOf(a) === tsOf(b) ? a.id.localeCompare(b.id) : tsOf(a) - tsOf(b)))
const hi = rings[i]!.ratio
const lo = i > 0 ? rings[i - 1]!.ratio : 0
const m = bucket.length
// Ignite in CLUSTERS, not a 1-by-1 trickle: split the band's (time-ordered)
// nodes into a few sub-bursts (~CLUSTER_SIZE each) that share an ignite
// moment, spaced across the band, with a hair of per-node jitter so a burst
// reads as organic rather than perfectly synchronous.
const clusters = Math.max(1, Math.round(m / CLUSTER_SIZE))
bucket.forEach((n, k) => {
const c = Math.min(clusters - 1, Math.floor((k / m) * clusters))
const jitter = ((hash(n.id) % 100) / 100 - 0.5) * (0.5 / clusters)
const f = clamp((c + 1) / clusters + jitter, 0.02, 1)
recByNode.set(n.id, lo + f * (hi - lo))
})
})
return {
index: indexFor,
rec: n => recByNode.get(n.id) ?? 0,
rings,
tr: n => placeRadius(indexFor(n), n.id)
}
}
// Build the radial time simulation: a node's distance from the core encodes its
// timestamp bucket (radial force dominates; charge/collide only spread nodes
// around their date ring). Rings are dated, equal-width gridlines.
export function buildSimulation(graph: StarmapGraph, onTick: () => void): BuiltSim {
const { maxTs, minTs, rec: recById, timed } = computeRecency(graph.nodes)
const { index, rec: recOf, rings, tr: trOf } = buildLayout(graph, recById, minTs, maxTs, timed)
const nodes: SimNode[] = graph.nodes.map(n => {
const rec = recOf(n)
const tr = trOf(n)
const angle = ((hash(n.id) % 3600) / 3600) * Math.PI * 2
return { ...n, outerRingIndex: index(n), rec, tr, vx: 0, vy: 0, x: Math.cos(angle) * tr, y: Math.sin(angle) * tr }
})
const byId = new Map(nodes.map(n => [n.id, n]))
const links: SimLink[] = graph.edges
.filter(e => byId.has(e.source) && byId.has(e.target))
.map(e => ({ source: e.source, target: e.target }))
const sim = forceSimulation(nodes)
.alphaDecay(0.05)
.velocityDecay(0.62)
.force('charge', forceManyBody<SimNode>().strength(-12))
.force(
'link',
forceLink<SimNode, SimLink>(links)
.id(n => n.id)
.distance(26)
.strength(0.06)
)
.force(
'collide',
forceCollide<SimNode>()
.radius(n => nodeRadius(n) + 2)
.iterations(2)
)
.force('radial', forceRadial<SimNode>(n => (n as SimNode).tr, 0, 0).strength(0.92))
.on('tick', onTick)
return { byId, links, nodes, rings, sim }
}
+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>
)
}
+90
View File
@@ -0,0 +1,90 @@
import { fmtDate } from '@/lib/time'
import type { StarmapNode } from '@/types/hermes'
export function formatDate(ts?: null | number): string {
if (!ts) {
return 'unknown'
}
try {
return fmtDate.format(new Date(ts * 1000))
} catch {
return 'unknown'
}
}
// Tag-style badge items for the hover tooltip — date first. Use-count is NOT a
// badge (rendered separately, right-aligned) so it's excluded here.
export function metaBadges(n: StarmapNode): string[] {
const out: string[] = [formatDate(n.timestamp)]
if (n.kind === 'memory') {
out.push(n.memorySource === 'profile' ? 'profile memory' : 'memory')
} else {
out.push(n.category)
if (n.createdBy === 'agent') {
out.push('learned')
}
if (n.pinned) {
out.push('pinned')
}
}
return out.filter(Boolean)
}
// Bare "xN" use-count, last in the badge row. Null when never used.
export function countLabel(n: StarmapNode): null | string {
return n.kind === 'skill' && n.useCount > 0 ? `x${n.useCount}` : null
}
// Footer-row content for the tooltip. Reserved primitive — returns nothing for
// now (skills have no UUID; their id is just the name). Wire real detail here
// later and the tooltip lays it out automatically.
export function nodeFooter(node: StarmapNode): null | string {
void node
return null
}
// Greedy word-wrap for the tooltip title so long memory lines don't blow out.
export function wrapText(ctx: CanvasRenderingContext2D, text: string, maxW: number): string[] {
const words = text.split(/\s+/).filter(Boolean)
const lines: string[] = []
let line = ''
for (const word of words) {
const next = line ? `${line} ${word}` : word
if (!line || ctx.measureText(next).width <= maxW) {
line = next
} else {
lines.push(line)
line = word
}
}
if (line) {
lines.push(line)
}
return lines
}
// Trim to fit maxW, appending an ellipsis (keeps floating labels compact so they
// don't span the overlay).
export function ellipsize(ctx: CanvasRenderingContext2D, text: string, maxW: number): string {
if (ctx.measureText(text).width <= maxW) {
return text
}
let s = text
while (s.length > 1 && ctx.measureText(`${s}…`).width > maxW) {
s = s.slice(0, -1)
}
return `${s.trimEnd()}…`
}
+105
View File
@@ -0,0 +1,105 @@
import type { StarmapGraph, StarmapNode } from '@/types/hermes'
import { clamp } from './geometry'
// Empty lead-in: push the oldest node off 0 so the timeline opens on a beat of
// emptiness (you watch the first node grow in). Radial position is otherwise a
// truthful linear map of time, so rings line up with the nodes they date.
export const LEAD_IN = 0.06
export const recForRatio = (ratio: number): number => LEAD_IN + (1 - LEAD_IN) * clamp(ratio, 0, 1)
export interface Recency {
maxTs: null | number
minTs: null | number
// id → recency ratio (0 oldest … 1 newest). Timed by timestamp when the span
// is real, else ordinal so an undated graph still "builds up" in a stable order.
rec: Map<string, number>
timed: boolean
}
// Shared recency model for both the radial layout (simulation.ts) and the
// timeline scrubber, so a node's ring distance and its ignite time agree.
export function computeRecency(nodes: StarmapNode[]): Recency {
const known = nodes
.map(n => (typeof n.timestamp === 'number' && Number.isFinite(n.timestamp) ? Number(n.timestamp) : null))
.filter((v): v is number => v !== null)
const minTs = known.length ? Math.min(...known) : null
const maxTs = known.length ? Math.max(...known) : null
const timed = minTs !== null && maxTs !== null && maxTs > minTs
const ordered = [...nodes].sort((a, b) => {
const at = typeof a.timestamp === 'number' ? a.timestamp : Infinity
const bt = typeof b.timestamp === 'number' ? b.timestamp : Infinity
return at === bt ? a.id.localeCompare(b.id) : at - bt
})
const ordRatio = new Map(ordered.map((n, i) => [n.id, ordered.length > 1 ? i / (ordered.length - 1) : 0]))
const rec = new Map<string, number>()
// Radius is a truthful linear map of time (ordinal only as a fallback for the
// undated). Co-timed nodes share a radius and fan out by ANGLE in the sim — so
// a burst reads as a populated ring, and the dated rings stay accurate.
for (const n of nodes) {
const ratio =
timed && typeof n.timestamp === 'number' && minTs !== null && maxTs !== null
? (Number(n.timestamp) - minTs) / (maxTs - minTs)
: (ordRatio.get(n.id) ?? 0)
rec.set(n.id, recForRatio(ratio))
}
return { maxTs, minTs, rec, timed }
}
export interface TimeBucket {
memory: number
skill: number
total: number
}
export interface TimeAxis {
buckets: TimeBucket[]
maxTotal: number
maxTs: null | number
minTs: null | number
// Total node count — the denominator for the "n / total" label when undated.
size: number
timed: boolean
}
// Bucket nodes across recency [0,1] into a fixed-width histogram — the little
// bars the scrubber rides over. Skill/memory kept separate so the bars can show
// the same two-tone split as the map glyphs.
export function buildTimeAxis(graph: StarmapGraph, bucketCount = 48): TimeAxis {
const { maxTs, minTs, rec, timed } = computeRecency(graph.nodes)
const n = Math.max(1, bucketCount)
const buckets: TimeBucket[] = Array.from({ length: n }, () => ({ memory: 0, skill: 0, total: 0 }))
for (const node of graph.nodes) {
const r = rec.get(node.id) ?? 0
const idx = clamp(Math.floor(r * n), 0, n - 1)
const b = buckets[idx]!
b.total += 1
if (node.kind === 'memory') {
b.memory += 1
} else {
b.skill += 1
}
}
const maxTotal = buckets.reduce((m, b) => Math.max(m, b.total), 0)
return { buckets, maxTotal, maxTs, minTs, size: graph.nodes.length, timed }
}
// Wall-clock date at a reveal ratio (linear in time when the graph is dated).
export function dateAtReveal(axis: TimeAxis, reveal: number): null | number {
if (!axis.timed || axis.minTs === null || axis.maxTs === null) {
return null
}
return Math.round(axis.minTs + clamp(reveal, 0, 1) * (axis.maxTs - axis.minTs))
}
+289
View File
@@ -0,0 +1,289 @@
import { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import { Codicon } from '@/components/ui/codicon'
import type { TimeAxis } from './time-axis'
interface TimelineProps {
axis: TimeAxis
// Colour for memory stars — matches the map's memory glyph.
memoryColor?: string
onScrub: (reveal: number) => void
onTogglePlay: () => void
playing: boolean
revealStore: RevealSignal
// Reveal positions (0–1) where rings spawn — drawn as anchor ticks.
ringStops?: number[]
}
interface RevealSignal {
get: () => number
subscribe: (listener: (value: number) => void) => () => void
}
interface Star {
delay: number
duration: number
kind: 'memory' | 'skill'
leftPct: number
opacity: number
size: number
topPct: number
}
const ACTIVE_MARKER_CLASS = 'opacity-100'
const INACTIVE_MARKER_CLASS = 'opacity-30'
// Busiest bucket gets this many stars; quieter ones scale down proportionally.
const MAX_STARS_PER_BUCKET = 7
// Deterministic PRNG (mulberry32) so a bucket's stars stay put across renders.
function rng(seed: number): () => number {
let a = seed >>> 0
return () => {
a += 0x6d2b79f5
let t = a
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
// Scatter each time bucket's activity into stars: count ∝ events, split between
// skill- and memory-coloured stars, jittered within the bucket's horizontal slot
// and across the track height. Vertical placement is biased toward the midline
// (a triangular distribution) so stars cluster near the centre more often than
// the edges. A starmap timeline for a starmap.
function buildStars(axis: TimeAxis): Star[] {
const n = Math.max(1, axis.buckets.length)
const stars: Star[] = []
axis.buckets.forEach((b, i) => {
if (b.total === 0) {
return
}
const intensity = axis.maxTotal > 0 ? b.total / axis.maxTotal : 0
// sqrt curve so a single co-timed burst (a packed core ring) doesn't crush
// every quieter bucket down to the 1-star floor and read as blank.
const count = Math.max(1, Math.round(Math.sqrt(intensity) * MAX_STARS_PER_BUCKET))
const skillCount = Math.round((b.skill / b.total) * count)
const r = rng(i * 9973 + 7)
const slot = 1 / n
const center = (i + 0.5) / n
for (let s = 0; s < count; s++) {
const jitter = (r() - 0.5) * slot * 0.9
// Average of two uniforms → triangular peak at 0.5, pulling stars toward
// the midline more often while still reaching the edges occasionally.
const vertical = (r() + r()) / 2
stars.push({
delay: r() * 3,
duration: 2.4 + r() * 2.6,
kind: s < skillCount ? 'skill' : 'memory',
leftPct: Math.max(0, Math.min(1, center + jitter)) * 100,
// Brighter, slightly larger stars are rarer.
opacity: 0.5 + r() * 0.5,
// Floor at 2px so a lone star in a quiet bucket still reads on black.
size: 2 + Math.round(r() * r() * 2.2),
topPct: 12 + vertical * 76
})
}
})
return stars
}
// Playback scrubber as a constellation: dim stars are the unrevealed future; a
// scanner sweep ignites them (bright + twinkling) left→right as the reveal
// advances. The bright layer is clipped by the reveal CSS var, so the rAF sweep
// in StarMap drives it with zero per-frame JS.
export const Timeline = memo(function Timeline({
axis,
memoryColor = 'var(--theme-secondary)',
onScrub,
onTogglePlay,
playing,
revealStore,
ringStops = []
}: TimelineProps) {
const trackRef = useRef<HTMLDivElement | null>(null)
const draggingRef = useRef(false)
const markerRefs = useRef<HTMLDivElement[]>([])
const stars = useMemo(() => buildStars(axis), [axis])
const syncReveal = useCallback(
(value: number) => {
const reveal = Math.max(0, Math.min(1, value))
const track = trackRef.current
if (track) {
track.style.setProperty('--starmap-reveal', String(reveal))
track.setAttribute('aria-valuenow', String(Math.round(reveal * 100)))
}
ringStops.forEach((stop, i) => {
const el = markerRefs.current[i]
if (!el) {
return
}
const active = stop <= reveal
el.classList.toggle(ACTIVE_MARKER_CLASS, active)
el.classList.toggle(INACTIVE_MARKER_CLASS, !active)
})
},
[ringStops]
)
useEffect(() => revealStore.subscribe(syncReveal), [revealStore, syncReveal])
useEffect(() => {
markerRefs.current.length = ringStops.length
syncReveal(revealStore.get())
}, [revealStore, ringStops.length, syncReveal])
const ratioAt = (clientX: number): number => {
const rect = trackRef.current?.getBoundingClientRect()
if (!rect || rect.width === 0) {
return revealStore.get()
}
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
}
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
draggingRef.current = true
e.currentTarget.setPointerCapture(e.pointerId)
onScrub(ratioAt(e.clientX))
}
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (draggingRef.current) {
onScrub(ratioAt(e.clientX))
}
}
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
draggingRef.current = false
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId)
}
}
const colorFor = (kind: Star['kind']) => (kind === 'skill' ? 'var(--theme-primary)' : memoryColor)
return (
<div className="pointer-events-auto flex w-[28rem] max-w-full items-center gap-3 [-webkit-app-region:no-drag]">
<style>{'@keyframes starmap-twinkle{0%,100%{opacity:var(--o,1)}50%{opacity:calc(var(--o,1) * 0.35)}}'}</style>
<button
aria-label={playing ? 'Pause' : 'Play timeline'}
className="flex size-5 shrink-0 items-center justify-center text-foreground/75 transition-colors hover:text-foreground"
onClick={onTogglePlay}
type="button"
>
<Codicon name={playing ? 'debug-pause' : 'triangle-right'} size={playing ? '0.8rem' : '0.95rem'} />
</button>
<div
aria-label="Timeline scrubber"
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={Math.round(revealStore.get() * 100)}
className="relative h-7 min-w-0 flex-1 cursor-pointer select-none touch-none"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
ref={trackRef}
role="slider"
style={{ '--starmap-reveal': revealStore.get() } as React.CSSProperties}
tabIndex={0}
>
{/* Dashed midline — a faint horizontal axis the stars ride over. */}
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 border-t border-dashed border-foreground/5"
/>
{/* Dim constellation — the unrevealed future. */}
<div aria-hidden className="absolute inset-0">
{stars.map((star, i) => (
<div
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full"
key={i}
style={{
backgroundColor: colorFor(star.kind),
height: star.size,
left: `${star.leftPct}%`,
opacity: 0.22,
top: `${star.topPct}%`,
width: star.size
}}
/>
))}
</div>
{/* Ignited constellation — bright + twinkling, clipped to the reveal. */}
<div
aria-hidden
className="absolute inset-0"
style={{ clipPath: 'inset(0 calc((1 - var(--starmap-reveal, 1)) * 100%) 0 0)' }}
>
{stars.map((star, i) => {
const color = colorFor(star.kind)
return (
<div
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full"
key={i}
style={
{
'--o': star.opacity,
animation: `starmap-twinkle ${star.duration}s ease-in-out ${star.delay}s infinite`,
backgroundColor: color,
height: star.size,
left: `${star.leftPct}%`,
opacity: star.opacity,
top: `${star.topPct}%`,
width: star.size
} as React.CSSProperties
}
/>
)
})}
</div>
{/* Ring-spawn anchor ticks — small bright stars that light up on pass. */}
<div aria-hidden className="absolute inset-0">
{ringStops.map((stop, i) => (
<div
className={`pointer-events-none absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--theme-primary)] ${INACTIVE_MARKER_CLASS}`}
key={i}
ref={el => {
if (el) {
markerRefs.current[i] = el
}
}}
style={{ left: `${stop * 100}%` }}
/>
))}
</div>
{/* Playhead — a thin white sweep line. */}
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 w-0.5 -translate-x-1/2 bg-foreground"
style={{ left: 'calc(var(--starmap-reveal, 1) * 100%)' }}
/>
</div>
</div>
)
})
+97
View File
@@ -0,0 +1,97 @@
import type { SimulationLinkDatum, SimulationNodeDatum } from 'd3-force'
import type { StarmapGraph, StarmapNode } from '@/types/hermes'
export type MemoryCard = StarmapGraph['memory'][number]
export type Shape = 'circle' | 'diamond' | 'hexagon' | 'square' | 'triangle'
export interface Viewport {
k: number
x: number
y: number
}
export interface Rgb {
b: number
g: number
r: number
}
export interface Rect {
h: number
w: number
x: number
y: number
}
export interface SimNode extends StarmapNode, SimulationNodeDatum {
outerRingIndex: number // first ring that caps this node's recency band
rec: number // recency 0 (oldest) → 1 (newest)
tr: number // time-anchored target radius
x: number
y: number
}
export interface SimLink extends SimulationLinkDatum<SimNode> {
source: SimNode | string
target: SimNode | string
}
// Per-mode line/ring style.
export interface GraphParams {
lineAlpha: number
lineDash: number
lineDashed: boolean
lineWidth: number
ringAlpha: number
ringDash: number
ringDashed: boolean
ringWidth: number
}
// Per-mode ring/orb params (band wash, light-sliver size, ring outline alpha, orb sheen).
export interface RingParams {
bandAlpha: number
lightSize: number
ringAlpha: number
sheen: number
}
export interface Palette {
bandInk: Rgb
base: Rgb
bg: Rgb
c: GraphParams
chipBg: string
darkTheme: boolean
inkInv: string
memoryInk: Rgb
primary: Rgb
skillInk: Rgb
}
export interface Ring {
label: null | string
r: number
ratio: number
}
export interface RingLabelRect {
h: number
i: number
w: number
x: number
y: number
}
export interface FadeBuckets {
// Per-element "birth" progress 0→1 used to ease position (nodes rise outward
// into place, rings grow out) as the scrubber reveals them. Separate from the
// alpha buckets so it stays monotonic and isn't perturbed by focus/selection.
appear: Map<string, number>
labels: Map<string, number>
links: Map<string, number>
nodes: Map<string, number>
rings: Map<string, number>
}