emdash-plugin-sitegraph 0.0.0-stage → 0.1.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.
@@ -0,0 +1,222 @@
1
+ // A small SVG graph view: pan, zoom, drag, fit. Hand-rolled on purpose: React Flow's
2
+ // zustand dependency breaks against EmDash's use-sync-external-store shim and takes the
3
+ // whole admin down with it. We already lay nodes out ourselves, so this is all we need.
4
+
5
+ import * as React from "react";
6
+
7
+ import { type Edge, isBroken, type Node, RELATION_LABEL, TYPE_LABEL } from "./api.js";
8
+
9
+ import { NODE_H as H, NODE_W as W, type Point } from "./layout.js";
10
+
11
+ export type { Point };
12
+ type View = { x: number; y: number; k: number };
13
+ const MIN_K = 0.2;
14
+ const MAX_K = 2;
15
+ const DRAG_THRESHOLD = 4;
16
+
17
+ // Provenance is carried by line style, never colour alone (SPEC D4).
18
+ const DASH = { DISCOVERED: undefined, DOCUMENTED: "7 5", INFERRED: "2 5" } as const;
19
+ const QUIET = new Set(["LINKS_TO", "PUBLISHES_AS"]);
20
+
21
+ /** Where the segment from a box's centre towards `to` leaves the box. */
22
+ function boxExit(from: Point, to: Point): Point {
23
+ const dx = to.x - from.x;
24
+ const dy = to.y - from.y;
25
+ if (dx === 0 && dy === 0) return from;
26
+ const scale = 1 / Math.max(Math.abs(dx) / (W / 2 + 4), Math.abs(dy) / (H / 2 + 4));
27
+ return { x: from.x + dx * scale, y: from.y + dy * scale };
28
+ }
29
+
30
+ function fit(points: Point[], width: number, height: number): View {
31
+ if (points.length === 0 || width === 0) return { x: width / 2, y: height / 2, k: 1 };
32
+ const xs = points.map((p) => p.x);
33
+ const ys = points.map((p) => p.y);
34
+ const minX = Math.min(...xs) - W / 2 - 40;
35
+ const maxX = Math.max(...xs) + W / 2 + 40;
36
+ const minY = Math.min(...ys) - H / 2 - 40;
37
+ const maxY = Math.max(...ys) + H / 2 + 40;
38
+ const k = Math.min(1.2, Math.max(MIN_K, Math.min(width / (maxX - minX), height / (maxY - minY))));
39
+ return { k, x: width / 2 - ((minX + maxX) / 2) * k, y: height / 2 - ((minY + maxY) / 2) * k };
40
+ }
41
+
42
+ export function GraphCanvas({
43
+ nodes,
44
+ edges,
45
+ positions,
46
+ centerId,
47
+ selectedId,
48
+ onSelect,
49
+ onExpand,
50
+ onMove,
51
+ }: {
52
+ nodes: Node[];
53
+ edges: Edge[];
54
+ positions: Map<string, Point>;
55
+ centerId: string | null;
56
+ selectedId: string | null;
57
+ onSelect: (id: string | null) => void;
58
+ onExpand: (id: string) => void;
59
+ onMove: (id: string, point: Point) => void;
60
+ }) {
61
+ const svgRef = React.useRef<SVGSVGElement>(null);
62
+ const [view, setView] = React.useState<View>({ x: 0, y: 0, k: 1 });
63
+ const drag = React.useRef<{ kind: "pan" | "node"; id?: string; start: Point; origin: Point; moved: boolean } | null>(null);
64
+ const markerId = React.useId().replace(/:/g, "");
65
+
66
+ const fitAll = React.useCallback(() => {
67
+ const el = svgRef.current;
68
+ if (!el) return;
69
+ setView(fit([...positions.values()], el.clientWidth, el.clientHeight));
70
+ }, [positions]);
71
+
72
+ // Refit when the graph is re-centred (a new search pick or an impact view), not on every drag.
73
+ React.useEffect(fitAll, [centerId, nodes.length]);
74
+
75
+ // Wheel zoom around the cursor. Registered natively so preventDefault stops page scroll.
76
+ React.useEffect(() => {
77
+ const el = svgRef.current;
78
+ if (!el) return;
79
+ const onWheel = (e: WheelEvent) => {
80
+ e.preventDefault();
81
+ const rect = el.getBoundingClientRect();
82
+ const cx = e.clientX - rect.left;
83
+ const cy = e.clientY - rect.top;
84
+ setView((v) => {
85
+ const k = Math.min(MAX_K, Math.max(MIN_K, v.k * Math.exp(-e.deltaY * 0.0015)));
86
+ return { k, x: cx - ((cx - v.x) * k) / v.k, y: cy - ((cy - v.y) * k) / v.k };
87
+ });
88
+ };
89
+ el.addEventListener("wheel", onWheel, { passive: false });
90
+ return () => el.removeEventListener("wheel", onWheel);
91
+ }, []);
92
+
93
+ const zoomBy = (factor: number) => {
94
+ const el = svgRef.current;
95
+ if (!el) return;
96
+ const cx = el.clientWidth / 2;
97
+ const cy = el.clientHeight / 2;
98
+ setView((v) => {
99
+ const k = Math.min(MAX_K, Math.max(MIN_K, v.k * factor));
100
+ return { k, x: cx - ((cx - v.x) * k) / v.k, y: cy - ((cy - v.y) * k) / v.k };
101
+ });
102
+ };
103
+
104
+ const onPointerDown = (e: React.PointerEvent, id?: string) => {
105
+ if (e.button !== 0) return;
106
+ e.stopPropagation();
107
+ (e.currentTarget as Element).setPointerCapture(e.pointerId);
108
+ const origin = id ? positions.get(id)! : { x: view.x, y: view.y };
109
+ drag.current = { kind: id ? "node" : "pan", id, start: { x: e.clientX, y: e.clientY }, origin, moved: false };
110
+ };
111
+
112
+ const onPointerMove = (e: React.PointerEvent) => {
113
+ const d = drag.current;
114
+ if (!d) return;
115
+ const dx = e.clientX - d.start.x;
116
+ const dy = e.clientY - d.start.y;
117
+ if (!d.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
118
+ d.moved = true;
119
+ if (d.kind === "pan") setView((v) => ({ ...v, x: d.origin.x + dx, y: d.origin.y + dy }));
120
+ else if (d.id) onMove(d.id, { x: d.origin.x + dx / view.k, y: d.origin.y + dy / view.k });
121
+ };
122
+
123
+ const onPointerUp = () => {
124
+ const d = drag.current;
125
+ drag.current = null;
126
+ if (d && !d.moved) onSelect(d.kind === "node" ? d.id! : null);
127
+ };
128
+
129
+ const visible = nodes.filter((n) => positions.has(n.id));
130
+
131
+ return (
132
+ <div className="sg-canvas">
133
+ <svg
134
+ ref={svgRef}
135
+ className="sg-svg"
136
+ role="group"
137
+ aria-label="Dependency graph"
138
+ onPointerDown={(e) => onPointerDown(e)}
139
+ onPointerMove={onPointerMove}
140
+ onPointerUp={onPointerUp}
141
+ >
142
+ <defs>
143
+ <marker id={markerId} viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
144
+ <path d="M0,0 L10,5 L0,10 z" className="sg-arrow" />
145
+ </marker>
146
+ <pattern id={`${markerId}-dots`} width={24 * view.k} height={24 * view.k} x={view.x} y={view.y} patternUnits="userSpaceOnUse">
147
+ <circle cx={1} cy={1} r={1} className="sg-dot" />
148
+ </pattern>
149
+ </defs>
150
+ <rect width="100%" height="100%" fill={`url(#${markerId}-dots)`} />
151
+ <g transform={`translate(${view.x} ${view.y}) scale(${view.k})`}>
152
+ {edges.map((edge) => {
153
+ const a = positions.get(edge.sourceNodeId);
154
+ const b = positions.get(edge.targetNodeId);
155
+ if (!a || !b) return null;
156
+ const start = boxExit(a, b);
157
+ const end = boxExit(b, a);
158
+ const label = QUIET.has(edge.relation) ? null : edge.label || RELATION_LABEL[edge.relation];
159
+ const active = edge.sourceNodeId === selectedId || edge.targetNodeId === selectedId;
160
+ return (
161
+ <g key={edge.id} className="sg-edge" data-prov={edge.provenance} data-active={active || undefined}>
162
+ <line x1={start.x} y1={start.y} x2={end.x} y2={end.y} strokeDasharray={DASH[edge.provenance]} markerEnd={`url(#${markerId})`} />
163
+ {label ? (
164
+ <text x={(start.x + end.x) / 2} y={(start.y + end.y) / 2 - 6} textAnchor="middle">
165
+ {label}
166
+ </text>
167
+ ) : null}
168
+ </g>
169
+ );
170
+ })}
171
+ {visible.map((node) => {
172
+ const p = positions.get(node.id)!;
173
+ const broken = isBroken(node);
174
+ return (
175
+ <foreignObject key={node.id} x={p.x - W / 2} y={p.y - H / 2} width={W} height={H} className="sg-fo">
176
+ <div
177
+ className="sg-node"
178
+ data-type={node.type}
179
+ data-broken={broken || undefined}
180
+ data-selected={node.id === selectedId || undefined}
181
+ data-center={node.id === centerId || undefined}
182
+ role="button"
183
+ tabIndex={0}
184
+ aria-pressed={node.id === selectedId}
185
+ aria-label={`${broken ? "Broken link" : TYPE_LABEL[node.type]}: ${node.label}. Press Enter to inspect, Shift+Enter to show its connections.`}
186
+ title={node.label}
187
+ onPointerDown={(e) => onPointerDown(e, node.id)}
188
+ onDoubleClick={() => onExpand(node.id)}
189
+ onKeyDown={(e) => {
190
+ if (e.key !== "Enter" && e.key !== " ") return;
191
+ e.preventDefault();
192
+ if (e.shiftKey) onExpand(node.id);
193
+ else onSelect(node.id);
194
+ }}
195
+ >
196
+ <span className="sg-node-type">{broken ? "Broken link" : TYPE_LABEL[node.type]}</span>
197
+ <span className="sg-node-label">{node.label}</span>
198
+ {node.criticality && node.criticality !== "LOW" ? (
199
+ <span className="sg-node-crit" data-level={node.criticality}>
200
+ {node.criticality[0] + node.criticality.slice(1).toLowerCase()}
201
+ </span>
202
+ ) : null}
203
+ </div>
204
+ </foreignObject>
205
+ );
206
+ })}
207
+ </g>
208
+ </svg>
209
+ <div className="sg-zoom" role="toolbar" aria-label="Zoom">
210
+ <button type="button" onClick={() => zoomBy(1.25)} aria-label="Zoom in">
211
+ +
212
+ </button>
213
+ <button type="button" onClick={() => zoomBy(0.8)} aria-label="Zoom out">
214
+ −
215
+ </button>
216
+ <button type="button" onClick={fitAll}>
217
+ Fit
218
+ </button>
219
+ </div>
220
+ </div>
221
+ );
222
+ }
@@ -0,0 +1,47 @@
1
+ import "./sitegraph.css";
2
+
3
+ import { Loader } from "@cloudflare/kumo";
4
+ import type { PluginAdminExports } from "emdash";
5
+ import * as React from "react";
6
+
7
+ import { api, message, type Overview } from "./api.js";
8
+ import { Explorer } from "./explorer.js";
9
+
10
+ function OverviewWidget() {
11
+ const [overview, setOverview] = React.useState<Overview>();
12
+ const [error, setError] = React.useState<string>();
13
+ React.useEffect(() => {
14
+ api.overview().then(setOverview, (e: unknown) => setError(message(e)));
15
+ }, []);
16
+
17
+ if (error) return <p role="alert">{error}</p>;
18
+ if (!overview) return <Loader />;
19
+ if (!overview.scan.last) {
20
+ return (
21
+ <p className="sg-widget">
22
+ Your site hasn't been mapped yet. <a href="/_emdash/admin/plugins/sitegraph/">Run the first scan</a>
23
+ </p>
24
+ );
25
+ }
26
+ return (
27
+ <div className="sg-widget">
28
+ <p>
29
+ {overview.counts.CONTENT} entries mapped,{" "}
30
+ <a href="/_emdash/admin/plugins/sitegraph/" data-alert={overview.brokenLinks > 0 || undefined}>
31
+ {overview.brokenLinks === 0
32
+ ? "no broken internal links"
33
+ : `${overview.brokenLinks} broken internal link${overview.brokenLinks === 1 ? "" : "s"}`}
34
+ </a>
35
+ .
36
+ </p>
37
+ </div>
38
+ );
39
+ }
40
+
41
+ export const pages: PluginAdminExports["pages"] = {
42
+ "/": Explorer,
43
+ };
44
+
45
+ export const widgets: PluginAdminExports["widgets"] = {
46
+ overview: OverviewWidget,
47
+ };
@@ -0,0 +1,114 @@
1
+ // Graph layout: seed new nodes on a ring, then relax them so boxes don't overlap each other
2
+ // and don't sit on top of lines they aren't part of. Only `movable` nodes move, so what the
3
+ // person already sees (or dragged into place) stays put.
4
+
5
+ export type Point = { x: number; y: number };
6
+
7
+ export const NODE_W = 176;
8
+ export const NODE_H = 62;
9
+
10
+ const GAP_X = NODE_W + 36;
11
+ const GAP_Y = NODE_H + 36;
12
+ const EDGE_LENGTH = 250;
13
+ const LINE_CLEARANCE = NODE_H / 2 + 18;
14
+ const ITERATIONS = 220;
15
+ // ponytail: O(n²) pairs + O(edges × nodes) line checks per step; fine for the ≤500-node
16
+ // views the API returns. A spatial grid is the upgrade if views ever get bigger.
17
+
18
+ /** Place new nodes on a ring around `origin`. */
19
+ export function seedRing(origin: Point, ids: string[], taken: Map<string, Point>): Map<string, Point> {
20
+ const fresh = ids.filter((id) => !taken.has(id));
21
+ const radius = Math.max(EDGE_LENGTH, fresh.length * 34);
22
+ const placed = new Map<string, Point>();
23
+ fresh.forEach((id, i) => {
24
+ const angle = (2 * Math.PI * i) / Math.max(fresh.length, 1) - Math.PI / 2;
25
+ placed.set(id, { x: origin.x + radius * Math.cos(angle), y: origin.y + radius * Math.sin(angle) });
26
+ });
27
+ return placed;
28
+ }
29
+
30
+ /** Push `movable` nodes apart and off unrelated lines; pull linked nodes toward EDGE_LENGTH. */
31
+ export function relax(
32
+ positions: Map<string, Point>,
33
+ edges: Array<{ sourceNodeId: string; targetNodeId: string }>,
34
+ movable: Set<string>,
35
+ ): Map<string, Point> {
36
+ const pos = new Map([...positions].map(([id, p]) => [id, { ...p }]));
37
+ const ids = [...pos.keys()];
38
+ const links = edges.filter((e) => pos.has(e.sourceNodeId) && pos.has(e.targetNodeId) && e.sourceNodeId !== e.targetNodeId);
39
+ if (![...movable].some((id) => pos.has(id))) return pos;
40
+
41
+ for (let step = 0; step < ITERATIONS; step++) {
42
+ const cooling = 1 - step / ITERATIONS;
43
+ const force = new Map(ids.map((id) => [id, { x: 0, y: 0 }]));
44
+ const add = (id: string, x: number, y: number) => {
45
+ const f = force.get(id)!;
46
+ f.x += x;
47
+ f.y += y;
48
+ };
49
+
50
+ // Boxes repel when they come closer than one box plus a gap.
51
+ for (let i = 0; i < ids.length; i++) {
52
+ for (let j = i + 1; j < ids.length; j++) {
53
+ const a = pos.get(ids[i]!)!;
54
+ const b = pos.get(ids[j]!)!;
55
+ let dx = b.x - a.x;
56
+ let dy = b.y - a.y;
57
+ if (dx === 0 && dy === 0) dx = (i % 2 ? 1 : -1) * 0.5;
58
+ const ox = GAP_X - Math.abs(dx);
59
+ const oy = GAP_Y - Math.abs(dy);
60
+ if (ox <= 0 || oy <= 0) continue;
61
+ // Separate along the axis that needs the smaller move.
62
+ const [px, py] = ox / GAP_X < oy / GAP_Y ? [Math.sign(dx) * ox, 0] : [0, Math.sign(dy || 1) * oy];
63
+ add(ids[i]!, -px / 2, -py / 2);
64
+ add(ids[j]!, px / 2, py / 2);
65
+ }
66
+ }
67
+
68
+ // Linked boxes settle at a readable distance.
69
+ for (const e of links) {
70
+ const a = pos.get(e.sourceNodeId)!;
71
+ const b = pos.get(e.targetNodeId)!;
72
+ const dx = b.x - a.x;
73
+ const dy = b.y - a.y;
74
+ const d = Math.hypot(dx, dy) || 1;
75
+ const pull = ((d - EDGE_LENGTH) / d) * 0.06;
76
+ add(e.sourceNodeId, dx * pull, dy * pull);
77
+ add(e.targetNodeId, -dx * pull, -dy * pull);
78
+ }
79
+
80
+ // A box sitting on a line it isn't part of gets nudged sideways off it.
81
+ for (const e of links) {
82
+ const a = pos.get(e.sourceNodeId)!;
83
+ const b = pos.get(e.targetNodeId)!;
84
+ const vx = b.x - a.x;
85
+ const vy = b.y - a.y;
86
+ const len2 = vx * vx + vy * vy || 1;
87
+ for (const id of ids) {
88
+ if (id === e.sourceNodeId || id === e.targetNodeId || !movable.has(id)) continue;
89
+ const p = pos.get(id)!;
90
+ const t = ((p.x - a.x) * vx + (p.y - a.y) * vy) / len2;
91
+ if (t <= 0.05 || t >= 0.95) continue;
92
+ const cx = a.x + t * vx;
93
+ const cy = a.y + t * vy;
94
+ let nx = p.x - cx;
95
+ let ny = p.y - cy;
96
+ const d = Math.hypot(nx, ny);
97
+ if (d >= LINE_CLEARANCE) continue;
98
+ if (d < 1e-6) [nx, ny] = [-vy, vx];
99
+ const n = Math.hypot(nx, ny) || 1;
100
+ add(id, (nx / n) * (LINE_CLEARANCE - d), (ny / n) * (LINE_CLEARANCE - d));
101
+ }
102
+ }
103
+
104
+ for (const id of ids) {
105
+ if (!movable.has(id)) continue;
106
+ const f = force.get(id)!;
107
+ const p = pos.get(id)!;
108
+ const limit = 40 * cooling + 2;
109
+ p.x += Math.max(-limit, Math.min(limit, f.x));
110
+ p.y += Math.max(-limit, Math.min(limit, f.y));
111
+ }
112
+ }
113
+ return pos;
114
+ }
@@ -0,0 +1,69 @@
1
+ import * as React from "react";
2
+
3
+ import { api, isBroken, type Node, TYPE_LABEL } from "./api.js";
4
+
5
+ /** Debounced search over graph nodes. Used to pick a relationship target. */
6
+ export function useNodeSearch(query: string, opts: { type?: Node["type"]; broken?: boolean } = {}) {
7
+ const [state, setState] = React.useState<{ items: Node[]; loading: boolean; error?: string }>({ items: [], loading: false });
8
+ const { type, broken } = opts;
9
+ React.useEffect(() => {
10
+ let live = true;
11
+ setState((s) => ({ ...s, loading: true }));
12
+ const timer = setTimeout(() => {
13
+ api
14
+ .search(query.trim(), { type, broken })
15
+ .then((r) => live && setState({ items: r.items, loading: false }))
16
+ .catch((e: unknown) => live && setState({ items: [], loading: false, error: e instanceof Error ? e.message : String(e) }));
17
+ }, 200);
18
+ return () => {
19
+ live = false;
20
+ clearTimeout(timer);
21
+ };
22
+ }, [query, type, broken]);
23
+ return state;
24
+ }
25
+
26
+ export function NodePicker({ value, onChange, exclude }: { value: Node | null; onChange: (node: Node | null) => void; exclude?: string }) {
27
+ const [query, setQuery] = React.useState("");
28
+ const { items, loading } = useNodeSearch(query);
29
+ const options = items.filter((n) => n.id !== exclude).slice(0, 8);
30
+ const id = React.useId();
31
+
32
+ if (value) {
33
+ return (
34
+ <div className="sg-picked">
35
+ <span>
36
+ <span className="sg-kind" data-type={value.type}>
37
+ {TYPE_LABEL[value.type]}
38
+ </span>{" "}
39
+ {value.label}
40
+ </span>
41
+ <button type="button" className="sg-link" onClick={() => onChange(null)}>
42
+ Change
43
+ </button>
44
+ </div>
45
+ );
46
+ }
47
+
48
+ return (
49
+ <div className="sg-field">
50
+ <label htmlFor={id}>
51
+ <span>Target</span>
52
+ </label>
53
+ <input id={id} type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Start typing a name or path" autoComplete="off" />
54
+ <ul className="sg-options" role="listbox" aria-busy={loading}>
55
+ {options.map((n) => (
56
+ <li key={n.id}>
57
+ <button type="button" onClick={() => onChange(n)}>
58
+ <span className="sg-kind" data-type={n.type}>
59
+ {isBroken(n) ? "Broken link" : TYPE_LABEL[n.type]}
60
+ </span>{" "}
61
+ {n.label}
62
+ </button>
63
+ </li>
64
+ ))}
65
+ {!loading && options.length === 0 ? <li className="sg-muted">No matches. Names match from their first letter.</li> : null}
66
+ </ul>
67
+ </div>
68
+ );
69
+ }