Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 59 additions & 26 deletions src/lib/components/desktop/GraphCanvas.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand All @@ -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);
Expand Down Expand Up @@ -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<string, { x: number; y: number }>();
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<string, { x: number; y: number }>();
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
Expand Down
6 changes: 5 additions & 1 deletion src/lib/engine/canvas-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
17 changes: 16 additions & 1 deletion src/lib/state/app-state.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading