@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,56 @@
|
|
|
1
|
+
// WIKI-GRAPH-DOC-PANE-1: the right-hand read-only doc pane of the Graph page. The page owns which doc it shows (`doc=` in the URL)
|
|
2
|
+
// and whether it is collapsed; this draws the note (the same markdown pieces NotePage uses) and a real link to the full page.
|
|
3
|
+
import React from "react";
|
|
4
|
+
import ReactMarkdown from "react-markdown";
|
|
5
|
+
import { ChevronsLeft, ChevronsRight, X } from "lucide-react";
|
|
6
|
+
import { Link, useNote } from "../../host";
|
|
7
|
+
import ErrorBox from "../../../parts/ErrorBox";
|
|
8
|
+
import Loading from "../../../parts/Loading";
|
|
9
|
+
import TypeBadge from "../../../parts/TypeBadge";
|
|
10
|
+
import { MD, MD_PLUGINS } from "../../../parts/Markdown";
|
|
11
|
+
import { fmtDate, noteUrl } from "../../../lib";
|
|
12
|
+
|
|
13
|
+
const ICON = "rounded-md border border-neutral-300 p-1 text-ink2 hover:text-ink";
|
|
14
|
+
|
|
15
|
+
/** The doc `id` of `workspace`, read-only. `collapsed` leaves a thin rail with the show button. */
|
|
16
|
+
export default function GraphDocPane({ workspace, id, collapsed, onToggle, onClose }) {
|
|
17
|
+
const q = useNote(workspace, id);
|
|
18
|
+
if (collapsed) {
|
|
19
|
+
return (
|
|
20
|
+
<aside aria-label="Doc" data-collapsed="1" className="pointer-events-auto flex w-10 shrink-0 flex-col items-center gap-2 border-l border-neutral-200 bg-ground py-3">
|
|
21
|
+
<button type="button" aria-label="Show doc" onClick={onToggle} className={ICON}><ChevronsLeft className="h-4 w-4" /></button>
|
|
22
|
+
<button type="button" aria-label="Close doc" onClick={onClose} className={ICON}><X className="h-4 w-4" /></button>
|
|
23
|
+
</aside>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
const n = q.data?.note;
|
|
27
|
+
return (
|
|
28
|
+
<aside aria-label="Doc" data-collapsed="0" className="flex min-h-0 min-w-0 flex-1 flex-col border-l border-neutral-200 bg-ground">
|
|
29
|
+
<div className="flex items-center gap-2 border-b border-neutral-200 px-4 py-2">
|
|
30
|
+
<Link to={noteUrl(workspace, id)} className="text-[13px] text-blue-600 hover:underline">Open doc</Link>
|
|
31
|
+
<button type="button" aria-label="Hide doc" onClick={onToggle} className={`${ICON} ml-auto`}><ChevronsRight className="h-4 w-4" /></button>
|
|
32
|
+
<button type="button" aria-label="Close doc" onClick={onClose} className={ICON}><X className="h-4 w-4" /></button>
|
|
33
|
+
</div>
|
|
34
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
|
35
|
+
{q.isLoading && <Loading />}
|
|
36
|
+
{q.error && <ErrorBox error={q.error} />}
|
|
37
|
+
{n && (
|
|
38
|
+
<article>
|
|
39
|
+
<h2 className="text-xl font-bold tracking-tight text-ink">{n.title}</h2>
|
|
40
|
+
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-ink2">
|
|
41
|
+
<TypeBadge type={n.kind} />
|
|
42
|
+
<span className="break-all font-mono text-neutral-500">{n.path}</span>
|
|
43
|
+
{n.mtime && <span>updated {fmtDate(n.mtime)}</span>}
|
|
44
|
+
</div>
|
|
45
|
+
<div className="mt-5">
|
|
46
|
+
{n.text == null
|
|
47
|
+
? <p className="text-sm text-neutral-500">Only this note's title and place are indexed; its text is not.</p>
|
|
48
|
+
: <div className="note-body"><ReactMarkdown skipHtml remarkPlugins={MD_PLUGINS} components={MD}>{n.text}</ReactMarkdown></div>}
|
|
49
|
+
{n.text_truncated && <p className="mt-6 text-xs text-neutral-500">This note is long; the rest is not shown here.</p>}
|
|
50
|
+
</div>
|
|
51
|
+
</article>
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
</aside>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { OTHER, OUTSIDE } from "../../lib/wikiGraph";
|
|
3
|
+
import { UNMAPPED } from "../../lib/areaFamilies";
|
|
4
|
+
import { graphTheme, hueFor } from "../../lib/graphTheme";
|
|
5
|
+
|
|
6
|
+
// WIKI-GRAPH-3D-COLOR-SPHERES-1: the legend is the colour key, the filter and the drill control, for the 3D and the 2D view. A row
|
|
7
|
+
// is a colour swatch, the group's name and doc count (one button: hide / show it, `aria-pressed`), and for a child folder an
|
|
8
|
+
// "open" button that drills into it. Groups past the palette share the grey and are one "other: N areas" row, which a disclosure opens into one row per area.
|
|
9
|
+
// WIKI-GRAPH-AREA-FAMILIES-1: groups that carry a `family` (colour by area) are rolled up instead: one row per family (swatch, name, "N areas", count) and
|
|
10
|
+
// one "unmapped: N areas" row, each toggling all its areas and opening into one row per area. The groups stay per area, so `hide=` keeps area names.
|
|
11
|
+
// State lives in the URL (`hide=`, `lv=`), never here, except whether the panel is folded and which disclosures are open.
|
|
12
|
+
const LOOSE = new Set(["(no area)", "(root)"]);
|
|
13
|
+
const PANEL = "pointer-events-auto rounded-lg border border-neutral-200/70 bg-ground/85 backdrop-blur";
|
|
14
|
+
const LINK = "text-xs text-blue-600 hover:underline";
|
|
15
|
+
|
|
16
|
+
/** `groups`: [{name, label?, count, color, path?}]; `hidden`: Set of group names; `onToggle(names[])` flips those names
|
|
17
|
+
* (all hidden -> shown, else hidden); `onDrill(path)` opens a child folder; `onAll()` / `onNone()` the bulk commands. */
|
|
18
|
+
export default function GraphLegend({ groups, hidden, scope = null, onToggle, onDrill, onAll, onNone }) {
|
|
19
|
+
const [open, setOpen] = useState(true);
|
|
20
|
+
const [showMore, setShowMore] = useState(false);
|
|
21
|
+
const [openFamilies, setOpenFamilies] = useState(() => new Set());
|
|
22
|
+
const byFamily = groups.some((g) => g.family);
|
|
23
|
+
const families = byFamily ? rollUp(groups) : [];
|
|
24
|
+
const rows = byFamily ? groups.filter((g) => LOOSE.has(g.name)) : groups.filter((g) => g.color !== OTHER || LOOSE.has(g.name));
|
|
25
|
+
const more = byFamily ? [] : groups.filter((g) => g.color === OTHER && !LOOSE.has(g.name)).sort((a, b) => b.count - a.count);
|
|
26
|
+
const moreCount = more.reduce((t, g) => t + g.count, 0);
|
|
27
|
+
const moreOff = more.length > 0 && more.every((g) => hidden.has(g.name));
|
|
28
|
+
const Swatch = ({ color: data, off }) => {
|
|
29
|
+
const color = hueFor(data); // the swatch is the node's colour in the mode in force (WIKI-GRAPH-LIGHT-HUES-1)
|
|
30
|
+
return <span className="inline-block h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: off ? "transparent" : color, border: `1px solid ${color}` }} />;
|
|
31
|
+
};
|
|
32
|
+
const row = (g) => {
|
|
33
|
+
const off = hidden.has(g.name);
|
|
34
|
+
return (
|
|
35
|
+
<li key={g.name} className="flex items-center gap-1">
|
|
36
|
+
<button type="button" aria-pressed={!off} title={off ? "Show this group" : "Hide this group"} onClick={() => onToggle([g.name])}
|
|
37
|
+
className={`flex min-w-0 flex-1 items-center gap-2 text-left text-xs ${off ? "text-neutral-400 line-through" : "text-ink2 hover:text-ink"}`}>
|
|
38
|
+
<Swatch color={g.color} off={off} />
|
|
39
|
+
<span className="truncate">{g.label ?? g.name}</span><span className="ml-auto text-neutral-400">{g.count}</span>
|
|
40
|
+
</button>
|
|
41
|
+
{g.path != null && onDrill && (
|
|
42
|
+
<button type="button" aria-label={`Open ${g.label ?? g.name}`} title="Open this folder" onClick={() => onDrill(g.path)} className="text-xs text-neutral-500 hover:text-ink">›</button>
|
|
43
|
+
)}
|
|
44
|
+
</li>
|
|
45
|
+
);
|
|
46
|
+
};
|
|
47
|
+
const familyRow = (f) => {
|
|
48
|
+
const off = f.areas.every((g) => hidden.has(g.name)), isOpen = openFamilies.has(f.name);
|
|
49
|
+
const label = f.unmapped ? `unmapped: ${f.areas.length} ${f.areas.length === 1 ? "area" : "areas"}` : f.name;
|
|
50
|
+
return (
|
|
51
|
+
<li key={f.name}>
|
|
52
|
+
<div className="flex items-center gap-1">
|
|
53
|
+
<button type="button" aria-pressed={!off} title={off ? "Show this family" : "Hide this family"} onClick={() => onToggle(f.areas.map((g) => g.name))}
|
|
54
|
+
className={`flex min-w-0 flex-1 items-center gap-2 text-left text-xs ${off ? "text-neutral-400 line-through" : "text-ink2 hover:text-ink"}`}>
|
|
55
|
+
<Swatch color={f.color} off={off} />
|
|
56
|
+
<span className="truncate">{label}</span>
|
|
57
|
+
{!f.unmapped && <span className="text-neutral-400">{f.areas.length} {f.areas.length === 1 ? "area" : "areas"}</span>}
|
|
58
|
+
<span className="ml-auto text-neutral-400">{f.count}</span>
|
|
59
|
+
</button>
|
|
60
|
+
<button type="button" aria-expanded={isOpen} aria-label={`${isOpen ? "Hide" : "Show"} the areas of ${f.unmapped ? "unmapped" : f.name}`}
|
|
61
|
+
onClick={() => setOpenFamilies(new Set(isOpen ? [...openFamilies].filter((n) => n !== f.name) : [...openFamilies, f.name]))}
|
|
62
|
+
className="text-xs text-neutral-500 hover:text-ink">{isOpen ? "▾" : "▸"}</button>
|
|
63
|
+
</div>
|
|
64
|
+
{isOpen && <ul className="mt-0.5 space-y-0.5 pl-4" aria-label={`Areas of ${f.unmapped ? "unmapped" : f.name}`}>{f.areas.map(row)}</ul>}
|
|
65
|
+
</li>
|
|
66
|
+
);
|
|
67
|
+
};
|
|
68
|
+
return (
|
|
69
|
+
<section className={`${PANEL} absolute bottom-10 left-3 flex max-h-[45%] w-60 max-w-[60%] flex-col`} aria-label="Colour legend" data-testid="graph-legend">
|
|
70
|
+
<div className="flex items-center gap-2 px-3 py-2">
|
|
71
|
+
<h2 className="text-xs font-semibold uppercase tracking-wide text-neutral-500">{scope ? `In ${scope}` : "Legend"}</h2>
|
|
72
|
+
<button type="button" onClick={onAll} className={`${LINK} ml-auto`}>all</button>
|
|
73
|
+
<button type="button" onClick={onNone} className={LINK}>none</button>
|
|
74
|
+
<button type="button" aria-expanded={open} aria-label={open ? "Fold legend" : "Unfold legend"} onClick={() => setOpen(!open)} className="text-xs text-neutral-500 hover:text-ink">{open ? "–" : "+"}</button>
|
|
75
|
+
</div>
|
|
76
|
+
{open && (
|
|
77
|
+
<div className="min-h-0 overflow-y-auto border-t border-neutral-200/70 px-3 py-2">
|
|
78
|
+
<ul className="space-y-0.5" aria-label="Legend">
|
|
79
|
+
{families.map(familyRow)}
|
|
80
|
+
{rows.map(row)}
|
|
81
|
+
{more.length > 0 && (
|
|
82
|
+
<li>
|
|
83
|
+
<div className="flex items-center gap-1">
|
|
84
|
+
<button type="button" aria-pressed={!moreOff} onClick={() => onToggle(more.map((g) => g.name))}
|
|
85
|
+
className={`flex min-w-0 flex-1 items-center gap-2 text-left text-xs ${moreOff ? "text-neutral-400 line-through" : "text-ink2 hover:text-ink"}`}>
|
|
86
|
+
<Swatch color={OTHER} off={moreOff} />
|
|
87
|
+
<span className="truncate">other: {more.length} {more.length === 1 ? "area" : "areas"}</span><span className="ml-auto text-neutral-400">{moreCount}</span>
|
|
88
|
+
</button>
|
|
89
|
+
<button type="button" aria-expanded={showMore} aria-label={showMore ? "Hide the other areas" : "Show the other areas"} onClick={() => setShowMore(!showMore)}
|
|
90
|
+
className="text-xs text-neutral-500 hover:text-ink">{showMore ? "▾" : "▸"}</button>
|
|
91
|
+
</div>
|
|
92
|
+
{showMore && <ul className="mt-0.5 space-y-0.5 pl-4" aria-label="Other areas">{more.map(row)}</ul>}
|
|
93
|
+
</li>
|
|
94
|
+
)}
|
|
95
|
+
</ul>
|
|
96
|
+
<dl className="mt-2 space-y-0.5 border-t border-neutral-200/70 pt-2 text-[11px] text-neutral-500" aria-label="Shapes">
|
|
97
|
+
<div className="flex items-center gap-2"><dt className="inline-block h-2.5 w-2.5 rounded-full bg-neutral-400" /><dd>doc</dd></div>
|
|
98
|
+
<div className="flex items-center gap-2"><dt className="inline-block h-2.5 w-2.5 rounded-full border border-neutral-400" style={{ background: "rgba(148,163,184,0.3)" }} /><dd>folder (glass shell)</dd></div>
|
|
99
|
+
<div className="flex items-center gap-2"><dt className="inline-block h-2.5 w-2.5 rounded-full bg-neutral-400" style={{ boxShadow: `0 0 0 2px ${graphTheme().halo}` }} /><dd>hub (halo)</dd></div>
|
|
100
|
+
{groups.some((g) => g.color === OUTSIDE) && <div className="flex items-center gap-2"><dt className="inline-block h-2.5 w-2.5 rounded-full" style={{ background: hueFor(OUTSIDE) }} /><dd>outside this folder</dd></div>}
|
|
101
|
+
</dl>
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
</section>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Groups carrying a `family` as one entry per family, biggest first, the unmapped one last: `{name, color, count, areas (biggest first), unmapped}`. */
|
|
109
|
+
function rollUp(groups) {
|
|
110
|
+
const by = new Map();
|
|
111
|
+
for (const g of groups) {
|
|
112
|
+
if (!g.family) continue;
|
|
113
|
+
const f = by.get(g.family) || { name: g.family, color: g.color, count: 0, areas: [], unmapped: g.family === UNMAPPED };
|
|
114
|
+
f.count += g.count; f.areas.push(g);
|
|
115
|
+
by.set(g.family, f);
|
|
116
|
+
}
|
|
117
|
+
for (const f of by.values()) f.areas.sort((a, b) => b.count - a.count || String(a.name).localeCompare(String(b.name)));
|
|
118
|
+
return [...by.values()].sort((a, b) => a.unmapped - b.unmapped || b.count - a.count || a.name.localeCompare(b.name));
|
|
119
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { formatRange, toggleLevel } from "../../lib/graphLevels";
|
|
3
|
+
|
|
4
|
+
const CHIP = "rounded-md border px-2 py-1 text-[13px] hover:text-ink";
|
|
5
|
+
const on = "border-blue-500 text-ink", off = "border-neutral-300 text-ink2";
|
|
6
|
+
|
|
7
|
+
/** WIKI-GRAPH-LEVELS-3D-1: the level picker. One chip per level (1..`max`) that shows a depth RANGE, plus "All docs" (the flat
|
|
8
|
+
* graph). `range` is [from, to] (the `lr=` value); `flat` means "All docs" is on, and then no level chip is pressed. A level
|
|
9
|
+
* chip toggles that level in the range (`toggleLevel`): a click that would leave a gap snaps to the covering range and says
|
|
10
|
+
* so. `onRange([from, to])` is told the new range (and leaves the flat graph); `onAll()` turns "All docs" on. A page-level
|
|
11
|
+
* component, so the later command palette can absorb it. */
|
|
12
|
+
export default function GraphLevelPicker({ range, max, flat, onRange, onAll }) {
|
|
13
|
+
const [note, setNote] = useState("");
|
|
14
|
+
if (!(max >= 1)) return null;
|
|
15
|
+
const pick = (k) => {
|
|
16
|
+
if (flat) { setNote(""); onRange([k, k]); return; }
|
|
17
|
+
const r = toggleLevel(range, k);
|
|
18
|
+
setNote(r.snapped ? `Levels show as a range: snapped to ${formatRange(r.range)}.` : "");
|
|
19
|
+
onRange(r.range);
|
|
20
|
+
};
|
|
21
|
+
return (
|
|
22
|
+
<div role="group" aria-label="Graph levels" className="flex flex-wrap items-center gap-1.5">
|
|
23
|
+
<span className="text-xs text-neutral-500">Levels</span>
|
|
24
|
+
{Array.from({ length: max }, (_, i) => i + 1).map((k) => {
|
|
25
|
+
const pressed = !flat && k >= range[0] && k <= range[1];
|
|
26
|
+
return (
|
|
27
|
+
<button key={k} type="button" aria-pressed={pressed} aria-label={`Level ${k}`} onClick={() => pick(k)} className={`${CHIP} ${pressed ? on : off}`}>{k}</button>
|
|
28
|
+
);
|
|
29
|
+
})}
|
|
30
|
+
<button type="button" aria-pressed={flat} onClick={() => { setNote(""); onAll(); }} className={`${CHIP} ${flat ? on : off}`}>All docs</button>
|
|
31
|
+
<span className="sr-only" role="status" aria-live="polite">{note}</span>
|
|
32
|
+
{note && <span aria-hidden="true" className="text-xs text-neutral-500">{note}</span>}
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// WIKI-GRAPH-EXPLORE-1b-SHELL (part A): the filter chips under the omnibox: one pill per step, the count, the "ignored" notice and
|
|
2
|
+
// the hint when the stack leaves nothing. Controlled: the page passes the explore state and its run and does the removing. Not wired in yet.
|
|
3
|
+
import { emptyHint, ignoredNotice, sentenceOf } from "../../../lib/graphExplore";
|
|
4
|
+
|
|
5
|
+
const PILL = "inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs";
|
|
6
|
+
const X = "rounded-full px-1 text-neutral-500 hover:bg-neutral-200 hover:text-ink";
|
|
7
|
+
|
|
8
|
+
/** Props: `state` (explore state), `run` (explorer.run result or null), `idx` + `explorer` (names, the empty hint), `ignored`
|
|
9
|
+
* (count dropped from a newer link), `onRemove(i)`, `onClear()`, `onRemoveLens(i)`. A path lens is drawn (the page lights it) and has a remove
|
|
10
|
+
* button; a highlight lens is decoded and round-trips but nothing draws it: an outlined pill titled "not drawn yet". Renders nothing when there is nothing to show. */
|
|
11
|
+
export default function FilterChips({ state, run = null, idx = null, explorer = null, ignored = 0, onRemove, onClear, onRemoveLens }) {
|
|
12
|
+
const { chips, lenses, countText } = sentenceOf(state, run, idx);
|
|
13
|
+
const notice = ignoredNotice(ignored);
|
|
14
|
+
if (!chips.length && !lenses.length && !notice) return null;
|
|
15
|
+
const hint = explorer && chips.length ? emptyHint(explorer, state) : null;
|
|
16
|
+
return (
|
|
17
|
+
<div className="mt-2 space-y-1" data-testid="graph-chips">
|
|
18
|
+
{(chips.length > 0 || lenses.length > 0) && (
|
|
19
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
20
|
+
{chips.map((c, i) => (
|
|
21
|
+
<span key={`${i}:${c.text}`} className={`${PILL} border-neutral-300 bg-neutral-100 text-ink`} title={c.unsupported ? "This filter is not supported yet" : undefined} data-unsupported={c.unsupported || undefined}>
|
|
22
|
+
{c.text}
|
|
23
|
+
<button type="button" aria-label={`Remove filter "${c.text}"`} onClick={() => onRemove(i)} className={X}>×</button>
|
|
24
|
+
</span>
|
|
25
|
+
))}
|
|
26
|
+
{lenses.map((l, i) => (
|
|
27
|
+
<span key={`lens:${i}:${l.text}`} className={`${PILL} border-dashed border-neutral-400 bg-white text-neutral-600`} title={l.path ? "Shortest path, lit on the graph" : "not drawn yet"} data-lens data-path={l.path || undefined} data-no-path={l.path && l.text.startsWith("no path") ? "" : undefined}>
|
|
28
|
+
{l.text}
|
|
29
|
+
{l.path && onRemoveLens && <button type="button" aria-label={`Remove lens "${l.text}"`} onClick={() => onRemoveLens(i)} className={X}>×</button>}
|
|
30
|
+
</span>
|
|
31
|
+
))}
|
|
32
|
+
{chips.length > 0 && <button type="button" onClick={onClear} className="text-xs text-neutral-500 underline hover:text-ink">Clear filters</button>}
|
|
33
|
+
</div>
|
|
34
|
+
)}
|
|
35
|
+
{countText && chips.length > 0 && <p className="text-xs text-neutral-500" data-testid="graph-chip-count">{countText}</p>}
|
|
36
|
+
{notice && <p className="text-xs text-amber-700" role="status">{notice}</p>}
|
|
37
|
+
{hint && (
|
|
38
|
+
<p className="text-xs text-neutral-600" data-testid="graph-empty-hint">
|
|
39
|
+
{hint.text}{" "}
|
|
40
|
+
<button type="button" aria-label={`Remove filter "${chips[hint.index].text}" (hint)`} onClick={() => onRemove(hint.index)} className="underline hover:text-ink">Remove it</button>
|
|
41
|
+
</p>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// WIKI-GRAPH-EXPLORE-1b-SHELL (part A): the Graph page's find box as an omnibox. Controlled: the page owns the text, the rows
|
|
2
|
+
// (graphShell omniRows) and what a row does; this draws the combobox, runs the keys and the active row. Not wired in yet.
|
|
3
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
4
|
+
import { moveCursor } from "../../../lib/graphShell";
|
|
5
|
+
|
|
6
|
+
const FALLBACK_INPUT = "rounded-md border border-neutral-300 bg-white px-2 py-1 text-[13px] text-ink";
|
|
7
|
+
|
|
8
|
+
/** Props: `term` / `onTerm(text)`; `rows` (omniRows); `open` / `onOpenChange(bool)` (the list); `countText` + `foundCount` (the live
|
|
9
|
+
* match line under the box); `onAction(row)` for fly / step / clear rows (a verb row fills the box itself and keeps focus);
|
|
10
|
+
* `onBackspaceEmpty()` (remove the last chip); `inputClassName`. Keys: Up/Down move the active row; Enter picks it, else the
|
|
11
|
+
* first row that has an action; Esc with text clears the text and closes the list (and stops there); Backspace on an empty box
|
|
12
|
+
* calls `onBackspaceEmpty`. `focusSignal`: a counter, each bump focuses the box. */
|
|
13
|
+
export default function Omnibox({ term, onTerm, rows, open, onOpenChange, countText = "", foundCount = null, onAction, onBackspaceEmpty, focusSignal = 0, inputClassName = FALLBACK_INPUT }) {
|
|
14
|
+
const [cursor, setCursor] = useState(-1);
|
|
15
|
+
const input = useRef(null);
|
|
16
|
+
const listId = useId();
|
|
17
|
+
const rowsKey = rows.map((r) => r.key).join("|");
|
|
18
|
+
useEffect(() => setCursor(-1), [rowsKey]);
|
|
19
|
+
useEffect(() => { if (focusSignal) input.current?.focus(); }, [focusSignal]); // the page asks for focus (the doc card's Path to…)
|
|
20
|
+
const shown = open && rows.length > 0;
|
|
21
|
+
|
|
22
|
+
const pick = (row) => {
|
|
23
|
+
if (!row?.action) return;
|
|
24
|
+
if (row.action.type === "verb") { onTerm(row.action.fill); onOpenChange(true); input.current?.focus(); return; }
|
|
25
|
+
onAction(row);
|
|
26
|
+
};
|
|
27
|
+
const onKeyDown = (e) => {
|
|
28
|
+
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
29
|
+
if (!rows.length) return;
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
onOpenChange(true);
|
|
32
|
+
setCursor(moveCursor(rows, cursor, e.key === "ArrowDown" ? 1 : -1));
|
|
33
|
+
} else if (e.key === "Enter") {
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
pick(rows[cursor]?.action ? rows[cursor] : rows.find((r) => r.action)); // the live rows: row 0 is the top title match for the live term
|
|
36
|
+
} else if (e.key === "Escape" && term) {
|
|
37
|
+
e.preventDefault(); e.stopPropagation();
|
|
38
|
+
onTerm(""); onOpenChange(false);
|
|
39
|
+
} else if (e.key === "Backspace" && term === "") {
|
|
40
|
+
onBackspaceEmpty?.();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="relative">
|
|
46
|
+
<input
|
|
47
|
+
ref={input} role="combobox" aria-expanded={shown} aria-controls={listId} aria-autocomplete="list"
|
|
48
|
+
aria-activedescendant={shown && cursor >= 0 ? `${listId}-${cursor}` : undefined}
|
|
49
|
+
value={term} onChange={(e) => { onTerm(e.target.value); onOpenChange(true); }} onKeyDown={onKeyDown}
|
|
50
|
+
placeholder="Find a doc by title" aria-label="Find a doc by title" className={`${inputClassName} w-full`}
|
|
51
|
+
/>
|
|
52
|
+
<p className="mt-1 min-h-[1.25rem] text-xs text-neutral-500" aria-live="polite" data-testid="graph-find-count" data-graph-find-count={foundCount === null ? "" : foundCount}>
|
|
53
|
+
{countText}
|
|
54
|
+
</p>
|
|
55
|
+
{shown && (
|
|
56
|
+
<ul id={listId} role="listbox" aria-label="Matching docs and filters" className="mt-1 overflow-hidden rounded-md border border-neutral-200 bg-white shadow-lg">
|
|
57
|
+
{rows.map((r, i) => (
|
|
58
|
+
<li key={r.key} id={`${listId}-${i}`} role="option" aria-selected={i === cursor} data-kind={r.kind}>
|
|
59
|
+
{r.action ? (
|
|
60
|
+
<button type="button" aria-label={r.label} onClick={() => pick(r)}
|
|
61
|
+
className={`flex w-full items-baseline justify-between gap-2 px-3 py-1.5 text-left text-[13px] text-ink hover:bg-neutral-100 ${i === cursor ? "bg-neutral-100" : ""}`}>
|
|
62
|
+
<span className="truncate">{r.label}</span>
|
|
63
|
+
{r.detail && <span className="shrink-0 text-xs text-neutral-500">{r.detail}</span>}
|
|
64
|
+
</button>
|
|
65
|
+
) : (
|
|
66
|
+
<p className="px-3 py-1.5 text-[13px] text-neutral-500">{r.label}</p>
|
|
67
|
+
)}
|
|
68
|
+
</li>
|
|
69
|
+
))}
|
|
70
|
+
</ul>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// WIKI-GRAPH-FOCUS-HOPS-1: the hub guard's "+N" badges on the overlay, shared by both canvases. A lit node whose hop ring
|
|
2
|
+
// was capped (layoutEngine hopRings `capped`) gets a small pill beside it naming how many neighbours it hides; a click on
|
|
3
|
+
// the pill expands that node alone (the canvas's `onBadge`). Positions are CSS pixels; the boxes come back for picking.
|
|
4
|
+
export const BADGE_H = 16;
|
|
5
|
+
const FONT = "600 11px ui-sans-serif, system-ui, sans-serif";
|
|
6
|
+
const FILL = "#1f2937", TEXT = "#f9fafb", EDGE = "#f59e0b";
|
|
7
|
+
|
|
8
|
+
/** Where each badge goes: `items` [{i, n, x, y, r}] (node index, hidden count, screen centre, drawn radius in px) ->
|
|
9
|
+
* [{i, n, text, x, y, w, h}] boxes, left edge just right of the node. `measure(text)` gives a text width in px. */
|
|
10
|
+
export function badgeBoxes(items, measure = (t) => 7 * t.length) {
|
|
11
|
+
return items.map(({ i, n, x, y, r }) => {
|
|
12
|
+
const text = `+${n}`, w = Math.ceil(measure(text)) + 10;
|
|
13
|
+
return { i, n, text, x: x + r + 3, y: y - BADGE_H / 2, w, h: BADGE_H };
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The node index whose badge box holds (x, y), or -1. */
|
|
18
|
+
export function badgeAt(boxes, x, y) {
|
|
19
|
+
for (const b of boxes || []) if (x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h) return b.i;
|
|
20
|
+
return -1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Draw the badges for `items` on the overlay context (after drawOverlay, which clears it). Returns their boxes. */
|
|
24
|
+
export function drawBadges(ctx, items, dpr) {
|
|
25
|
+
if (!items.length) return [];
|
|
26
|
+
if (!ctx) return badgeBoxes(items);
|
|
27
|
+
ctx.save();
|
|
28
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
29
|
+
ctx.font = FONT;
|
|
30
|
+
const boxes = badgeBoxes(items, (t) => ctx.measureText?.(t)?.width || 7 * t.length);
|
|
31
|
+
ctx.textAlign = "center";
|
|
32
|
+
ctx.textBaseline = "middle";
|
|
33
|
+
for (const b of boxes) {
|
|
34
|
+
ctx.fillStyle = FILL;
|
|
35
|
+
ctx.strokeStyle = EDGE;
|
|
36
|
+
ctx.lineWidth = 1.5;
|
|
37
|
+
ctx.beginPath();
|
|
38
|
+
if (ctx.roundRect) ctx.roundRect(b.x, b.y, b.w, b.h, b.h / 2); else ctx.rect(b.x, b.y, b.w, b.h);
|
|
39
|
+
ctx.fill();
|
|
40
|
+
ctx.stroke();
|
|
41
|
+
ctx.fillStyle = TEXT;
|
|
42
|
+
ctx.fillText(b.text, b.x + b.w / 2, b.y + b.h / 2 + 0.5);
|
|
43
|
+
}
|
|
44
|
+
ctx.restore();
|
|
45
|
+
return boxes;
|
|
46
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// WIKI-GRAPH-CLUSTER-NORELAYOUT-1, restyled by WIKI-GRAPH-CLUSTER-REGROUP-1 (owner 2026-10-21): the 3D group outlines, drawn
|
|
2
|
+
// only with the "Group outlines" toggle. One InstancedMesh of spheres, FrontSide only (one flat layer a bubble, no stacked
|
|
3
|
+
// inner volume), at OUTLINE_STYLE.fill with no depth write, so the docs in a bubble stay sharp; the thin ring is drawn on the
|
|
4
|
+
// 2D overlay (drawBubbleLabels `outlines`) at the bubble's projected silhouette, with the group's name above it. Fed by
|
|
5
|
+
// groupBubbles (lib/groupBubbles.js); nothing here touches the layout.
|
|
6
|
+
import { Color, DynamicDrawUsage, FrontSide, IcosahedronGeometry, InstancedMesh, MeshBasicMaterial } from "three";
|
|
7
|
+
import { project } from "../../../lib/graph3d";
|
|
8
|
+
import { OUTLINE_STYLE } from "../../../lib/groupBubbles";
|
|
9
|
+
import { graphTheme } from "../../../lib/graphTheme";
|
|
10
|
+
|
|
11
|
+
/** Bubbles in `scene`. update(bubbles) with [{centre, radius, color}] (colour as 0xRRGGBB); clear(); dispose(). */
|
|
12
|
+
export function createBubbles3d(scene) {
|
|
13
|
+
const geo = new IcosahedronGeometry(1, 2);
|
|
14
|
+
const face = new MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: OUTLINE_STYLE.fill, depthWrite: false, side: FrontSide });
|
|
15
|
+
const tint = new Color();
|
|
16
|
+
let cap = 0, meshes = [];
|
|
17
|
+
const make = (n) => {
|
|
18
|
+
for (const m of meshes) { scene.remove(m); m.dispose(); }
|
|
19
|
+
cap = Math.max(8, n);
|
|
20
|
+
meshes = [face].map((mat) => {
|
|
21
|
+
const m = new InstancedMesh(geo, mat, cap);
|
|
22
|
+
m.instanceMatrix.setUsage(DynamicDrawUsage);
|
|
23
|
+
m.count = 0;
|
|
24
|
+
m.frustumCulled = false;
|
|
25
|
+
m.renderOrder = -1; // before the edges and arrowheads among the see-through objects
|
|
26
|
+
m.userData.bubbles = true;
|
|
27
|
+
scene.add(m);
|
|
28
|
+
return m;
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
make(0);
|
|
32
|
+
return {
|
|
33
|
+
get count() { return meshes[0].count; },
|
|
34
|
+
meshes: () => meshes,
|
|
35
|
+
update(bubbles) {
|
|
36
|
+
if (bubbles.length > cap) make(bubbles.length);
|
|
37
|
+
for (const m of meshes) {
|
|
38
|
+
const a = m.instanceMatrix.array;
|
|
39
|
+
bubbles.forEach((b, k) => {
|
|
40
|
+
const o = 16 * k, r = b.radius;
|
|
41
|
+
a.fill(0, o, o + 16);
|
|
42
|
+
a[o] = a[o + 5] = a[o + 10] = r;
|
|
43
|
+
a[o + 12] = b.centre[0]; a[o + 13] = b.centre[1]; a[o + 14] = b.centre[2]; a[o + 15] = 1;
|
|
44
|
+
m.setColorAt(k, tint.setHex(b.color));
|
|
45
|
+
});
|
|
46
|
+
m.count = bubbles.length;
|
|
47
|
+
m.instanceMatrix.needsUpdate = true;
|
|
48
|
+
if (m.instanceColor) m.instanceColor.needsUpdate = true;
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
clear() { for (const m of meshes) m.count = 0; },
|
|
52
|
+
dispose() {
|
|
53
|
+
for (const m of meshes) { scene.remove(m); m.dispose(); }
|
|
54
|
+
geo.dispose(); face.dispose();
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Where each bubble's name goes this frame: [{text, x, y, cx, cy, r, color}] in CSS px: the name above the bubble's projected
|
|
60
|
+
* top, the projected centre and silhouette radius for the outline ring; bubbles behind the camera are skipped. `frame` is
|
|
61
|
+
* the base's frame() ({vp, focal}). */
|
|
62
|
+
export function bubbleLabels(bubbles, frame, w, h) {
|
|
63
|
+
const n = bubbles.length;
|
|
64
|
+
if (!n) return [];
|
|
65
|
+
const xyz = new Float32Array(3 * n), rad = new Float32Array(n), scr = new Float32Array(3 * n), rpx = new Float32Array(n);
|
|
66
|
+
bubbles.forEach((b, k) => { xyz.set(b.centre, 3 * k); rad[k] = b.radius; });
|
|
67
|
+
project(xyz, rad, n, frame.vp, frame.focal, w, h, scr, rpx);
|
|
68
|
+
const out = [];
|
|
69
|
+
for (let k = 0; k < n; k++) if (scr[3 * k + 2] > 1e-6) out.push({ text: bubbles[k].group, x: scr[3 * k], y: scr[3 * k + 1] - rpx[k] - 4, cx: scr[3 * k], cy: scr[3 * k + 1], r: rpx[k], color: bubbles[k].color });
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The bubble names on the overlay context (after drawOverlay, which clears it); with `outlines`, each bubble's thin ring too. */
|
|
74
|
+
export function drawBubbleLabels(ctx, labels, dpr, { outlines = false } = {}) {
|
|
75
|
+
if (!ctx || !labels.length) return;
|
|
76
|
+
ctx.save();
|
|
77
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
78
|
+
if (outlines) {
|
|
79
|
+
ctx.lineWidth = OUTLINE_STYLE.width;
|
|
80
|
+
for (const l of labels) {
|
|
81
|
+
if (!(l.r > 0)) continue;
|
|
82
|
+
const c = l.color ?? 0xffffff;
|
|
83
|
+
ctx.strokeStyle = `rgba(${(c >> 16) & 255},${(c >> 8) & 255},${c & 255},${OUTLINE_STYLE.line})`;
|
|
84
|
+
ctx.beginPath();
|
|
85
|
+
ctx.arc(l.cx, l.cy, l.r, 0, 2 * Math.PI);
|
|
86
|
+
ctx.stroke();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
ctx.font = "600 12px ui-sans-serif, system-ui, sans-serif";
|
|
90
|
+
ctx.textAlign = "center";
|
|
91
|
+
ctx.textBaseline = "bottom";
|
|
92
|
+
ctx.fillStyle = graphTheme().bubbleLabel;
|
|
93
|
+
for (const l of labels) ctx.fillText(l.text, l.x, l.y);
|
|
94
|
+
ctx.restore();
|
|
95
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// WIKI-GRAPH-OBSIDIAN-1: the base layer without WebGL, on Canvas2D: the same edges and nodes as pixiBase.js, over the same
|
|
2
|
+
// worker layout and under the same overlay, so every interaction works the same. Chosen over Pixi's own Canvas renderer
|
|
3
|
+
// (build-report.md "fallback decision"): no second Pixi renderer chunk, no dependence on Pixi's GPU detection, and this
|
|
4
|
+
// drawing (edges as one path, nodes batched by colour, culled to the view) is today's, proven at 5k docs. The cost is a
|
|
5
|
+
// full redraw on every changed frame, so a pan on a large graph is slower than on the GPU.
|
|
6
|
+
import { ARROW_CAP, arrowsFor, fillArrows } from "../../../lib/graphArrows";
|
|
7
|
+
import { CORE, HALO, LOOK, SHELL_ALPHA, drawRadius } from "../../../lib/graphView";
|
|
8
|
+
import { edgeAlphaFor, linksInView } from "../../../lib/wikiGraph";
|
|
9
|
+
import { LINK_BUCKETS, linkBuckets } from "../../../lib/linkWeight";
|
|
10
|
+
import { graphTheme } from "../../../lib/graphTheme";
|
|
11
|
+
|
|
12
|
+
const ARROW_ALPHA = 0.75;
|
|
13
|
+
const ARROW_MARGIN = 200; // heads are culled to the view grown by this many px, so a small pan reuses the path
|
|
14
|
+
|
|
15
|
+
const hex = (c) => `#${c.toString(16).padStart(6, "0")}`;
|
|
16
|
+
|
|
17
|
+
export function createCanvasBase(canvas, { w, h, dpr, background }) {
|
|
18
|
+
let ctx = null;
|
|
19
|
+
try { ctx = canvas.getContext("2d"); } catch { ctx = null; } // jsdom has no canvas: nothing is drawn, nothing breaks
|
|
20
|
+
let size = { w, h, dpr }, sc = null, byColour = [];
|
|
21
|
+
const tris = new Float32Array(6 * ARROW_CAP);
|
|
22
|
+
let arrowPath = null, arrowAt = null, arrowStale = true;
|
|
23
|
+
let alphaAt = null, alphaStale = true, alphaNow = 0.1; // edge alpha from the links in view, redone on stale or a real move
|
|
24
|
+
|
|
25
|
+
const restyle = (s) => {
|
|
26
|
+
const lists = new Map();
|
|
27
|
+
for (let i = 0; i < s.n; i++) {
|
|
28
|
+
let l = lists.get(s.colors[i]);
|
|
29
|
+
if (!l) lists.set(s.colors[i], (l = []));
|
|
30
|
+
l.push(i);
|
|
31
|
+
}
|
|
32
|
+
byColour = [...lists.entries()].map(([c, l]) => [hex(c), Uint32Array.from(l)]);
|
|
33
|
+
};
|
|
34
|
+
const place = () => {
|
|
35
|
+
canvas.width = Math.round(size.w * size.dpr);
|
|
36
|
+
canvas.height = Math.round(size.h * size.dpr);
|
|
37
|
+
canvas.style.width = `${size.w}px`;
|
|
38
|
+
canvas.style.height = `${size.h}px`;
|
|
39
|
+
};
|
|
40
|
+
place();
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
kind: "canvas",
|
|
44
|
+
setData(s) { sc = s; restyle(s); arrowStale = true; alphaStale = true; },
|
|
45
|
+
setStyle(s) { sc = s; restyle(s); arrowStale = true; alphaStale = true; },
|
|
46
|
+
setVisible(s) { sc = s; arrowStale = true; alphaStale = true; },
|
|
47
|
+
setPositions(s) { sc = s; arrowStale = true; alphaStale = true; },
|
|
48
|
+
setView(s) { sc = s; },
|
|
49
|
+
resize(w2, h2, dpr2) { size = { w: w2, h: h2, dpr: dpr2 }; arrowStale = true; alphaStale = true; place(); },
|
|
50
|
+
render() {
|
|
51
|
+
if (!ctx || !sc) return false;
|
|
52
|
+
const { w: W, h: H, dpr: D } = size;
|
|
53
|
+
const { pos, view: v, mask, sizes, big, visPairs, visCount } = sc;
|
|
54
|
+
ctx.setTransform(D, 0, 0, D, 0, 0);
|
|
55
|
+
ctx.fillStyle = background;
|
|
56
|
+
ctx.fillRect(0, 0, W, H);
|
|
57
|
+
ctx.setTransform(D * v.k, 0, 0, D * v.k, D * v.x, D * v.y);
|
|
58
|
+
if (alphaStale || !alphaAt || alphaAt.k !== v.k || Math.abs(alphaAt.x - v.x) > ARROW_MARGIN / 2 || Math.abs(alphaAt.y - v.y) > ARROW_MARGIN / 2) {
|
|
59
|
+
alphaStale = false;
|
|
60
|
+
alphaAt = { x: v.x, y: v.y, k: v.k };
|
|
61
|
+
alphaNow = edgeAlphaFor(linksInView(pos, visPairs, visCount, v, W, H), W, H);
|
|
62
|
+
}
|
|
63
|
+
ctx.strokeStyle = `rgba(${graphTheme().linkRgb},${alphaNow})`;
|
|
64
|
+
const bk = sc.visBuckets || linkBuckets(null, visCount); // one pass a width bucket, 1 px up to LINK_BUCKETS px (linkWeight.js)
|
|
65
|
+
for (let w = 1; w <= LINK_BUCKETS; w++) {
|
|
66
|
+
if (bk.start[w] === bk.start[w + 1]) continue;
|
|
67
|
+
ctx.lineWidth = w / v.k;
|
|
68
|
+
ctx.beginPath();
|
|
69
|
+
for (let j = bk.start[w]; j < bk.start[w + 1]; j++) {
|
|
70
|
+
const e = 2 * bk.idx[j], a = visPairs[e], b = visPairs[e + 1];
|
|
71
|
+
ctx.moveTo(pos[2 * a], pos[2 * a + 1]);
|
|
72
|
+
ctx.lineTo(pos[2 * b], pos[2 * b + 1]);
|
|
73
|
+
}
|
|
74
|
+
ctx.stroke();
|
|
75
|
+
}
|
|
76
|
+
if (arrowsFor(sc.visibleN ?? sc.n, v.k, big)) { // heads: one cached path of triangles in world units (graphArrows.js)
|
|
77
|
+
const moved = !arrowAt || arrowAt.k !== v.k || Math.abs(arrowAt.x - v.x) > ARROW_MARGIN / 2 || Math.abs(arrowAt.y - v.y) > ARROW_MARGIN / 2;
|
|
78
|
+
if (arrowStale || moved) {
|
|
79
|
+
arrowStale = false;
|
|
80
|
+
arrowAt = { x: v.x, y: v.y, k: v.k };
|
|
81
|
+
const t = fillArrows(sc, v, W, H, tris, ARROW_MARGIN);
|
|
82
|
+
arrowPath = t ? new Path2D() : null;
|
|
83
|
+
for (let i = 0; i < 6 * t; i += 6) {
|
|
84
|
+
arrowPath.moveTo(tris[i], tris[i + 1]);
|
|
85
|
+
arrowPath.lineTo(tris[i + 2], tris[i + 3]);
|
|
86
|
+
arrowPath.lineTo(tris[i + 4], tris[i + 5]);
|
|
87
|
+
arrowPath.closePath();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (arrowPath) { ctx.fillStyle = `rgba(${graphTheme().linkRgb},${ARROW_ALPHA})`; ctx.fill(arrowPath); }
|
|
91
|
+
}
|
|
92
|
+
const x0 = -v.x / v.k, y0 = -v.y / v.k, x1 = (W - v.x) / v.k, y1 = (H - v.y) / v.k, m = 25 / v.k;
|
|
93
|
+
// Looks (graphView LOOK, the same as 3D): a hub gets a white disc a little bigger than itself (its halo rim), a folder is a
|
|
94
|
+
// translucent shell at full radius round a solid core.
|
|
95
|
+
const looks = sc.looks;
|
|
96
|
+
const visible = (i, x, y) => mask[i] && x >= x0 - m && x <= x1 + m && y >= y0 - m && y <= y1 + m;
|
|
97
|
+
if (looks) {
|
|
98
|
+
ctx.fillStyle = graphTheme().halo;
|
|
99
|
+
ctx.beginPath();
|
|
100
|
+
for (let i = 0; i < sc.n; i++) {
|
|
101
|
+
if (looks[i] !== LOOK.HUB) continue;
|
|
102
|
+
const x = pos[2 * i], y = pos[2 * i + 1];
|
|
103
|
+
if (!visible(i, x, y)) continue;
|
|
104
|
+
const r = HALO * drawRadius(sizes[i], v.k, big);
|
|
105
|
+
ctx.moveTo(x + r, y); ctx.arc(x, y, r, 0, 2 * Math.PI);
|
|
106
|
+
}
|
|
107
|
+
ctx.fill();
|
|
108
|
+
}
|
|
109
|
+
for (const [colour, list] of byColour) {
|
|
110
|
+
ctx.fillStyle = colour;
|
|
111
|
+
if (looks) {
|
|
112
|
+
ctx.globalAlpha = SHELL_ALPHA;
|
|
113
|
+
ctx.beginPath();
|
|
114
|
+
for (const i of list) {
|
|
115
|
+
if (looks[i] !== LOOK.FOLDER) continue;
|
|
116
|
+
const x = pos[2 * i], y = pos[2 * i + 1];
|
|
117
|
+
if (!visible(i, x, y)) continue;
|
|
118
|
+
const r = drawRadius(sizes[i], v.k, big);
|
|
119
|
+
ctx.moveTo(x + r, y); ctx.arc(x, y, r, 0, 2 * Math.PI);
|
|
120
|
+
}
|
|
121
|
+
ctx.fill();
|
|
122
|
+
ctx.globalAlpha = 1;
|
|
123
|
+
}
|
|
124
|
+
ctx.beginPath();
|
|
125
|
+
for (const i of list) {
|
|
126
|
+
const x = pos[2 * i], y = pos[2 * i + 1];
|
|
127
|
+
if (!visible(i, x, y)) continue;
|
|
128
|
+
const r = drawRadius(sizes[i], v.k, big) * (looks && looks[i] === LOOK.FOLDER ? CORE : 1);
|
|
129
|
+
if (r * v.k < 2.5) ctx.rect(x - r, y - r, 2 * r, 2 * r); // a square that small reads as a dot and is cheaper
|
|
130
|
+
else { ctx.moveTo(x + r, y); ctx.arc(x, y, r, 0, 2 * Math.PI); }
|
|
131
|
+
}
|
|
132
|
+
ctx.fill();
|
|
133
|
+
}
|
|
134
|
+
return true;
|
|
135
|
+
},
|
|
136
|
+
destroy() { sc = null; },
|
|
137
|
+
};
|
|
138
|
+
}
|