From 666f2003880bcd37c77977a63ab58a6983ad8f22 Mon Sep 17 00:00:00 2001 From: NeoVand Date: Fri, 19 Jun 2026 18:02:15 -0500 Subject: [PATCH] feat(timeline): hide subcategory nodes; load straight into the saved layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two focused changes, nothing else touched (fonts/width/spacing unchanged): - Timeline drops subcategory nodes. They were parked on top of their parent category, so their label just collided with the category's — the lanes are the grouping here, so they add nothing. - The chosen layout is persisted (localStorage) and restored on load, and the force-graph bloom is skipped when the page opens straight into a non-force layout. So refreshing while in the timeline lands directly in the timeline instead of playing the force intro first and only then switching. Verified in-browser: refresh into timeline shows the timeline immediately (no force flash), in-app layout switches still animate, console clean. Co-Authored-By: Claude Opus 4.8 --- src/lib/components/desktop/GraphCanvas.svelte | 85 +++++++++++++------ src/lib/engine/canvas-renderer.ts | 6 +- src/lib/state/app-state.svelte.ts | 17 +++- 3 files changed, 80 insertions(+), 28 deletions(-) diff --git a/src/lib/components/desktop/GraphCanvas.svelte b/src/lib/components/desktop/GraphCanvas.svelte index 40afbb9..3bf59b4 100644 --- a/src/lib/components/desktop/GraphCanvas.svelte +++ b/src/lib/components/desktop/GraphCanvas.svelte @@ -13,6 +13,7 @@ import type { GraphNode } from '$lib/data/types'; import { createSimulation, warmUpSimulation, syncPositions } from '$lib/engine/simulation'; + import { persistLayoutMode } from '$lib/state/app-state.svelte'; import { render, findNodeAtPosition } from '$lib/engine/canvas-renderer'; import { RenderLoop } from '$lib/engine/render-loop.svelte'; import { getAppState } from '$lib/state/context'; @@ -454,10 +455,11 @@ : mode === 'timeline' ? computeTimelinePositions(nodes) : computeMeshPositions(nodes); - if (!prefersReducedMotion.current) { - captureLayoutSource(); - } - layoutTargets = positions; + + // First render already in a non-force layout (e.g. refresh while in + // timeline) — snap straight there, no force-graph bloom/slide first. + const landDirectly = prevLayout === null; + // Zoom to fit the target layout (use target positions, not current) // In mesh mode, only consider protocol nodes for the bounding box — // hub and categories are parked at origin and don't render. @@ -469,12 +471,37 @@ mode === 'mesh' ? targetNodes.filter((n) => n.type === 'protocol' || n.type === 'subcategory') : targetNodes; - appState.focusOnSubgraph(focusNodes, width, height, isPanelOccupied() ? undefined : 0); + + if (landDirectly) { + for (const n of nodes) { + const t = positions.get(n.id); + if (t) { + n.x = t.x; + n.y = t.y; + n.vx = 0; + n.vy = 0; + } + } + layoutTargets = null; + springStates.clear(); + appState.focusOnSubgraph(focusNodes, width, height, undefined, true); + } else { + if (!prefersReducedMotion.current) { + captureLayoutSource(); + } + layoutTargets = positions; + appState.focusOnSubgraph(focusNodes, width, height, isPanelOccupied() ? undefined : 0); + } }); } prevLayout = mode; }); + // Remember the chosen layout so a refresh reopens it. + $effect(() => { + persistLayoutMode(appState.layoutMode); + }); + function handleMouseMove(e: MouseEvent) { if (isPanning) { appState.pan(e.clientX - lastMouseX, e.clientY - lastMouseY); @@ -623,30 +650,36 @@ // read current positions instead of stale static x:0/y:0 values. appState.registerLiveNodes(nodes); - // Warm up to compute settled target positions. With the bloom we - // then stash those targets, reset visible positions to (0,0), and - // glide each node into place — but we keep the warmed simulation - // frozen during the bloom so the existing graph doesn't shake. + // Warm up the force layout so its settled positions are ready (for the + // bloom, and for later switches back to force). warmUpSimulation(simulation); - syncPositions(simulation, nodes); - - if (!prefersReducedMotion.current) { - // Capture every node's settled position as its bloom target. - bloomNodeTargets = new Map(); - for (const node of nodes) { - bloomNodeTargets.set(node.id, { x: node.x, y: node.y }); - if (node.type !== 'hub') { - // Park off-screen until birth — invisible thanks to - // birthScales[id] = 0. - node.x = 0; - node.y = 0; - node.vx = 0; - node.vy = 0; + + // The chronological bloom is the *force* layout's intro. If the page + // loads straight into a non-force layout (e.g. refresh while in + // timeline), skip it entirely and let the layout effect land the nodes + // directly in that layout — so the force graph never flashes first. + const initialForce = appState.layoutMode === 'force'; + if (initialForce) { + syncPositions(simulation, nodes); + + if (!prefersReducedMotion.current) { + // Capture every node's settled position as its bloom target. + bloomNodeTargets = new Map(); + for (const node of nodes) { + bloomNodeTargets.set(node.id, { x: node.x, y: node.y }); + if (node.type !== 'hub') { + // Park off-screen until birth — invisible thanks to + // birthScales[id] = 0. + node.x = 0; + node.y = 0; + node.vx = 0; + node.vy = 0; + } + birthScales.set(node.id, 0); + bloomVelocities.set(node.id, { vx: 0, vy: 0 }); } - birthScales.set(node.id, 0); - bloomVelocities.set(node.id, { vx: 0, vy: 0 }); + bloomActive = true; } - bloomActive = true; } // Register touch/wheel handlers as non-passive so preventDefault() works diff --git a/src/lib/engine/canvas-renderer.ts b/src/lib/engine/canvas-renderer.ts index 4d2f77f..d5aea14 100644 --- a/src/lib/engine/canvas-renderer.ts +++ b/src/lib/engine/canvas-renderer.ts @@ -269,7 +269,11 @@ export function render(ctx: CanvasRenderingContext2D, options: RenderOptions): v const visibleNodes = layoutMode === 'mesh' ? nodes.filter((n) => n.type === 'protocol' || n.type === 'subcategory') - : nodes; + : layoutMode === 'timeline' + ? // Subcategories are parked on top of their category here, so their + // label just collides with the category's — drop them in the timeline. + nodes.filter((n) => n.type !== 'subcategory') + : nodes; const sortedNodes = [...visibleNodes].sort((a, b) => { const order = { protocol: 0, subcategory: 1, category: 2, hub: 3 }; return order[a.type] - order[b.type]; diff --git a/src/lib/state/app-state.svelte.ts b/src/lib/state/app-state.svelte.ts index ca64ac4..def37e7 100644 --- a/src/lib/state/app-state.svelte.ts +++ b/src/lib/state/app-state.svelte.ts @@ -3,9 +3,24 @@ import type { LayoutMode } from '$lib/engine/layouts'; import type { Concept } from '$lib/data/concepts'; import type { Journey } from '$lib/data/journeys'; +const LAYOUT_STORAGE_KEY = 'protocol-lab:layout-mode'; +const VALID_LAYOUTS: readonly LayoutMode[] = ['force', 'radial', 'timeline', 'mesh']; + +/** Last layout the user chose, so a refresh reopens it instead of resetting. */ +function readPersistedLayout(): LayoutMode { + if (typeof localStorage === 'undefined') return 'force'; + const v = localStorage.getItem(LAYOUT_STORAGE_KEY); + return v && (VALID_LAYOUTS as readonly string[]).includes(v) ? (v as LayoutMode) : 'force'; +} + +/** Persist the chosen layout (call whenever `layoutMode` changes). */ +export function persistLayoutMode(mode: LayoutMode): void { + if (typeof localStorage !== 'undefined') localStorage.setItem(LAYOUT_STORAGE_KEY, mode); +} + export class AppState { selectedNode: GraphNode | null = $state(null); - layoutMode: LayoutMode = $state('force'); + layoutMode: LayoutMode = $state(readPersistedLayout()); hoveredNode: GraphNode | null = $state(null); /** * When non-null, NodeTooltip anchors itself to this rect (used by