@smartmemory/wiki-components 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -2
- package/package.json +26 -4
- package/src/data-client.d.ts +27 -1
- package/src/data-client.jsx +3 -0
- package/src/graph/components/wiki/DocCard.jsx +46 -0
- package/src/graph/components/wiki/GraphBreadcrumb.jsx +23 -0
- package/src/graph/components/wiki/GraphCanvas.jsx +663 -0
- package/src/graph/components/wiki/GraphCanvas3D.jsx +1366 -0
- package/src/graph/components/wiki/GraphDocPane.jsx +56 -0
- package/src/graph/components/wiki/GraphLegend.jsx +119 -0
- package/src/graph/components/wiki/GraphLevelPicker.jsx +35 -0
- package/src/graph/components/wiki/graph/FilterChips.jsx +45 -0
- package/src/graph/components/wiki/graph/Omnibox.jsx +74 -0
- package/src/graph/components/wiki/graph/badges.js +46 -0
- package/src/graph/components/wiki/graph/bubbles3d.js +95 -0
- package/src/graph/components/wiki/graph/canvasBase.js +138 -0
- package/src/graph/components/wiki/graph/containment.js +49 -0
- package/src/graph/components/wiki/graph/overlay.js +134 -0
- package/src/graph/components/wiki/graph/pixiBase.js +210 -0
- package/src/graph/components/wiki/graph/threeBase.js +231 -0
- package/src/graph/hooks/useDebounced.js +11 -0
- package/src/graph/host.jsx +56 -0
- package/src/graph/index.d.ts +26 -0
- package/src/graph/index.js +4 -0
- package/src/graph/lib/areaFamilies.js +32 -0
- package/src/graph/lib/camFollow.js +30 -0
- package/src/graph/lib/canvasPort.js +93 -0
- package/src/graph/lib/cardVisibility.js +58 -0
- package/src/graph/lib/docCard.js +28 -0
- package/src/graph/lib/forceWorker.js +123 -0
- package/src/graph/lib/forceWorker3d.js +136 -0
- package/src/graph/lib/graph3d.js +330 -0
- package/src/graph/lib/graphArrows.js +108 -0
- package/src/graph/lib/graphExplore.js +546 -0
- package/src/graph/lib/graphFind.js +46 -0
- package/src/graph/lib/graphLasso.js +44 -0
- package/src/graph/lib/graphLevels.js +367 -0
- package/src/graph/lib/graphOrient.js +145 -0
- package/src/graph/lib/graphPath.js +86 -0
- package/src/graph/lib/graphPin.js +48 -0
- package/src/graph/lib/graphPress.js +27 -0
- package/src/graph/lib/graphShell.js +296 -0
- package/src/graph/lib/graphTheme.js +50 -0
- package/src/graph/lib/graphView.js +343 -0
- package/src/graph/lib/graphViews.js +103 -0
- package/src/graph/lib/groupBubbles.js +128 -0
- package/src/graph/lib/hubLines.js +51 -0
- package/src/graph/lib/idleTurn.js +31 -0
- package/src/graph/lib/labelDepth.js +59 -0
- package/src/graph/lib/layoutClient.js +69 -0
- package/src/graph/lib/layoutEngine.js +184 -0
- package/src/graph/lib/linkWeight.js +67 -0
- package/src/graph/lib/orphanPull.js +10 -0
- package/src/graph/lib/perf.js +14 -0
- package/src/graph/lib/reducedMotion.js +36 -0
- package/src/graph/lib/robustFit.js +62 -0
- package/src/graph/lib/wikiGraph.js +298 -0
- package/src/graph/pages/GraphPage.jsx +703 -0
- package/src/index.d.ts +6 -1
- package/src/index.js +2 -1
- package/src/lib.js +3 -0
- package/src/parts/Markdown.jsx +22 -0
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
import React, { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Link, useAspectHint, useQuery, useSearchParams, useWiki } from "../host";
|
|
3
|
+
import { useDataClient } from "../../data-client";
|
|
4
|
+
import ErrorBox from "../../parts/ErrorBox";
|
|
5
|
+
import { perfNow, perfSpan } from "../lib/perf";
|
|
6
|
+
import { INPUT, noteUrl } from "../../lib";
|
|
7
|
+
import { LOD_N, MAX_NODES, buildGraph, neighbourhood, recolour, scopeColors } from "../lib/wikiGraph";
|
|
8
|
+
import {
|
|
9
|
+
DIM_DEFAULT, PARAM, PHYSICS_DEFAULTS, adjacencyOf, chooseView, DIM_MAX, formatHidden, hideEveryGroup, hopSet, localGraph, parseDim, parseHidden, parsePhysics, showEveryGroup,
|
|
10
|
+
} from "../lib/graphView";
|
|
11
|
+
import {
|
|
12
|
+
autoBase, baseOffset, breadcrumb, buildLevelView, currentLevel, expand, expandToDepth, folderId, folderPathOf, folderPaths, formatLevels, formatRange, hubsFor,
|
|
13
|
+
isFolderId, maxLevel, parseLevels, parseRange, upOne,
|
|
14
|
+
} from "../lib/graphLevels";
|
|
15
|
+
import { folderGroups } from "../lib/layoutEngine";
|
|
16
|
+
import { FIND_DEBOUNCE_MS, countText, drawnFor, findMatches, hasTerm, litIds } from "../lib/graphFind";
|
|
17
|
+
import { createExplorer, visiblePredicate } from "../lib/graphExplore";
|
|
18
|
+
import { prefersReducedMotion, watchReducedMotion } from "../lib/reducedMotion";
|
|
19
|
+
import { readIdleTurn, writeIdleTurn } from "../lib/idleTurn";
|
|
20
|
+
import { addStep, chipText, clearAll, folderCounts, omniRows, parseVerb, pushRecent, readExplore, readRecent, recentKey, removeStep, withExplore, exploreUnchanged, writeStore } from "../lib/graphShell";
|
|
21
|
+
import { docUnder, requestTreeSync } from "../host";
|
|
22
|
+
import { lassoHits, lassoText } from "../lib/graphLasso";
|
|
23
|
+
import { SAVE_FAILED, appliedText, defaultViewName, deleteView, makeView, paramsOfUrl, readViews, restorablePins, saveView } from "../lib/graphViews";
|
|
24
|
+
import { clearPathLens, pathNote, pathView, removeLens, setPathLens } from "../lib/graphPath";
|
|
25
|
+
import { stepOrient } from "../lib/graphOrient";
|
|
26
|
+
import { useColorMode } from "../host";
|
|
27
|
+
import { hueFor, setGraphMode } from "../lib/graphTheme";
|
|
28
|
+
import useDebounced from "../hooks/useDebounced";
|
|
29
|
+
import PageHeader from "../../parts/PageHeader";
|
|
30
|
+
import GraphCanvas from "../components/wiki/GraphCanvas";
|
|
31
|
+
import GraphDocPane from "../components/wiki/GraphDocPane";
|
|
32
|
+
import GraphBreadcrumb from "../components/wiki/GraphBreadcrumb";
|
|
33
|
+
import GraphLevelPicker from "../components/wiki/GraphLevelPicker";
|
|
34
|
+
import GraphLegend from "../components/wiki/GraphLegend";
|
|
35
|
+
import DocCard from "../components/wiki/DocCard";
|
|
36
|
+
import { CARD_HINT, escAction, useCardVisibility } from "../lib/cardVisibility";
|
|
37
|
+
import { cardAnchor } from "../lib/docCard";
|
|
38
|
+
import Omnibox from "../components/wiki/graph/Omnibox";
|
|
39
|
+
import FilterChips from "../components/wiki/graph/FilterChips";
|
|
40
|
+
import Loading from "../../parts/Loading";
|
|
41
|
+
|
|
42
|
+
const GraphCanvas3D = lazy(() => import("../components/wiki/GraphCanvas3D")); // three.js loads only when the 3D view is drawn
|
|
43
|
+
|
|
44
|
+
let webgl2 = null; // the probe's answer, cached: a throwaway canvas per render would leak contexts
|
|
45
|
+
/** Is WebGL2 available (3D needs it)? Probed once on a throwaway canvas. `resetWebGL2Probe` is for tests. */
|
|
46
|
+
function hasWebGL2() {
|
|
47
|
+
if (webgl2 === null) {
|
|
48
|
+
try { webgl2 = !!document.createElement("canvas").getContext("webgl2"); } catch { webgl2 = false; }
|
|
49
|
+
}
|
|
50
|
+
return webgl2;
|
|
51
|
+
}
|
|
52
|
+
export const resetWebGL2Probe = () => { webgl2 = null; };
|
|
53
|
+
let warned3d = false; // the missing-WebGL2 warning is logged once per page load
|
|
54
|
+
|
|
55
|
+
const PAGE = 200; // the graph query's page size limit
|
|
56
|
+
const SHOWN_NEIGHBOURS = 40;
|
|
57
|
+
const PANE_DEFAULT = 50, PANE_MIN = 25, PANE_MAX = 75; // the doc pane's share of the graph surface, in percent
|
|
58
|
+
const PANEL = "pointer-events-auto rounded-lg border border-neutral-200/70 bg-ground/85 backdrop-blur";
|
|
59
|
+
const BUTTON = "rounded-md border border-neutral-300 px-2 py-1 text-[13px] text-ink2 hover:text-ink disabled:opacity-40";
|
|
60
|
+
// A pressed toolbar toggle (WIKI-GRAPH-2D-CLUSTER-ONCE-1, owner 2026-10-22: "the button should be backgrounded when on"): a filled
|
|
61
|
+
// background on top of the blue border it already had. No graph toolbar toggle had a filled style before.
|
|
62
|
+
const PRESSED = "border-blue-500 bg-blue-500/15 text-ink";
|
|
63
|
+
|
|
64
|
+
/** Every doc of the workspace and the links between them (WIKI-GRAPH-VIEW-UX-1): the `graph` query with no root, in ONE
|
|
65
|
+
* request (`page: {all: true}`, revalidated by ETag; WIKI-GRAPH-LOAD-1). A host without that mode answers
|
|
66
|
+
* `invalid_request`: then page by cursor until done or MAX_NODES docs. `total` is the workspace's doc count, so a capped
|
|
67
|
+
* fetch can say so. Timed as the `wiki-graph:fetch` measure. */
|
|
68
|
+
export async function wholeGraph(workspace, client) {
|
|
69
|
+
const t0 = perfNow();
|
|
70
|
+
let items, edges = [], total = 0, layout = null; // layout: the host's served warm start, when it holds one (WIKI-GRAPH-WARMSTART-3D-1)
|
|
71
|
+
try {
|
|
72
|
+
const r = await client.graphAll({ workspace });
|
|
73
|
+
({ items, edges, total } = r);
|
|
74
|
+
layout = r.layout || null;
|
|
75
|
+
} catch (e) {
|
|
76
|
+
if (e?.code !== "invalid_request") throw e;
|
|
77
|
+
console.warn("wiki graph: this host does not support the one-shot graph query (page.all); paging instead");
|
|
78
|
+
items = [];
|
|
79
|
+
let cursor = null;
|
|
80
|
+
do {
|
|
81
|
+
const r = await client.graphPage({ workspace, size: PAGE, ...(cursor ? { cursor } : {}) });
|
|
82
|
+
items.push(...r.items);
|
|
83
|
+
edges.push(...(r.edges || []));
|
|
84
|
+
total = r.total ?? items.length;
|
|
85
|
+
cursor = r.next_cursor || null;
|
|
86
|
+
} while (cursor && items.length < MAX_NODES);
|
|
87
|
+
}
|
|
88
|
+
perfSpan("wiki-graph:fetch", t0);
|
|
89
|
+
return { items: items.slice(0, MAX_NODES), edges: edges || [], total: Math.max(total ?? 0, items.length), layout };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One physics slider, 0..100 (ported from the spike's GraphG6Page, e95dfeb). */
|
|
93
|
+
function Slider({ label, name, value, onChange }) {
|
|
94
|
+
return (
|
|
95
|
+
<label className="flex items-center gap-2 text-xs text-ink2">
|
|
96
|
+
<span className="w-24">{label}</span>
|
|
97
|
+
<input type="range" min={0} max={100} value={value} aria-label={label} onChange={(e) => onChange(name, Number(e.target.value))} className="w-28" />
|
|
98
|
+
<span className="w-6 text-right tabular-nums text-neutral-400">{value}</span>
|
|
99
|
+
</label>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const NO_GROUPS = new Set(); // the level view filters the legend before it aggregates, so the canvas masks nothing
|
|
104
|
+
const FOLDER_GROUP = "(folders)";
|
|
105
|
+
const isFormField = (el) => !!el && (["INPUT", "SELECT", "TEXTAREA"].includes(el.tagName) || el.isContentEditable);
|
|
106
|
+
|
|
107
|
+
/** A level graph as the canvas draws it: folders get a title and a size-driven radius (their doc count), docs are as they were.
|
|
108
|
+
* `areas` (layoutEngine folderGroups): a folder's most common area, its `bubbleGroup` for the cluster bubbles and targets
|
|
109
|
+
* (WIKI-GRAPH-CLUSTER-REGROUP-1); its legend `group` and colour stay "(folders)". */
|
|
110
|
+
const forCanvas = (lg, areas) => ({
|
|
111
|
+
links: lg.links,
|
|
112
|
+
contains: lg.contains, // hub folder -> drawn child: dashed lines and layout springs, not links
|
|
113
|
+
nodes: lg.nodes.map((n) => (n.kind === "folder" ? { ...n, title: `${n.label} (${n.size})`, group: FOLDER_GROUP, degree: n.size, bubbleGroup: areas?.get(n.id) ?? null } : n)),
|
|
114
|
+
});
|
|
115
|
+
const HOPS_DEFAULT = 2; // WIKI-GRAPH-FOCUS-HOPS-1: a focus lights 2 hops unless the URL says otherwise
|
|
116
|
+
|
|
117
|
+
const STATE_TEXT = { running: "laying out", paused: "paused", settled: "settled" };
|
|
118
|
+
|
|
119
|
+
/** The whole wiki as a graph, like Obsidian's (WIKI-GRAPH-OBSIDIAN-1): every doc a dot coloured by area or folder,
|
|
120
|
+
* sized by its links, every link a line, drawn on the GPU (Canvas2D without WebGL) and laid out in a worker. Type a
|
|
121
|
+
* title to focus a doc, click a dot to open it, scroll to zoom, drag to pan. The legend hides groups, a focused doc
|
|
122
|
+
* shows only its neighbourhood (the hops stepper sets how far), the sliders tune the layout live. Everything a reader set lives in the
|
|
123
|
+
* URL (`root`, `hide`, `hops`, `by`, `size`, `repel`, `link`, `centre`, `cluster`, `outlines`), so a link reproduces the view;
|
|
124
|
+
* `?root=<doc id>` (a doc page's "Graph" link, and every old link) opens it focused on that doc.
|
|
125
|
+
* WIKI-GRAPH-LEVELS-1 c2: it opens at level 0, one dot per top-level folder (sized by its docs, edges by the links between
|
|
126
|
+
* folders). Double-click a folder to open it in place, Esc or the breadcrumb to go back up, "Show all docs" (`flat=1`)
|
|
127
|
+
* for the flat graph. The open folders live in `lv=`. */
|
|
128
|
+
export const GRAPH_ASPECTS = ["product", "category", "initiative", "status"];
|
|
129
|
+
|
|
130
|
+
export default function GraphPage() {
|
|
131
|
+
const { workspace } = useWiki();
|
|
132
|
+
const client = useDataClient();
|
|
133
|
+
const { resolved: colorMode } = useColorMode();
|
|
134
|
+
setGraphMode(colorMode); // before the canvases render; they are keyed by it so a switch rebuilds the bases (UI-LIGHT-MODE-LM8-1)
|
|
135
|
+
const [sp, setSp] = useSearchParams();
|
|
136
|
+
const canvas = useRef(null);
|
|
137
|
+
const focusId = sp.get(PARAM.root) || null;
|
|
138
|
+
// `by=`: absent is "scope" (colours relative to the folder you are looking into, WIKI-GRAPH-3D-COLOR-SPHERES-1); area and folder stay global.
|
|
139
|
+
const by = sp.get(PARAM.by) === "folder" ? "folder" : sp.get(PARAM.by) === "area" ? "area" : "scope";
|
|
140
|
+
const hideRaw = sp.get(PARAM.hide) || "";
|
|
141
|
+
const hidden = useMemo(() => parseHidden(hideRaw), [hideRaw]); // one Set per value: GraphCanvas compares it by identity
|
|
142
|
+
const hops = Math.max(1, Math.min(4, Math.round(Number(sp.get(PARAM.hops))) || HOPS_DEFAULT)); // the focus's lit rings and the docs a focus draws (one param)
|
|
143
|
+
const dim = parseDim(sp.get(PARAM.dim)); // how dark the unlit nodes go while something is lit (one level, 2D and 3D, URL `dim=`)
|
|
144
|
+
const sizeByDegree = sp.get(PARAM.size) !== "flat";
|
|
145
|
+
const [repelRaw, linkRaw, centreRaw] = [sp.get(PARAM.repel), sp.get(PARAM.link), sp.get(PARAM.centre)];
|
|
146
|
+
const physics = useMemo( // one object per value triple: GraphCanvas messages the layout only when a value changes
|
|
147
|
+
() => parsePhysics(new Map([[PARAM.repel, repelRaw], [PARAM.link, linkRaw], [PARAM.centre, centreRaw]])),
|
|
148
|
+
[repelRaw, linkRaw, centreRaw],
|
|
149
|
+
);
|
|
150
|
+
const flat = sp.get(PARAM.flat) === "1"; // the flat graph of every doc; off (default) is the folder levels
|
|
151
|
+
const lvRaw = sp.get(PARAM.levels) || "";
|
|
152
|
+
const cluster = sp.get(PARAM.cluster) === "1"; // 3D: move each group's nodes into its own cluster; 2D: one clustered layout, then it stays put (WIKI-GRAPH-2D-CLUSTER-ONCE-1); off (default) is the plain force layout
|
|
153
|
+
const outlines = sp.get(PARAM.outlines) === "1"; // 3D group outlines while clustered: off by default (owner addendum 2026-10-21); 2D never draws outlines
|
|
154
|
+
const [idleTurn, setIdleTurnState] = useState(readIdleTurn); // "Turn slowly when idle" (3D only): off unless the reader switched it on, this browser only, never in the URL or a saved view
|
|
155
|
+
const [reduced, setReduced] = useState(() => prefersReducedMotion());
|
|
156
|
+
useEffect(() => watchReducedMotion(setReduced), []);
|
|
157
|
+
const setIdleTurn = (on) => { setIdleTurnState(on); writeIdleTurn(on); };
|
|
158
|
+
const [panelOpen, setPanelOpen] = useState(false); // the View drawer: closed at every width until the View button opens it (owner decision 4, WIKI-GRAPH-EXPLORE-1b-SHELL)
|
|
159
|
+
const forced = sp.get(PARAM.renderer) === "canvas" ? "canvas" : null;
|
|
160
|
+
const requestedView = sp.get(PARAM.view) === "2d" ? "2d" : sp.get(PARAM.view) === "3d" ? "3d" : null;
|
|
161
|
+
const [unavailable3d, setUnavailable3d] = useState(false); // the 3D surface could not start: stay on 2D
|
|
162
|
+
const has3d = hasWebGL2();
|
|
163
|
+
const view = unavailable3d ? "2d" : chooseView({ webgl2: has3d, requested: requestedView });
|
|
164
|
+
const [lassoSel, setLassoSel] = useState(null); // the 3D lasso's selection {ids, inside, behind}: local state like the View drawer, Esc clears it (EXPLORE-1c-UI)
|
|
165
|
+
const [lassoLive, setLassoLive] = useState(null); // {inside, behind} while the Shift-drag is down
|
|
166
|
+
const [pinIds, setPinIds] = useState([]); // the 3D surface's pinned docs (drag-to-pin, EXPLORE-1c-UI r2): page state for this surface only, never in the URL or history; the canvas clears it when the surface changes
|
|
167
|
+
const lassoMarks = useMemo(() => (lassoSel || pinIds.length ? { ...(lassoSel ? { selected: new Set(lassoSel.ids) } : {}), ...(pinIds.length ? { pinned: new Set(pinIds) } : {}) } : null), [lassoSel, pinIds]); // one object per selection or pin set: the canvas relights on identity
|
|
168
|
+
useEffect(() => { if (view !== "3d") { setLassoSel(null); setLassoLive(null); setPinIds([]); } }, [view]); // no lasso or pin in 2D
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (requestedView === "3d" && !has3d && !warned3d) {
|
|
171
|
+
warned3d = true;
|
|
172
|
+
console.warn("wiki graph: view=3d needs WebGL2, which is unavailable, drawing the 2D view instead.");
|
|
173
|
+
}
|
|
174
|
+
}, [requestedView, has3d]);
|
|
175
|
+
const docId = sp.get(PARAM.doc) || null; // the doc the right-hand pane shows (WIKI-GRAPH-DOC-PANE-1)
|
|
176
|
+
const [paneCollapsed, setPaneCollapsed] = useState(false);
|
|
177
|
+
const [panePct, setPanePct] = useState(PANE_DEFAULT);
|
|
178
|
+
const surface = useRef(null);
|
|
179
|
+
const [term, setTerm] = useState("");
|
|
180
|
+
const [layout, setLayout] = useState({ state: null, ms: null });
|
|
181
|
+
|
|
182
|
+
const q = useQuery({ queryKey: ["graph-whole", workspace], queryFn: () => wholeGraph(workspace, client), staleTime: 60_000 });
|
|
183
|
+
// The feature aspects stay greyed until WIKI-GRAPH-ASPECT-GROUP-1 groups by them; the hint says why (WIKI-FEATURE-VIEWS-1).
|
|
184
|
+
const aspectHint = useAspectHint();
|
|
185
|
+
const flatGraph = useMemo(() => (q.data ? buildGraph(q.data.items, q.data.edges, { by: by === "scope" ? "folder" : by, layout: q.data.layout }) : null), [q.data, by]);
|
|
186
|
+
const adj = useMemo(() => (flatGraph ? adjacencyOf(flatGraph.links) : null), [flatGraph]);
|
|
187
|
+
// Filter chips (WIKI-GRAPH-EXPLORE-1b-SHELL): the `x` param holds the stack; its mask is one more visibility predicate, ANDed with the legend's and the local graph's.
|
|
188
|
+
const xRaw = sp.get(PARAM.x);
|
|
189
|
+
const explore = useMemo(() => readExplore(xRaw), [xRaw]);
|
|
190
|
+
const steps = explore.state.steps;
|
|
191
|
+
const explorer = useMemo(() => (flatGraph ? createExplorer(flatGraph) : null), [flatGraph]);
|
|
192
|
+
const run = useMemo(() => (explorer && steps.length ? explorer.run(steps) : null), [explorer, steps]);
|
|
193
|
+
const stackVisible = useMemo(() => (run ? visiblePredicate(explorer.idx, run.mask) : undefined), [run, explorer]);
|
|
194
|
+
const folderCount = useMemo(() => (flatGraph ? folderCounts(flatGraph.nodes) : new Map()), [flatGraph]);
|
|
195
|
+
const focus = flatGraph && focusId ? flatGraph.byId.get(focusId) || null : null;
|
|
196
|
+
const cardAtNode = view === "3d" && !!focus; // the doc card sits at the node in 3D (DocCard); 2D keeps it in the panel (no screen-position API there)
|
|
197
|
+
const card = useCardVisibility(focusId, cardAtNode); // the card hides itself after a few seconds in 3D; Esc and the info button show/hide it (2D: the panel info, no timer)
|
|
198
|
+
// the drawn set: the whole graph, or with a focus only the docs within `hops` links of it (WIKI-GRAPH-FOCUS-HOPS-ONLY-1)
|
|
199
|
+
const localIds = useMemo(() => (focus ? hopSet(adj, focus.id, hops, flatGraph.byId) : null), [flatGraph, adj, focus, hops]);
|
|
200
|
+
// The base level: a single folder is opened for the reader (WIKI-GRAPH-LEVELS-AUTOEXPAND-1). `lv=` holds only their own expansions on top.
|
|
201
|
+
// In scope mode the keys (hence `hide=`) depend on the level, so the base ignores the legend: hide -> base -> scope -> keys -> hide would loop.
|
|
202
|
+
const baseVisible = useMemo(() => {
|
|
203
|
+
if (!flatGraph) return undefined;
|
|
204
|
+
const hide = by === "scope" ? NO_GROUPS : hidden;
|
|
205
|
+
return hide.size || localIds || stackVisible ? (id) => !hide.has(flatGraph.byId.get(id)?.group) && (!localIds || localIds.has(id)) && (!stackVisible || stackVisible(id)) : undefined;
|
|
206
|
+
}, [flatGraph, by, hidden, localIds, stackVisible]);
|
|
207
|
+
const base = useMemo(() => (flatGraph && !flat ? autoBase(flatGraph, baseVisible) : NO_GROUPS), [flatGraph, flat, baseVisible]);
|
|
208
|
+
const explicit = useMemo(() => parseLevels(lvRaw, flatGraph ? folderPaths(flatGraph) : null, base), [lvRaw, flatGraph, base]); // one Set per value, only real folders
|
|
209
|
+
const explicitOpen = useMemo(() => (base.size ? new Set([...base, ...explicit]) : explicit), [base, explicit]); // the reader's own folders, on the base
|
|
210
|
+
// The level picker (`lr=`): a depth range, 1-1 by default. Its folders open on top of the reader's own (`lv=` never holds them).
|
|
211
|
+
const offset = useMemo(() => baseOffset(base), [base]);
|
|
212
|
+
const maxLv = useMemo(() => (flatGraph ? maxLevel(flatGraph, offset) : 0), [flatGraph, offset]);
|
|
213
|
+
const rangeRaw = sp.get(PARAM.range);
|
|
214
|
+
const range = useMemo(() => parseRange(rangeRaw, maxLv) ?? [1, 1], [rangeRaw, maxLv]);
|
|
215
|
+
const rangeOpen = useMemo(() => (flatGraph && !flat && range[1] > 1 ? expandToDepth(flatGraph, range[0], range[1], { base }) : null), [flatGraph, flat, range, base]);
|
|
216
|
+
const expanded = useMemo(() => (rangeOpen ? new Set([...explicitOpen, ...rangeOpen]) : explicitOpen), [rangeOpen, explicitOpen]);
|
|
217
|
+
const [drill, setDrill] = useState(null); // {id, n}: the folder the camera fits (its drawn children); n re-aims a repeat drill
|
|
218
|
+
const drillN = useRef(0);
|
|
219
|
+
const drillInto = (id) => setDrill(id ? { id, n: ++drillN.current } : null);
|
|
220
|
+
// The scope (and the breadcrumb trail) follows the drill when there is one, else the reader's own deepest folder (never the range's, which would pick any deep folder).
|
|
221
|
+
const drillPath = drill ? folderPathOf(drill.id) : null;
|
|
222
|
+
const level = drillPath && !base.has(drillPath) ? drillPath : currentLevel(explicitOpen);
|
|
223
|
+
// Colours are relative to the scope, over the UNFILTERED graph, so hiding a group never recolours (and never relayouts).
|
|
224
|
+
const scoped = useMemo(() => (flatGraph && by === "scope" ? scopeColors(flatGraph, level) : null), [flatGraph, by, level]);
|
|
225
|
+
const graph = useMemo(() => (flatGraph && scoped ? recolour(flatGraph, scoped) : flatGraph), [flatGraph, scoped]);
|
|
226
|
+
// The level view filters docs before it aggregates them: the legend's hidden groups and the local graph's hops are one predicate.
|
|
227
|
+
const visible = useMemo(
|
|
228
|
+
() => (graph && (hidden.size || localIds || stackVisible) ? (id) => !hidden.has(graph.byId.get(id)?.group) && (!localIds || localIds.has(id)) && (!stackVisible || stackVisible(id)) : undefined),
|
|
229
|
+
[graph, hidden, localIds, stackVisible],
|
|
230
|
+
);
|
|
231
|
+
// Only the range's folders stay drawn as hubs: a folder the reader double-clicks open is replaced by its children, as before.
|
|
232
|
+
const levels = useMemo(
|
|
233
|
+
() => (graph && !flat ? buildLevelView(graph, expanded, { visible, hubs: rangeOpen ? hubsFor(rangeOpen, range[0], { base }) : undefined }) : null),
|
|
234
|
+
[graph, flat, expanded, visible, rangeOpen, range, base],
|
|
235
|
+
);
|
|
236
|
+
const drawn = useMemo(() => {
|
|
237
|
+
if (!graph) return null;
|
|
238
|
+
if (levels) return forCanvas(levels, folderGroups(levels.nodes, graph));
|
|
239
|
+
if (!localIds && !stackVisible) return graph;
|
|
240
|
+
return localGraph(graph, new Set(graph.nodes.filter((n) => (!localIds || localIds.has(n.id)) && (!stackVisible || stackVisible(n.id))).map((n) => n.id)));
|
|
241
|
+
}, [graph, levels, localIds, stackVisible]);
|
|
242
|
+
const layoutKey = `${focus ? `local:${focus.id}:${hops}` : "all"}`; // cluster is a drawing, not a layout input: toggling it never changes the key
|
|
243
|
+
const neighbours = useMemo(() => {
|
|
244
|
+
if (!graph || !focus) return [];
|
|
245
|
+
const ids = [...neighbourhood(graph.links, focus.id)].filter((id) => id !== focus.id);
|
|
246
|
+
return ids.map((id) => graph.byId.get(id)).filter(Boolean).sort((a, b) => a.title.localeCompare(b.title));
|
|
247
|
+
}, [graph, focus]);
|
|
248
|
+
// Find (WIKI-GRAPH-FIND-FILTER-LIVE-1): the debounced term sets a match set the canvases light and dim through the focus path. It is
|
|
249
|
+
// never a layout input (not in `layoutKey`), so typing posts nothing to the worker and moves nothing. Clearing skips the debounce.
|
|
250
|
+
const settledTerm = useDebounced(term, FIND_DEBOUNCE_MS);
|
|
251
|
+
const findTerm = hasTerm(term) && !parseVerb(term).verb && !parseVerb(settledTerm).verb ? settledTerm : ""; // a verb ("in core") is a filter being typed, not a title to light
|
|
252
|
+
const found = useMemo(() => (graph && hasTerm(findTerm) ? findMatches(graph.nodes, findTerm, visible) : null), [graph, findTerm, visible]);
|
|
253
|
+
const drawnIds = useMemo(() => (drawn ? new Set(drawn.nodes.map((n) => n.id)) : null), [drawn]);
|
|
254
|
+
const match = useMemo(() => (found && drawnIds ? litIds(found, drawnIds) : null), [found, drawnIds]);
|
|
255
|
+
// The path lens (EXPLORE-1c-UI r3): the shortest path over the FULL graph, lit through the canvas marks (3D) or the match set (2D); it only marks, nothing is removed.
|
|
256
|
+
const path = useMemo(() => (explorer && graph && drawnIds ? pathView(explore.state, explorer.idx, drawnIds, graph.byId) : null), [explorer, graph, drawnIds, explore.state]);
|
|
257
|
+
// A focused doc the level view collapses into a folder has no node to ring (the canvas focus index is -1): ring the folder that stands for it, as a mark.
|
|
258
|
+
// Not a focus: that would pin and regroup the folder (WIKI-GRAPH-CANVAS-FOCUS-COLLAPSED-FU-1; the card hangs from the same `cardAnchor`).
|
|
259
|
+
const focusRep = useMemo(() => { const a = cardAnchor(focus, drawnIds); return a && a !== focus.id ? a : null; }, [focus, drawnIds]);
|
|
260
|
+
const marks = useMemo(() => {
|
|
261
|
+
if (!lassoMarks && !path?.marks && !focusRep) return null;
|
|
262
|
+
const m = { ...(lassoMarks || {}), ...(path?.marks || {}) };
|
|
263
|
+
if (focusRep) m.selected = new Set([...(m.selected || []), focusRep]);
|
|
264
|
+
return m;
|
|
265
|
+
}, [lassoMarks, path, focusRep]); // 3D canvas marks: the lasso and pins, the path's lit docs and links, the collapsed focus's folder ring
|
|
266
|
+
const pathMatch = useMemo(() => (view === "2d" && path?.marks ? [...new Set([...(match || []), ...path.marks.lit])] : match), [view, path, match]);
|
|
267
|
+
const [listOpen, setListOpen] = useState(false);
|
|
268
|
+
const [omniFocus, setOmniFocus] = useState(0); // bumped by the doc card's Path to… to put the cursor in the omnibox
|
|
269
|
+
const [recent, setRecent] = useState(() => readRecent(workspace));
|
|
270
|
+
const [views, setViews] = useState(() => readViews(workspace)); // saved views (EXPLORE-1c-UI r5): newest first, this browser only
|
|
271
|
+
const [saving, setSaving] = useState(null); // the View drawer's name field: null when closed, else the text being typed
|
|
272
|
+
const [viewNote, setViewNote] = useState(""); // the status line after a save or an apply
|
|
273
|
+
const [restore, setRestore] = useState(null); // a saved view's camera and pins, waiting for the canvas to take them once its URL state has drawn
|
|
274
|
+
// Recent and views are read from per-workspace storage, and a workspace switch keeps this page mounted (WIKI-GRAPH-VIEW-WORKSPACE-FU-1): on
|
|
275
|
+
// a change, re-read both and drop the pending name field, status line and restore, all of which belong to the workspace left behind.
|
|
276
|
+
const [storeWs, setStoreWs] = useState(workspace);
|
|
277
|
+
if (storeWs !== workspace) {
|
|
278
|
+
setStoreWs(workspace);
|
|
279
|
+
setRecent(readRecent(workspace));
|
|
280
|
+
setViews(readViews(workspace));
|
|
281
|
+
setSaving(null);
|
|
282
|
+
setViewNote("");
|
|
283
|
+
setRestore(null);
|
|
284
|
+
}
|
|
285
|
+
// The omnibox's rows (docs, folders, verbs, filter previews, recent docs) for the LIVE term: row 0 is the top title match, so Enter keeps aiming at it.
|
|
286
|
+
const omniList = useMemo(
|
|
287
|
+
() => (graph ? omniRows({ text: term, nodes: graph.nodes, visible, idx: explorer?.idx ?? null, counts: folderCount, recent, views, steps, explorer, byId: graph.byId }) : []),
|
|
288
|
+
[graph, term, visible, explorer, folderCount, recent, views, steps],
|
|
289
|
+
);
|
|
290
|
+
const [aim, setAim] = useState(null); // {doc, n}: the doc the camera frames (Enter on the top match); n re-aims a repeat
|
|
291
|
+
// The drawn node for it, resolved against what is drawn NOW: a focus re-narrows the graph, so the node it sat in before (a folder) may be gone.
|
|
292
|
+
const aimId = aim && drawnIds ? drawnFor(aim.doc, drawnIds) : null;
|
|
293
|
+
const aimNode = useMemo(() => (aimId ? { id: aimId, n: aim.n } : null), [aimId, aim]);
|
|
294
|
+
const aimN = useRef(0);
|
|
295
|
+
// Badge-opened hubs ("+N", FOCUS-HOPS-1): one Set per focus; a new focus starts with none open. The Set keeps its identity
|
|
296
|
+
// until a badge is clicked, so the canvas re-rings only then.
|
|
297
|
+
const [opened, setOpened] = useState({ root: null, ids: null });
|
|
298
|
+
const open = opened.root === focusId ? opened.ids : null;
|
|
299
|
+
const onBadge = (n) => setOpened((o) => ({ root: focusId, ids: new Set([...(o.root === focusId && o.ids ? o.ids : []), n.id]) }));
|
|
300
|
+
// The id the canvas focuses: the doc, or a drawn folder (a folder may be the root, FOCUS-HOPS-1).
|
|
301
|
+
const canvasFocus = focus?.id || (focusId && drawnIds?.has(focusId) ? focusId : null);
|
|
302
|
+
|
|
303
|
+
/** Set (or with null/"" remove) URL params, replacing the history entry. */
|
|
304
|
+
const set = (patch, push = false) => setSp((prev) => {
|
|
305
|
+
const p = new URLSearchParams(prev);
|
|
306
|
+
for (const [k, v] of Object.entries(patch)) { if (v === null || v === undefined || v === "") p.delete(k); else p.set(k, String(v)); }
|
|
307
|
+
return p;
|
|
308
|
+
}, { replace: !push });
|
|
309
|
+
const setFocus = (id) => {
|
|
310
|
+
setDrill(null);
|
|
311
|
+
setAim(null);
|
|
312
|
+
// `depth` is the old page's depth control: the whole graph has no depth. No focus means the whole graph again; `hops` stays.
|
|
313
|
+
set(id ? { depth: null, [PARAM.root]: id } : { depth: null, [PARAM.root]: null });
|
|
314
|
+
setTerm("");
|
|
315
|
+
};
|
|
316
|
+
// Enter or a click on a match: select it and frame it, keeping the term so the other matches stay lit until Esc or clear.
|
|
317
|
+
const pick = (doc) => {
|
|
318
|
+
if (!doc) return;
|
|
319
|
+
setDrill(null);
|
|
320
|
+
set({ depth: null, [PARAM.root]: doc.id });
|
|
321
|
+
setListOpen(false);
|
|
322
|
+
setAim({ doc, n: ++aimN.current });
|
|
323
|
+
};
|
|
324
|
+
// Chip edits push a history entry (Back undoes one), unlike the replacing `set`; every other param rides along.
|
|
325
|
+
// A no-op edit (clear with nothing to clear, a repeated step) adds none, so Back always undoes a real change.
|
|
326
|
+
const setExplore = (next) => { if (!exploreUnchanged(sp, next)) setSp((prev) => withExplore(prev, next), { replace: false }); };
|
|
327
|
+
const onOmniAction = (row) => {
|
|
328
|
+
const a = row.action;
|
|
329
|
+
if (a.type === "fly") {
|
|
330
|
+
pick(graph.byId.get(a.id));
|
|
331
|
+
const next = pushRecent(recent, a.id);
|
|
332
|
+
setRecent(next);
|
|
333
|
+
writeStore(recentKey(workspace), next);
|
|
334
|
+
} else if (a.type === "step") { setExplore(addStep(explore.state, a.step)); setTerm(""); setListOpen(false); }
|
|
335
|
+
else if (a.type === "clear") { setExplore(clearAll(explore.state)); setListOpen(false); }
|
|
336
|
+
else if (a.type === "view") { const v = views.find((x) => x.name === a.name); if (v) applyView(v); setTerm(""); setListOpen(false); }
|
|
337
|
+
else if (a.type === "lens") { setExplore(setPathLens(explore.state, a.lens.from, a.lens.to)); setTerm(""); setListOpen(false); }
|
|
338
|
+
};
|
|
339
|
+
// Saved views: the URL goes first (its focus fly-to or fit-all sets the orbit target as today), then the orientation is applied round that
|
|
340
|
+
// target and the pins are put back (only those drawn). 2D applies the URL alone. The camera and pins are never in the URL.
|
|
341
|
+
const saveCurrent = (name) => {
|
|
342
|
+
const nm = String(name || "").trim();
|
|
343
|
+
if (!nm) return;
|
|
344
|
+
const c = view === "3d" ? canvas.current : null;
|
|
345
|
+
const r = saveView(workspace, makeView({ name: nm, url: `?${sp.toString()}`, orient: c?.orientation?.() || null, pins: c?.pinList ? c.pinList() : null }));
|
|
346
|
+
if (r.ok) setViews(r.list);
|
|
347
|
+
setSaving(null);
|
|
348
|
+
setViewNote(r.ok ? `Saved "${nm}".` : SAVE_FAILED);
|
|
349
|
+
};
|
|
350
|
+
const applyView = (v) => {
|
|
351
|
+
setDrill(null);
|
|
352
|
+
setAim(null);
|
|
353
|
+
setSp(paramsOfUrl(v.url), { replace: false });
|
|
354
|
+
setViewNote(appliedText(v, view));
|
|
355
|
+
setRestore(view === "3d" && (v.orient || v.pins?.length) ? { v } : null);
|
|
356
|
+
};
|
|
357
|
+
// The doc card's Path to…: the omnibox takes `path <title> to ` and asks for the second doc, the way it asks for any doc argument.
|
|
358
|
+
const onPathTo = (doc) => { setTerm(`path ${doc.title} to `); setListOpen(true); setOmniFocus((n) => n + 1); };
|
|
359
|
+
// 3D: a fly-to frames the doc through the canvas's own camera (fitIdx beats the focus framing) and rings it; 2D takes `aim`.
|
|
360
|
+
useEffect(() => {
|
|
361
|
+
if (view !== "3d" || !aimNode) return;
|
|
362
|
+
canvas.current?.fitTo?.([aimNode.id]);
|
|
363
|
+
canvas.current?.flash?.(aimNode.id);
|
|
364
|
+
}, [aimNode, view]);
|
|
365
|
+
useEffect(() => {
|
|
366
|
+
const c = canvas.current;
|
|
367
|
+
if (!restore || view !== "3d" || !drawnIds || !c?.applyOrient) return;
|
|
368
|
+
const { v } = restore;
|
|
369
|
+
const { pins, skipped } = restorablePins(v.pins, drawnIds);
|
|
370
|
+
if (v.orient && !c.applyOrient(v.orient)) return; // the surface is not up yet: try again when the layout moves
|
|
371
|
+
const n = v.pins ? c.setPins(pins) : 0;
|
|
372
|
+
if (n < 0 || (pins.length && n === 0 && layout.state !== "settled")) return;
|
|
373
|
+
setRestore(null);
|
|
374
|
+
setViewNote(appliedText(v, "3d", skipped + (pins.length - n)));
|
|
375
|
+
}, [restore, view, drawnIds, layout.state]);
|
|
376
|
+
// Clear the focus and keep the term and hops (a click on empty space, or Esc).
|
|
377
|
+
const clearFocus = () => { setAim(null); set({ [PARAM.root]: null }); };
|
|
378
|
+
const setDim = (v) => { const d = parseDim(v); set({ [PARAM.dim]: d === DIM_DEFAULT ? null : d }); };
|
|
379
|
+
const setHops = (h) => set({ [PARAM.hops]: h === HOPS_DEFAULT ? null : h });
|
|
380
|
+
const setHidden = (next) => set({ [PARAM.hide]: formatHidden(next) });
|
|
381
|
+
// The base is never written. In scope mode a move to another scope re-keys the legend, so the old `hide=` goes with it.
|
|
382
|
+
const setLevels = (next) => set({
|
|
383
|
+
[PARAM.levels]: formatLevels(new Set([...next].filter((p) => !base.has(p)))),
|
|
384
|
+
...(by === "scope" && currentLevel(new Set([...base, ...next])) !== level ? { [PARAM.hide]: null } : {}),
|
|
385
|
+
});
|
|
386
|
+
// A doc opens in the pane, never by leaving the graph. Opening adds a history entry (Back closes it); following the click replaces.
|
|
387
|
+
const showDoc = (id, extra = {}) => { setPaneCollapsed(false); set({ ...extra, [PARAM.doc]: id }, !docId); }; // one set(): react-router applies a functional update to the render-time params, so two calls in a handler lose the first
|
|
388
|
+
// A double click (or Enter) opens the doc AND shows its place in the left folder tree; a single click that follows the pane does not.
|
|
389
|
+
const openDoc = (id, extra) => { showDoc(id, extra); requestTreeSync({ id }); };
|
|
390
|
+
const closeDoc = () => set({ [PARAM.doc]: null });
|
|
391
|
+
const startDrag = (e) => {
|
|
392
|
+
e.preventDefault();
|
|
393
|
+
const box = surface.current?.parentElement?.getBoundingClientRect();
|
|
394
|
+
if (!box?.width) return;
|
|
395
|
+
const move = (m) => setPanePct(Math.round(Math.max(PANE_MIN, Math.min(PANE_MAX, ((box.right - m.clientX) / box.width) * 100))));
|
|
396
|
+
const stop = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", stop); };
|
|
397
|
+
window.addEventListener("pointermove", move);
|
|
398
|
+
window.addEventListener("pointerup", stop);
|
|
399
|
+
};
|
|
400
|
+
const setRange = (r) => { set({ [PARAM.range]: r[0] === 1 && r[1] === 1 ? null : formatRange(r), [PARAM.flat]: null }); setDrill(null); };
|
|
401
|
+
const showAll = () => { set({ [PARAM.flat]: flat ? null : "1" }); setDrill(null); };
|
|
402
|
+
// With a base the trail starts at it: no "All", and the first crumb goes back to the base, never to level 0.
|
|
403
|
+
const crumbs = level === null ? [] : breadcrumb(level);
|
|
404
|
+
const up = useRef(null); // the Esc handler reads the latest state through this; it stops at the base
|
|
405
|
+
const clearLasso = () => setLassoSel(null);
|
|
406
|
+
// The canvas draws and samples the polygon; the page judges it on the drawn docs' projected centres, front and back (3D-UX-1 D2).
|
|
407
|
+
const onPin = ({ ids }) => setPinIds(ids);
|
|
408
|
+
const onLasso = ({ phase, pts }) => {
|
|
409
|
+
const c = canvas.current;
|
|
410
|
+
if (phase === "cancel" || !c?.screenPositions) { setLassoLive(null); return; }
|
|
411
|
+
const hit = lassoHits(pts, c.screenPositions(), c.pivotDepth?.() ?? null);
|
|
412
|
+
if (phase === "end") { setLassoLive(null); setLassoSel(hit.inside ? hit : null); } else setLassoLive({ inside: hit.inside, behind: hit.behind });
|
|
413
|
+
};
|
|
414
|
+
const clearPath = () => setExplore(clearPathLens(explore.state));
|
|
415
|
+
const levelUp = flat ? null : explicit.size ? () => {
|
|
416
|
+
const gone = currentLevel(explicit), up1 = new Set([...base, ...upOne(explicit)]);
|
|
417
|
+
const parent = gone ? gone.split("/").slice(0, -1).join("/") : "";
|
|
418
|
+
setLevels(up1);
|
|
419
|
+
drillInto(parent && !base.has(parent) ? folderId(parent) : null); // fit the parent's children, or everything at the top
|
|
420
|
+
} : drill ? () => { // no folder of the reader's own to close: step the camera up from the drilled folder
|
|
421
|
+
const parent = folderPathOf(drill.id).split("/").slice(0, -1).join("/");
|
|
422
|
+
drillInto(parent && !base.has(parent) ? folderId(parent) : null);
|
|
423
|
+
} : null;
|
|
424
|
+
// Esc: a lasso selection first, then the pane, then (with a focus) the card's show/hide, then up a level, then the path lens (escAction)
|
|
425
|
+
up.current = { clearLasso, closeDoc, toggleCard: card.esc, up: levelUp, clearPath }[escAction({ lasso: !!lassoSel, docOpen: !!docId, focus: !!focusId, up: !!levelUp, pathLens: !!path?.lens })] || null;
|
|
426
|
+
const turn = useRef(false); // true in 3D: the keydown listener may turn and zoom the camera
|
|
427
|
+
turn.current = view === "3d";
|
|
428
|
+
const enter = useRef(null); // Enter on the focused doc opens it in the pane
|
|
429
|
+
enter.current = focus && !docId && !isFolderId(focus.id) ? () => openDoc(focus.id) : null;
|
|
430
|
+
useEffect(() => {
|
|
431
|
+
const onKey = (e) => {
|
|
432
|
+
if (isFormField(e.target)) return;
|
|
433
|
+
if (turn.current && !e.ctrlKey && !e.metaKey && !e.altKey) { // 3D keyboard turn/zoom: Shift+arrows, + - 0 (never Ctrl/Cmd/Alt: Cmd+- stays browser zoom)
|
|
434
|
+
const c = canvas.current, cur = c?.orientation?.(true), next = cur && stepOrient(cur, e.key, e.shiftKey);
|
|
435
|
+
if (next) { e.preventDefault(); c.applyOrient(next); return; }
|
|
436
|
+
}
|
|
437
|
+
if (e.key === "Escape" && up.current) up.current();
|
|
438
|
+
else if (e.key === "Enter" && !["BUTTON", "A"].includes(e.target?.tagName) && enter.current) enter.current();
|
|
439
|
+
};
|
|
440
|
+
document.addEventListener("keydown", onKey);
|
|
441
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
442
|
+
}, []);
|
|
443
|
+
const onPhysics = (k, v) => set({ [PARAM[k]]: v === PHYSICS_DEFAULTS[k] ? null : v });
|
|
444
|
+
const onLayoutState = (state, ms) => setLayout({ state, ms });
|
|
445
|
+
// A click focuses the node (its hop rings light), keeps the term, and follows the pane while it is open.
|
|
446
|
+
const select = (id) => { setDrill(null); setAim(null); set({ depth: null, [PARAM.root]: id }); };
|
|
447
|
+
const onOpen = (n) => {
|
|
448
|
+
select(n.id);
|
|
449
|
+
if (docId && !isFolderId(n.id)) showDoc(n.id);
|
|
450
|
+
};
|
|
451
|
+
const onExpand = (n) => {
|
|
452
|
+
// A double click selects the doc too, so the panel's card and the pane name the same doc (a real double click clicks first, a keyboard Enter does not).
|
|
453
|
+
if (!isFolderId(n.id)) { setDrill(null); setAim(null); openDoc(n.id, { depth: null, [PARAM.root]: n.id }); return; }
|
|
454
|
+
const path = folderPathOf(n.id);
|
|
455
|
+
setLevels(expand(explicitOpen, path));
|
|
456
|
+
drillInto(n.id);
|
|
457
|
+
requestTreeSync({ id: docUnder(graph.nodes, path), folder: path }); // the folder's source comes from one of its docs
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const loaded = graph?.nodes.length || 0;
|
|
461
|
+
const levelDocs = levels ? levels.nodes.reduce((t, n) => t + (n.hub ? 0 : n.kind === "folder" ? n.size : 1), 0) : 0;
|
|
462
|
+
const hiddenDocs = !drawn ? 0 : levels ? graph.nodes.filter((n) => hidden.has(n.group) && (!localIds || localIds.has(n.id))).length : drawn.nodes.filter((n) => hidden.has(n.group)).length;
|
|
463
|
+
const ready = !q.isLoading && !!graph && loaded > 0;
|
|
464
|
+
// The loaded graph fills the whole area beside the nav (WIKI-GRAPH-FULLVIEW-1, Layout's full-bleed mode): the canvas is
|
|
465
|
+
// the page, the controls float over it as panels, and only the panels take pointer events.
|
|
466
|
+
if (!ready) {
|
|
467
|
+
return (
|
|
468
|
+
<div className="h-full overflow-y-auto px-5 py-10 md:py-12">
|
|
469
|
+
<PageHeader title="Graph">
|
|
470
|
+
{graph && (
|
|
471
|
+
<>
|
|
472
|
+
{loaded} docs · {graph.links.length} links
|
|
473
|
+
{q.data.total > loaded && <> · showing first {loaded} of {q.data.total} docs</>}
|
|
474
|
+
</>
|
|
475
|
+
)}
|
|
476
|
+
</PageHeader>
|
|
477
|
+
<ErrorBox error={q.error} />
|
|
478
|
+
{q.isLoading ? <Loading /> : graph && <p className="text-sm text-neutral-400">No docs in this workspace yet.</p>}
|
|
479
|
+
</div>
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
return (
|
|
483
|
+
<div className="absolute inset-0 flex" data-testid="graph-fullview">
|
|
484
|
+
<div ref={surface} className="relative min-w-0 flex-1">
|
|
485
|
+
{view === "3d" ? (
|
|
486
|
+
<Suspense fallback={null}>
|
|
487
|
+
<GraphCanvas3D
|
|
488
|
+
key={colorMode} ref={canvas} graph={drawn} layoutKey={layoutKey} hidden={levels ? NO_GROUPS : hidden} focusId={canvasFocus} match={match} aim={aimNode} sizeByDegree={sizeByDegree}
|
|
489
|
+
physics={physics} cluster={cluster} outlines={outlines} idleTurn={idleTurn && !reduced} drill={drill} hops={hops} dim={dim} open={open} onOpen={onOpen} onExpand={onExpand} onBadge={onBadge} onBackground={clearFocus}
|
|
490
|
+
onLasso={onLasso} onPin={onPin} marks={marks} onLayoutState={onLayoutState} height="fill"
|
|
491
|
+
onUnavailable={(reason) => { console.warn(`wiki graph: the 3D view is unavailable (${reason}), drawing the 2D view instead.`); setUnavailable3d(true); }}
|
|
492
|
+
/>
|
|
493
|
+
</Suspense>
|
|
494
|
+
) : (
|
|
495
|
+
<GraphCanvas
|
|
496
|
+
key={colorMode} ref={canvas} graph={drawn} layoutKey={layoutKey} hidden={levels ? NO_GROUPS : hidden} focusId={canvasFocus} match={pathMatch} aim={aimNode} sizeByDegree={sizeByDegree}
|
|
497
|
+
physics={physics} cluster={cluster} forced={forced} drill={drill} hops={hops} dim={dim} open={open} onOpen={onOpen} onExpand={onExpand} onBadge={onBadge} onBackground={clearFocus}
|
|
498
|
+
onLayoutState={onLayoutState} height="fill"
|
|
499
|
+
/>
|
|
500
|
+
)}
|
|
501
|
+
{path?.folders.length > 0 && (
|
|
502
|
+
<div className="pointer-events-none absolute bottom-10 left-1/2 max-w-[70%] -translate-x-1/2 rounded-md bg-ground/80 px-2 py-1 text-xs text-ink2" role="status" data-testid="graph-path-note">
|
|
503
|
+
{pathNote(path.folders, (id) => folderPathOf(id) || "(root)")}
|
|
504
|
+
</div>
|
|
505
|
+
)}
|
|
506
|
+
{(lassoLive || lassoSel) && (
|
|
507
|
+
<div className="pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 rounded-md bg-ground/80 px-2 py-1 text-xs text-ink2" role="status" data-testid="graph-lasso-count">
|
|
508
|
+
{lassoLive ? lassoText(lassoLive.inside, lassoLive.behind) : `${lassoText(lassoSel.inside, lassoSel.behind).replace("inside", "selected")}. Esc clears.`}
|
|
509
|
+
</div>
|
|
510
|
+
)}
|
|
511
|
+
{viewNote && (
|
|
512
|
+
<div className="pointer-events-none absolute bottom-16 left-1/2 max-w-[70%] -translate-x-1/2 rounded-md bg-ground/80 px-2 py-1 text-xs text-ink2" role="status" data-testid="graph-view-note">{viewNote}</div>
|
|
513
|
+
)}
|
|
514
|
+
{cardAtNode && <DocCard canvas={canvas} focus={focus} anchor={cardAnchor(focus, drawnIds)} to={noteUrl(workspace, focus.id)} onPathTo={onPathTo} shown={card.shown} reduced={reduced} onHold={card.hold} onRelease={card.release} onFocusIn={card.focusIn} onFocusOut={card.focusOut} />}
|
|
515
|
+
<GraphLegend
|
|
516
|
+
groups={graph.groups} hidden={hidden} scope={by === "scope" && level ? level : null}
|
|
517
|
+
onToggle={(names) => setHidden(names.every((n) => hidden.has(n)) ? new Set([...hidden].filter((h) => !names.includes(h))) : new Set([...hidden, ...names]))}
|
|
518
|
+
onDrill={by === "scope" ? (path) => { setLevels(expand(explicitOpen, path)); drillInto(folderId(path)); } : null}
|
|
519
|
+
onAll={() => setHidden(showEveryGroup())} onNone={() => setHidden(hideEveryGroup(graph.groups))}
|
|
520
|
+
/>
|
|
521
|
+
<div className="pointer-events-none absolute bottom-3 left-3 max-w-[60%] rounded-md bg-ground/70 px-2 py-1 text-xs text-neutral-400" aria-live="polite" data-layout-state={layout.state || ""}>
|
|
522
|
+
{focus && <>{levels ? levelDocs : drawn.nodes.length} docs within {hops} {hops === 1 ? "link" : "links"} of {focus.title || focus.id}. </>}
|
|
523
|
+
{hiddenDocs > 0 && <>{hiddenDocs} hidden by the legend. </>}
|
|
524
|
+
{layout.state && <>Layout {STATE_TEXT[layout.state]}{layout.state === "settled" && layout.ms != null && <> in {(layout.ms / 1000).toFixed(1)} s</>}.</>}
|
|
525
|
+
</div>
|
|
526
|
+
{/* Top overlays share one wrapping row: the Levels bar on the left, the controls panel on the right. When the surface is too narrow for both
|
|
527
|
+
(doc pane open, phone) the panel drops below the bar instead of covering it. In that case it also stops above the legend (bottom-left, up to 45%
|
|
528
|
+
tall): 15rem = the legend's 2.5rem offset + 6.5rem for the Levels bar (which can wrap to two lines) + 6rem for the find panel above it (input, count
|
|
529
|
+
line; chips add more, so the column scrolls). The left column stacks the find panel over the Levels bar. */}
|
|
530
|
+
<div data-testid="graph-overlays" className="pointer-events-none absolute inset-0 flex flex-wrap content-start items-start justify-between gap-3 p-3">
|
|
531
|
+
<div className="pointer-events-none flex max-w-[60%] flex-col items-start gap-3" data-testid="graph-left-column">
|
|
532
|
+
<div className={`${PANEL} w-80 max-w-full p-3`} data-testid="graph-find-panel">
|
|
533
|
+
<Omnibox
|
|
534
|
+
term={term} onTerm={setTerm} rows={omniList} open={listOpen} onOpenChange={setListOpen} inputClassName={INPUT}
|
|
535
|
+
countText={found ? countText(found.length) : ""} foundCount={found ? found.length : null}
|
|
536
|
+
onAction={onOmniAction} focusSignal={omniFocus} onBackspaceEmpty={() => setExplore(removeStep(explore.state, steps.length - 1))}
|
|
537
|
+
/>
|
|
538
|
+
<FilterChips
|
|
539
|
+
state={explore.state} run={run} idx={explorer?.idx ?? null} explorer={explorer} ignored={explore.ignored}
|
|
540
|
+
onRemove={(i) => setExplore(removeStep(explore.state, i))} onClear={() => setExplore(clearAll(explore.state))}
|
|
541
|
+
onRemoveLens={(i) => setExplore(removeLens(explore.state, i))}
|
|
542
|
+
/>
|
|
543
|
+
</div>
|
|
544
|
+
<div className={`${PANEL} flex max-w-full items-center gap-3 px-3 py-2`}>
|
|
545
|
+
{(!flat || level !== null) && <GraphBreadcrumb crumbs={crumbs} all={!base.size} onSelect={(path) => { setLevels(path === null ? new Set() : new Set(breadcrumb(path).map((c) => c.path))); drillInto(path === null || base.has(path) ? null : folderId(path)); }} />}
|
|
546
|
+
<GraphLevelPicker range={range} max={maxLv} flat={flat} onRange={setRange} onAll={showAll} />
|
|
547
|
+
{has3d && !unavailable3d && (
|
|
548
|
+
<button type="button" aria-label={view === "3d" ? "Switch to 2D view" : "Switch to 3D view"} onClick={() => set({ [PARAM.view]: view === "3d" ? "2d" : null })}
|
|
549
|
+
className={BUTTON}>{view === "3d" ? "2D" : "3D"}</button>
|
|
550
|
+
)}
|
|
551
|
+
</div>
|
|
552
|
+
</div>
|
|
553
|
+
<div data-testid="graph-controls-column" className={`pointer-events-none ml-auto flex max-w-full flex-col items-end gap-3 ${docId ? "max-h-[calc(55%-15rem)]" : "max-lg:max-h-[calc(55%-15rem)]"}`}>
|
|
554
|
+
<ErrorBox error={q.error} />
|
|
555
|
+
<section className={`${PANEL} flex min-h-0 w-80 max-w-full flex-col`} aria-label="Graph view">
|
|
556
|
+
<div className="flex items-center gap-2 p-3">
|
|
557
|
+
<div className="min-w-0 flex-1">
|
|
558
|
+
<h1 className="text-base font-semibold tracking-tight text-ink">Graph</h1>
|
|
559
|
+
<span className="block text-xs text-neutral-500">
|
|
560
|
+
{loaded} docs · {graph.links.length} links
|
|
561
|
+
{q.data.total > loaded && <> · showing first {loaded} of {q.data.total} docs</>}
|
|
562
|
+
</span>
|
|
563
|
+
</div>
|
|
564
|
+
<button type="button" onClick={() => setPanelOpen(!panelOpen)} aria-expanded={panelOpen} aria-controls="graph-controls" className={BUTTON}>
|
|
565
|
+
{panelOpen ? "Hide view" : "View"}
|
|
566
|
+
</button>
|
|
567
|
+
</div>
|
|
568
|
+
{panelOpen && (
|
|
569
|
+
<div id="graph-controls" className="min-h-0 space-y-3 overflow-y-auto border-t border-neutral-200/70 p-3">
|
|
570
|
+
<div role="group" aria-label="Hops" className="flex items-center gap-2 text-[13px] text-ink2">
|
|
571
|
+
Hops
|
|
572
|
+
<button type="button" aria-label="Fewer hops" disabled={hops <= 1} onClick={() => setHops(hops - 1)} className={BUTTON}>−</button>
|
|
573
|
+
<span data-testid="graph-hops" className="w-4 text-center text-ink">{hops}</span>
|
|
574
|
+
<button type="button" aria-label="More hops" disabled={hops >= 4} onClick={() => setHops(hops + 1)} className={BUTTON}>+</button>
|
|
575
|
+
</div>
|
|
576
|
+
<label className="flex items-center gap-2 text-[13px] text-ink2">
|
|
577
|
+
Dim
|
|
578
|
+
<input type="range" min="0" max={DIM_MAX} step="0.01" value={dim} aria-label="Dim" onChange={(e) => setDim(e.target.value)} className="min-w-0 flex-1" />
|
|
579
|
+
<span data-testid="graph-dim" className="w-9 text-right tabular-nums text-ink">{dim}</span>
|
|
580
|
+
</label>
|
|
581
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
|
582
|
+
<label className="flex w-full min-w-0 items-center gap-2 text-[13px] text-ink2">
|
|
583
|
+
Colour by
|
|
584
|
+
<select value={by} onChange={(e) => set({ [PARAM.by]: e.target.value === "scope" ? null : e.target.value, [PARAM.hide]: null })} aria-label="Colour by" className={`${INPUT} min-w-0 flex-1`}>
|
|
585
|
+
<option value="scope">current level</option>
|
|
586
|
+
<option value="area">area</option>
|
|
587
|
+
<option value="folder">folder</option>
|
|
588
|
+
{GRAPH_ASPECTS.map((a) => <option key={a} value={a} disabled title={aspectHint}>{`${a} (${aspectHint})`}</option>)}
|
|
589
|
+
</select>
|
|
590
|
+
</label>
|
|
591
|
+
<label className="flex items-center gap-1.5 text-[13px] text-ink2">
|
|
592
|
+
<input type="checkbox" checked={sizeByDegree} onChange={(e) => set({ [PARAM.size]: e.target.checked ? null : "flat" })} />
|
|
593
|
+
Size by links
|
|
594
|
+
</label>
|
|
595
|
+
</div>
|
|
596
|
+
{view === "3d" && (
|
|
597
|
+
<label className="flex flex-wrap items-center gap-2 text-[13px] text-ink2">
|
|
598
|
+
<input type="checkbox" checked={idleTurn && !reduced} disabled={reduced} onChange={(e) => setIdleTurn(e.target.checked)} />
|
|
599
|
+
Turn slowly when idle
|
|
600
|
+
{reduced && <span className="text-[12px] text-ink2">Off while your system reduces motion</span>}
|
|
601
|
+
</label>
|
|
602
|
+
)}
|
|
603
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
604
|
+
<button type="button" onClick={() => canvas.current?.fit()} className={BUTTON}>Zoom to fit</button>
|
|
605
|
+
<button type="button" aria-pressed={cluster} onClick={() => set({ [PARAM.cluster]: cluster ? null : "1" })}
|
|
606
|
+
className={`${BUTTON} ${cluster ? PRESSED : ""}`}>Cluster by group</button>
|
|
607
|
+
{view === "3d" && cluster && (
|
|
608
|
+
<button type="button" aria-pressed={outlines} onClick={() => set({ [PARAM.outlines]: outlines ? null : "1" })}
|
|
609
|
+
className={`${BUTTON} ${outlines ? PRESSED : ""}`}>Group outlines</button>
|
|
610
|
+
)}
|
|
611
|
+
<button type="button" disabled={layout.state !== "running"} onClick={() => canvas.current?.pause()} className={BUTTON}>Pause</button>
|
|
612
|
+
<button type="button" disabled={layout.state !== "paused"} onClick={() => canvas.current?.resume()} className={BUTTON}>Resume</button>
|
|
613
|
+
{view === "3d" && pinIds.length > 0 && (
|
|
614
|
+
<button type="button" onClick={() => canvas.current?.releasePins?.()} className={BUTTON} data-testid="graph-release-pins">Release pins ({pinIds.length})</button>
|
|
615
|
+
)}
|
|
616
|
+
<button type="button" disabled={!layout.state} title={cluster && view === "2d" ? "Lay the groups out into clusters again, once" : undefined} onClick={() => canvas.current?.reheat()} className={BUTTON}>Reheat</button>
|
|
617
|
+
</div>
|
|
618
|
+
<div className="space-y-2" data-testid="graph-saved-views">
|
|
619
|
+
{saving === null ? (
|
|
620
|
+
<button type="button" onClick={() => setSaving(defaultViewName(steps.map((st) => chipText(st, explorer?.idx ?? null)), views))} className={BUTTON} data-testid="graph-save-view">Save view…</button>
|
|
621
|
+
) : (
|
|
622
|
+
<form onSubmit={(e) => { e.preventDefault(); saveCurrent(saving); }} className="flex items-center gap-2">
|
|
623
|
+
<input autoFocus value={saving} onChange={(e) => setSaving(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") { e.stopPropagation(); setSaving(null); } }}
|
|
624
|
+
aria-label="View name" maxLength={80} className={`${INPUT} min-w-0 flex-1`} data-testid="graph-view-name" />
|
|
625
|
+
<button type="submit" disabled={!saving.trim()} className={BUTTON}>Save</button>
|
|
626
|
+
<button type="button" onClick={() => setSaving(null)} className={BUTTON}>Cancel</button>
|
|
627
|
+
</form>
|
|
628
|
+
)}
|
|
629
|
+
{views.length > 0 && (
|
|
630
|
+
<ul className="space-y-1" aria-label="Saved views" data-testid="graph-views">
|
|
631
|
+
{views.map((v) => (
|
|
632
|
+
<li key={v.name} className="flex items-center gap-1 text-[13px]" data-view={v.name}>
|
|
633
|
+
<button type="button" onClick={() => applyView(v)} className="min-w-0 flex-1 truncate text-left text-ink hover:underline" data-testid="graph-view-apply">{v.name}</button>
|
|
634
|
+
<button type="button" aria-label={`Delete view ${v.name}`} onClick={() => setViews(deleteView(workspace, v.name))} className={BUTTON} data-testid="graph-view-delete">×</button>
|
|
635
|
+
</li>
|
|
636
|
+
))}
|
|
637
|
+
</ul>
|
|
638
|
+
)}
|
|
639
|
+
</div>
|
|
640
|
+
{focus ? (
|
|
641
|
+
<div className="rounded-md border border-neutral-200/70 p-3">
|
|
642
|
+
{!cardAtNode && card.shown && ( // 3D shows title, folder, area and Open note in the card at the node (DocCard); 2D keeps them here (Esc or the info button hides them)
|
|
643
|
+
<>
|
|
644
|
+
<h2 className="text-base font-semibold text-ink">{focus.title}</h2>
|
|
645
|
+
<p className="mt-1 break-all font-mono text-xs text-neutral-500">{focus.folder || "(root)"}</p>
|
|
646
|
+
{focus.area && <p className="mt-1 text-xs text-ink2">area {focus.area}</p>}
|
|
647
|
+
</>
|
|
648
|
+
)}
|
|
649
|
+
<div className={`${cardAtNode || !card.shown ? "" : "mt-3 "}flex gap-3 text-[13px]`}>
|
|
650
|
+
{!cardAtNode && card.shown && <Link to={noteUrl(workspace, focus.id)} className="text-blue-600 hover:underline">Open note</Link>}
|
|
651
|
+
{!cardAtNode && card.shown && <button type="button" onClick={() => onPathTo(focus)} className="text-blue-600 hover:underline">Path to…</button>}
|
|
652
|
+
<button type="button" onClick={() => setFocus(null)} className="text-neutral-500 hover:text-ink">Show whole graph</button>
|
|
653
|
+
<button type="button" onClick={card.esc} aria-pressed={card.shown} aria-label={card.shown ? "Hide doc info" : "Show doc info"} className="text-neutral-500 hover:text-ink" data-testid="graph-card-toggle">Info</button>
|
|
654
|
+
</div>
|
|
655
|
+
<p className="mt-2 text-[11px] text-neutral-400" data-testid="graph-card-hint">{CARD_HINT}</p>
|
|
656
|
+
<h3 className="mt-4 text-xs font-semibold uppercase tracking-wide text-neutral-500">Linked docs ({neighbours.length})</h3>
|
|
657
|
+
<ul className="mt-1 space-y-0.5">
|
|
658
|
+
{neighbours.slice(0, SHOWN_NEIGHBOURS).map((n) => (
|
|
659
|
+
<li key={n.id}>
|
|
660
|
+
<button type="button" onClick={() => setFocus(n.id)} className="flex w-full items-center gap-2 truncate text-left text-[13px] text-ink2 hover:text-ink">
|
|
661
|
+
<span className="inline-block h-2 w-2 shrink-0 rounded-full" style={{ background: hueFor(n.color) }} />{n.title}
|
|
662
|
+
</button>
|
|
663
|
+
</li>
|
|
664
|
+
))}
|
|
665
|
+
</ul>
|
|
666
|
+
{neighbours.length > SHOWN_NEIGHBOURS && <p className="mt-1 text-xs text-neutral-400">and {neighbours.length - SHOWN_NEIGHBOURS} more</p>}
|
|
667
|
+
</div>
|
|
668
|
+
) : (
|
|
669
|
+
<div className="text-[13px] text-ink2">
|
|
670
|
+
{focusId && !isFolderId(focusId) && <p className="mb-3 text-amber-700">That doc is not in this graph.</p>}
|
|
671
|
+
<p>Find a doc by title to focus it and its links.</p>
|
|
672
|
+
</div>
|
|
673
|
+
)}
|
|
674
|
+
<div>
|
|
675
|
+
<h2 className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Physics</h2>
|
|
676
|
+
<div className="mt-2 space-y-1">
|
|
677
|
+
<Slider label="Repel" name="repel" value={physics.repel} onChange={onPhysics} />
|
|
678
|
+
<Slider label="Link distance" name="link" value={physics.link} onChange={onPhysics} />
|
|
679
|
+
<Slider label="Centre force" name="centre" value={physics.centre} onChange={onPhysics} />
|
|
680
|
+
</div>
|
|
681
|
+
</div>
|
|
682
|
+
<p className="text-xs text-neutral-400">
|
|
683
|
+
Scroll to zoom, drag to pan, hover to light a doc's links, click a dot to follow it in the doc pane, double-click to open it.
|
|
684
|
+
{loaded > LOD_N && " Large graph: names show as you zoom in."}
|
|
685
|
+
</p>
|
|
686
|
+
</div>
|
|
687
|
+
)}
|
|
688
|
+
</section>
|
|
689
|
+
</div>
|
|
690
|
+
</div>
|
|
691
|
+
</div>
|
|
692
|
+
{docId && (
|
|
693
|
+
<>
|
|
694
|
+
{!paneCollapsed && <div role="separator" aria-orientation="vertical" aria-label="Resize doc pane" onPointerDown={startDrag} className="hidden w-1 shrink-0 cursor-col-resize bg-neutral-200/70 hover:bg-blue-400 md:block" />}
|
|
695
|
+
<div data-testid="graph-doc-pane" style={{ "--pane-w": `${panePct}%` }}
|
|
696
|
+
className={paneCollapsed ? "flex shrink-0" : "absolute inset-0 z-20 flex md:static md:inset-auto md:w-[var(--pane-w)] md:shrink-0"}>
|
|
697
|
+
<GraphDocPane workspace={workspace} id={docId} collapsed={paneCollapsed} onToggle={() => setPaneCollapsed(!paneCollapsed)} onClose={closeDoc} />
|
|
698
|
+
</div>
|
|
699
|
+
</>
|
|
700
|
+
)}
|
|
701
|
+
</div>
|
|
702
|
+
);
|
|
703
|
+
}
|