@arcote.tech/arc-map 0.7.27 → 0.7.29
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 +4 -6
- package/src/app/nodes.tsx +0 -247
- package/src/app/theme.ts +1 -0
- package/src/app/usecase-view.tsx +64 -144
- package/src/catalog.ts +0 -0
- package/src/index.ts +7 -2
- package/src/mapper.ts +78 -0
- package/src/module-index.ts +1 -1
- package/src/scan.ts +1 -1
- package/src/server.ts +48 -1
- package/src/types.ts +39 -0
- package/tests/catalog.test.ts +67 -0
- package/src/app/arc.tsx +0 -92
- package/src/app/contexts.tsx +0 -72
- package/src/app/mdx-view.tsx +0 -74
- package/src/app/ref-context.tsx +0 -49
- package/src/app/structure.tsx +0 -95
- package/src/app/subgraph.ts +0 -127
- package/src/app/usecase-layout.ts +0 -249
- package/src/app/usecase-model.ts +0 -346
- package/src/refs.ts +0 -121
- package/tests/refs.test.ts +0 -56
- package/tests/subgraph.test.ts +0 -54
- package/tests/usecase-model.test.ts +0 -197
package/src/app/subgraph.ts
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Build the scoped sub-graph for a use case from its `<Arc>` references and the
|
|
3
|
-
* full map. Pure and dependency-free (testable in bun).
|
|
4
|
-
*
|
|
5
|
-
* Node set = referenced ∪ direct neighbours (1 hop) ∪ all nodes on the shortest
|
|
6
|
-
* UNDIRECTED connecting paths between pairs of referenced nodes (so the plumbing
|
|
7
|
-
* that links two steps — e.g. command → event → listener → event → view — shows
|
|
8
|
-
* even when the author didn't reference it). Bounded by a max path length.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import type { MapEdge, MapJson } from "../types";
|
|
12
|
-
|
|
13
|
-
/** Default cap for connecting-path length (in hops). */
|
|
14
|
-
export const DEFAULT_MAX_PATH = 5;
|
|
15
|
-
|
|
16
|
-
export interface SubgraphInput {
|
|
17
|
-
map: MapJson;
|
|
18
|
-
/** Referenced node ids in document order (may include unresolved — filtered). */
|
|
19
|
-
referenced: string[];
|
|
20
|
-
/** Extra nodes added via expand-on-click. */
|
|
21
|
-
expanded?: string[];
|
|
22
|
-
maxPath?: number;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface SubgraphResult {
|
|
26
|
-
/** All node ids included in the sub-graph. */
|
|
27
|
-
nodeIds: Set<string>;
|
|
28
|
-
/** Referenced node ids that exist in the map, in document order, de-duped. */
|
|
29
|
-
referencedIds: string[];
|
|
30
|
-
/** Edges induced on the node set. */
|
|
31
|
-
edges: MapEdge[];
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function buildAdjacency(map: MapJson): Map<string, Set<string>> {
|
|
35
|
-
const adj = new Map<string, Set<string>>();
|
|
36
|
-
const link = (a: string, b: string) => {
|
|
37
|
-
let s = adj.get(a);
|
|
38
|
-
if (!s) adj.set(a, (s = new Set()));
|
|
39
|
-
s.add(b);
|
|
40
|
-
};
|
|
41
|
-
for (const e of map.edges) {
|
|
42
|
-
link(e.source, e.target);
|
|
43
|
-
link(e.target, e.source);
|
|
44
|
-
}
|
|
45
|
-
return adj;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Undirected neighbours of a node (for 1-hop and expand-on-click). */
|
|
49
|
-
export function neighborsOf(map: MapJson, nodeId: string): string[] {
|
|
50
|
-
return [...buildAdjacency(map).get(nodeId) ?? []];
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** BFS shortest path (undirected) from `start` to `goal`, capped at maxLen hops. */
|
|
54
|
-
function shortestPath(
|
|
55
|
-
adj: Map<string, Set<string>>,
|
|
56
|
-
start: string,
|
|
57
|
-
goal: string,
|
|
58
|
-
maxLen: number,
|
|
59
|
-
): string[] | null {
|
|
60
|
-
if (start === goal) return [start];
|
|
61
|
-
const prev = new Map<string, string>();
|
|
62
|
-
const depth = new Map<string, number>([[start, 0]]);
|
|
63
|
-
const queue: string[] = [start];
|
|
64
|
-
while (queue.length) {
|
|
65
|
-
const cur = queue.shift()!;
|
|
66
|
-
const d = depth.get(cur)!;
|
|
67
|
-
if (d >= maxLen) continue;
|
|
68
|
-
for (const nb of adj.get(cur) ?? []) {
|
|
69
|
-
if (depth.has(nb)) continue;
|
|
70
|
-
depth.set(nb, d + 1);
|
|
71
|
-
prev.set(nb, cur);
|
|
72
|
-
if (nb === goal) {
|
|
73
|
-
const path = [goal];
|
|
74
|
-
let p = cur;
|
|
75
|
-
while (p !== start) {
|
|
76
|
-
path.push(p);
|
|
77
|
-
p = prev.get(p)!;
|
|
78
|
-
}
|
|
79
|
-
path.push(start);
|
|
80
|
-
return path.reverse();
|
|
81
|
-
}
|
|
82
|
-
queue.push(nb);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function buildSubgraph(input: SubgraphInput): SubgraphResult {
|
|
89
|
-
const { map, maxPath = DEFAULT_MAX_PATH } = input;
|
|
90
|
-
const exists = new Set(map.elements.map((e) => e.id));
|
|
91
|
-
const adj = buildAdjacency(map);
|
|
92
|
-
|
|
93
|
-
// Resolved referenced ids, de-duped, in document order.
|
|
94
|
-
const referencedIds: string[] = [];
|
|
95
|
-
const seen = new Set<string>();
|
|
96
|
-
for (const id of input.referenced) {
|
|
97
|
-
if (exists.has(id) && !seen.has(id)) {
|
|
98
|
-
seen.add(id);
|
|
99
|
-
referencedIds.push(id);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const nodeIds = new Set<string>(referencedIds);
|
|
104
|
-
|
|
105
|
-
// Expanded (click) nodes + their neighbours.
|
|
106
|
-
for (const id of input.expanded ?? []) {
|
|
107
|
-
if (!exists.has(id)) continue;
|
|
108
|
-
nodeIds.add(id);
|
|
109
|
-
for (const nb of adj.get(id) ?? []) nodeIds.add(nb);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// 1-hop neighbours of referenced.
|
|
113
|
-
for (const id of referencedIds) {
|
|
114
|
-
for (const nb of adj.get(id) ?? []) nodeIds.add(nb);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Connecting paths between every pair of referenced nodes.
|
|
118
|
-
for (let i = 0; i < referencedIds.length; i++) {
|
|
119
|
-
for (let j = i + 1; j < referencedIds.length; j++) {
|
|
120
|
-
const path = shortestPath(adj, referencedIds[i], referencedIds[j], maxPath);
|
|
121
|
-
if (path) for (const n of path) nodeIds.add(n);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const edges = map.edges.filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target));
|
|
126
|
-
return { nodeIds, referencedIds, edges };
|
|
127
|
-
}
|
|
@@ -1,249 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Deterministic 2-D layout for the use-case data-flow diagram.
|
|
3
|
-
*
|
|
4
|
-
* x = depth (time / dependency order)
|
|
5
|
-
* y = lane (parallel / branch track)
|
|
6
|
-
*
|
|
7
|
-
* Independent steps land at the same depth on separate lanes (stacked
|
|
8
|
-
* vertically). Per-depth horizontal spacing reserves room for any expanded
|
|
9
|
-
* dependency columns, so expanding never reshuffles the backbone.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import type { Edge, Node } from "@xyflow/react";
|
|
13
|
-
import type { MapJson } from "../types";
|
|
14
|
-
import { REL_SIDE, type RelKind, type UseCaseModel } from "./usecase-model";
|
|
15
|
-
import { EDGE_COLOR, ELEMENT_COLOR } from "./theme";
|
|
16
|
-
|
|
17
|
-
const STEP_W = 215;
|
|
18
|
-
const STEP_HALF = STEP_W / 2;
|
|
19
|
-
const DEP_W = 178;
|
|
20
|
-
const COL_GAP_X = 250;
|
|
21
|
-
const COL_HALF = COL_GAP_X + DEP_W / 2;
|
|
22
|
-
const MARGIN = 80;
|
|
23
|
-
const BASELINE_Y = 360;
|
|
24
|
-
const LANE_GAP_Y = 230;
|
|
25
|
-
const DEP_H = 48;
|
|
26
|
-
const DEP_GAP_Y = 14;
|
|
27
|
-
|
|
28
|
-
/** Edge kinds where the target is an INPUT to the source (read / triggered-by). */
|
|
29
|
-
const INPUT_KINDS = new Set(["queries", "listens", "handles"]);
|
|
30
|
-
|
|
31
|
-
const REL_COLOR: Record<RelKind, string> = {
|
|
32
|
-
queries: EDGE_COLOR.queries,
|
|
33
|
-
mutates: EDGE_COLOR.mutates,
|
|
34
|
-
mutatedBy: EDGE_COLOR.mutates,
|
|
35
|
-
queriedBy: EDGE_COLOR.queries,
|
|
36
|
-
handledBy: EDGE_COLOR.handles,
|
|
37
|
-
usedByUI: ELEMENT_COLOR.page,
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
export interface UseCaseLayoutInput {
|
|
41
|
-
model: UseCaseModel;
|
|
42
|
-
map: MapJson;
|
|
43
|
-
expanded: Record<string, RelKind[]>;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface UseCaseLayoutResult {
|
|
47
|
-
nodes: Node[];
|
|
48
|
-
edges: Edge[];
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function layoutUseCaseModel(input: UseCaseLayoutInput): UseCaseLayoutResult {
|
|
52
|
-
const { model, map, expanded } = input;
|
|
53
|
-
const elementById = new Map(map.elements.map((e) => [e.id, e]));
|
|
54
|
-
for (const [id, el] of model.uiById) if (!elementById.has(id)) elementById.set(id, el);
|
|
55
|
-
const nodes: Node[] = [];
|
|
56
|
-
const edges: Edge[] = [];
|
|
57
|
-
const steps = model.steps;
|
|
58
|
-
if (steps.length === 0) return { nodes, edges };
|
|
59
|
-
|
|
60
|
-
// Expanded items per step, split by side.
|
|
61
|
-
const sideItems = new Map<string, { left: { kind: RelKind; id: string }[]; right: { kind: RelKind; id: string }[] }>();
|
|
62
|
-
for (const s of steps) {
|
|
63
|
-
const left: { kind: RelKind; id: string }[] = [];
|
|
64
|
-
const right: { kind: RelKind; id: string }[] = [];
|
|
65
|
-
for (const kind of expanded[s.occId] ?? []) {
|
|
66
|
-
const bucket = REL_SIDE[kind] === "left" ? left : right;
|
|
67
|
-
for (const id of s.badges[kind] ?? []) bucket.push({ kind, id });
|
|
68
|
-
}
|
|
69
|
-
sideItems.set(s.occId, { left, right });
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Per-depth reservation: does any step at depth d expand a left / right column?
|
|
73
|
-
const maxDepth = Math.max(...steps.map((s) => s.depth));
|
|
74
|
-
const leftAt: boolean[] = [];
|
|
75
|
-
const rightAt: boolean[] = [];
|
|
76
|
-
for (const s of steps) {
|
|
77
|
-
const si = sideItems.get(s.occId)!;
|
|
78
|
-
if (si.left.length) leftAt[s.depth] = true;
|
|
79
|
-
if (si.right.length) rightAt[s.depth] = true;
|
|
80
|
-
}
|
|
81
|
-
const xByDepth: number[] = [];
|
|
82
|
-
for (let d = 0; d <= maxDepth; d++) {
|
|
83
|
-
const halfLeft = leftAt[d] ? COL_HALF : STEP_HALF;
|
|
84
|
-
const halfRight = (dd: number) => (rightAt[dd] ? COL_HALF : STEP_HALF);
|
|
85
|
-
xByDepth[d] = d === 0 ? halfLeft : xByDepth[d - 1] + halfRight(d - 1) + MARGIN + halfLeft;
|
|
86
|
-
}
|
|
87
|
-
const stepY = (lane: number) => BASELINE_Y + lane * LANE_GAP_Y;
|
|
88
|
-
|
|
89
|
-
// 1) Step nodes.
|
|
90
|
-
for (const s of steps) {
|
|
91
|
-
nodes.push({
|
|
92
|
-
id: s.occId,
|
|
93
|
-
type: "step",
|
|
94
|
-
position: { x: xByDepth[s.depth] - STEP_HALF, y: stepY(s.lane) },
|
|
95
|
-
data: { step: s, expanded: expanded[s.occId] ?? [] },
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Backbone edges (document order), skipping parallel siblings (same block,
|
|
100
|
-
// different lane) which are concurrent rather than sequential.
|
|
101
|
-
for (let i = 1; i < steps.length; i++) {
|
|
102
|
-
const a = steps[i - 1];
|
|
103
|
-
const b = steps[i];
|
|
104
|
-
if (a.blockId && a.blockId === b.blockId && a.lane !== b.lane) continue;
|
|
105
|
-
edges.push({
|
|
106
|
-
id: `bb:${i}`,
|
|
107
|
-
source: a.occId,
|
|
108
|
-
target: b.occId,
|
|
109
|
-
style: { stroke: "#c4b5fd", strokeWidth: 1.5, strokeDasharray: "3 5", opacity: 0.6 },
|
|
110
|
-
data: { backbone: true },
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// element id -> first step occId + depth (for relation edges & connectors).
|
|
115
|
-
const firstOccByElement = new Map<string, string>();
|
|
116
|
-
const depthByElement = new Map<string, number>();
|
|
117
|
-
for (const s of steps) {
|
|
118
|
-
if (s.exists && !firstOccByElement.has(s.element!.id)) {
|
|
119
|
-
firstOccByElement.set(s.element!.id, s.occId);
|
|
120
|
-
depthByElement.set(s.element!.id, s.depth);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
const nodeIdForElement = (elId: string): string | undefined =>
|
|
124
|
-
firstOccByElement.get(elId) ?? (model.visible.has(elId) ? `conn:${elId}` : undefined);
|
|
125
|
-
|
|
126
|
-
// 2) Connector nodes — placed BETWEEN the two narrated steps they connect,
|
|
127
|
-
// interpolated left→right by their flow order, on a dedicated lane just
|
|
128
|
-
// below the backbone (so they never overlap a step) and rendered like any
|
|
129
|
-
// other element node.
|
|
130
|
-
const stepDepthByOcc = new Map(steps.map((s) => [s.occId, s.depth]));
|
|
131
|
-
const connDepth = new Map<string, number>();
|
|
132
|
-
const maxLane = Math.max(...steps.map((s) => s.lane));
|
|
133
|
-
const connLaneY = BASELINE_Y + (maxLane + 1) * LANE_GAP_Y;
|
|
134
|
-
const xStack = new Map<number, number>(); // rounded x → vertical stack count
|
|
135
|
-
for (const c of model.connectors) {
|
|
136
|
-
const da = depthByElement.get(stepElementForSeq(model, c.between[0])) ?? 0;
|
|
137
|
-
const db = depthByElement.get(stepElementForSeq(model, c.between[1])) ?? da;
|
|
138
|
-
// Spread intermediates evenly across the span between the two steps.
|
|
139
|
-
const frac = c.order / (c.count + 1);
|
|
140
|
-
const pseudoDepth = da + (db - da) * frac;
|
|
141
|
-
const x = xByDepth[da] + (xByDepth[db] - xByDepth[da]) * frac;
|
|
142
|
-
const key = Math.round(x);
|
|
143
|
-
const idx = xStack.get(key) ?? 0;
|
|
144
|
-
xStack.set(key, idx + 1);
|
|
145
|
-
const y = connLaneY + idx * (DEP_H + DEP_GAP_Y);
|
|
146
|
-
connDepth.set(`conn:${c.id}`, pseudoDepth);
|
|
147
|
-
nodes.push({ id: `conn:${c.id}`, type: "connector", position: { x: x - DEP_W / 2, y }, data: { element: c.element } });
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const depthOf = (nodeId: string): number =>
|
|
151
|
-
connDepth.get(nodeId) ?? stepDepthByOcc.get(nodeId) ?? 0;
|
|
152
|
-
|
|
153
|
-
// Relation edges, oriented by data-flow:
|
|
154
|
-
// - queries/listens/handles = INPUT → draw element→consumer (arrow into step);
|
|
155
|
-
// - mutates/emits = OUTPUT → draw producer→element, but DROP backward outputs
|
|
156
|
-
// (an output pointing to an earlier element is noise).
|
|
157
|
-
const seenEdge = new Set<string>();
|
|
158
|
-
for (const e of map.edges) {
|
|
159
|
-
const sId = nodeIdForElement(e.source);
|
|
160
|
-
const tId = nodeIdForElement(e.target);
|
|
161
|
-
if (!sId || !tId || sId === tId) continue;
|
|
162
|
-
const input = INPUT_KINDS.has(e.kind);
|
|
163
|
-
const from = input ? tId : sId;
|
|
164
|
-
const to = input ? sId : tId;
|
|
165
|
-
if (!input && depthOf(to) < depthOf(from)) continue; // backward output → skip
|
|
166
|
-
const key = `${from}->${to}:${e.kind}`;
|
|
167
|
-
if (seenEdge.has(key)) continue;
|
|
168
|
-
seenEdge.add(key);
|
|
169
|
-
const color = EDGE_COLOR[e.kind];
|
|
170
|
-
edges.push({
|
|
171
|
-
id: `rel:${key}`,
|
|
172
|
-
source: from,
|
|
173
|
-
target: to,
|
|
174
|
-
label: e.kind,
|
|
175
|
-
style: { stroke: color, strokeWidth: 1.5, opacity: 0.75 },
|
|
176
|
-
labelStyle: { fill: color, fontSize: 9, fontWeight: 600 },
|
|
177
|
-
labelBgStyle: { fill: "#fff", fillOpacity: 0.8 },
|
|
178
|
-
markerEnd: { type: "arrowclosed", color } as any,
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// 2b) UI scope-usage edges — a narrated screen (page/component) connects to
|
|
183
|
-
// the context elements it uses via useQuery/useMutation. query = reads
|
|
184
|
-
// (input, element→screen); mutation = triggers (output, screen→element).
|
|
185
|
-
// Drawn only when BOTH ends are visible; otherwise it stays a `usedByUI`
|
|
186
|
-
// badge. Dashed indigo to read as "UI uses", distinct from domain edges.
|
|
187
|
-
for (const e of map.componentEdges ?? []) {
|
|
188
|
-
const sId = nodeIdForElement(e.source);
|
|
189
|
-
const tId = nodeIdForElement(e.target);
|
|
190
|
-
if (!sId || !tId || sId === tId) continue;
|
|
191
|
-
const input = e.usage === "query";
|
|
192
|
-
const from = input ? tId : sId;
|
|
193
|
-
const to = input ? sId : tId;
|
|
194
|
-
if (!input && depthOf(to) < depthOf(from)) continue; // backward output → skip
|
|
195
|
-
const key = `ui:${from}->${to}:${e.usage}`;
|
|
196
|
-
if (seenEdge.has(key)) continue;
|
|
197
|
-
seenEdge.add(key);
|
|
198
|
-
const color = ELEMENT_COLOR.page;
|
|
199
|
-
edges.push({
|
|
200
|
-
id: `uirel:${key}`,
|
|
201
|
-
source: from,
|
|
202
|
-
target: to,
|
|
203
|
-
label: input ? "czyta" : "wywołuje",
|
|
204
|
-
style: { stroke: color, strokeWidth: 1.5, opacity: 0.7, strokeDasharray: "4 3" },
|
|
205
|
-
labelStyle: { fill: color, fontSize: 9, fontWeight: 600 },
|
|
206
|
-
labelBgStyle: { fill: "#fff", fillOpacity: 0.8 },
|
|
207
|
-
markerEnd: { type: "arrowclosed", color } as any,
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// 3) Expanded badge columns around each step.
|
|
212
|
-
for (const s of steps) {
|
|
213
|
-
const si = sideItems.get(s.occId)!;
|
|
214
|
-
const cx = xByDepth[s.depth];
|
|
215
|
-
const cy = stepY(s.lane);
|
|
216
|
-
const place = (items: { kind: RelKind; id: string }[], side: "left" | "right") => {
|
|
217
|
-
const colX = cx + (side === "left" ? -COL_GAP_X : COL_GAP_X) - DEP_W / 2;
|
|
218
|
-
const total = items.length;
|
|
219
|
-
items.forEach((it, k) => {
|
|
220
|
-
const el = elementById.get(it.id);
|
|
221
|
-
if (!el) return;
|
|
222
|
-
const y = cy + (k - (total - 1) / 2) * (DEP_H + DEP_GAP_Y);
|
|
223
|
-
const depNodeId = `dep:${s.occId}:${it.kind}:${it.id}`;
|
|
224
|
-
nodes.push({ id: depNodeId, type: "dep", position: { x: colX, y }, data: { element: el, relKind: it.kind } });
|
|
225
|
-
const intoStep = it.kind === "queries" || it.kind === "queriedBy" || it.kind === "mutatedBy" || it.kind === "usedByUI";
|
|
226
|
-
const color = REL_COLOR[it.kind];
|
|
227
|
-
edges.push({
|
|
228
|
-
id: `de:${depNodeId}`,
|
|
229
|
-
source: intoStep ? depNodeId : s.occId,
|
|
230
|
-
target: intoStep ? s.occId : depNodeId,
|
|
231
|
-
style: { stroke: color, strokeWidth: 1.5, opacity: 0.9 },
|
|
232
|
-
markerEnd: { type: "arrowclosed", color } as any,
|
|
233
|
-
});
|
|
234
|
-
});
|
|
235
|
-
};
|
|
236
|
-
place(si.left, "left");
|
|
237
|
-
place(si.right, "right");
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
return { nodes, edges };
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** Find the element id of the first step at a given seq (for connector depth). */
|
|
244
|
-
function stepElementForSeq(model: UseCaseModel, seq: number): string {
|
|
245
|
-
const s = model.steps.find((x) => x.seq === seq);
|
|
246
|
-
return s?.element?.id ?? "";
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
export { ELEMENT_COLOR };
|