@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.
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Hierarchical auto-layout via ELK (`layered`): spaces are compound container
3
+ * nodes, elements are their children, and domain edges drive a crossing-
4
+ * minimized left-to-right flow (command → event → view). React component nodes
5
+ * are placed in a grid block to the left of the domain bounding box.
6
+ *
7
+ * Async — ELK runs off the main work and returns a Promise. Falls back to a
8
+ * deterministic grid if ELK ever throws.
9
+ */
10
+
11
+ import ELK from "elkjs/lib/elk.bundled.js";
12
+ import type { Edge, Node } from "@xyflow/react";
13
+ import type { MapEdge, MapElement, MapJson } from "../types";
14
+ import {
15
+ COMPONENT_EDGE_MUTATION,
16
+ COMPONENT_EDGE_QUERY,
17
+ DIM,
18
+ EDGE_COLOR,
19
+ } from "./theme";
20
+
21
+ const LABEL_BG = "#ffffff";
22
+ const LABEL_BG_OPACITY = 0.78;
23
+ const COMP_COLS = 3;
24
+
25
+ const elk = new ELK();
26
+
27
+ const ROOT_OPTS: Record<string, string> = {
28
+ "elk.algorithm": "layered",
29
+ "elk.direction": "RIGHT",
30
+ "elk.hierarchyHandling": "INCLUDE_CHILDREN",
31
+ "elk.layered.spacing.nodeNodeBetweenLayers": "75",
32
+ "elk.spacing.nodeNode": "38",
33
+ "elk.layered.spacing.edgeNodeBetweenLayers": "28",
34
+ "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
35
+ "elk.layered.nodePlacement.strategy": "BRANDES_KOEPF",
36
+ "elk.layered.cycleBreaking.strategy": "GREEDY",
37
+ "elk.spacing.componentComponent": "90",
38
+ "elk.separateConnectedComponents": "true",
39
+ };
40
+
41
+ const SPACE_OPTS: Record<string, string> = {
42
+ // Reserve room at the top for the space header label.
43
+ "elk.padding": "[top=46,left=20,bottom=20,right=20]",
44
+ "elk.spacing.nodeNode": "26",
45
+ "elk.layered.spacing.nodeNodeBetweenLayers": "55",
46
+ };
47
+
48
+ export interface LayoutResult {
49
+ nodes: Node[];
50
+ edges: Edge[];
51
+ }
52
+
53
+ interface ElkNode {
54
+ id: string;
55
+ x?: number;
56
+ y?: number;
57
+ width?: number;
58
+ height?: number;
59
+ children?: ElkNode[];
60
+ layoutOptions?: Record<string, string>;
61
+ edges?: unknown[];
62
+ }
63
+
64
+ export async function layoutMap(map: MapJson): Promise<LayoutResult> {
65
+ const elementsBySpace = new Map<string, MapJson["elements"]>();
66
+ for (const space of map.spaces) elementsBySpace.set(space.id, []);
67
+ for (const el of map.elements) {
68
+ if (!elementsBySpace.has(el.spaceId)) elementsBySpace.set(el.spaceId, []);
69
+ elementsBySpace.get(el.spaceId)!.push(el);
70
+ }
71
+ const spaceName = new Map(map.spaces.map((s) => [s.id, s.name]));
72
+ const nodeIds = new Set(map.elements.map((e) => e.id));
73
+
74
+ // ---- Build the compound ELK graph (spaces → elements). ----
75
+ const graph: ElkNode = {
76
+ id: "root",
77
+ layoutOptions: ROOT_OPTS,
78
+ children: [...elementsBySpace.entries()]
79
+ .filter(([, els]) => els.length > 0)
80
+ .map(([spaceId, els]) => ({
81
+ id: `space:${spaceId}`,
82
+ layoutOptions: SPACE_OPTS,
83
+ children: els.map((el) => ({
84
+ id: el.id,
85
+ width: DIM.elementW,
86
+ height: DIM.elementH,
87
+ })),
88
+ })),
89
+ edges: map.edges
90
+ .filter((e) => nodeIds.has(e.source) && nodeIds.has(e.target))
91
+ .map((e) => ({ id: e.id, sources: [e.source], targets: [e.target] })),
92
+ };
93
+
94
+ const nodes: Node[] = [];
95
+ let minX = Infinity;
96
+ let minY = Infinity;
97
+
98
+ try {
99
+ const res = (await elk.layout(graph as any)) as ElkNode;
100
+ for (const space of res.children ?? []) {
101
+ const sx = space.x ?? 0;
102
+ const sy = space.y ?? 0;
103
+ const spaceId = space.id.replace(/^space:/, "");
104
+ minX = Math.min(minX, sx);
105
+ minY = Math.min(minY, sy);
106
+ nodes.push({
107
+ id: space.id,
108
+ type: "space",
109
+ position: { x: sx, y: sy },
110
+ data: { label: spaceName.get(spaceId) ?? spaceId, count: space.children?.length ?? 0 },
111
+ style: { width: space.width ?? 200, height: space.height ?? 120 },
112
+ selectable: false,
113
+ draggable: true,
114
+ });
115
+ const byId = new Map(map.elements.map((e) => [e.id, e]));
116
+ for (const child of space.children ?? []) {
117
+ const el = byId.get(child.id);
118
+ if (!el) continue;
119
+ nodes.push({
120
+ id: el.id,
121
+ type: "element",
122
+ parentId: space.id,
123
+ extent: "parent",
124
+ position: { x: child.x ?? 0, y: child.y ?? 0 },
125
+ data: { element: el },
126
+ });
127
+ }
128
+ }
129
+ } catch {
130
+ return gridFallback(map);
131
+ }
132
+
133
+ if (!isFinite(minX)) {
134
+ minX = 0;
135
+ minY = 0;
136
+ }
137
+
138
+ // ---- Component block: grid to the left of the domain. ----
139
+ const blockW = COMP_COLS * (DIM.componentW + DIM.gap);
140
+ const startX = minX - blockW - 180;
141
+ map.components.forEach((c, i) => {
142
+ const col = i % COMP_COLS;
143
+ const row = Math.floor(i / COMP_COLS);
144
+ nodes.push({
145
+ id: `component:${c.id}`,
146
+ type: "component",
147
+ position: {
148
+ x: startX + col * (DIM.componentW + DIM.gap),
149
+ y: minY + row * (DIM.componentH + DIM.componentGapY),
150
+ },
151
+ data: { component: c },
152
+ });
153
+ });
154
+
155
+ return { nodes, edges: buildEdges(map) };
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Edges
160
+ // ---------------------------------------------------------------------------
161
+
162
+ function buildEdges(map: MapJson): Edge[] {
163
+ const edges: Edge[] = [];
164
+ for (const e of map.edges) {
165
+ const color = EDGE_COLOR[e.kind];
166
+ edges.push({
167
+ id: e.id,
168
+ source: e.source,
169
+ target: e.target,
170
+ data: { kind: e.kind, crossContext: e.crossContext },
171
+ style: {
172
+ stroke: color,
173
+ strokeWidth: e.crossContext ? 2 : 1.25,
174
+ strokeDasharray: e.crossContext ? "6 4" : undefined,
175
+ opacity: e.crossContext ? 0.5 : 0.22,
176
+ },
177
+ labelStyle: { fill: color, fontSize: 10, fontWeight: 600 },
178
+ labelBgStyle: { fill: LABEL_BG, fillOpacity: LABEL_BG_OPACITY },
179
+ labelBgPadding: [4, 2] as [number, number],
180
+ labelBgBorderRadius: 4,
181
+ markerEnd: { type: "arrowclosed", color } as any,
182
+ });
183
+ }
184
+ for (const ce of map.componentEdges) {
185
+ const color = ce.usage === "query" ? COMPONENT_EDGE_QUERY : COMPONENT_EDGE_MUTATION;
186
+ edges.push({
187
+ id: `ce:${ce.id}`,
188
+ source: `component:${ce.source}`,
189
+ target: ce.target,
190
+ data: { component: true, usage: ce.usage },
191
+ style: { stroke: color, strokeWidth: 1.25, strokeDasharray: "2 3", opacity: 0.45 },
192
+ markerEnd: { type: "arrowclosed", color } as any,
193
+ });
194
+ }
195
+ return edges;
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Fallback grid (only if ELK throws)
200
+ // ---------------------------------------------------------------------------
201
+
202
+ function gridFallback(map: MapJson): LayoutResult {
203
+ const nodes: Node[] = [];
204
+ const elementsBySpace = new Map<string, MapJson["elements"]>();
205
+ for (const el of map.elements) {
206
+ if (!elementsBySpace.has(el.spaceId)) elementsBySpace.set(el.spaceId, []);
207
+ elementsBySpace.get(el.spaceId)!.push(el);
208
+ }
209
+ const spaceName = new Map(map.spaces.map((s) => [s.id, s.name]));
210
+ let cursorX = 0;
211
+ let cursorY = 0;
212
+ let rowHeight = 0;
213
+ for (const [spaceId, els] of elementsBySpace) {
214
+ if (els.length === 0) continue;
215
+ const cols = Math.min(6, Math.max(1, Math.ceil(Math.sqrt(els.length))));
216
+ const rows = Math.ceil(els.length / cols);
217
+ const groupW = cols * DIM.elementW + (cols - 1) * DIM.gap + DIM.pad * 2;
218
+ const groupH = rows * DIM.elementH + (rows - 1) * DIM.gap + DIM.pad * 2 + DIM.header;
219
+ if (cursorX > 0 && cursorX + groupW > 4200) {
220
+ cursorX = 0;
221
+ cursorY += rowHeight + DIM.groupGapY;
222
+ rowHeight = 0;
223
+ }
224
+ nodes.push({
225
+ id: `space:${spaceId}`,
226
+ type: "space",
227
+ position: { x: cursorX, y: cursorY },
228
+ data: { label: spaceName.get(spaceId) ?? spaceId, count: els.length },
229
+ style: { width: groupW, height: groupH },
230
+ selectable: false,
231
+ draggable: true,
232
+ });
233
+ els.forEach((el, i) => {
234
+ nodes.push({
235
+ id: el.id,
236
+ type: "element",
237
+ parentId: `space:${spaceId}`,
238
+ extent: "parent",
239
+ position: {
240
+ x: DIM.pad + (i % cols) * (DIM.elementW + DIM.gap),
241
+ y: DIM.header + DIM.pad + Math.floor(i / cols) * (DIM.elementH + DIM.gap),
242
+ },
243
+ data: { element: el },
244
+ });
245
+ });
246
+ cursorX += groupW + DIM.groupGapX;
247
+ rowHeight = Math.max(rowHeight, groupH);
248
+ }
249
+ return { nodes, edges: buildEdges(map) };
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Use-case "ordered spine" layout
254
+ // ---------------------------------------------------------------------------
255
+
256
+ const USECASE_OPTS: Record<string, string> = {
257
+ "elk.algorithm": "layered",
258
+ "elk.direction": "DOWN",
259
+ "elk.layered.spacing.nodeNodeBetweenLayers": "70",
260
+ "elk.spacing.nodeNode": "44",
261
+ "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
262
+ "elk.layered.nodePlacement.strategy": "BRANDES_KOEPF",
263
+ "elk.layered.cycleBreaking.strategy": "GREEDY",
264
+ "elk.separateConnectedComponents": "false",
265
+ };
266
+
267
+ /** Domain edges (no components), styled for the smaller use-case sub-graph. */
268
+ function useCaseEdges(edges: MapEdge[]): Edge[] {
269
+ return edges.map((e) => {
270
+ const color = EDGE_COLOR[e.kind];
271
+ return {
272
+ id: e.id,
273
+ source: e.source,
274
+ target: e.target,
275
+ data: { kind: e.kind, crossContext: e.crossContext },
276
+ style: {
277
+ stroke: color,
278
+ strokeWidth: e.crossContext ? 2.5 : 1.75,
279
+ strokeDasharray: e.crossContext ? "6 4" : undefined,
280
+ opacity: 0.85,
281
+ },
282
+ label: e.kind,
283
+ labelStyle: { fill: color, fontSize: 10, fontWeight: 600 },
284
+ labelBgStyle: { fill: LABEL_BG, fillOpacity: LABEL_BG_OPACITY },
285
+ labelBgPadding: [4, 2] as [number, number],
286
+ labelBgBorderRadius: 4,
287
+ markerEnd: { type: "arrowclosed", color } as any,
288
+ };
289
+ });
290
+ }
291
+
292
+ export interface UseCaseLayoutInput {
293
+ elements: MapElement[];
294
+ edges: MapEdge[];
295
+ /** Referenced element ids in document order — become the numbered spine. */
296
+ referencedIds: string[];
297
+ }
298
+
299
+ /**
300
+ * Lay out a use-case sub-graph as an ordered spine. Synthetic ordering edges
301
+ * between consecutive referenced nodes force them into document order (top→down)
302
+ * via the layered algorithm; related/connector nodes branch off. The synthetic
303
+ * edges are not rendered.
304
+ */
305
+ export async function layoutUseCase(input: UseCaseLayoutInput): Promise<LayoutResult> {
306
+ const { elements, edges, referencedIds } = input;
307
+ const seq = new Map(referencedIds.map((id, i) => [id, i + 1]));
308
+
309
+ const orderingEdges = referencedIds
310
+ .slice(1)
311
+ .map((id, i) => ({ id: `__seq:${i}`, sources: [referencedIds[i]], targets: [id] }));
312
+
313
+ const graph = {
314
+ id: "root",
315
+ layoutOptions: USECASE_OPTS,
316
+ children: elements.map((el) => ({ id: el.id, width: DIM.elementW, height: DIM.elementH })),
317
+ edges: [
318
+ ...edges.map((e) => ({ id: e.id, sources: [e.source], targets: [e.target] })),
319
+ ...orderingEdges,
320
+ ],
321
+ };
322
+
323
+ const nodes: Node[] = [];
324
+ try {
325
+ const res = (await elk.layout(graph as any)) as any;
326
+ const byId = new Map(elements.map((e) => [e.id, e]));
327
+ for (const child of res.children ?? []) {
328
+ const el = byId.get(child.id);
329
+ if (!el) continue;
330
+ nodes.push({
331
+ id: el.id,
332
+ type: "element",
333
+ position: { x: child.x ?? 0, y: child.y ?? 0 },
334
+ data: { element: el, seq: seq.get(el.id), referenced: seq.has(el.id) },
335
+ });
336
+ }
337
+ } catch {
338
+ // Fallback: simple column for referenced, grid for the rest.
339
+ elements.forEach((el, i) => {
340
+ nodes.push({
341
+ id: el.id,
342
+ type: "element",
343
+ position: { x: (i % 4) * (DIM.elementW + DIM.gap), y: Math.floor(i / 4) * (DIM.elementH + DIM.gap) },
344
+ data: { element: el, seq: seq.get(el.id), referenced: seq.has(el.id) },
345
+ });
346
+ });
347
+ }
348
+
349
+ return { nodes, edges: useCaseEdges(edges) };
350
+ }
@@ -0,0 +1,225 @@
1
+ /** Top-level map view: React Flow canvas + legend + detail panel. */
2
+
3
+ import {
4
+ Background,
5
+ Controls,
6
+ MiniMap,
7
+ ReactFlow,
8
+ useEdgesState,
9
+ useNodesState,
10
+ type Node,
11
+ } from "@xyflow/react";
12
+ import { useEffect, useMemo, useState } from "react";
13
+ import type { MapComponent, MapElement, MapJson } from "../types";
14
+ import { layoutMap } from "./layout";
15
+ import { nodeTypes } from "./nodes";
16
+ import { DetailPanel } from "./detail-panel";
17
+ import { EDGE_COLOR, ELEMENT_COLOR, GLASS } from "./theme";
18
+
19
+ function Legend({ showComponents, onToggle }: { showComponents: boolean; onToggle: () => void }) {
20
+ return (
21
+ <div
22
+ style={{
23
+ position: "absolute",
24
+ top: 14,
25
+ left: 14,
26
+ zIndex: 5,
27
+ background: GLASS.card,
28
+ backdropFilter: GLASS.blur,
29
+ WebkitBackdropFilter: GLASS.blur,
30
+ border: `1px solid ${GLASS.border}`,
31
+ borderRadius: 16,
32
+ padding: 14,
33
+ fontSize: 11,
34
+ display: "flex",
35
+ flexDirection: "column",
36
+ gap: 7,
37
+ boxShadow: GLASS.shadow,
38
+ }}
39
+ >
40
+ <div style={{ fontWeight: 800, color: GLASS.text, marginBottom: 2, fontSize: 13, letterSpacing: 0.2 }}>
41
+ Arc Context Map
42
+ </div>
43
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 8, maxWidth: 248 }}>
44
+ {Object.entries(ELEMENT_COLOR).filter(([k]) => k !== "unknown").map(([k, c]) => (
45
+ <span key={k} style={{ color: GLASS.textMuted, display: "flex", alignItems: "center", gap: 4 }}>
46
+ <span style={{ width: 9, height: 9, borderRadius: 3, background: c, display: "inline-block" }} /> {k}
47
+ </span>
48
+ ))}
49
+ </div>
50
+ <div style={{ height: 1, background: GLASS.border, margin: "3px 0" }} />
51
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 8, maxWidth: 248 }}>
52
+ {Object.entries(EDGE_COLOR).map(([k, c]) => (
53
+ <span key={k} style={{ color: GLASS.textMuted, display: "flex", alignItems: "center", gap: 4 }}>
54
+ <span style={{ width: 14, height: 0, borderTop: `2px solid ${c}`, display: "inline-block" }} /> {k}
55
+ </span>
56
+ ))}
57
+ </div>
58
+ <div style={{ color: GLASS.textFaint }}>— — dashed = cross-context</div>
59
+ <label style={{ display: "flex", gap: 6, alignItems: "center", color: GLASS.textMuted, marginTop: 2, cursor: "pointer" }}>
60
+ <input type="checkbox" checked={showComponents} onChange={onToggle} /> React consumers
61
+ </label>
62
+ </div>
63
+ );
64
+ }
65
+
66
+ export function MapView({ map }: { map: MapJson }) {
67
+ // Off by default — the clean domain map first; React consumers are a toggle.
68
+ const [showComponents, setShowComponents] = useState(false);
69
+
70
+ const [nodes, setNodes, onNodesChange] = useNodesState([] as Node[]);
71
+ const [edges, setEdges, onEdgesChange] = useEdgesState([] as any[]);
72
+ const [laidOut, setLaidOut] = useState(false);
73
+ const [selectedElement, setSelectedElement] = useState<MapElement>();
74
+ const [selectedComponent, setSelectedComponent] = useState<MapComponent>();
75
+
76
+ // Compute the hierarchical ELK layout when the data changes (async).
77
+ useEffect(() => {
78
+ let cancelled = false;
79
+ setLaidOut(false);
80
+ layoutMap(map).then((layout) => {
81
+ if (cancelled) return;
82
+ setNodes(layout.nodes);
83
+ setEdges(layout.edges);
84
+ setLaidOut(true);
85
+ });
86
+ return () => {
87
+ cancelled = true;
88
+ };
89
+ }, [map, setNodes, setEdges]);
90
+
91
+ // Focus: when a node is hovered or selected, only its edges + neighbours stay
92
+ // bright; everything else dims. Turns the dense graph into something readable.
93
+ const [hoveredId, setHoveredId] = useState<string | null>(null);
94
+ const focusId =
95
+ hoveredId ??
96
+ (selectedElement ? selectedElement.id : selectedComponent ? `component:${selectedComponent.id}` : null);
97
+
98
+ // Toggle the React-component layer (nodes + their edges).
99
+ const visibleNodes = useMemo(
100
+ () => (showComponents ? nodes : nodes.filter((n) => n.type !== "component")),
101
+ [nodes, showComponents],
102
+ );
103
+ const visibleEdges = useMemo(
104
+ () => (showComponents ? edges : edges.filter((e) => !String(e.id).startsWith("ce:"))),
105
+ [edges, showComponents],
106
+ );
107
+
108
+ // Adjacency for focus highlighting.
109
+ const neighbours = useMemo(() => {
110
+ const m = new Map<string, Set<string>>();
111
+ const add = (a: string, b: string) => {
112
+ (m.get(a) ?? m.set(a, new Set()).get(a)!).add(b);
113
+ };
114
+ for (const e of edges as any[]) {
115
+ add(e.source, e.target);
116
+ add(e.target, e.source);
117
+ }
118
+ return m;
119
+ }, [edges]);
120
+
121
+ const focusNodes = useMemo(() => {
122
+ if (!focusId) return visibleNodes;
123
+ const keep = neighbours.get(focusId) ?? new Set<string>();
124
+ return visibleNodes.map((n) => {
125
+ if (n.type === "space") return n;
126
+ const active = n.id === focusId || keep.has(n.id);
127
+ return active ? n : { ...n, style: { ...n.style, opacity: 0.18 } };
128
+ });
129
+ }, [visibleNodes, focusId, neighbours]);
130
+
131
+ const focusEdges = useMemo(() => {
132
+ if (!focusId) return visibleEdges;
133
+ return visibleEdges.map((e: any) => {
134
+ const active = e.source === focusId || e.target === focusId;
135
+ return active
136
+ ? {
137
+ ...e,
138
+ label: e.data?.kind ?? e.data?.usage,
139
+ labelStyle: { fill: e.style?.stroke ?? "#555", fontSize: 10, fontWeight: 600 },
140
+ labelBgStyle: { fill: "#ffffff", fillOpacity: 0.85 },
141
+ labelBgPadding: [4, 2],
142
+ labelBgBorderRadius: 4,
143
+ style: { ...e.style, opacity: 1, strokeWidth: Math.max(2, e.style?.strokeWidth ?? 1.5) },
144
+ zIndex: 10,
145
+ }
146
+ : { ...e, label: undefined, style: { ...e.style, opacity: 0.04 } };
147
+ });
148
+ }, [visibleEdges, focusId]);
149
+
150
+ const onNodeClick = (_: unknown, node: Node) => {
151
+ if (node.type === "element") {
152
+ setSelectedComponent(undefined);
153
+ setSelectedElement((node.data as { element: MapElement }).element);
154
+ } else if (node.type === "component") {
155
+ setSelectedElement(undefined);
156
+ setSelectedComponent((node.data as { component: MapComponent }).component);
157
+ }
158
+ };
159
+
160
+ return (
161
+ <div style={{ width: "100%", height: "100%", position: "relative" }}>
162
+ <Legend showComponents={showComponents} onToggle={() => setShowComponents((v) => !v)} />
163
+ {!laidOut ? (
164
+ <div
165
+ style={{
166
+ position: "absolute",
167
+ inset: 0,
168
+ display: "flex",
169
+ alignItems: "center",
170
+ justifyContent: "center",
171
+ color: GLASS.textMuted,
172
+ fontSize: 14,
173
+ zIndex: 3,
174
+ }}
175
+ >
176
+ Liczę układ grafu…
177
+ </div>
178
+ ) : null}
179
+ <ReactFlow
180
+ key={laidOut ? "ready" : "loading"}
181
+ nodes={focusNodes}
182
+ edges={focusEdges}
183
+ nodeTypes={nodeTypes}
184
+ onNodesChange={onNodesChange}
185
+ onEdgesChange={onEdgesChange}
186
+ onNodeClick={onNodeClick}
187
+ onNodeMouseEnter={(_, n) => setHoveredId(n.id)}
188
+ onNodeMouseLeave={() => setHoveredId(null)}
189
+ onPaneClick={() => {
190
+ setSelectedElement(undefined);
191
+ setSelectedComponent(undefined);
192
+ }}
193
+ fitView
194
+ fitViewOptions={{ padding: 0.12 }}
195
+ minZoom={0.05}
196
+ proOptions={{ hideAttribution: true }}
197
+ >
198
+ <Background color="#c9b8e8" gap={26} size={1.5} />
199
+ <Controls />
200
+ <MiniMap
201
+ pannable
202
+ zoomable
203
+ maskColor="rgba(124,58,237,0.08)"
204
+ nodeColor={(n) =>
205
+ n.type === "element"
206
+ ? ELEMENT_COLOR[(n.data as { element: MapElement }).element.kind] ?? "#64748b"
207
+ : n.type === "component"
208
+ ? "#a78bfa"
209
+ : "rgba(124,58,237,0.18)"
210
+ }
211
+ style={{ background: "rgba(255,255,255,0.6)", border: `1px solid ${GLASS.border}`, borderRadius: 12 }}
212
+ />
213
+ </ReactFlow>
214
+ <DetailPanel
215
+ map={map}
216
+ selectedElement={selectedElement}
217
+ selectedComponent={selectedComponent}
218
+ onClose={() => {
219
+ setSelectedElement(undefined);
220
+ setSelectedComponent(undefined);
221
+ }}
222
+ />
223
+ </div>
224
+ );
225
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Renders a use-case MDX string in the browser. Compiles via `@mdx-js/mdx`
3
+ * `evaluate` with the injected `<Arc>` component, wrapped in ArcRefsProvider so
4
+ * the referenced elements are collected (in document order) for the diagram.
5
+ */
6
+
7
+ import { evaluate } from "@mdx-js/mdx";
8
+ import remarkFrontmatter from "remark-frontmatter";
9
+ import remarkGfm from "remark-gfm";
10
+ import { useEffect, useState, type ComponentType } from "react";
11
+ // eslint-disable-next-line import/no-unresolved
12
+ import * as runtime from "react/jsx-runtime";
13
+ import { Arc } from "./arc";
14
+ import { Branch, Lane, Parallel, Step } from "./structure";
15
+ import { ArcRefsProvider } from "./ref-context";
16
+ import type { ArcRegEntry } from "./contexts";
17
+ import { GLASS } from "./theme";
18
+
19
+ const mdxComponents = {
20
+ Arc,
21
+ Parallel,
22
+ Branch,
23
+ Lane,
24
+ Step,
25
+ h1: (p: any) => <h1 style={{ fontSize: 24, color: GLASS.text, margin: "4px 0 12px" }} {...p} />,
26
+ h2: (p: any) => <h2 style={{ fontSize: 18, color: GLASS.text, margin: "22px 0 8px" }} {...p} />,
27
+ h3: (p: any) => <h3 style={{ fontSize: 15, color: GLASS.text, margin: "16px 0 6px" }} {...p} />,
28
+ p: (p: any) => <p style={{ fontSize: 14, lineHeight: 1.7, color: "#3b2a55", margin: "8px 0" }} {...p} />,
29
+ li: (p: any) => <li style={{ fontSize: 14, lineHeight: 1.7, color: "#3b2a55" }} {...p} />,
30
+ code: (p: any) => (
31
+ <code style={{ background: "rgba(124,58,237,0.08)", padding: "1px 5px", borderRadius: 5, fontSize: "0.9em" }} {...p} />
32
+ ),
33
+ a: (p: any) => <a style={{ color: "#7c3aed" }} {...p} />,
34
+ };
35
+
36
+ export function MdxView({ mdx, onRefs }: { mdx: string; onRefs: (refs: ArcRegEntry[]) => void }) {
37
+ const [Comp, setComp] = useState<ComponentType<any> | null>(null);
38
+ const [error, setError] = useState<string | null>(null);
39
+
40
+ useEffect(() => {
41
+ let cancelled = false;
42
+ setComp(null);
43
+ setError(null);
44
+ evaluate(mdx, {
45
+ ...(runtime as any),
46
+ remarkPlugins: [remarkFrontmatter, remarkGfm],
47
+ })
48
+ .then((mod) => {
49
+ if (!cancelled) setComp(() => mod.default as ComponentType<any>);
50
+ })
51
+ .catch((e) => {
52
+ if (!cancelled) setError(String(e));
53
+ });
54
+ return () => {
55
+ cancelled = true;
56
+ };
57
+ }, [mdx]);
58
+
59
+ if (error) {
60
+ return (
61
+ <pre style={{ color: "#be123c", fontSize: 12, whiteSpace: "pre-wrap" }}>
62
+ Błąd renderu MDX:{"\n"}
63
+ {error}
64
+ </pre>
65
+ );
66
+ }
67
+ if (!Comp) return <div style={{ color: GLASS.textMuted }}>Renderuję dokument…</div>;
68
+
69
+ return (
70
+ <ArcRefsProvider onRefs={onRefs}>
71
+ <Comp components={mdxComponents} />
72
+ </ArcRefsProvider>
73
+ );
74
+ }