diff --git a/.changeset/notebook-navigation-explorations.md b/.changeset/notebook-navigation-explorations.md new file mode 100644 index 00000000000..2dc859076ab --- /dev/null +++ b/.changeset/notebook-navigation-explorations.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Notebook view navigation prototypes: a ⌘K command palette, back/forward jump history, Vimium-style hint-jump (`f`), peek previews on references, and a zoom/pan camera with a minimap for the net diagram. diff --git a/libs/@hashintel/petrinaut/docs/notebook-view.md b/libs/@hashintel/petrinaut/docs/notebook-view.md index 4bf7645488e..f9562c0bb6b 100644 --- a/libs/@hashintel/petrinaut/docs/notebook-view.md +++ b/libs/@hashintel/petrinaut/docs/notebook-view.md @@ -19,6 +19,13 @@ Two badges can appear after a cell's name: - **`initial`** (blue) — this place must hold tokens in the initial state. Nothing in the net can produce its first token: either nothing feeds it at all, or it belongs to a pool that only circulates what it starts with (a resource pool such as a set of machines). If a scenario leaves every place of such a group empty, the transitions that need it can never fire. - **`↻N`** (tinted) — this node is part of cycle _N_. Hover the badge to light up the whole cycle in the list and the graph. +## Getting around + +- **Command palette** — press **⌘K** (Ctrl+K) to open a palette that fuzzy-matches every cell name and the view's commands (cell order, kind filters, expand/collapse all, graph focus). **Enter** jumps to a cell or runs a command; **Escape** closes. +- **Jump history** — following a reference (an arc's place, an explorer row, a palette result) is a jump, and jumps are remembered: the **‹ ›** buttons in the toolbar, or **⌥←** / **⌥→**, go back and forward through them, like a browser. +- **Hint jump** — press **f** while in the list to label every visible row with a letter chip; type a chip's letters to jump straight to that row. **Escape** (or any other key) leaves the mode. +- **Peek** — hover a reference (an arc's place name, an explorer row, a graph node) or move keyboard focus onto it, and a card previews the target cell — its kind, summary, and dependent counts — without navigating. + ## Toolbar - **Search** — type in the search box (or press **/**) to fuzzy-match cell names; matching characters are highlighted and other cells fade. **↑** / **↓** step through matches, **Enter** jumps to the first one, **Escape** clears. @@ -35,6 +42,7 @@ The right-hand panel draws the whole net as a top-to-bottom flow graph of places - Selecting a place or transition highlights it in the graph: dependencies in blue, dependents in orange, a node connected in both directions in purple. Clicking a node in the graph selects its cell. - The **target button** re-organizes the graph around the selected node: what it depends on stacks above it, what depends on it below, everything else settles underneath. The re-layout is animated. Toggle it off to return to the default flow layout. +- The diagram is a camera: **scroll** to zoom toward the cursor, **drag** the background to pan, and **double-click** the background to fit the whole net again. Zoomed far out, node labels give way to shapes alone. The minimap in the corner shows where you are — press or drag it to move the camera. - Below the graph, the selected cell's dependencies and dependents are listed in full — including types, equations, and parameters, which the diagram itself leaves out. - Drag the panel's left edge to resize it, and the divider above the lists to trade space between the graph and the lists. diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/command-palette.tsx b/libs/@hashintel/petrinaut/src/ui/views/Notebook/command-palette.tsx new file mode 100644 index 00000000000..1f5ffd3082b --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/command-palette.tsx @@ -0,0 +1,255 @@ +import { useState } from "react"; + +import { css, cva } from "@hashintel/ds-helpers/css"; + +import { CELL_KIND_ICONS, CELL_KIND_LABELS } from "./cell-kinds"; +import { HighlightedName } from "./notebook-cell"; +import { cellName, fuzzyMatchName } from "./notebook-model"; + +import type { NotebookCell as NotebookCellModel } from "./notebook-model"; + +/** A view command the palette can run, beside the jumpable cells. */ +export type PaletteAction = { + id: string; + label: string; + /** Right-aligned state or shortcut hint. */ + hint?: string; + run: () => void; +}; + +const backdropStyle = css({ + position: "fixed", + inset: "[0]", + zIndex: "modal", + backgroundColor: "[rgba(15, 18, 24, 0.25)]", +}); + +const panelStyle = css({ + position: "fixed", + top: "[14%]", + left: "[50%]", + transform: "translateX(-50%)", + width: "[min(600px, calc(100vw - 48px))]", + zIndex: "modal", + display: "flex", + flexDirection: "column", + backgroundColor: "neutral.s00", + borderRadius: "lg", + borderWidth: "[1px]", + borderStyle: "solid", + borderColor: "neutral.s40", + boxShadow: + "[0px 12px 32px -8px rgba(0,0,0,0.25), 0px 2px 8px rgba(0,0,0,0.1)]", + overflow: "hidden", +}); + +const inputStyle = css({ + width: "full", + paddingX: "3", + paddingY: "2.5", + fontSize: "sm", + color: "neutral.s115", + backgroundColor: "[transparent]", + borderWidth: "[0]", + borderBottomWidth: "[1px]", + borderBottomStyle: "solid", + borderBottomColor: "neutral.s30", + outline: "[none]", +}); + +const listStyle = css({ + maxHeight: "[340px]", + overflowY: "auto", + paddingY: "1", +}); + +const entryStyle = cva({ + base: { + display: "flex", + alignItems: "center", + gap: "2", + width: "full", + paddingX: "3", + paddingY: "1.5", + fontSize: "sm", + textAlign: "left", + backgroundColor: "[transparent]", + borderWidth: "[0]", + cursor: "pointer", + color: "neutral.s115", + }, + variants: { + isSelected: { + true: { backgroundColor: "blue.s20" }, + false: {}, + }, + }, +}); + +const entryKindStyle = css({ + flexShrink: 0, + fontSize: "xs", + fontFamily: "mono", + color: "purple.s100", + width: "[76px]", +}); + +const entryHintStyle = css({ + marginLeft: "auto", + flexShrink: 0, + fontSize: "xs", + color: "neutral.fg.subtle", +}); + +const emptyStyle = css({ + paddingX: "3", + paddingY: "3", + fontSize: "sm", + color: "neutral.fg.subtle", +}); + +type PaletteEntry = + | { kind: "action"; action: PaletteAction; indices: number[] } + | { kind: "cell"; cell: NotebookCellModel; indices: number[] }; + +const MAX_LISTED_CELLS = 40; + +export interface CommandPaletteProps { + /** Every cell, unfiltered — jumping reveals a hidden kind. */ + cells: NotebookCellModel[]; + actions: PaletteAction[]; + onJumpToCell: (cellId: string) => void; + onClose: () => void; +} + +/** + * The ⌘K palette: one keyboard-first surface that fuzzy-matches the view's + * commands and every cell name. Actions list first, cells after; Enter runs + * the selection, Escape closes. Modelled on the Linear/VS Code palettes. + */ +export const CommandPalette: React.FC = ({ + cells, + actions, + onJumpToCell, + onClose, +}) => { + const [query, setQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + + const trimmed = query.trim(); + const entries: PaletteEntry[] = [ + ...actions.flatMap((action): PaletteEntry[] => { + const indices = + trimmed === "" ? [] : fuzzyMatchName(trimmed, action.label); + return indices === null ? [] : [{ kind: "action", action, indices }]; + }), + ...cells.flatMap((cell): PaletteEntry[] => { + const indices = + trimmed === "" ? [] : fuzzyMatchName(trimmed, cellName(cell)); + return indices === null ? [] : [{ kind: "cell", cell, indices }]; + }), + ].slice(0, actions.length + MAX_LISTED_CELLS); + + const clampedIndex = Math.min(selectedIndex, entries.length - 1); + + const runEntry = (entry: PaletteEntry) => { + if (entry.kind === "action") { + entry.action.run(); + } else { + onJumpToCell(entry.cell.id); + } + onClose(); + }; + + return ( + <> +
+
+ element?.focus()} + className={inputStyle} + placeholder="Jump to a cell or run a command…" + value={query} + onChange={(event) => { + setQuery(event.target.value); + setSelectedIndex(0); + }} + onKeyDown={(event) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedIndex(Math.min(clampedIndex + 1, entries.length - 1)); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedIndex(Math.max(clampedIndex - 1, 0)); + } else if (event.key === "Enter") { + event.preventDefault(); + const entry = entries[clampedIndex]; + if (entry) { + runEntry(entry); + } + } else if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onClose(); + } + }} + /> +
+ {entries.length === 0 ? ( +
Nothing matches "{trimmed}".
+ ) : ( + entries.map((entry, at) => { + const isSelected = at === clampedIndex; + if (entry.kind === "action") { + return ( + + ); + } + const KindIcon = CELL_KIND_ICONS[entry.cell.kind]; + return ( + + ); + }) + )} +
+
+ + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/graph-explorer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Notebook/graph-explorer.tsx index 6de4fa9830b..9711298d426 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Notebook/graph-explorer.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/graph-explorer.tsx @@ -171,6 +171,7 @@ const NodeRow: React.FC<{ ref={grid.register(index, 0)} tabIndex={grid.tabIndexFor(index, 0)} className={nodeRowStyle} + data-peek-cell={node.id} onClick={() => onNavigate(node)} onKeyDown={grid.onKeyDown(index, 0)} onFocus={() => grid.onFocusCell(index, 0)} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/hint-jump.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/hint-jump.test.ts new file mode 100644 index 00000000000..bd10f1a0df0 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/hint-jump.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { hintLabels, matchHint } from "./hint-jump"; + +describe("hintLabels", () => { + it("uses single letters while they suffice", () => { + expect(hintLabels(3)).toEqual(["a", "s", "d"]); + expect(hintLabels(9)).toHaveLength(9); + expect(new Set(hintLabels(9)).size).toBe(9); + }); + + it("switches every label to pairs when singles run out", () => { + const labels = hintLabels(12); + expect(labels).toHaveLength(12); + expect(labels.every((label) => label.length === 2)).toBe(true); + expect(new Set(labels).size).toBe(12); + }); + + it("never makes one label a prefix of another", () => { + for (const count of [5, 9, 10, 40, 81]) { + const labels = hintLabels(count); + for (const label of labels) { + expect(labels.filter((other) => other.startsWith(label))).toHaveLength( + 1, + ); + } + } + }); +}); + +describe("matchHint", () => { + it("matches a complete single-letter label", () => { + expect(matchHint("s", 3)).toEqual({ kind: "match", index: 1 }); + }); + + it("stays pending on a valid pair prefix", () => { + expect(matchHint("a", 12)).toEqual({ kind: "pending" }); + expect(matchHint("as", 12)).toEqual({ kind: "match", index: 1 }); + }); + + it("reports a dead end for characters no label starts with", () => { + expect(matchHint("z", 12)).toEqual({ kind: "none" }); + expect(matchHint("az", 12)).toEqual({ kind: "none" }); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/hint-jump.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/hint-jump.ts new file mode 100644 index 00000000000..7a870da50c7 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/hint-jump.ts @@ -0,0 +1,45 @@ +/** + * Hint-jump labelling: Vimium/avy-style two-keystroke navigation. Pressing + * the trigger key labels every visible row with a short letter sequence; + * typing a label jumps to its row. + */ + +/** Home-row-first alphabet, so the common labels stay under the fingers. */ +const HINT_LETTERS = "asdfghjkl".split(""); + +/** + * A label per target, all the same length so no label is a prefix of + * another: single letters while they suffice, letter pairs beyond that. + */ +export function hintLabels(count: number): string[] { + if (count <= HINT_LETTERS.length) { + return HINT_LETTERS.slice(0, count); + } + const labels: string[] = []; + for (const first of HINT_LETTERS) { + for (const second of HINT_LETTERS) { + labels.push(`${first}${second}`); + if (labels.length === count) { + return labels; + } + } + } + return labels; +} + +export type HintMatch = + | { kind: "pending" } + | { kind: "match"; index: number } + | { kind: "none" }; + +/** How typed characters resolve against the labels of `hintLabels(count)`. */ +export function matchHint(typed: string, count: number): HintMatch { + const labels = hintLabels(count); + const index = labels.indexOf(typed); + if (index !== -1) { + return { kind: "match", index }; + } + return labels.some((label) => label.startsWith(typed)) + ? { kind: "pending" } + : { kind: "none" }; +} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-viewport.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-viewport.test.ts new file mode 100644 index 00000000000..fda87faf3c6 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-viewport.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { + centerViewportOn, + fitViewport, + MAX_SCALE, + MIN_SCALE, + panViewport, + visibleRegion, + zoomViewport, +} from "./net-graph-viewport"; + +const PANE = { width: 400, height: 300 }; + +describe("fitViewport", () => { + it("centres a small layout at 1:1 instead of magnifying it", () => { + const viewport = fitViewport({ width: 200, height: 100 }, PANE); + expect(viewport.scale).toBe(1); + expect(viewport.x).toBe(100); + expect(viewport.y).toBe(100); + }); + + it("shrinks a large layout to fit with the margin", () => { + const viewport = fitViewport({ width: 800, height: 300 }, PANE); + expect(viewport.scale).toBeCloseTo((400 - 32) / 800); + // Centred: equal slack on both sides. + expect(viewport.x).toBeCloseTo((400 - 800 * viewport.scale) / 2); + }); +}); + +describe("zoomViewport", () => { + it("keeps the layout point under the cursor fixed", () => { + const viewport = { x: 50, y: 20, scale: 1 }; + const cursor = { x: 150, y: 120 }; + const layoutUnderCursor = { + x: (cursor.x - viewport.x) / viewport.scale, + y: (cursor.y - viewport.y) / viewport.scale, + }; + const zoomed = zoomViewport(viewport, cursor, -200); + expect(zoomed.scale).toBeGreaterThan(viewport.scale); + expect(layoutUnderCursor.x * zoomed.scale + zoomed.x).toBeCloseTo(cursor.x); + expect(layoutUnderCursor.y * zoomed.scale + zoomed.y).toBeCloseTo(cursor.y); + }); + + it("clamps at both scale bounds", () => { + const viewport = { x: 0, y: 0, scale: 1 }; + expect(zoomViewport(viewport, { x: 0, y: 0 }, 10_000).scale).toBe( + MIN_SCALE, + ); + expect(zoomViewport(viewport, { x: 0, y: 0 }, -10_000).scale).toBe( + MAX_SCALE, + ); + }); +}); + +describe("visibleRegion and centerViewportOn", () => { + it("round-trips: centring on a point puts it mid-region", () => { + const centred = centerViewportOn( + { x: 0, y: 0, scale: 0.5 }, + { x: 300, y: 200 }, + PANE, + ); + const region = visibleRegion(centred, PANE); + expect(region.x + region.width / 2).toBeCloseTo(300); + expect(region.y + region.height / 2).toBeCloseTo(200); + }); + + it("panning shifts the visible region opposite to the drag", () => { + const viewport = { x: 0, y: 0, scale: 1 }; + const region = visibleRegion(panViewport(viewport, 40, -30), PANE); + expect(region.x).toBe(-40); + expect(region.y).toBe(30); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-viewport.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-viewport.ts new file mode 100644 index 00000000000..7439ef71fb3 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-viewport.ts @@ -0,0 +1,84 @@ +/** + * Viewport math for the net diagram: an overview+detail camera over the + * laid-out graph. The viewport maps layout coordinates to pane pixels as + * `pane = layout * scale + (x, y)`. + */ + +export type Viewport = { x: number; y: number; scale: number }; + +export type Size = { width: number; height: number }; + +export const MIN_SCALE = 0.15; +export const MAX_SCALE = 2.5; + +const clampScale = (scale: number): number => + Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale)); + +/** + * The camera that shows the whole layout centred in the pane, zoomed in no + * further than 1:1 so small nets don't blow up to fill the space. + */ +export function fitViewport(content: Size, pane: Size, margin = 16): Viewport { + const scale = clampScale( + Math.min( + 1, + (pane.width - margin * 2) / Math.max(content.width, 1), + (pane.height - margin * 2) / Math.max(content.height, 1), + ), + ); + return { + scale, + x: (pane.width - content.width * scale) / 2, + y: (pane.height - content.height * scale) / 2, + }; +} + +/** Zoom by a wheel delta towards a fixed point, in pane coordinates. */ +export function zoomViewport( + viewport: Viewport, + panePoint: { x: number; y: number }, + deltaY: number, +): Viewport { + const scale = clampScale(viewport.scale * Math.exp(-deltaY * 0.0022)); + // The layout point under the cursor stays under the cursor. + const ratio = scale / viewport.scale; + return { + scale, + x: panePoint.x - (panePoint.x - viewport.x) * ratio, + y: panePoint.y - (panePoint.y - viewport.y) * ratio, + }; +} + +export function panViewport( + viewport: Viewport, + dx: number, + dy: number, +): Viewport { + return { ...viewport, x: viewport.x + dx, y: viewport.y + dy }; +} + +/** The pane's visible region, in layout coordinates — the minimap's window. */ +export function visibleRegion( + viewport: Viewport, + pane: Size, +): { x: number; y: number; width: number; height: number } { + return { + x: -viewport.x / viewport.scale, + y: -viewport.y / viewport.scale, + width: pane.width / viewport.scale, + height: pane.height / viewport.scale, + }; +} + +/** Re-centre the camera on a layout point, keeping the current zoom. */ +export function centerViewportOn( + viewport: Viewport, + layoutPoint: { x: number; y: number }, + pane: Size, +): Viewport { + return { + ...viewport, + x: pane.width / 2 - layoutPoint.x * viewport.scale, + y: pane.height / 2 - layoutPoint.y * viewport.scale, + }; +} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph.tsx b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph.tsx index 6a57f53b8f2..a3c73e4d93f 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph.tsx @@ -1,43 +1,78 @@ -import { use, useId } from "react"; +import { use, useEffect, useEffectEvent, useId, useRef, useState } from "react"; import { css, cva } from "@hashintel/ds-helpers/css"; import { UserSettingsContext } from "../../../react/state/user-settings-context"; import { cycleTint } from "./net-cycles"; -import { useNetGraphTransition } from "./net-graph-animation"; +import { layoutSignature, useNetGraphTransition } from "./net-graph-animation"; import { edgePath, layoutNetGraph, NET_NODE_HEIGHT, NET_NODE_WIDTH, } from "./net-graph-layout"; +import { + centerViewportOn, + fitViewport, + panViewport, + visibleRegion, + zoomViewport, +} from "./net-graph-viewport"; import type { CycleGroup } from "./net-cycles"; -import type { PositionedNetNode } from "./net-graph-layout"; +import type { NetGraphLayout, PositionedNetNode } from "./net-graph-layout"; +import type { Size, Viewport } from "./net-graph-viewport"; import type { InitialPlaceGroup } from "./net-siphons"; import type { NetGraph, NetGraphNode } from "./notebook-model"; -/** Fills the pane it is given; the diagram scrolls inside when it overflows. */ -const scrollContainerStyle = css({ +/** + * Fills the pane it is given; the diagram is a zoom/pan camera inside + * (overview+detail), so nothing scrolls — the minimap shows where you are. + */ +const paneStyle = css({ + position: "relative", flex: "[1]", minWidth: "[0]", minHeight: "[0]", - overflow: "auto", + overflow: "hidden", borderWidth: "[1px]", borderStyle: "solid", borderColor: "neutral.s30", borderRadius: "md", backgroundColor: "neutral.s05", - display: "grid", }); -/** - * Centres the diagram when it is smaller than the pane, while still letting - * it grow past the edges (and scroll) when the net is large. - */ -const svgWrapperStyle = css({ - margin: "auto", - padding: "2", +const canvasStyle = css({ + position: "absolute", + inset: "[0]", + width: "full", + height: "full", + cursor: "grab", + _active: { cursor: "grabbing" }, +}); + +const minimapStyle = css({ + position: "absolute", + right: "2", + bottom: "2", + padding: "[3px]", + backgroundColor: "neutral.a80", + backdropFilter: "[blur(2px)]", + borderWidth: "[1px]", + borderStyle: "solid", + borderColor: "neutral.s40", + borderRadius: "sm", + cursor: "crosshair", + lineHeight: "[0]", +}); + +const minimapNodeStyle = css({ fill: "neutral.s60" }); + +const minimapWindowStyle = css({ + fill: "[none]", + stroke: "blue.s90", + strokeWidth: "[1.5]", + vectorEffect: "[non-scaling-stroke]", }); const shapeStyle = cva({ @@ -198,6 +233,73 @@ const truncate = (name: string, maxChars: number): string => const cornerRadius = (node: NetGraphNode): number => node.kind === "place" ? NET_NODE_HEIGHT / 2 : 3; +const MINIMAP_WIDTH = 132; + +/** + * The whole layout in a corner thumbnail with the camera's window drawn on + * it; pressing (or dragging) re-centres the camera there. + */ +const Minimap: React.FC<{ + layout: NetGraphLayout; + viewport: Viewport; + pane: Size; + onCenter: (layoutPoint: { x: number; y: number }) => void; +}> = ({ layout, viewport, pane, onCenter }) => { + const width = Math.max(layout.width, 1); + const height = Math.max(layout.height, 1); + // Fit the thumbnail inside a bounded box without distorting the layout. + const mapScale = Math.min(MINIMAP_WIDTH / width, 104 / height); + const mapWidth = Math.max(24, width * mapScale); + const mapHeight = Math.max(24, height * mapScale); + const window_ = visibleRegion(viewport, pane); + + const centerAt = (event: React.MouseEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + onCenter({ + x: ((event.clientX - rect.left) / rect.width) * width, + y: ((event.clientY - rect.top) / rect.height) * height, + }); + }; + + return ( +
+ { + event.preventDefault(); + centerAt(event); + }} + onMouseMove={(event) => { + if (event.buttons % 2 === 1) { + centerAt(event); + } + }} + > + {layout.nodes.map((node) => ( + + ))} + + +
+ ); +}; + export interface NetGraphViewProps { graph: NetGraph; /** The selected place or transition, or null when nothing relevant is selected. */ @@ -250,6 +352,99 @@ export const NetGraphView: React.FC = ({ enabled: showAnimations, }); + // The camera over the layout. Held as element state (not a ref) so the + // measurement and wheel effects re-run if the pane mounts late. + const [paneElement, setPaneElement] = useState(null); + const [paneSize, setPaneSize] = useState(null); + const [viewport, setViewport] = useState({ x: 0, y: 0, scale: 1 }); + const panRef = useRef<{ x: number; y: number } | null>(null); + + useEffect(() => { + if (paneElement === null) { + return; + } + const observer = new ResizeObserver(() => { + setPaneSize({ + width: paneElement.clientWidth, + height: paneElement.clientHeight, + }); + }); + observer.observe(paneElement); + return () => observer.disconnect(); + }, [paneElement]); + + // React registers wheel listeners passively, so preventDefault (keeping + // the page from scrolling while zooming) needs a native listener. + const handleWheel = useEffectEvent((event: WheelEvent) => { + if (paneElement === null) { + return; + } + event.preventDefault(); + const rect = paneElement.getBoundingClientRect(); + const point = { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }; + setViewport((current) => zoomViewport(current, point, event.deltaY)); + }); + useEffect(() => { + if (paneElement === null) { + return; + } + paneElement.addEventListener("wheel", handleWheel, { passive: false }); + return () => paneElement.removeEventListener("wheel", handleWheel); + }, [paneElement]); + + const fitCamera = () => { + if (paneSize !== null) { + setViewport( + fitViewport({ width: layout.width, height: layout.height }, paneSize), + ); + } + }; + + // Re-fit whenever the nodes land somewhere new (focus re-layout, edits) — + // adjusted during render rather than in an effect, so the first paint of a + // new layout is already fitted. + const signature = layoutSignature(layout); + const [fittedSignature, setFittedSignature] = useState(null); + if (paneSize !== null && fittedSignature !== signature) { + setFittedSignature(signature); + setViewport( + fitViewport({ width: layout.width, height: layout.height }, paneSize), + ); + } + + const beginPan = (event: React.MouseEvent) => { + panRef.current = { x: event.clientX, y: event.clientY }; + const gesture = new AbortController(); + const stop = () => { + panRef.current = null; + gesture.abort(); + }; + const move = (moveEvent: MouseEvent) => { + // A release outside the window must not leave a ghost drag behind. + if (moveEvent.buttons === 0) { + stop(); + return; + } + const last = panRef.current; + if (last === null) { + return; + } + panRef.current = { x: moveEvent.clientX, y: moveEvent.clientY }; + setViewport((current) => + panViewport( + current, + moveEvent.clientX - last.x, + moveEvent.clientY - last.y, + ), + ); + }; + document.addEventListener("mousemove", move, { signal: gesture.signal }); + document.addEventListener("mouseup", stop, { signal: gesture.signal }); + }; + if (layout.nodes.length === 0) { return (

@@ -293,38 +488,55 @@ export const NetGraphView: React.FC = ({ const nodesById = new Map(layout.nodes.map((node) => [node.id, node])); + const showLabels = viewport.scale >= 0.55; + return ( -

-
- - - {EDGE_ROLES.map((role) => ( - - - - ))} - +
+ { + // Background (or middle-button) drags pan; node clicks stay clicks. + if ( + event.button === 1 || + (event.button === 0 && event.target === event.currentTarget) + ) { + event.preventDefault(); + beginPan(event); + } + }} + onDoubleClick={(event) => { + if (event.target === event.currentTarget) { + fitCamera(); + } + }} + > + + {EDGE_ROLES.map((role) => ( + + + + ))} + + {layout.edges.map((edge) => { const from = nodesById.get(edge.from); const to = nodesById.get(edge.to); @@ -384,6 +596,7 @@ export const NetGraphView: React.FC = ({ cycle === undefined ? undefined : () => onHoverCycle(null) } role="button" + data-peek-cell={node.id} // Out of the tab order: the explorer's list rows are the // keyboard path to these nodes, matching the worksheet's // one-tab-stop model. @@ -449,28 +662,44 @@ export const NetGraphView: React.FC = ({ })} /> )} - - {truncate( - node.name, - MAX_LABEL_CHARS - - (initialGroup === undefined ? 0 : MARKER_LABEL_COST), - )} - + {/* Semantic zoom: labels would be unreadable specks far + out, so the shapes carry the story alone. */} + {showLabels && ( + + {truncate( + node.name, + MAX_LABEL_CHARS - + (initialGroup === undefined ? 0 : MARKER_LABEL_COST), + )} + + )} ); })} - -
+ + + {paneSize !== null && ( + + setViewport((current) => + centerViewportOn(current, layoutPoint, paneSize), + ) + } + /> + )}
); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-cell.tsx b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-cell.tsx index 4e79aad20d6..341caf58b66 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-cell.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-cell.tsx @@ -906,6 +906,7 @@ const ArcLine: React.FC<{ tabIndex={-1} className={arcJumpStyle} title="Jump to this place" + data-peek-cell={placeId} onClick={() => parts.navigateToCell(placeId)} > {placeName(net, placeId)} @@ -1297,7 +1298,27 @@ const EquationBody: React.FC<{ ); }; -const HighlightedName: React.FC<{ +/** The one-line summary a cell's row shows — reused by peek previews. */ +export const cellSummary = ( + net: ActiveNetDefinition, + cell: NotebookCellModel, +): string => { + switch (cell.kind) { + case "place": + return placeSummary(net, cell.place); + case "transition": + return transitionSummary(net, cell.transition); + case "type": + return colorSummary(cell.color); + case "differentialEquation": + return equationSummary(net, cell.equation); + case "parameter": + return parameterSummary(cell.parameter); + } +}; + +/** Renders `name` with the fuzzy-matched characters marked. */ +export const HighlightedName: React.FC<{ name: string; matchIndices: number[] | null; }> = ({ name, matchIndices }) => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-view.tsx index 75d7070ff8e..86db8447a9a 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-view.tsx @@ -1,7 +1,7 @@ -import { use, useEffect, useRef, useState } from "react"; +import { use, useEffect, useEffectEvent, useRef, useState } from "react"; import { Button, SegmentedControl, TextInput } from "@hashintel/ds-components"; -import { css } from "@hashintel/ds-helpers/css"; +import { css, cx } from "@hashintel/ds-helpers/css"; import { ActiveNetContext } from "../../../react/state/active-net-context"; import { EditorContext } from "../../../react/state/editor-context"; @@ -11,8 +11,10 @@ import { focusLands } from "../../worksheet/focus-flow"; import { FocusRoot, FocusStack } from "../../worksheet/focus-stack"; import { useFocusStops } from "../../worksheet/use-focus-stops"; import { CELL_KIND_PLURAL_LABELS, CELL_KINDS } from "./cell-kinds"; +import { CommandPalette } from "./command-palette"; import { CONNECTION_GUTTER_WIDTH, ConnectionLines } from "./connection-lines"; import { GraphExplorer } from "./graph-explorer"; +import { hintLabels, matchHint } from "./hint-jump"; import { buildCycleMembership, findCycleGroups } from "./net-cycles"; import { layoutNetGraph } from "./net-graph-layout"; import { @@ -32,8 +34,11 @@ import { noConnections, } from "./notebook-model"; import { orderCellsTopologically } from "./notebook-order"; +import { PeekCard, peekPosition } from "./peek-card"; +import { useJumpHistory } from "./use-jump-history"; import type { FocusStop } from "../../worksheet/use-focus-stops"; +import type { PaletteAction } from "./command-palette"; import type { InitialPlaceGroup } from "./net-siphons"; import type { NodeRef, @@ -128,6 +133,32 @@ const hoistInitialPlaces = ( ...flowOrder.filter((id) => !initialByPlace.has(id)), ]; +/** The letter chips hint-jump overlays on each visible row. */ +const hintChipStyle = css({ + position: "absolute", + left: "[4px]", + zIndex: "[1]", + paddingX: "1", + borderRadius: "sm", + fontSize: "[10px]", + fontFamily: "mono", + fontWeight: "semibold", + backgroundColor: "yellow.s30", + borderWidth: "[1px]", + borderStyle: "solid", + borderColor: "yellow.s70", + color: "neutral.s115", + pointerEvents: "none", +}); + +const hintChipDeadStyle = css({ + opacity: "[0.25]", +}); + +const hintChipTypedStyle = css({ + color: "orange.s110", +}); + const emptyStyle = css({ fontSize: "sm", color: "neutral.fg.subtle", @@ -165,6 +196,20 @@ const NotebookViewContent: React.FC = () => { () => new Set(), ); const [searchQuery, setSearchQuery] = useState(""); + const [isPaletteOpen, setPaletteOpen] = useState(false); + // Peek preview over a hovered/focused reference, IDE-style. + const [peek, setPeek] = useState<{ + cellId: string; + position: { left: number; top: number }; + } | null>(null); + const peekTimerRef = useRef(null); + + // Hint-jump: targets are measured when the mode is entered, so the chips + // stay put even if the list re-renders while a label is being typed. + const [hint, setHint] = useState<{ + typed: string; + targets: { cellId: string; top: number }[]; + } | null>(null); const [explorerWidth, setExplorerWidth] = useState(DEFAULT_EXPLORER_WIDTH); const [cellOrder, setCellOrder] = useState("document"); const [focusOnSelection, setFocusOnSelection] = useState(false); @@ -405,16 +450,202 @@ const NotebookViewContent: React.FC = () => { } }; + const jumps = useJumpHistory(); + + /** + * Teleport to a cell — a reference jump rather than an arrow move. The + * target's kind is revealed if the filter hides it, and the jump lands in + * the history so back/forward can retrace it. + */ + const jumpToCell = (cellId: string, options?: { recordJump?: boolean }) => { + const target = cells.find(({ id }) => id === cellId); + if (target === undefined) { + return; + } + if (options?.recordJump ?? true) { + jumps.record(selectedId, cellId); + } + if (!visibleKinds.has(target.kind)) { + setVisibleKinds((previous) => new Set(previous).add(target.kind)); + } + selectCell(target, { focus: true }); + }; + + const goBack = () => { + const target = jumps.back(selectedId); + if (target !== null) { + jumpToCell(target, { recordJump: false }); + } + }; + + const goForward = () => { + const target = jumps.forward(selectedId); + if (target !== null) { + jumpToCell(target, { recordJump: false }); + } + }; + + /** Label every row currently inside the scroll viewport. */ + const enterHintMode = () => { + const content = contentRef.current; + const scroller = content?.parentElement; + if (!content || !scroller) { + return; + } + const targets = [ + ...content.querySelectorAll("[data-cell-row]"), + ] + .filter( + (row) => + row.offsetTop + row.offsetHeight > scroller.scrollTop && + row.offsetTop < scroller.scrollTop + scroller.clientHeight, + ) + .flatMap((row) => + row.dataset.cellRow === undefined + ? [] + : [{ cellId: row.dataset.cellRow, top: row.offsetTop }], + ); + if (targets.length > 0) { + setHint({ typed: "", targets }); + } + }; + + // While hint-jump is active every key belongs to it: letters build up a + // label, a completed label jumps, anything else leaves the mode. + const isHintActive = hint !== null; + const handleHintKey = useEffectEvent((event: KeyboardEvent) => { + if (hint === null) { + return; + } + event.preventDefault(); + event.stopPropagation(); + if ( + !/^[a-z]$/u.test(event.key) || + event.metaKey || + event.ctrlKey || + event.altKey + ) { + setHint(null); + return; + } + const typed = hint.typed + event.key; + const outcome = matchHint(typed, hint.targets.length); + if (outcome.kind === "match") { + setHint(null); + jumpToCell(hint.targets[outcome.index]!.cellId); + } else if (outcome.kind === "pending") { + setHint({ ...hint, typed }); + } else { + setHint(null); + } + }); + useEffect(() => { + if (!isHintActive) { + return; + } + window.addEventListener("keydown", handleHintKey, true); + return () => window.removeEventListener("keydown", handleHintKey, true); + }, [isHintActive]); + + // View-level shortcuts. ⌘K opens the palette from anywhere except a code + // editor (Monaco owns ⌘K chords); Alt+←/→ retrace jumps browser-style, + // except in inputs and editors (word-wise caret movement on some + // platforms). + const handleViewShortcut = useEffectEvent((event: KeyboardEvent) => { + const target = event.target as HTMLElement; + const inCodeEditor = target.closest(".monaco-editor") !== null; + if ( + (event.metaKey || event.ctrlKey) && + event.key.toLowerCase() === "k" && + !inCodeEditor + ) { + event.preventDefault(); + setPaletteOpen((open) => !open); + return; + } + const inTextEntry = + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable || + inCodeEditor; + if ( + event.key === "f" && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !inTextEntry && + !isPaletteOpen && + !isHintActive + ) { + event.preventDefault(); + enterHintMode(); + return; + } + if ( + !event.altKey || + (event.key !== "ArrowLeft" && event.key !== "ArrowRight") + ) { + return; + } + if (inTextEntry) { + return; + } + event.preventDefault(); + if (event.key === "ArrowLeft") { + goBack(); + } else { + goForward(); + } + }); + useEffect(() => { + window.addEventListener("keydown", handleViewShortcut); + return () => window.removeEventListener("keydown", handleViewShortcut); + }, []); + const navigateToNode = (node: NodeRef) => { - // Jumping to a kind the filter hides would select an invisible cell, so - // reveal that kind as part of the navigation. - if (!visibleKinds.has(node.type)) { - setVisibleKinds((previous) => new Set(previous).add(node.type)); + jumpToCell(node.id); + }; + + const schedulePeek = (anchor: HTMLElement, delayMs: number) => { + const cellId = anchor.dataset.peekCell; + if (cellId === undefined) { + return; + } + if (peekTimerRef.current !== null) { + window.clearTimeout(peekTimerRef.current); + } + peekTimerRef.current = window.setTimeout(() => { + peekTimerRef.current = null; + const rect = anchor.getBoundingClientRect(); + setPeek({ + cellId, + position: peekPosition(rect, { + width: window.innerWidth, + height: window.innerHeight, + }), + }); + }, delayMs); + }; + + const cancelPeek = () => { + if (peekTimerRef.current !== null) { + window.clearTimeout(peekTimerRef.current); + peekTimerRef.current = null; } - selectItem({ type: node.type, id: node.id }); - focusCellRow(node.id); + setPeek(null); }; + // The card must not outlive what it is anchored to: any scroll hides it. + const isPeekOpen = peek !== null; + const handleAnyScroll = useEffectEvent(() => cancelPeek()); + useEffect(() => { + if (!isPeekOpen) { + return; + } + window.addEventListener("scroll", handleAnyScroll, true); + return () => window.removeEventListener("scroll", handleAnyScroll, true); + }, [isPeekOpen]); + /** * Step the selection to the next/previous navigable cell without moving * focus, so arrows work from the search box while typing continues. @@ -442,10 +673,124 @@ const NotebookViewContent: React.FC = () => { searchInputRef.current?.select(); }; + const paletteActions: PaletteAction[] = [ + { + id: "toggle-order", + label: "Toggle cell order", + hint: + cellOrder === "document" + ? "document → topological" + : "topological → document", + run: () => + setCellOrder(cellOrder === "document" ? "topological" : "document"), + }, + { + id: "expand-all", + label: "Expand all cells", + run: () => setExpandedIds(new Set(visibleCells.map(({ id }) => id))), + }, + { + id: "collapse-all", + label: "Collapse all cells", + run: () => setExpandedIds(new Set()), + }, + { + id: "show-all-kinds", + label: "Show all cell kinds", + run: () => setVisibleKinds(new Set(CELL_KINDS)), + }, + ...CELL_KINDS.map( + (kind): PaletteAction => ({ + id: `toggle-${kind}`, + label: `Toggle ${CELL_KIND_PLURAL_LABELS[kind].toLowerCase()}`, + hint: visibleKinds.has(kind) ? "shown" : "hidden", + run: () => toggleKind(kind), + }), + ), + { + id: "toggle-focus-mode", + label: "Organize graph around selection", + hint: focusOnSelection ? "on" : "off", + run: () => setFocusOnSelection((previous) => !previous), + }, + { + id: "focus-search", + label: "Search cells", + hint: "/", + run: () => focusSearch(), + }, + ]; + + const peekCell = + peek === null ? undefined : cells.find(({ id }) => id === peek.cellId); + return ( -
+
{ + const anchor = (event.target as HTMLElement).closest( + "[data-peek-cell]", + ); + if (anchor !== null) { + schedulePeek(anchor, 350); + } + }} + onMouseOut={(event) => { + const from = (event.target as HTMLElement).closest( + "[data-peek-cell]", + ); + const to = + event.relatedTarget instanceof HTMLElement + ? event.relatedTarget.closest("[data-peek-cell]") + : null; + if (from !== null && from !== to) { + cancelPeek(); + } + }} + onFocus={(event) => { + const target = event.target as HTMLElement; + const anchor = + target.closest("[data-peek-cell]") ?? + (target.dataset.cellPart !== undefined + ? target.querySelector("[data-peek-cell]") + : null); + if (anchor !== null) { + schedulePeek(anchor, 0); + } else { + cancelPeek(); + } + }} + onBlur={(event) => { + if (event.relatedTarget === null) { + cancelPeek(); + } + }} + >
+
+ {peekCell !== undefined && peek !== null && ( + + )} + + {isPaletteOpen && ( + jumpToCell(cellId)} + onClose={() => setPaletteOpen(false)} + /> + )} +
({ + left: Math.max( + CARD_MARGIN, + Math.min(anchor.left, viewport.width - CARD_WIDTH - CARD_MARGIN), + ), + top: + anchor.bottom + CARD_MARGIN + CARD_ESTIMATED_HEIGHT > viewport.height + ? Math.max(CARD_MARGIN, anchor.top - CARD_MARGIN - CARD_ESTIMATED_HEIGHT) + : anchor.bottom + CARD_MARGIN, +}); + +export interface PeekCardProps { + net: ActiveNetDefinition; + cell: NotebookCellModel; + dependentCount: DependentCount | undefined; + position: { left: number; top: number }; +} + +/** + * An IDE-style peek: hovering or focusing a reference (an arc's place, an + * explorer row) previews the target cell — kind, name, summary, dependents — + * without navigating to it. + */ +export const PeekCard: React.FC = ({ + net, + cell, + dependentCount, + position, +}) => { + const KindIcon = CELL_KIND_ICONS[cell.kind]; + return ( +
+
+ + {CELL_KIND_LABELS[cell.kind]} + {cellName(cell)} +
+ {cellSummary(net, cell)} + {dependentCount !== undefined && ( + + {dependentCount.direct} direct → {dependentCount.transitive} total + dependents + + )} +
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/use-jump-history.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/use-jump-history.test.ts new file mode 100644 index 00000000000..a63625cfe94 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/use-jump-history.test.ts @@ -0,0 +1,76 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { useJumpHistory } from "./use-jump-history"; + +describe("useJumpHistory", () => { + it("starts with nowhere to go", () => { + const { result } = renderHook(() => useJumpHistory()); + expect(result.current.canGoBack).toBe(false); + expect(result.current.canGoForward).toBe(false); + }); + + it("walks back along recorded jumps and forward again", () => { + const { result } = renderHook(() => useJumpHistory()); + + act(() => result.current.record("a", "b")); + act(() => result.current.record("b", "c")); + expect(result.current.canGoBack).toBe(true); + + let target: string | null = null; + act(() => { + target = result.current.back("c"); + }); + expect(target).toBe("b"); + act(() => { + target = result.current.back("b"); + }); + expect(target).toBe("a"); + expect(result.current.canGoBack).toBe(false); + expect(result.current.canGoForward).toBe(true); + + act(() => { + target = result.current.forward("a"); + }); + expect(target).toBe("b"); + act(() => { + target = result.current.forward("b"); + }); + expect(target).toBe("c"); + expect(result.current.canGoForward).toBe(false); + }); + + it("discards the forward leg when a new jump is recorded", () => { + const { result } = renderHook(() => useJumpHistory()); + + act(() => result.current.record("a", "b")); + act(() => { + result.current.back("b"); + }); + expect(result.current.canGoForward).toBe(true); + + act(() => result.current.record("a", "z")); + expect(result.current.canGoForward).toBe(false); + let target: string | null = null; + act(() => { + target = result.current.back("z"); + }); + expect(target).toBe("a"); + }); + + it("ignores self-jumps and unknown origins", () => { + const { result } = renderHook(() => useJumpHistory()); + + act(() => result.current.record(null, "b")); + act(() => result.current.record("b", "b")); + expect(result.current.canGoBack).toBe(false); + let target: string | null = "unset" as string | null; + act(() => { + target = result.current.back(null); + }); + expect(target).toBeNull(); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/use-jump-history.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/use-jump-history.ts new file mode 100644 index 00000000000..49c4379e296 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/use-jump-history.ts @@ -0,0 +1,71 @@ +import { useState } from "react"; + +/** Jumps the history remembers on each side of the current position. */ +const MAX_REMEMBERED_JUMPS = 100; + +export type JumpHistory = { + canGoBack: boolean; + canGoForward: boolean; + /** Remember a jump about to happen; a new jump discards the forward leg. */ + record: (fromCellId: string | null, toCellId: string) => void; + /** The cell to return to, already popped — or null when at the start. */ + back: (currentCellId: string | null) => string | null; + /** The cell to revisit, already popped — or null when at the end. */ + forward: (currentCellId: string | null) => string | null; +}; + +/** + * An IDE-style jump list for the notebook: reference jumps (an arc to its + * place, an explorer row to its cell) push where the user came from, and + * back/forward walk that trail. Plain arrow-key moves and clicks are not + * jumps — only navigations that teleport across the list are remembered. + */ +export function useJumpHistory(): JumpHistory { + const [stacks, setStacks] = useState<{ past: string[]; future: string[] }>({ + past: [], + future: [], + }); + + return { + canGoBack: stacks.past.length > 0, + canGoForward: stacks.future.length > 0, + record: (fromCellId, toCellId) => { + setStacks(({ past }) => ({ + past: + fromCellId === null || fromCellId === toCellId + ? past + : [...past.slice(-(MAX_REMEMBERED_JUMPS - 1)), fromCellId], + // A fresh jump forks the trail: the forward leg no longer applies. + future: [], + })); + }, + back: (currentCellId) => { + const target = stacks.past.at(-1) ?? null; + if (target === null) { + return null; + } + setStacks(({ past, future }) => ({ + past: past.slice(0, -1), + future: + currentCellId === null + ? future + : [currentCellId, ...future.slice(0, MAX_REMEMBERED_JUMPS - 1)], + })); + return target; + }, + forward: (currentCellId) => { + const target = stacks.future[0] ?? null; + if (target === null) { + return null; + } + setStacks(({ past, future }) => ({ + past: + currentCellId === null + ? past + : [...past.slice(-(MAX_REMEMBERED_JUMPS - 1)), currentCellId], + future: future.slice(1), + })); + return target; + }, + }; +} diff --git a/libs/@local/petrinaut-arch-docs/content/notebook/navigation-explorations.mdx b/libs/@local/petrinaut-arch-docs/content/notebook/navigation-explorations.mdx new file mode 100644 index 00000000000..8934483d2e2 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/content/notebook/navigation-explorations.mdx @@ -0,0 +1,106 @@ +--- +title: Notebook navigation explorations +description: Five prototyped navigation and graph-visualization ideas, where they came from, and how each is built. +attachTo: ui.views.notebook +--- + +The notebook view is an experiment, and this page documents a second round of +experiments layered on it: five prototypes exploring faster navigation, +lighter-weight reading, and a more capable net diagram. Each one borrows a +pattern with an established track record elsewhere; the sources are listed at +the end. + +## 1. Command palette (⌘K) + +One keyboard-first surface that fuzzy-matches everything the view can do: +every cell by name, plus view commands (cell order, kind filters, +expand/collapse all, graph focus mode). Enter runs the selection; a cell +match teleports to it through the same jump path as every other reference, +so the jump history covers it. + +Built as a self-contained overlay (`command-palette.tsx`) fed by the view: +`cells` come in unfiltered so a jump can reveal a hidden kind, and actions +are plain `{ label, hint, run }` records, so new commands are one array entry. +The fuzzy matcher and highlight renderer are the ones the search box already +uses — the palette adds no second matching implementation. + +## 2. Jump history (⌥← / ⌥→) + +Reference navigation — an arc's place, an explorer row, a palette jump — is a +teleport, and teleports strand you without a way back. The notebook now keeps +an IDE-style jump list: every teleport records where you came from, +back/forward walk the trail, and a fresh jump forks it (the forward leg is +discarded), exactly like a browser or VS Code's ctrl+O/ctrl+I. + +Plain arrow moves and row clicks are deliberately _not_ recorded: they are +how you look around, not how you got lost. The stacks live in +`use-jump-history.ts` as a pure hook with unit tests; the toolbar's ‹ › +buttons and ⌥←/⌥→ both drive it. + +## 3. Hint-jump (`f`) + +Vimium/avy-style two-keystroke navigation: press `f` anywhere outside a text +field and every visible row grows a home-row letter chip; type a chip's +letters and the selection teleports there. Single letters while nine rows or +fewer are visible, uniform two-letter labels beyond that — labels are never +prefixes of each other, so no timeout or Enter is needed. + +Targets are measured once on entry (row offsets against the scroll viewport) +so the chips stay put while a label is typed. Label generation and matching +are pure (`hint-jump.ts`, tested); the view owns only the overlay and the +key capture, which swallows every key until the mode exits. + +## 4. Graph camera: zoom, pan, minimap + +The whole-net diagram previously scrolled; now it is a camera +(overview+detail): wheel zooms toward the cursor, dragging the background +pans, double-click refits, and an aspect-correct minimap in the corner shows +the camera's window over the whole layout — press or drag it to re-centre. +Labels disappear below half zoom (semantic-zoom lite): at that size they are +unreadable specks that only add noise. + +The camera is pure math over `{ x, y, scale }` (`net-graph-viewport.ts`, +tested: cursor-anchored zoom, clamped scale, minimap window round-trips). +The layout animation is untouched — it writes transforms to the node groups +_inside_ the camera group, so the two compose. A new layout refits the +camera during render, so the first paint of a re-layout is already framed. + +## 5. Peek cards + +Hovering or keyboard-focusing any reference — an arc's place, an explorer +row, a graph node — previews the target cell (kind, name, summary, dependent +counts) in a floating card without navigating, VS Code's peek-definition +reduced to a glance. Focus shows it immediately; hover waits a beat; any +scroll hides it. The card is `pointer-events: none`, so steering toward it +can never flicker it away. + +Wired by delegation: one handler set on the view container reacts to any +element carrying `data-peek-cell`, so new peekable surfaces are one +attribute, not new wiring. + +## What next + +- The palette wants recents-first ordering and scoped sub-menus once it has + more commands (the Linear/Raycast "drill in" pattern). +- Hint-jump could label body parts, not only rows, when a cell is expanded. +- The minimap could double as a hover target for peek cards. +- The camera invites hover-neighbourhood dimming (fade all but the n-hop + neighbourhood) — the selection highlight already computes the sets. + +## Sources + +Patterns and prior art consulted while designing these prototypes: + +- Jupyter's command-mode navigation and palette: + [Notebook basics](https://jupyter-notebook.readthedocs.io/en/stable/examples/Notebook/Notebook%20Basics.html), + [recent keyboard-navigation improvements](https://blog.jupyter.org/recent-keyboard-navigation-improvements-in-jupyter-4df32f97628d) +- Command palette pattern: + [uxpatterns.dev on command palettes](https://uxpatterns.dev/patterns/advanced/command-palette), + [cmdk](https://codingdunia.com/ui-components/cmdk/) +- Hint navigation: [Vimium's link hints](https://github.com/gdh1995/vimium-c/wiki/Using-Link-Hints), + [avy](https://karthinks.com/software/avy-can-do-anything/) +- Peek and jump lists: [VS Code code navigation](https://code.visualstudio.com/docs/editing/editingevolved) +- Overview+detail, zooming and focus+context: + [Cockburn, Karlson & Bederson's review](https://www.researchgate.net/publication/220566544_A_Review_of_OverviewDetail_Zooming_and_FocusContext_Interfaces), + [semantic zoom and minimaps](https://arxiv.org/pdf/2510.00003) +- Petri-net tooling context: [Petri Nets World tools database](https://www.informatik.uni-hamburg.de/TGI/PetriNets/tools/quick.html)