@arcote.tech/arc-map 0.7.27
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/package.json +38 -0
- package/src/app/arc.tsx +92 -0
- package/src/app/contexts.tsx +72 -0
- package/src/app/detail-panel.tsx +164 -0
- package/src/app/index.tsx +107 -0
- package/src/app/layout.ts +350 -0
- package/src/app/map-view.tsx +225 -0
- package/src/app/mdx-view.tsx +74 -0
- package/src/app/nodes.tsx +478 -0
- package/src/app/ref-context.tsx +49 -0
- package/src/app/structure.tsx +95 -0
- package/src/app/subgraph.ts +127 -0
- package/src/app/theme.ts +67 -0
- package/src/app/usecase-layout.ts +249 -0
- package/src/app/usecase-model.ts +346 -0
- package/src/app/usecase-tree.tsx +104 -0
- package/src/app/usecase-view.tsx +173 -0
- package/src/index.ts +18 -0
- package/src/mapper.ts +392 -0
- package/src/module-index.ts +70 -0
- package/src/refs.ts +121 -0
- package/src/scan.ts +393 -0
- package/src/server.ts +262 -0
- package/src/types.ts +154 -0
- package/src/use-cases/discover.ts +109 -0
- package/tests/discover.test.ts +42 -0
- package/tests/fixtures/pkg/detail-page.tsx +11 -0
- package/tests/fixtures/pkg/direct.tsx +7 -0
- package/tests/fixtures/pkg/list-page.tsx +11 -0
- package/tests/fixtures/pkg/routes.ts +6 -0
- package/tests/fixtures/pkg/scope.ts +4 -0
- package/tests/fixtures/uc/pkgA/use-cases/bar.mdx +3 -0
- package/tests/fixtures/uc/pkgA/use-cases/sub/foo.mdx +9 -0
- package/tests/mapper.test.ts +115 -0
- package/tests/refs.test.ts +56 -0
- package/tests/scan.test.ts +71 -0
- package/tests/subgraph.test.ts +54 -0
- package/tests/usecase-model.test.ts +197 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Use-case data-flow model. Turns the ordered `<Arc>` occurrences + the full
|
|
3
|
+
* map into a per-step model:
|
|
4
|
+
*
|
|
5
|
+
* - steps: one node PER occurrence (no dedup); existing or missing.
|
|
6
|
+
* - connectors: elements on the shortest undirected path between two existing
|
|
7
|
+
* steps (the plumbing the author didn't narrate).
|
|
8
|
+
* - per step: related elements that are NOT already visible, grouped by relation
|
|
9
|
+
* kind, ready to be collapsed into badges / expanded into columns.
|
|
10
|
+
*
|
|
11
|
+
* Pure and dependency-free (testable in bun).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { MapEdge, MapElement, MapJson } from "../types";
|
|
15
|
+
import type { ResolvedRef } from "../refs";
|
|
16
|
+
import type { PathSeg } from "./contexts";
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_MAX_PATH = 5;
|
|
19
|
+
|
|
20
|
+
/** Relation kinds surfaced as badges / expandable columns. */
|
|
21
|
+
export type RelKind =
|
|
22
|
+
| "queries" // forward: this step reads X → left column
|
|
23
|
+
| "mutates" // forward: this step writes X → right column
|
|
24
|
+
| "mutatedBy" // reverse: X is written by others (producers) → left column (arrows in)
|
|
25
|
+
| "queriedBy" // reverse: X is read by others → left column
|
|
26
|
+
| "handledBy" // reverse: X (event) handled/observed by others → left column
|
|
27
|
+
| "usedByUI"; // reverse: React components/pages that use X (ts-morph) → left column
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Which side a relation expands to. Forward outputs (`mutates`) go right; every
|
|
31
|
+
* INCOMING relationship — what feeds the step (`queries`) and everyone who acts
|
|
32
|
+
* on / reads / observes / renders it (`mutatedBy`, `queriedBy`, `handledBy`,
|
|
33
|
+
* `usedByUI`) — goes left. Edge arrows keep their semantic direction regardless.
|
|
34
|
+
*/
|
|
35
|
+
export const REL_SIDE: Record<RelKind, "left" | "right"> = {
|
|
36
|
+
queries: "left",
|
|
37
|
+
queriedBy: "left",
|
|
38
|
+
mutatedBy: "left",
|
|
39
|
+
handledBy: "left",
|
|
40
|
+
usedByUI: "left",
|
|
41
|
+
mutates: "right",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export interface OccurrenceRef {
|
|
45
|
+
occId: string;
|
|
46
|
+
ref: ResolvedRef | null;
|
|
47
|
+
label: string;
|
|
48
|
+
/** Structural path (parallel/branch/lane/step) from the MDX. */
|
|
49
|
+
path?: PathSeg[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface StepNode {
|
|
53
|
+
occId: string;
|
|
54
|
+
seq: number;
|
|
55
|
+
ref: ResolvedRef | null;
|
|
56
|
+
exists: boolean;
|
|
57
|
+
element?: MapElement;
|
|
58
|
+
label: string;
|
|
59
|
+
/** Method name actually used (aggregate mutationMethod/queryMethod), if any. */
|
|
60
|
+
method?: { name: string; kind: "mutation" | "query" };
|
|
61
|
+
/** Related elements NOT already visible, grouped by relation kind. */
|
|
62
|
+
badges: Partial<Record<RelKind, string[]>>;
|
|
63
|
+
/** Horizontal position (time / dependency depth), 0-based. */
|
|
64
|
+
depth: number;
|
|
65
|
+
/** Vertical track (parallel / branch lane), 0-based. */
|
|
66
|
+
lane: number;
|
|
67
|
+
/** True for branch (alternative / XOR) lanes. */
|
|
68
|
+
alt?: boolean;
|
|
69
|
+
/** Label of the enclosing `<Step>` phase, if any. */
|
|
70
|
+
stepTitle?: string;
|
|
71
|
+
/** Label of the enclosing `<Lane>`, if any. */
|
|
72
|
+
laneTitle?: string;
|
|
73
|
+
/** Id of the enclosing parallel/branch block, if any. */
|
|
74
|
+
blockId?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ConnectorNode {
|
|
78
|
+
id: string;
|
|
79
|
+
element: MapElement;
|
|
80
|
+
/** seq of the two narrated steps this connector sits between, flow-ordered
|
|
81
|
+
* [upstream → downstream]. */
|
|
82
|
+
between: [number, number];
|
|
83
|
+
/** 1-based position among the intermediates on the directed path. */
|
|
84
|
+
order: number;
|
|
85
|
+
/** Total intermediates on that path (for spreading between the two steps). */
|
|
86
|
+
count: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface UseCaseModel {
|
|
90
|
+
steps: StepNode[];
|
|
91
|
+
connectors: ConnectorNode[];
|
|
92
|
+
/** Visible element ids: existing step elements + connectors. */
|
|
93
|
+
visible: Set<string>;
|
|
94
|
+
/** element id -> seq of its first existing step occurrence. */
|
|
95
|
+
firstSeqByElement: Map<string, number>;
|
|
96
|
+
/** Pseudo-elements for React components/pages (UI layer) by id. */
|
|
97
|
+
uiById: Map<string, MapElement>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Graph helpers (directed flow adjacency + BFS shortest path)
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Directed left→right flow adjacency: each edge points producer→consumer, so a
|
|
106
|
+
* BFS path follows the actual data flow.
|
|
107
|
+
* - mutates/emits: source produces target → source → target
|
|
108
|
+
* - queries/listens/handles: source consumes target → target → source
|
|
109
|
+
* Mirrors the layout's INPUT/OUTPUT edge orientation, so a connector found here
|
|
110
|
+
* lies on the same flow the diagram draws — i.e. genuinely BETWEEN the two
|
|
111
|
+
* narrated steps, never a shared upstream/downstream they both happen to touch.
|
|
112
|
+
*/
|
|
113
|
+
function flowAdjacency(edges: MapEdge[]): Map<string, Set<string>> {
|
|
114
|
+
const adj = new Map<string, Set<string>>();
|
|
115
|
+
const link = (a: string, b: string) => {
|
|
116
|
+
let s = adj.get(a);
|
|
117
|
+
if (!s) adj.set(a, (s = new Set()));
|
|
118
|
+
s.add(b);
|
|
119
|
+
};
|
|
120
|
+
for (const e of edges) {
|
|
121
|
+
if (e.kind === "mutates" || e.kind === "emits") link(e.source, e.target);
|
|
122
|
+
else link(e.target, e.source);
|
|
123
|
+
}
|
|
124
|
+
return adj;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function shortestPath(
|
|
128
|
+
adj: Map<string, Set<string>>,
|
|
129
|
+
start: string,
|
|
130
|
+
goal: string,
|
|
131
|
+
maxLen: number,
|
|
132
|
+
): string[] | null {
|
|
133
|
+
if (start === goal) return [start];
|
|
134
|
+
const prev = new Map<string, string>();
|
|
135
|
+
const depth = new Map<string, number>([[start, 0]]);
|
|
136
|
+
const queue = [start];
|
|
137
|
+
while (queue.length) {
|
|
138
|
+
const cur = queue.shift()!;
|
|
139
|
+
const d = depth.get(cur)!;
|
|
140
|
+
if (d >= maxLen) continue;
|
|
141
|
+
for (const nb of adj.get(cur) ?? []) {
|
|
142
|
+
if (depth.has(nb)) continue;
|
|
143
|
+
depth.set(nb, d + 1);
|
|
144
|
+
prev.set(nb, cur);
|
|
145
|
+
if (nb === goal) {
|
|
146
|
+
const path = [goal];
|
|
147
|
+
let p = cur;
|
|
148
|
+
while (p !== start) {
|
|
149
|
+
path.push(p);
|
|
150
|
+
p = prev.get(p)!;
|
|
151
|
+
}
|
|
152
|
+
path.push(start);
|
|
153
|
+
return path.reverse();
|
|
154
|
+
}
|
|
155
|
+
queue.push(nb);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Reverse relation index
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
function reverseSources(edges: MapEdge[], target: string, kinds: MapEdge["kind"][]): string[] {
|
|
166
|
+
const out = new Set<string>();
|
|
167
|
+
for (const e of edges) {
|
|
168
|
+
if (e.target === target && kinds.includes(e.kind)) out.add(e.source);
|
|
169
|
+
}
|
|
170
|
+
return [...out];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function forwardDeps(step: StepNode): { queries: string[]; mutates: string[] } {
|
|
174
|
+
const el = step.element;
|
|
175
|
+
if (!el) return { queries: [], mutates: [] };
|
|
176
|
+
if (step.method) {
|
|
177
|
+
const m = el.methods?.find((x) => x.name === step.method!.name);
|
|
178
|
+
return { queries: m?.queryDeps ?? [], mutates: m?.mutationDeps ?? [] };
|
|
179
|
+
}
|
|
180
|
+
return { queries: el.queryDeps ?? [], mutates: el.mutationDeps ?? [] };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Public builder
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
export interface BuildModelInput {
|
|
188
|
+
map: MapJson;
|
|
189
|
+
occurrences: OccurrenceRef[];
|
|
190
|
+
maxPath?: number;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Assign depth (time, x) and lane (parallel/branch track, y) from the structural
|
|
195
|
+
* paths. Plain steps flow as a sequence; a `<Parallel>`/`<Branch>` block places
|
|
196
|
+
* its members at the same base depth on separate lanes (sequences within a lane
|
|
197
|
+
* extend the depth). The whole block occupies one slot of the outer sequence.
|
|
198
|
+
*/
|
|
199
|
+
function assignDepthLane(steps: StepNode[], occurrences: OccurrenceRef[]): void {
|
|
200
|
+
let depthCursor = 0;
|
|
201
|
+
let block: { id: string; base: number; maxLocal: number; laneDepth: Map<number, number> } | null = null;
|
|
202
|
+
const finalize = () => {
|
|
203
|
+
if (block) {
|
|
204
|
+
depthCursor = block.base + block.maxLocal + 1;
|
|
205
|
+
block = null;
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
steps.forEach((step, i) => {
|
|
209
|
+
const path = occurrences[i].path ?? [];
|
|
210
|
+
const P = path.find((s) => s.type === "parallel" || s.type === "branch");
|
|
211
|
+
if (!P) {
|
|
212
|
+
finalize();
|
|
213
|
+
step.depth = depthCursor;
|
|
214
|
+
step.lane = 0;
|
|
215
|
+
depthCursor += 1;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (!block || block.id !== P.id) {
|
|
219
|
+
finalize();
|
|
220
|
+
block = { id: P.id, base: depthCursor, maxLocal: 0, laneDepth: new Map() };
|
|
221
|
+
}
|
|
222
|
+
const lane = P.laneIndex ?? 0;
|
|
223
|
+
const local = block.laneDepth.get(lane) ?? 0;
|
|
224
|
+
step.depth = block.base + local;
|
|
225
|
+
step.lane = lane;
|
|
226
|
+
step.alt = P.type === "branch";
|
|
227
|
+
step.blockId = P.id;
|
|
228
|
+
block.laneDepth.set(lane, local + 1);
|
|
229
|
+
block.maxLocal = Math.max(block.maxLocal, local);
|
|
230
|
+
});
|
|
231
|
+
finalize();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function buildUseCaseModel(input: BuildModelInput): UseCaseModel {
|
|
235
|
+
const { map, occurrences, maxPath = DEFAULT_MAX_PATH } = input;
|
|
236
|
+
const elementById = new Map(map.elements.map((e) => [e.id, e]));
|
|
237
|
+
|
|
238
|
+
// Pseudo-elements for React components (ts-morph UI layer). Referenced via
|
|
239
|
+
// `<Arc component>` and surfaced as the `usedByUI` badge.
|
|
240
|
+
const uiById = new Map<string, MapElement>();
|
|
241
|
+
for (const c of map.components) {
|
|
242
|
+
uiById.set(c.id, { id: c.id, name: c.name ?? c.file, kind: "page", spaceId: "ui" });
|
|
243
|
+
}
|
|
244
|
+
const resolve = (id: string) => elementById.get(id) ?? uiById.get(id);
|
|
245
|
+
|
|
246
|
+
// 1) Steps — one per occurrence.
|
|
247
|
+
const steps: StepNode[] = occurrences.map((o, i) => {
|
|
248
|
+
const exists = !!o.ref && !!resolve(o.ref.nodeId);
|
|
249
|
+
const path = o.path ?? [];
|
|
250
|
+
return {
|
|
251
|
+
occId: o.occId,
|
|
252
|
+
seq: i + 1,
|
|
253
|
+
ref: o.ref,
|
|
254
|
+
exists,
|
|
255
|
+
element: exists ? resolve(o.ref!.nodeId) : undefined,
|
|
256
|
+
label: o.label,
|
|
257
|
+
method: o.ref?.method,
|
|
258
|
+
badges: {},
|
|
259
|
+
depth: 0,
|
|
260
|
+
lane: 0,
|
|
261
|
+
stepTitle: path.find((s) => s.type === "step")?.title,
|
|
262
|
+
laneTitle: path.find((s) => s.type === "lane")?.title,
|
|
263
|
+
};
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
assignDepthLane(steps, occurrences);
|
|
267
|
+
|
|
268
|
+
// First occurrence seq per existing element id.
|
|
269
|
+
const firstSeqByElement = new Map<string, number>();
|
|
270
|
+
for (const s of steps) {
|
|
271
|
+
if (s.exists && !firstSeqByElement.has(s.element!.id)) {
|
|
272
|
+
firstSeqByElement.set(s.element!.id, s.seq);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const stepElementIds = [...firstSeqByElement.keys()];
|
|
276
|
+
|
|
277
|
+
// 2) Connectors — elements on a DIRECTED flow path between two narrated steps.
|
|
278
|
+
// Following flow direction (not undirected) guarantees the connector sits
|
|
279
|
+
// BETWEEN the two steps left→right, never a node that's upstream of both.
|
|
280
|
+
const flowAdj = flowAdjacency(map.edges);
|
|
281
|
+
const connectorMap = new Map<string, ConnectorNode>();
|
|
282
|
+
for (let i = 0; i < stepElementIds.length; i++) {
|
|
283
|
+
for (let j = i + 1; j < stepElementIds.length; j++) {
|
|
284
|
+
const a = stepElementIds[i];
|
|
285
|
+
const b = stepElementIds[j];
|
|
286
|
+
// Try both flow orientations; keep the first directed path found.
|
|
287
|
+
let path = shortestPath(flowAdj, a, b, maxPath);
|
|
288
|
+
let from = a;
|
|
289
|
+
let to = b;
|
|
290
|
+
if (!path) {
|
|
291
|
+
path = shortestPath(flowAdj, b, a, maxPath);
|
|
292
|
+
from = b;
|
|
293
|
+
to = a;
|
|
294
|
+
}
|
|
295
|
+
if (!path) continue;
|
|
296
|
+
// Intermediates only: drop the endpoints and any other narrated step that
|
|
297
|
+
// happens to fall on the path (those render as steps, not connectors).
|
|
298
|
+
const intermediates = path.filter((id) => !firstSeqByElement.has(id));
|
|
299
|
+
const between: [number, number] = [
|
|
300
|
+
firstSeqByElement.get(from)!,
|
|
301
|
+
firstSeqByElement.get(to)!,
|
|
302
|
+
];
|
|
303
|
+
intermediates.forEach((id, k) => {
|
|
304
|
+
const el = elementById.get(id);
|
|
305
|
+
if (!el || connectorMap.has(id)) return;
|
|
306
|
+
connectorMap.set(id, {
|
|
307
|
+
id,
|
|
308
|
+
element: el,
|
|
309
|
+
between,
|
|
310
|
+
order: k + 1,
|
|
311
|
+
count: intermediates.length,
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const connectors = [...connectorMap.values()];
|
|
317
|
+
|
|
318
|
+
// 3) Visible set.
|
|
319
|
+
const visible = new Set<string>([...stepElementIds, ...connectorMap.keys()]);
|
|
320
|
+
|
|
321
|
+
// 4) Per-step badges — related elements NOT visible, grouped by relation kind.
|
|
322
|
+
for (const s of steps) {
|
|
323
|
+
if (!s.exists) continue;
|
|
324
|
+
const id = s.element!.id;
|
|
325
|
+
const fwd = forwardDeps(s);
|
|
326
|
+
const usedByUI = map.componentEdges.filter((e) => e.target === id).map((e) => e.source);
|
|
327
|
+
const groups: Partial<Record<RelKind, string[]>> = {
|
|
328
|
+
queries: fwd.queries,
|
|
329
|
+
mutates: fwd.mutates,
|
|
330
|
+
mutatedBy: reverseSources(map.edges, id, ["mutates", "emits"]),
|
|
331
|
+
queriedBy: reverseSources(map.edges, id, ["queries"]),
|
|
332
|
+
handledBy: reverseSources(map.edges, id, ["handles", "listens"]),
|
|
333
|
+
usedByUI,
|
|
334
|
+
};
|
|
335
|
+
const badges: Partial<Record<RelKind, string[]>> = {};
|
|
336
|
+
for (const [kind, list] of Object.entries(groups) as [RelKind, string[]][]) {
|
|
337
|
+
// UI badge targets live in uiById; the rest are context elements.
|
|
338
|
+
const known = kind === "usedByUI" ? uiById : elementById;
|
|
339
|
+
const filtered = [...new Set(list)].filter((t) => t !== id && !visible.has(t) && known.has(t));
|
|
340
|
+
if (filtered.length) badges[kind] = filtered;
|
|
341
|
+
}
|
|
342
|
+
s.badges = badges;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return { steps, connectors, visible, firstSeqByElement, uiById };
|
|
346
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** Folder-tree navigator for use cases, mirroring packages/<pkg>/use-cases/**. */
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import type { UseCaseEntry } from "../types";
|
|
5
|
+
import { GLASS } from "./theme";
|
|
6
|
+
|
|
7
|
+
interface TreeNode {
|
|
8
|
+
name: string;
|
|
9
|
+
path: string;
|
|
10
|
+
children: Map<string, TreeNode>;
|
|
11
|
+
entry?: UseCaseEntry;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function buildTree(entries: UseCaseEntry[]): TreeNode {
|
|
15
|
+
const root: TreeNode = { name: "", path: "", children: new Map() };
|
|
16
|
+
for (const e of entries) {
|
|
17
|
+
const segments = [e.package, ...e.subPath.split("/")];
|
|
18
|
+
let node = root;
|
|
19
|
+
let path = "";
|
|
20
|
+
segments.forEach((seg, i) => {
|
|
21
|
+
path = path ? `${path}/${seg}` : seg;
|
|
22
|
+
let child = node.children.get(seg);
|
|
23
|
+
if (!child) {
|
|
24
|
+
child = { name: seg, path, children: new Map() };
|
|
25
|
+
node.children.set(seg, child);
|
|
26
|
+
}
|
|
27
|
+
if (i === segments.length - 1) child.entry = e;
|
|
28
|
+
node = child;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return root;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function NodeRow({
|
|
35
|
+
node,
|
|
36
|
+
depth,
|
|
37
|
+
selectedId,
|
|
38
|
+
onSelect,
|
|
39
|
+
}: {
|
|
40
|
+
node: TreeNode;
|
|
41
|
+
depth: number;
|
|
42
|
+
selectedId: string | null;
|
|
43
|
+
onSelect: (id: string) => void;
|
|
44
|
+
}) {
|
|
45
|
+
const isLeaf = !!node.entry;
|
|
46
|
+
const label = isLeaf ? node.entry!.title : node.name.replace(/\.mdx$/, "");
|
|
47
|
+
const selected = isLeaf && node.entry!.id === selectedId;
|
|
48
|
+
const children = [...node.children.values()].sort(
|
|
49
|
+
(a, b) => Number(!!a.entry) - Number(!!b.entry) || a.name.localeCompare(b.name),
|
|
50
|
+
);
|
|
51
|
+
return (
|
|
52
|
+
<div>
|
|
53
|
+
<div
|
|
54
|
+
onClick={isLeaf ? () => onSelect(node.entry!.id) : undefined}
|
|
55
|
+
style={{
|
|
56
|
+
padding: "4px 8px",
|
|
57
|
+
paddingLeft: 8 + depth * 14,
|
|
58
|
+
fontSize: 12.5,
|
|
59
|
+
borderRadius: 8,
|
|
60
|
+
cursor: isLeaf ? "pointer" : "default",
|
|
61
|
+
color: isLeaf ? GLASS.text : GLASS.textMuted,
|
|
62
|
+
fontWeight: isLeaf ? 500 : 700,
|
|
63
|
+
background: selected ? "rgba(124,58,237,0.14)" : "transparent",
|
|
64
|
+
whiteSpace: "nowrap",
|
|
65
|
+
overflow: "hidden",
|
|
66
|
+
textOverflow: "ellipsis",
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
{isLeaf ? "📄 " : "📁 "}
|
|
70
|
+
{label}
|
|
71
|
+
</div>
|
|
72
|
+
{children.map((c) => (
|
|
73
|
+
<NodeRow key={c.path} node={c} depth={depth + 1} selectedId={selectedId} onSelect={onSelect} />
|
|
74
|
+
))}
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function UseCaseTree({
|
|
80
|
+
useCases,
|
|
81
|
+
selectedId,
|
|
82
|
+
onSelect,
|
|
83
|
+
}: {
|
|
84
|
+
useCases: UseCaseEntry[];
|
|
85
|
+
selectedId: string | null;
|
|
86
|
+
onSelect: (id: string) => void;
|
|
87
|
+
}) {
|
|
88
|
+
const tree = useMemo(() => buildTree(useCases), [useCases]);
|
|
89
|
+
const roots = [...tree.children.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
90
|
+
if (useCases.length === 0) {
|
|
91
|
+
return (
|
|
92
|
+
<div style={{ padding: 14, fontSize: 12, color: GLASS.textMuted }}>
|
|
93
|
+
Brak use-case'ów. Dodaj <code>packages/<pkg>/use-cases/**/*.mdx</code>.
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return (
|
|
98
|
+
<div style={{ padding: 6 }}>
|
|
99
|
+
{roots.map((r) => (
|
|
100
|
+
<NodeRow key={r.path} node={r} depth={0} selectedId={selectedId} onSelect={onSelect} />
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Use-case mode: folder tree | MDX prose | data-flow diagram.
|
|
3
|
+
*
|
|
4
|
+
* Selecting a use case fetches its raw MDX; rendering collects `<Arc>` refs (one
|
|
5
|
+
* per occurrence, document order). The model builds a horizontal backbone of
|
|
6
|
+
* steps + connectors; per-step badges expand into left/right dependency columns.
|
|
7
|
+
* Hovering a chip highlights its step node and vice-versa.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
Background,
|
|
12
|
+
Controls,
|
|
13
|
+
MiniMap,
|
|
14
|
+
ReactFlow,
|
|
15
|
+
type Node,
|
|
16
|
+
} from "@xyflow/react";
|
|
17
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
18
|
+
import type { MapElement, MapJson, UseCaseEntry } from "../types";
|
|
19
|
+
import { buildUseCaseModel } from "./usecase-model";
|
|
20
|
+
import { layoutUseCaseModel } from "./usecase-layout";
|
|
21
|
+
import { nodeTypes } from "./nodes";
|
|
22
|
+
import { UseCaseTree } from "./usecase-tree";
|
|
23
|
+
import { MdxView } from "./mdx-view";
|
|
24
|
+
import { ELEMENT_COLOR, GLASS } from "./theme";
|
|
25
|
+
import {
|
|
26
|
+
ExpandContext,
|
|
27
|
+
FocusContext,
|
|
28
|
+
MapDataContext,
|
|
29
|
+
type ArcRegEntry,
|
|
30
|
+
} from "./contexts";
|
|
31
|
+
|
|
32
|
+
function elementIdOf(n: Node): string | undefined {
|
|
33
|
+
const d = n.data as any;
|
|
34
|
+
return d?.step?.element?.id ?? d?.element?.id;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function UseCaseView({ map, useCases }: { map: MapJson; useCases: UseCaseEntry[] }) {
|
|
38
|
+
const [selectedId, setSelectedId] = useState<string | null>(useCases[0]?.id ?? null);
|
|
39
|
+
const [mdx, setMdx] = useState<string>("");
|
|
40
|
+
const [refs, setRefs] = useState<ArcRegEntry[]>([]);
|
|
41
|
+
const [expanded, setExpanded] = useState<Record<string, string[]>>({});
|
|
42
|
+
const [focusNodeId, setFocusNodeId] = useState<string | null>(null);
|
|
43
|
+
const [scrollToKey, setScrollToKey] = useState<string | null>(null);
|
|
44
|
+
|
|
45
|
+
const proseRef = useRef<HTMLDivElement>(null);
|
|
46
|
+
|
|
47
|
+
// Auto-select the first use case once the list loads (list arrives async).
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!selectedId && useCases.length) setSelectedId(useCases[0].id);
|
|
50
|
+
}, [useCases, selectedId]);
|
|
51
|
+
|
|
52
|
+
// Fetch raw MDX on selection.
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!selectedId) return;
|
|
55
|
+
setRefs([]);
|
|
56
|
+
setExpanded({});
|
|
57
|
+
fetch(`/api/use-cases/raw?id=${encodeURIComponent(selectedId)}`)
|
|
58
|
+
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
|
59
|
+
.then(setMdx)
|
|
60
|
+
.catch((e) => setMdx(`# Błąd\n\nNie udało się wczytać: ${String(e)}`));
|
|
61
|
+
}, [selectedId]);
|
|
62
|
+
|
|
63
|
+
// Model from the ordered occurrences (no dedup).
|
|
64
|
+
const model = useMemo(
|
|
65
|
+
() =>
|
|
66
|
+
buildUseCaseModel({
|
|
67
|
+
map,
|
|
68
|
+
occurrences: refs.map((r) => ({ occId: r.id, ref: r.ref, label: r.label, path: r.path })),
|
|
69
|
+
}),
|
|
70
|
+
[map, refs],
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const layout = useMemo(
|
|
74
|
+
() => layoutUseCaseModel({ model, map, expanded: expanded as Record<string, any> }),
|
|
75
|
+
[model, map, expanded],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const toggle = useMemo(
|
|
79
|
+
() => (occId: string, relKind: string) =>
|
|
80
|
+
setExpanded((prev) => {
|
|
81
|
+
const cur = prev[occId] ?? [];
|
|
82
|
+
const next = cur.includes(relKind) ? cur.filter((k) => k !== relKind) : [...cur, relKind];
|
|
83
|
+
return { ...prev, [occId]: next };
|
|
84
|
+
}),
|
|
85
|
+
[],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// occId step click -> scroll prose to its chip.
|
|
89
|
+
const keyByOcc = useMemo(() => {
|
|
90
|
+
const m = new Map<string, string>();
|
|
91
|
+
for (const s of model.steps) if (s.ref) m.set(s.occId, s.ref.key);
|
|
92
|
+
return m;
|
|
93
|
+
}, [model]);
|
|
94
|
+
|
|
95
|
+
// Highlighting is done inside the node components via FocusContext, so the
|
|
96
|
+
// `nodes` array identity stays stable on hover (no re-render churn / flicker).
|
|
97
|
+
const nodes = layout.nodes;
|
|
98
|
+
|
|
99
|
+
// Prose scroll on node click.
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!scrollToKey || !proseRef.current) return;
|
|
102
|
+
const el = proseRef.current.querySelector(`[data-arc-key="${CSS.escape(scrollToKey)}"]`);
|
|
103
|
+
el?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
104
|
+
setScrollToKey(null);
|
|
105
|
+
}, [scrollToKey]);
|
|
106
|
+
|
|
107
|
+
const focusState = useMemo(
|
|
108
|
+
() => ({ focusNodeId, setFocusNodeId, scrollToKey, setScrollToKey }),
|
|
109
|
+
[focusNodeId, scrollToKey],
|
|
110
|
+
);
|
|
111
|
+
const expandState = useMemo(() => ({ expanded, toggle }), [expanded, toggle]);
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<MapDataContext.Provider value={map}>
|
|
115
|
+
<FocusContext.Provider value={focusState}>
|
|
116
|
+
<ExpandContext.Provider value={expandState}>
|
|
117
|
+
<div style={{ display: "grid", gridTemplateColumns: "240px minmax(360px, 1fr) minmax(460px, 1.2fr)", height: "100%" }}>
|
|
118
|
+
{/* Tree */}
|
|
119
|
+
<div style={{ borderRight: `1px solid ${GLASS.border}`, overflow: "auto", background: GLASS.card }}>
|
|
120
|
+
<UseCaseTree useCases={useCases} selectedId={selectedId} onSelect={setSelectedId} />
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{/* Prose */}
|
|
124
|
+
<div ref={proseRef} style={{ overflow: "auto", padding: "24px 28px", borderRight: `1px solid ${GLASS.border}` }}>
|
|
125
|
+
{selectedId ? (
|
|
126
|
+
<MdxView key={selectedId} mdx={mdx} onRefs={setRefs} />
|
|
127
|
+
) : (
|
|
128
|
+
<div style={{ color: GLASS.textMuted }}>Wybierz use-case z drzewa po lewej.</div>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
{/* Diagram */}
|
|
133
|
+
<div style={{ position: "relative" }}>
|
|
134
|
+
<ReactFlow
|
|
135
|
+
key={selectedId ?? "none"}
|
|
136
|
+
nodes={nodes}
|
|
137
|
+
edges={layout.edges}
|
|
138
|
+
nodeTypes={nodeTypes}
|
|
139
|
+
nodesDraggable={false}
|
|
140
|
+
onNodeMouseEnter={(_, n) => {
|
|
141
|
+
const id = elementIdOf(n);
|
|
142
|
+
if (id) setFocusNodeId(id);
|
|
143
|
+
}}
|
|
144
|
+
onNodeMouseLeave={() => setFocusNodeId(null)}
|
|
145
|
+
onNodeClick={(_, n) => {
|
|
146
|
+
const k = keyByOcc.get(n.id);
|
|
147
|
+
if (k) setScrollToKey(k);
|
|
148
|
+
}}
|
|
149
|
+
fitView
|
|
150
|
+
fitViewOptions={{ padding: 0.18 }}
|
|
151
|
+
minZoom={0.1}
|
|
152
|
+
proOptions={{ hideAttribution: true }}
|
|
153
|
+
>
|
|
154
|
+
<Background color="#c9b8e8" gap={26} size={1.5} />
|
|
155
|
+
<Controls />
|
|
156
|
+
<MiniMap
|
|
157
|
+
pannable
|
|
158
|
+
zoomable
|
|
159
|
+
nodeColor={(n) => {
|
|
160
|
+
const d = n.data as any;
|
|
161
|
+
const k = d?.step?.element?.kind ?? d?.element?.kind;
|
|
162
|
+
return (k && ELEMENT_COLOR[k as MapElement["kind"]]) || (d?.step && !d.step.exists ? "#e11d48" : "#64748b");
|
|
163
|
+
}}
|
|
164
|
+
style={{ background: "rgba(255,255,255,0.6)", border: `1px solid ${GLASS.border}`, borderRadius: 12 }}
|
|
165
|
+
/>
|
|
166
|
+
</ReactFlow>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</ExpandContext.Provider>
|
|
170
|
+
</FocusContext.Provider>
|
|
171
|
+
</MapDataContext.Provider>
|
|
172
|
+
);
|
|
173
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @arcote.tech/arc-map — interactive map of Arc context spaces, elements,
|
|
3
|
+
* event-flow and React useScope usages. Server-side entry; the browser app
|
|
4
|
+
* lives under `./app`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { startMapServer } from "./server";
|
|
8
|
+
export type { MapServer, MapServerOptions } from "./server";
|
|
9
|
+
export { buildMapJson } from "./mapper";
|
|
10
|
+
export type { BuildMapInput, ModuleInfo } from "./mapper";
|
|
11
|
+
export { scanScopeUsages } from "./scan";
|
|
12
|
+
export type { ScanInput, ScanResult } from "./scan";
|
|
13
|
+
export { buildModuleIndex } from "./module-index";
|
|
14
|
+
export { discoverUseCases, parseFrontmatter } from "./use-cases/discover";
|
|
15
|
+
export type { UseCasePackage, DiscoveredUseCase } from "./use-cases/discover";
|
|
16
|
+
export { resolveRef } from "./refs";
|
|
17
|
+
export type { ArcRefProps, ResolvedRef, ArcRefKind } from "./refs";
|
|
18
|
+
export * from "./types";
|