@eventcatalog/core 4.10.0 → 4.10.1
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/dist/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +1 -1
- package/dist/analytics/log-build.js +3 -3
- package/dist/{chunk-BQXEWQ5J.js → chunk-OC2RGFPU.js} +1 -1
- package/dist/{chunk-HFAQXAG3.js → chunk-PHG7KEUH.js} +1 -1
- package/dist/{chunk-IC6XSXJO.js → chunk-QIV7LWVJ.js} +1 -1
- package/dist/{chunk-5XURY2JV.js → chunk-SQNVL7PI.js} +1 -1
- package/dist/{chunk-BDFTJVGN.js → chunk-T2HLSITU.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +1 -1
- package/dist/eventcatalog.js +5 -5
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/eventcatalog/astro.config.mjs +6 -0
- package/eventcatalog/src/components/CatalogGraph/CatalogForceGraph.tsx +260 -175
- package/eventcatalog/src/components/MDX/ArchitectureGraph/ArchitectureGraph.astro +62 -0
- package/eventcatalog/src/components/MDX/ArchitectureGraph/ArchitectureGraphPortal.tsx +22 -0
- package/eventcatalog/src/components/MDX/ArchitectureGraph/AstroArchitectureGraph.tsx +42 -0
- package/eventcatalog/src/components/MDX/components.tsx +13 -0
- package/eventcatalog/src/enterprise/custom-documentation/pages/docs/custom/index.astro +46 -1
- package/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro +41 -0
- package/eventcatalog/src/pages/index.astro +34 -2
- package/eventcatalog/src/pages/visualiser/graph/index.astro +2 -23
- package/eventcatalog/src/utils/node-graphs/catalog-force-graph.ts +41 -0
- package/package.json +3 -3
|
@@ -140,7 +140,7 @@ var verifyRequiredFieldsAreInCatalogConfigFile = async (projectDirectory) => {
|
|
|
140
140
|
var import_os = __toESM(require("os"), 1);
|
|
141
141
|
|
|
142
142
|
// package.json
|
|
143
|
-
var version = "4.10.
|
|
143
|
+
var version = "4.10.1";
|
|
144
144
|
|
|
145
145
|
// src/constants.ts
|
|
146
146
|
var VERSION = version;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
log_build_default
|
|
3
|
-
} from "../chunk-
|
|
4
|
-
import "../chunk-
|
|
3
|
+
} from "../chunk-PHG7KEUH.js";
|
|
4
|
+
import "../chunk-OC2RGFPU.js";
|
|
5
5
|
import "../chunk-K2XIENVT.js";
|
|
6
|
-
import "../chunk-
|
|
6
|
+
import "../chunk-T2HLSITU.js";
|
|
7
7
|
import "../chunk-6QENHZZP.js";
|
|
8
8
|
export {
|
|
9
9
|
log_build_default as default
|
package/dist/constants.cjs
CHANGED
package/dist/constants.js
CHANGED
package/dist/eventcatalog.cjs
CHANGED
|
@@ -144,7 +144,7 @@ var verifyRequiredFieldsAreInCatalogConfigFile = async (projectDirectory) => {
|
|
|
144
144
|
var import_picocolors = __toESM(require("picocolors"), 1);
|
|
145
145
|
|
|
146
146
|
// package.json
|
|
147
|
-
var version = "4.10.
|
|
147
|
+
var version = "4.10.1";
|
|
148
148
|
|
|
149
149
|
// src/constants.ts
|
|
150
150
|
var VERSION = version;
|
package/dist/eventcatalog.js
CHANGED
|
@@ -4,8 +4,8 @@ import {
|
|
|
4
4
|
import "./chunk-CA4U2JP7.js";
|
|
5
5
|
import {
|
|
6
6
|
log_build_default
|
|
7
|
-
} from "./chunk-
|
|
8
|
-
import "./chunk-
|
|
7
|
+
} from "./chunk-PHG7KEUH.js";
|
|
8
|
+
import "./chunk-OC2RGFPU.js";
|
|
9
9
|
import "./chunk-K2XIENVT.js";
|
|
10
10
|
import {
|
|
11
11
|
FederationConflictError,
|
|
@@ -31,10 +31,10 @@ import {
|
|
|
31
31
|
} from "./chunk-B7HCX5HM.js";
|
|
32
32
|
import {
|
|
33
33
|
generate
|
|
34
|
-
} from "./chunk-
|
|
34
|
+
} from "./chunk-SQNVL7PI.js";
|
|
35
35
|
import {
|
|
36
36
|
logger
|
|
37
|
-
} from "./chunk-
|
|
37
|
+
} from "./chunk-QIV7LWVJ.js";
|
|
38
38
|
import {
|
|
39
39
|
resolve_catalog_dependencies_default
|
|
40
40
|
} from "./chunk-LHR4G2UO.js";
|
|
@@ -54,7 +54,7 @@ import {
|
|
|
54
54
|
import "./chunk-W5JQON7Z.js";
|
|
55
55
|
import {
|
|
56
56
|
VERSION
|
|
57
|
-
} from "./chunk-
|
|
57
|
+
} from "./chunk-T2HLSITU.js";
|
|
58
58
|
import {
|
|
59
59
|
linkCoreNodeModules,
|
|
60
60
|
resolveInstalledCoreNodeModules
|
package/dist/generate.cjs
CHANGED
|
@@ -108,7 +108,7 @@ var getEventCatalogConfigFile = async (projectDirectory) => {
|
|
|
108
108
|
var import_picocolors = __toESM(require("picocolors"), 1);
|
|
109
109
|
|
|
110
110
|
// package.json
|
|
111
|
-
var version = "4.10.
|
|
111
|
+
var version = "4.10.1";
|
|
112
112
|
|
|
113
113
|
// src/constants.ts
|
|
114
114
|
var VERSION = version;
|
package/dist/generate.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
generate
|
|
3
|
-
} from "./chunk-
|
|
4
|
-
import "./chunk-
|
|
5
|
-
import "./chunk-
|
|
3
|
+
} from "./chunk-SQNVL7PI.js";
|
|
4
|
+
import "./chunk-QIV7LWVJ.js";
|
|
5
|
+
import "./chunk-T2HLSITU.js";
|
|
6
6
|
import "./chunk-6QENHZZP.js";
|
|
7
7
|
export {
|
|
8
8
|
generate
|
package/dist/utils/cli-logger.js
CHANGED
|
@@ -190,6 +190,12 @@ export default defineConfig({
|
|
|
190
190
|
'semver',
|
|
191
191
|
'diff',
|
|
192
192
|
'diff2html',
|
|
193
|
+
// Used by the ArchitectureGraph embeds — discovering these lazily mid-session
|
|
194
|
+
// triggers a re-optimize that 504s every already-loaded chunk
|
|
195
|
+
'd3-force',
|
|
196
|
+
'd3-selection',
|
|
197
|
+
'd3-zoom',
|
|
198
|
+
'd3-drag',
|
|
193
199
|
],
|
|
194
200
|
},
|
|
195
201
|
},
|
|
@@ -30,23 +30,40 @@ import { drag } from 'd3-drag';
|
|
|
30
30
|
import { getColorForCollection, tailwind500RgbByColor } from '@utils/collection-colors';
|
|
31
31
|
import { getIconForCollection } from '@utils/collections/icons';
|
|
32
32
|
import { buildUrl } from '@utils/url-builder';
|
|
33
|
+
import type { CatalogGraphWireLink, CatalogGraphWireNode } from '@utils/node-graphs/catalog-force-graph';
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
export interface CatalogGraphWireNode {
|
|
36
|
-
/** Resource id, e.g. `InventoryService` */
|
|
37
|
-
id: string;
|
|
38
|
-
label: string;
|
|
39
|
-
collection: string;
|
|
40
|
-
version?: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** [sourceIndex, targetIndex, labelIndex] into the nodes / linkLabels arrays */
|
|
44
|
-
export type CatalogGraphWireLink = [number, number, number];
|
|
35
|
+
export type { CatalogGraphWireLink, CatalogGraphWireNode };
|
|
45
36
|
|
|
46
37
|
interface Props {
|
|
47
38
|
nodes: CatalogGraphWireNode[];
|
|
48
39
|
links: CatalogGraphWireLink[];
|
|
49
40
|
linkLabels: string[];
|
|
41
|
+
/** Seed the initial lens. URL state wins when `syncUrl` is on. */
|
|
42
|
+
initialLens?: string;
|
|
43
|
+
/** Seed the initial focused node key, e.g. `domains/Orders`. URL state wins when `syncUrl` is on. */
|
|
44
|
+
initialFocus?: string;
|
|
45
|
+
/** Seed the initial focus depth (1–3). URL state wins when `syncUrl` is on. */
|
|
46
|
+
initialFocusDepth?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Read the view state from the URL and mirror changes back into it — the
|
|
49
|
+
* standalone /visualiser/graph page. Off for graphs embedded in docs pages,
|
|
50
|
+
* which must not rewrite their host page's query string.
|
|
51
|
+
*/
|
|
52
|
+
syncUrl?: boolean;
|
|
53
|
+
/** Show the lens picker / detail slider (hidden on embedded graphs, which pin a lens) */
|
|
54
|
+
showLensPicker?: boolean;
|
|
55
|
+
showSearch?: boolean;
|
|
56
|
+
showLegend?: boolean;
|
|
57
|
+
/** When off, plain scrolling over the canvas scrolls the page — zooming needs ctrl/cmd+scroll */
|
|
58
|
+
zoomOnScroll?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Base URL of the standalone /visualiser/graph page. When set, an "open"
|
|
61
|
+
* link is rendered whose query string carries the CURRENT view state (focus,
|
|
62
|
+
* depth, lens, hidden collections), so the full page opens on exactly the
|
|
63
|
+
* view the embedded graph is showing.
|
|
64
|
+
*/
|
|
65
|
+
openInGraphUrl?: string;
|
|
66
|
+
openInGraphLabel?: string;
|
|
50
67
|
}
|
|
51
68
|
|
|
52
69
|
interface GraphNode extends CatalogGraphWireNode {
|
|
@@ -243,7 +260,21 @@ const distanceToHull = (px: number, py: number, hull: [number, number][]) => {
|
|
|
243
260
|
return min;
|
|
244
261
|
};
|
|
245
262
|
|
|
246
|
-
const CatalogForceGraph = ({
|
|
263
|
+
const CatalogForceGraph = ({
|
|
264
|
+
nodes,
|
|
265
|
+
links,
|
|
266
|
+
linkLabels,
|
|
267
|
+
initialLens,
|
|
268
|
+
initialFocus,
|
|
269
|
+
initialFocusDepth,
|
|
270
|
+
syncUrl = true,
|
|
271
|
+
showLensPicker = true,
|
|
272
|
+
openInGraphUrl,
|
|
273
|
+
openInGraphLabel,
|
|
274
|
+
showSearch = true,
|
|
275
|
+
showLegend = true,
|
|
276
|
+
zoomOnScroll = true,
|
|
277
|
+
}: Props) => {
|
|
247
278
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
248
279
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
249
280
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
|
@@ -255,37 +286,58 @@ const CatalogForceGraph = ({ nodes, links, linkLabels }: Props) => {
|
|
|
255
286
|
const iconSpritesRef = useRef(new Map<string, HTMLCanvasElement>());
|
|
256
287
|
const drawRef = useRef<() => void>(() => {});
|
|
257
288
|
// View state initialises from the URL so people can share what they're looking
|
|
258
|
-
// at (?lens=domains&focus=services/OrderService&depth=2&detail=3&hide=teams,entities)
|
|
289
|
+
// at (?lens=domains&focus=services/OrderService&depth=2&detail=3&hide=teams,entities).
|
|
290
|
+
// Embedded graphs (syncUrl off) seed from the initial* props instead — their
|
|
291
|
+
// host page's query string is not theirs to read or write.
|
|
292
|
+
const urlParam = (key: string) => (syncUrl ? new URLSearchParams(window.location.search).get(key) : null);
|
|
259
293
|
const [hiddenCollections, setHiddenCollections] = useState<Set<string>>(() => {
|
|
260
|
-
const hide =
|
|
294
|
+
const hide = urlParam('hide');
|
|
261
295
|
return new Set(hide ? hide.split(',').filter(Boolean) : []);
|
|
262
296
|
});
|
|
263
297
|
const [lensKey, setLensKey] = useState(() => {
|
|
264
|
-
const lens =
|
|
298
|
+
const lens = urlParam('lens') ?? initialLens;
|
|
265
299
|
return lens && lens in LENSES ? lens : 'all';
|
|
266
300
|
});
|
|
267
|
-
const [focusKey, setFocusKey] = useState<string | null>(() =>
|
|
301
|
+
const [focusKey, setFocusKey] = useState<string | null>(() => urlParam('focus') ?? initialFocus ?? null);
|
|
268
302
|
const [focusDepth, setFocusDepth] = useState(() =>
|
|
269
|
-
Math.min(3, Math.max(1, Number(
|
|
303
|
+
Math.min(3, Math.max(1, Number(urlParam('depth')) || initialFocusDepth || 1))
|
|
270
304
|
);
|
|
271
305
|
// How many hops of detail radiate out from a lens's anchor nodes (4 = everything)
|
|
272
|
-
const [lensDepth, setLensDepth] = useState(() =>
|
|
273
|
-
Math.min(MAX_LENS_DEPTH, Math.max(1, Number(new URLSearchParams(window.location.search).get('detail')) || 1))
|
|
274
|
-
);
|
|
306
|
+
const [lensDepth, setLensDepth] = useState(() => Math.min(MAX_LENS_DEPTH, Math.max(1, Number(urlParam('detail')) || 1)));
|
|
275
307
|
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
const params = new URLSearchParams(window.location.search);
|
|
308
|
+
// Serialise the current view state into query params — shared between the
|
|
309
|
+
// URL sync below and the embedded graphs' "open in graph" link
|
|
310
|
+
const applyViewParams = (params: URLSearchParams) => {
|
|
280
311
|
const setOrDelete = (key: string, value: string | null) => (value ? params.set(key, value) : params.delete(key));
|
|
281
312
|
setOrDelete('lens', lensKey !== 'all' ? lensKey : null);
|
|
282
313
|
setOrDelete('focus', focusKey);
|
|
283
314
|
setOrDelete('depth', focusKey && focusDepth !== 1 ? String(focusDepth) : null);
|
|
284
315
|
setOrDelete('detail', lensDepth !== 1 ? String(lensDepth) : null);
|
|
285
316
|
setOrDelete('hide', hiddenCollections.size > 0 ? [...hiddenCollections].sort().join(',') : null);
|
|
286
|
-
|
|
317
|
+
return params;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// Reflect the view state back into the URL (replaceState, so no history spam);
|
|
321
|
+
// unrelated params — e.g. the stress page's ?nodes — are preserved
|
|
322
|
+
useEffect(() => {
|
|
323
|
+
if (!syncUrl) return;
|
|
324
|
+
const query = applyViewParams(new URLSearchParams(window.location.search)).toString();
|
|
287
325
|
window.history.replaceState(null, '', `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`);
|
|
288
326
|
}, [lensKey, focusKey, focusDepth, lensDepth, hiddenCollections]);
|
|
327
|
+
|
|
328
|
+
// The "open in graph" link tracks the live view, so what opens is what's shown
|
|
329
|
+
const openInGraphHref = (() => {
|
|
330
|
+
if (!openInGraphUrl) return undefined;
|
|
331
|
+
const query = applyViewParams(new URLSearchParams()).toString();
|
|
332
|
+
return `${openInGraphUrl}${query ? `?${query}` : ''}`;
|
|
333
|
+
})();
|
|
334
|
+
// The island hydrates client-only, so the graph would otherwise pop in — fade
|
|
335
|
+
// it in on mount instead (the rAF lets the opacity-0 frame paint first)
|
|
336
|
+
const [visible, setVisible] = useState(false);
|
|
337
|
+
useEffect(() => {
|
|
338
|
+
const raf = requestAnimationFrame(() => setVisible(true));
|
|
339
|
+
return () => cancelAnimationFrame(raf);
|
|
340
|
+
}, []);
|
|
289
341
|
const [searchValue, setSearchValue] = useState('');
|
|
290
342
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
291
343
|
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
|
|
@@ -985,10 +1037,15 @@ const CatalogForceGraph = ({ nodes, links, linkLabels }: Props) => {
|
|
|
985
1037
|
.scaleExtent([0.1, 4])
|
|
986
1038
|
// Let node drags win over panning: ignore presses that start on a node
|
|
987
1039
|
.filter((event) => {
|
|
1040
|
+
// Embedded graphs must not trap touch scrolling — a one-finger swipe over
|
|
1041
|
+
// the canvas scrolls the page; a two-finger gesture pans/zooms the graph
|
|
1042
|
+
if (event.type === 'touchstart' && !zoomOnScroll && event.touches.length < 2) return false;
|
|
988
1043
|
if (event.type === 'mousedown' || event.type === 'touchstart') {
|
|
989
1044
|
const [x, y] = pointerPosition(event);
|
|
990
1045
|
return !findNode(x, y);
|
|
991
1046
|
}
|
|
1047
|
+
// Embedded graphs must not hijack page scrolling — zooming needs ctrl/cmd there
|
|
1048
|
+
if (event.type === 'wheel' && !zoomOnScroll && !event.ctrlKey && !event.metaKey) return false;
|
|
992
1049
|
return !event.ctrlKey || event.type === 'wheel';
|
|
993
1050
|
})
|
|
994
1051
|
.on('zoom', (event) => {
|
|
@@ -1000,6 +1057,9 @@ const CatalogForceGraph = ({ nodes, links, linkLabels }: Props) => {
|
|
|
1000
1057
|
});
|
|
1001
1058
|
|
|
1002
1059
|
const dragBehaviour = drag<HTMLCanvasElement, unknown>()
|
|
1060
|
+
// On embeds, touch node-drags would also swallow page swipes that happen to
|
|
1061
|
+
// start on a node — leave touch to the page there (tap-to-focus still works)
|
|
1062
|
+
.filter((event) => (zoomOnScroll || event.type !== 'touchstart') && !event.ctrlKey && !event.button)
|
|
1003
1063
|
.subject((event) => {
|
|
1004
1064
|
const [x, y] = pointerPosition(event.sourceEvent);
|
|
1005
1065
|
const node = findNode(x, y);
|
|
@@ -1171,7 +1231,7 @@ const CatalogForceGraph = ({ nodes, links, linkLabels }: Props) => {
|
|
|
1171
1231
|
resizeObserver.disconnect();
|
|
1172
1232
|
themeObserver.disconnect();
|
|
1173
1233
|
};
|
|
1174
|
-
}, [viewNodes, viewLinks, lens, hiddenCollections, clusterAssignments, focusKey, focusAncestors]);
|
|
1234
|
+
}, [viewNodes, viewLinks, lens, hiddenCollections, clusterAssignments, focusKey, focusAncestors, zoomOnScroll]);
|
|
1175
1235
|
|
|
1176
1236
|
const toggleCollection = (collection: string) => {
|
|
1177
1237
|
setHiddenCollections((current) => {
|
|
@@ -1185,7 +1245,10 @@ const CatalogForceGraph = ({ nodes, links, linkLabels }: Props) => {
|
|
|
1185
1245
|
const focusedNode = focusKey ? graph.nodes.find((n) => n.key === focusKey) : undefined;
|
|
1186
1246
|
|
|
1187
1247
|
return (
|
|
1188
|
-
<div
|
|
1248
|
+
<div
|
|
1249
|
+
ref={containerRef}
|
|
1250
|
+
className={`relative h-full w-full overflow-hidden motion-safe:transition-opacity motion-safe:duration-500 ${visible ? 'opacity-100' : 'opacity-0'}`}
|
|
1251
|
+
>
|
|
1189
1252
|
{/* Hidden icon sources: React renders them once, the sprite effect rasterises
|
|
1190
1253
|
them for the canvas. Both lucide (color prop) and heroicons (currentColor
|
|
1191
1254
|
via style) end up with white strokes. */}
|
|
@@ -1205,138 +1268,158 @@ const CatalogForceGraph = ({ nodes, links, linkLabels }: Props) => {
|
|
|
1205
1268
|
className="pointer-events-none absolute z-10 rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] px-3 py-2 text-xs text-[rgb(var(--ec-page-text))] shadow-md transition-opacity duration-100"
|
|
1206
1269
|
style={{ opacity: 0 }}
|
|
1207
1270
|
/>
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1271
|
+
{/* Top-left chrome stacks in one column so the link and lens picker never overlap */}
|
|
1272
|
+
<div className="absolute left-3 top-3 z-10 flex flex-col items-start gap-2">
|
|
1273
|
+
{showLensPicker && (
|
|
1274
|
+
<div className="flex flex-col gap-2 rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] px-3 py-2 text-xs shadow-xs">
|
|
1275
|
+
<div className="flex items-center gap-2">
|
|
1276
|
+
<label htmlFor="catalog-graph-lens" className="font-semibold text-[rgb(var(--ec-page-text))]">
|
|
1277
|
+
Lens
|
|
1278
|
+
</label>
|
|
1279
|
+
<select
|
|
1280
|
+
id="catalog-graph-lens"
|
|
1281
|
+
value={lensKey}
|
|
1282
|
+
onChange={(event) => {
|
|
1283
|
+
// A new lens is a fresh view — drop any focus and legend filters
|
|
1284
|
+
setLensKey(event.target.value);
|
|
1285
|
+
setFocusKey(null);
|
|
1286
|
+
setFocusDepth(1);
|
|
1287
|
+
setHiddenCollections(new Set());
|
|
1288
|
+
}}
|
|
1289
|
+
title={lens.description}
|
|
1290
|
+
className="rounded-sm border border-[rgb(var(--ec-input-border))] bg-[rgb(var(--ec-input-bg))] px-2 py-1 text-[rgb(var(--ec-input-text))]"
|
|
1291
|
+
>
|
|
1292
|
+
{Object.entries(LENSES).map(([key, { label }]) => (
|
|
1293
|
+
<option key={key} value={key}>
|
|
1294
|
+
{label}
|
|
1295
|
+
</option>
|
|
1296
|
+
))}
|
|
1297
|
+
</select>
|
|
1298
|
+
</div>
|
|
1299
|
+
{lens.hubCollections && !focusedNode && (
|
|
1300
|
+
<label
|
|
1301
|
+
className="flex items-center gap-2 text-[rgb(var(--ec-page-text-muted))]"
|
|
1302
|
+
title="How many relationship hops to show around the anchor nodes"
|
|
1303
|
+
>
|
|
1304
|
+
Detail
|
|
1305
|
+
<input
|
|
1306
|
+
type="range"
|
|
1307
|
+
min={1}
|
|
1308
|
+
max={MAX_LENS_DEPTH}
|
|
1309
|
+
step={1}
|
|
1310
|
+
value={lensDepth}
|
|
1311
|
+
onChange={(event) => setLensDepth(Number(event.target.value))}
|
|
1312
|
+
className="w-24 accent-[rgb(var(--ec-accent))]"
|
|
1313
|
+
/>
|
|
1314
|
+
<span className="w-5 text-[rgb(var(--ec-page-text))]">{lensDepth >= MAX_LENS_DEPTH ? 'All' : lensDepth}</span>
|
|
1315
|
+
</label>
|
|
1316
|
+
)}
|
|
1317
|
+
</div>
|
|
1318
|
+
)}
|
|
1319
|
+
{openInGraphHref && (
|
|
1320
|
+
<a
|
|
1321
|
+
href={openInGraphHref}
|
|
1322
|
+
className="rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] px-3 py-2 text-xs text-[rgb(var(--ec-page-text))] shadow-xs hover:bg-[rgb(var(--ec-accent-subtle))]"
|
|
1237
1323
|
>
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
type="range"
|
|
1241
|
-
min={1}
|
|
1242
|
-
max={MAX_LENS_DEPTH}
|
|
1243
|
-
step={1}
|
|
1244
|
-
value={lensDepth}
|
|
1245
|
-
onChange={(event) => setLensDepth(Number(event.target.value))}
|
|
1246
|
-
className="w-24 accent-[rgb(var(--ec-accent))]"
|
|
1247
|
-
/>
|
|
1248
|
-
<span className="w-5 text-[rgb(var(--ec-page-text))]">{lensDepth >= MAX_LENS_DEPTH ? 'All' : lensDepth}</span>
|
|
1249
|
-
</label>
|
|
1324
|
+
{openInGraphLabel ?? 'Open full screen'} →
|
|
1325
|
+
</a>
|
|
1250
1326
|
)}
|
|
1251
1327
|
</div>
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
<span className="
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1328
|
+
{(showSearch || focusedNode) && (
|
|
1329
|
+
// z-20: the search dropdown must paint over the Resources legend below it
|
|
1330
|
+
<div className="absolute right-3 top-3 z-20 flex flex-col gap-2 rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] px-3 py-2 text-xs shadow-xs">
|
|
1331
|
+
{showSearch && (
|
|
1332
|
+
<div ref={searchContainerRef} className="relative">
|
|
1333
|
+
<input
|
|
1334
|
+
type="text"
|
|
1335
|
+
placeholder="Search & focus…"
|
|
1336
|
+
value={searchValue}
|
|
1337
|
+
onChange={(event) => {
|
|
1338
|
+
setSearchValue(event.target.value);
|
|
1339
|
+
setShowSuggestions(true);
|
|
1340
|
+
setSelectedSuggestionIndex(-1);
|
|
1341
|
+
}}
|
|
1342
|
+
onFocus={() => setShowSuggestions(true)}
|
|
1343
|
+
onKeyDown={handleSearchKeyDown}
|
|
1344
|
+
className="w-56 rounded-sm border border-[rgb(var(--ec-input-border))] bg-[rgb(var(--ec-input-bg))] py-1 pl-2 pr-7 text-[rgb(var(--ec-input-text))] placeholder:text-[rgb(var(--ec-page-text-muted))] focus:outline-none focus:ring-2 focus:ring-[rgb(var(--ec-accent))]"
|
|
1345
|
+
/>
|
|
1346
|
+
{searchValue && (
|
|
1347
|
+
<button
|
|
1348
|
+
type="button"
|
|
1349
|
+
onClick={() => setSearchValue('')}
|
|
1350
|
+
aria-label="Clear search"
|
|
1351
|
+
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-[rgb(var(--ec-page-text-muted))] hover:text-[rgb(var(--ec-page-text))]"
|
|
1352
|
+
>
|
|
1353
|
+
×
|
|
1354
|
+
</button>
|
|
1355
|
+
)}
|
|
1356
|
+
{showSuggestions && searchSuggestions.length > 0 && (
|
|
1357
|
+
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-64 overflow-y-auto rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] shadow-lg">
|
|
1358
|
+
{searchSuggestions.map((node, index) => {
|
|
1359
|
+
const Icon = getIconForCollection(node.collection);
|
|
1360
|
+
const chipStyle = collectionChipStyle(node.collection);
|
|
1361
|
+
const isSelected = index === selectedSuggestionIndex;
|
|
1362
|
+
return (
|
|
1363
|
+
<button
|
|
1364
|
+
key={node.key}
|
|
1365
|
+
type="button"
|
|
1366
|
+
onClick={() => selectSuggestion(node)}
|
|
1367
|
+
onMouseEnter={() => setSelectedSuggestionIndex(index)}
|
|
1368
|
+
className={`flex w-full items-center gap-2 px-2 py-1.5 text-left ${isSelected ? 'bg-[rgb(var(--ec-accent-subtle))]' : ''}`}
|
|
1369
|
+
>
|
|
1370
|
+
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md" style={chipStyle}>
|
|
1371
|
+
<Icon width={13} height={13} />
|
|
1372
|
+
</span>
|
|
1373
|
+
<span className="min-w-0 flex-1">
|
|
1374
|
+
<span className="block truncate font-medium text-[rgb(var(--ec-page-text))]">{node.label}</span>
|
|
1375
|
+
{node.version && (
|
|
1376
|
+
<span className="block truncate text-[10px] text-[rgb(var(--ec-page-text-muted))]">
|
|
1377
|
+
v{node.version}
|
|
1378
|
+
</span>
|
|
1379
|
+
)}
|
|
1380
|
+
</span>
|
|
1381
|
+
<span className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium" style={chipStyle}>
|
|
1382
|
+
{COLLECTION_LABELS[node.collection] ?? node.collection}
|
|
1383
|
+
</span>
|
|
1384
|
+
</button>
|
|
1385
|
+
);
|
|
1386
|
+
})}
|
|
1387
|
+
</div>
|
|
1388
|
+
)}
|
|
1306
1389
|
</div>
|
|
1307
1390
|
)}
|
|
1391
|
+
{focusedNode && (
|
|
1392
|
+
<>
|
|
1393
|
+
<button
|
|
1394
|
+
type="button"
|
|
1395
|
+
onClick={() => setFocusKey(null)}
|
|
1396
|
+
className="flex items-center gap-1 self-start rounded-full border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-accent-subtle))] px-2 py-0.5 text-[rgb(var(--ec-page-text))] hover:opacity-80"
|
|
1397
|
+
title="Clear focus"
|
|
1398
|
+
>
|
|
1399
|
+
<span
|
|
1400
|
+
className="inline-block h-2 w-2 rounded-full"
|
|
1401
|
+
style={{ backgroundColor: nodeColor(focusedNode.collection) }}
|
|
1402
|
+
/>
|
|
1403
|
+
Focused: {focusedNode.label}
|
|
1404
|
+
<span aria-hidden="true">×</span>
|
|
1405
|
+
</button>
|
|
1406
|
+
<label className="flex items-center gap-2 text-[rgb(var(--ec-page-text-muted))]">
|
|
1407
|
+
Depth
|
|
1408
|
+
<input
|
|
1409
|
+
type="range"
|
|
1410
|
+
min={1}
|
|
1411
|
+
max={3}
|
|
1412
|
+
step={1}
|
|
1413
|
+
value={focusDepth}
|
|
1414
|
+
onChange={(event) => setFocusDepth(Number(event.target.value))}
|
|
1415
|
+
className="w-24 accent-[rgb(var(--ec-accent))]"
|
|
1416
|
+
/>
|
|
1417
|
+
<span className="w-3 text-[rgb(var(--ec-page-text))]">{focusDepth}</span>
|
|
1418
|
+
</label>
|
|
1419
|
+
</>
|
|
1420
|
+
)}
|
|
1308
1421
|
</div>
|
|
1309
|
-
|
|
1310
|
-
<>
|
|
1311
|
-
<button
|
|
1312
|
-
type="button"
|
|
1313
|
-
onClick={() => setFocusKey(null)}
|
|
1314
|
-
className="flex items-center gap-1 self-start rounded-full border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-accent-subtle))] px-2 py-0.5 text-[rgb(var(--ec-page-text))] hover:opacity-80"
|
|
1315
|
-
title="Clear focus"
|
|
1316
|
-
>
|
|
1317
|
-
<span
|
|
1318
|
-
className="inline-block h-2 w-2 rounded-full"
|
|
1319
|
-
style={{ backgroundColor: nodeColor(focusedNode.collection) }}
|
|
1320
|
-
/>
|
|
1321
|
-
Focused: {focusedNode.label}
|
|
1322
|
-
<span aria-hidden="true">×</span>
|
|
1323
|
-
</button>
|
|
1324
|
-
<label className="flex items-center gap-2 text-[rgb(var(--ec-page-text-muted))]">
|
|
1325
|
-
Depth
|
|
1326
|
-
<input
|
|
1327
|
-
type="range"
|
|
1328
|
-
min={1}
|
|
1329
|
-
max={3}
|
|
1330
|
-
step={1}
|
|
1331
|
-
value={focusDepth}
|
|
1332
|
-
onChange={(event) => setFocusDepth(Number(event.target.value))}
|
|
1333
|
-
className="w-24 accent-[rgb(var(--ec-accent))]"
|
|
1334
|
-
/>
|
|
1335
|
-
<span className="w-3 text-[rgb(var(--ec-page-text))]">{focusDepth}</span>
|
|
1336
|
-
</label>
|
|
1337
|
-
</>
|
|
1338
|
-
)}
|
|
1339
|
-
</div>
|
|
1422
|
+
)}
|
|
1340
1423
|
{focusedNode && (
|
|
1341
1424
|
<div className="absolute bottom-3 left-3 z-10 rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] p-3 text-xs shadow-xs">
|
|
1342
1425
|
<div className="mb-2 font-semibold text-[rgb(var(--ec-page-text))]">You are here</div>
|
|
@@ -1377,28 +1460,30 @@ const CatalogForceGraph = ({ nodes, links, linkLabels }: Props) => {
|
|
|
1377
1460
|
</ul>
|
|
1378
1461
|
</div>
|
|
1379
1462
|
)}
|
|
1380
|
-
|
|
1381
|
-
<div className="
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
<
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1463
|
+
{showLegend && (
|
|
1464
|
+
<div className="absolute bottom-3 right-3 z-10 rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] p-3 text-xs shadow-xs">
|
|
1465
|
+
<div className="mb-2 font-semibold text-[rgb(var(--ec-page-text))]">Resources</div>
|
|
1466
|
+
<ul className="space-y-1">
|
|
1467
|
+
{collectionsInGraph.map(({ collection, count }) => {
|
|
1468
|
+
const hidden = hiddenCollections.has(collection);
|
|
1469
|
+
return (
|
|
1470
|
+
<li key={collection}>
|
|
1471
|
+
<button
|
|
1472
|
+
type="button"
|
|
1473
|
+
onClick={() => toggleCollection(collection)}
|
|
1474
|
+
className={`flex w-full items-center gap-2 rounded-sm px-1 py-0.5 text-left hover:bg-[rgb(var(--ec-accent-subtle))] ${hidden ? 'opacity-40' : ''}`}
|
|
1475
|
+
>
|
|
1476
|
+
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: nodeColor(collection) }} />
|
|
1477
|
+
<span className="text-[rgb(var(--ec-page-text))]">
|
|
1478
|
+
{COLLECTION_LABELS[collection] ?? collection} ({count})
|
|
1479
|
+
</span>
|
|
1480
|
+
</button>
|
|
1481
|
+
</li>
|
|
1482
|
+
);
|
|
1483
|
+
})}
|
|
1484
|
+
</ul>
|
|
1485
|
+
</div>
|
|
1486
|
+
)}
|
|
1402
1487
|
</div>
|
|
1403
1488
|
);
|
|
1404
1489
|
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
import AstroArchitectureGraph from './AstroArchitectureGraph';
|
|
3
|
+
import { getCatalogForceGraph, toCatalogForceGraphWire } from '@utils/node-graphs/catalog-force-graph';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
// Resource to focus the graph on (its neighbourhood fills the view). Omit both
|
|
7
|
+
// to render the whole catalog graph.
|
|
8
|
+
id?: string;
|
|
9
|
+
collection?: string;
|
|
10
|
+
/** Relationship hops rendered around the focused resource (1–3) */
|
|
11
|
+
depth?: number;
|
|
12
|
+
/** Lens to render: all | domains | systems | services | teams | messages */
|
|
13
|
+
lens?: string;
|
|
14
|
+
showSearch?: boolean;
|
|
15
|
+
showLegend?: boolean;
|
|
16
|
+
/** Off by default — embedded graphs pin a lens rather than offer the picker */
|
|
17
|
+
showLensPicker?: boolean;
|
|
18
|
+
// Override the DOM portal the graph mounts into. Defaults to
|
|
19
|
+
// `${id}-architecture-graph-portal` (or `catalog-architecture-graph-portal`).
|
|
20
|
+
portalId?: string;
|
|
21
|
+
href?: {
|
|
22
|
+
label: string;
|
|
23
|
+
url: string;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const {
|
|
28
|
+
id,
|
|
29
|
+
collection,
|
|
30
|
+
depth = 2,
|
|
31
|
+
lens,
|
|
32
|
+
showSearch = true,
|
|
33
|
+
showLegend = true,
|
|
34
|
+
showLensPicker = false,
|
|
35
|
+
portalId,
|
|
36
|
+
href,
|
|
37
|
+
} = Astro.props;
|
|
38
|
+
|
|
39
|
+
const graph = await getCatalogForceGraph();
|
|
40
|
+
const wire = toCatalogForceGraphWire(graph);
|
|
41
|
+
|
|
42
|
+
// Only focus a resource that exists in the graph — it holds latest versions
|
|
43
|
+
// only, so e.g. an old-version page falls back to the whole catalog view
|
|
44
|
+
const focusKey =
|
|
45
|
+
id && collection && graph.nodes.some((node) => node.id === `${collection}/${id}`) ? `${collection}/${id}` : undefined;
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
<AstroArchitectureGraph
|
|
49
|
+
client:only="react"
|
|
50
|
+
portalId={portalId ?? `${id ?? 'catalog'}-architecture-graph-portal`}
|
|
51
|
+
nodes={wire.nodes}
|
|
52
|
+
links={wire.links}
|
|
53
|
+
linkLabels={wire.linkLabels}
|
|
54
|
+
initialFocus={focusKey}
|
|
55
|
+
initialFocusDepth={depth}
|
|
56
|
+
initialLens={lens}
|
|
57
|
+
showSearch={showSearch}
|
|
58
|
+
showLegend={showLegend}
|
|
59
|
+
showLensPicker={showLensPicker}
|
|
60
|
+
href={href?.url}
|
|
61
|
+
hrefLabel={href?.label}
|
|
62
|
+
/>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// The portal id pairs each scanner-rendered island with its MDX placeholder.
|
|
2
|
+
// The occurrence index keeps multiple no-id embeds on one page from colliding
|
|
3
|
+
// (the first occurrence keeps the unsuffixed id).
|
|
4
|
+
export const architectureGraphPortalId = (id: string | undefined, occurrenceIndex: number) =>
|
|
5
|
+
`${id ?? 'catalog'}-architecture-graph-portal${occurrenceIndex > 0 ? `-${occurrenceIndex}` : ''}`;
|
|
6
|
+
|
|
7
|
+
// Empty placeholder rendered by the MDX component map. The real graph is
|
|
8
|
+
// rendered by ArchitectureGraph.astro (which builds the data server-side) and
|
|
9
|
+
// portals itself into this div by id — same pattern as NodeGraphPortal.
|
|
10
|
+
const ArchitectureGraphPortal = (props: any) => {
|
|
11
|
+
return (
|
|
12
|
+
<div
|
|
13
|
+
className="not-prose h-[30em] my-6 mb-12 w-full relative border! border-[rgb(var(--ec-page-border))]! rounded-md overflow-hidden"
|
|
14
|
+
id={architectureGraphPortalId(props.id, props.occurrenceIndex ?? 0)}
|
|
15
|
+
style={{
|
|
16
|
+
maxHeight: props.maxHeight ? `${props.maxHeight}em` : `30em`,
|
|
17
|
+
}}
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export default ArchitectureGraphPortal;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import CatalogForceGraph from '@components/CatalogGraph/CatalogForceGraph';
|
|
4
|
+
import type { CatalogGraphWireLink, CatalogGraphWireNode } from '@utils/node-graphs/catalog-force-graph';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
/** DOM id of the placeholder div ArchitectureGraphPortal rendered into the MDX content */
|
|
8
|
+
portalId: string;
|
|
9
|
+
nodes: CatalogGraphWireNode[];
|
|
10
|
+
links: CatalogGraphWireLink[];
|
|
11
|
+
linkLabels: string[];
|
|
12
|
+
initialLens?: string;
|
|
13
|
+
initialFocus?: string;
|
|
14
|
+
initialFocusDepth?: number;
|
|
15
|
+
showSearch?: boolean;
|
|
16
|
+
showLegend?: boolean;
|
|
17
|
+
showLensPicker?: boolean;
|
|
18
|
+
/** Base URL of the standalone /visualiser/graph page — the graph renders an
|
|
19
|
+
* "open full screen" link carrying its live view state (focus, depth, lens) */
|
|
20
|
+
href?: string;
|
|
21
|
+
hrefLabel?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The island mounts outside the MDX content, finds the placeholder div the MDX
|
|
25
|
+
// component map emitted, and portals the graph into it — same pattern as the
|
|
26
|
+
// visualiser's NodeGraph.
|
|
27
|
+
const AstroArchitectureGraph = ({ portalId, href, hrefLabel, ...graphProps }: Props) => {
|
|
28
|
+
const [container, setContainer] = useState<HTMLElement | null>(null);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
setContainer(document.getElementById(portalId));
|
|
32
|
+
}, [portalId]);
|
|
33
|
+
|
|
34
|
+
if (!container) return null;
|
|
35
|
+
|
|
36
|
+
return createPortal(
|
|
37
|
+
<CatalogForceGraph {...graphProps} syncUrl={false} zoomOnScroll={false} openInGraphUrl={href} openInGraphLabel={hrefLabel} />,
|
|
38
|
+
container
|
|
39
|
+
);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export default AstroArchitectureGraph;
|
|
@@ -35,6 +35,7 @@ import MermaidFileLoader from '@components/MDX/MermaidFileLoader/MermaidFileLoad
|
|
|
35
35
|
import LikeC4View from '@components/MDX/LikeC4View/LikeC4View.astro';
|
|
36
36
|
// Portals: required for server/client components
|
|
37
37
|
import NodeGraphPortal from '@components/MDX/NodeGraph/NodeGraphPortal';
|
|
38
|
+
import ArchitectureGraphPortal from '@components/MDX/ArchitectureGraph/ArchitectureGraphPortal';
|
|
38
39
|
import ContextDiagramPortal from '@components/MDX/ContextDiagram/ContextDiagramPortal';
|
|
39
40
|
import SystemContextMapPortal from '@components/MDX/SystemContextMap/SystemContextMapPortal';
|
|
40
41
|
import SchemaViewerPortal from '@components/MDX/SchemaViewer/SchemaViewerPortal';
|
|
@@ -50,6 +51,10 @@ const getEntityMapCollection = (props: any, mdxProp: any) => {
|
|
|
50
51
|
};
|
|
51
52
|
|
|
52
53
|
const components = (props: any) => {
|
|
54
|
+
// Occurrence counter so multiple <ArchitectureGraph/> tags on one page each get
|
|
55
|
+
// their own portal — MDX renders in document order, matching the body scan order
|
|
56
|
+
// the host pages use to render the islands.
|
|
57
|
+
let architectureGraphOccurrence = 0;
|
|
53
58
|
return {
|
|
54
59
|
Attachments: (mdxProp: any) => jsx(Attachments, { ...props, ...mdxProp }),
|
|
55
60
|
CustomProperties: (mdxProp: any) => jsx(CustomProperties, { ...props, ...mdxProp }),
|
|
@@ -69,6 +74,14 @@ const components = (props: any) => {
|
|
|
69
74
|
ADRTable: (mdxProp: any) => jsx(ADRTable, { ...props, ...mdxProp }),
|
|
70
75
|
EntityPropertiesTable: (mdxProp: any) => jsx(EntityPropertiesTable, { ...props, ...mdxProp }),
|
|
71
76
|
NodeGraph: (mdxProp: any) => jsx(NodeGraphPortal, { ...props.data, ...mdxProp, props, mdxProp }),
|
|
77
|
+
ArchitectureGraph: (mdxProp: any) =>
|
|
78
|
+
jsx(ArchitectureGraphPortal, {
|
|
79
|
+
...props.data,
|
|
80
|
+
...mdxProp,
|
|
81
|
+
occurrenceIndex: architectureGraphOccurrence++,
|
|
82
|
+
props,
|
|
83
|
+
mdxProp,
|
|
84
|
+
}),
|
|
72
85
|
ComponentDiagram: (mdxProp: any) => jsx(NodeGraphPortal, { ...props.data, ...mdxProp, props, mdxProp }),
|
|
73
86
|
ContextDiagram: (mdxProp: any) => jsx(ContextDiagramPortal, { ...props.data, ...mdxProp, props, mdxProp }),
|
|
74
87
|
SystemContextMap: (mdxProp: any) => jsx(SystemContextMapPortal, { ...mdxProp }),
|
|
@@ -14,9 +14,17 @@ import { getMDXComponentsByName, parseMdxBooleanProp } from '@utils/markdown';
|
|
|
14
14
|
import { getAdjacentPages, getNavigationItems } from '@enterprise/custom-documentation/utils/custom-docs';
|
|
15
15
|
import CustomDocsNav from '@enterprise/custom-documentation/components/CustomDocsNav/CustomDocsNav.astro';
|
|
16
16
|
import NodeGraph from '@components/MDX/NodeGraph/NodeGraph.astro';
|
|
17
|
+
import ArchitectureGraph from '@components/MDX/ArchitectureGraph/ArchitectureGraph.astro';
|
|
18
|
+
import { architectureGraphPortalId } from '@components/MDX/ArchitectureGraph/ArchitectureGraphPortal';
|
|
17
19
|
import CopyAsMarkdown from '@components/CopyAsMarkdown';
|
|
18
20
|
|
|
19
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
isVisualiserEnabled,
|
|
23
|
+
isMarkdownDownloadEnabled,
|
|
24
|
+
isRSSEnabled,
|
|
25
|
+
isEventCatalogChatEnabled,
|
|
26
|
+
isArchitectureGraphEnabled,
|
|
27
|
+
} from '@utils/feature';
|
|
20
28
|
|
|
21
29
|
const props = Astro.props;
|
|
22
30
|
const doc = props.data;
|
|
@@ -48,6 +56,18 @@ const resolvedNodeGraphs = await Promise.all(
|
|
|
48
56
|
})
|
|
49
57
|
);
|
|
50
58
|
|
|
59
|
+
// Architecture graphs embedded via <ArchitectureGraph />. Custom docs have no
|
|
60
|
+
// resource of their own, so with no `id` + `type` the whole catalog is rendered.
|
|
61
|
+
const architectureGraphs = (getMDXComponentsByName(props.body, 'ArchitectureGraph') || []).map((graph: any) => ({
|
|
62
|
+
id: graph.id,
|
|
63
|
+
collection: graph.type ? resourceToCollectionMap[graph.type as keyof typeof resourceToCollectionMap] : undefined,
|
|
64
|
+
depth: graph.depth ? Number(graph.depth) : undefined,
|
|
65
|
+
lens: graph.lens,
|
|
66
|
+
search: parseMdxBooleanProp(graph.search, true),
|
|
67
|
+
legend: parseMdxBooleanProp(graph.legend, true),
|
|
68
|
+
lensPicker: parseMdxBooleanProp(graph.lensPicker, false),
|
|
69
|
+
}));
|
|
70
|
+
|
|
51
71
|
const { prev, next } = await getAdjacentPages(currentSlug);
|
|
52
72
|
|
|
53
73
|
const ownersRaw = doc?.owners || [];
|
|
@@ -171,6 +191,31 @@ const editUrl =
|
|
|
171
191
|
})
|
|
172
192
|
}
|
|
173
193
|
|
|
194
|
+
{
|
|
195
|
+
architectureGraphs.length > 0 &&
|
|
196
|
+
architectureGraphs.map((graph: any, occurrenceIndex: number) => (
|
|
197
|
+
<ArchitectureGraph
|
|
198
|
+
id={graph.id}
|
|
199
|
+
collection={graph.collection}
|
|
200
|
+
depth={graph.depth}
|
|
201
|
+
lens={graph.lens}
|
|
202
|
+
showSearch={graph.search}
|
|
203
|
+
showLegend={graph.legend}
|
|
204
|
+
showLensPicker={graph.lensPicker}
|
|
205
|
+
portalId={architectureGraphPortalId(graph.id ?? (doc as any).id, occurrenceIndex)}
|
|
206
|
+
href={
|
|
207
|
+
isArchitectureGraphEnabled()
|
|
208
|
+
? {
|
|
209
|
+
label: 'Open full screen',
|
|
210
|
+
// Base URL only — the graph appends its live view state (focus, depth, lens)
|
|
211
|
+
url: buildUrl('/visualiser/graph'),
|
|
212
|
+
}
|
|
213
|
+
: undefined
|
|
214
|
+
}
|
|
215
|
+
/>
|
|
216
|
+
))
|
|
217
|
+
}
|
|
218
|
+
|
|
174
219
|
<!-- Previous / Next Navigation -->
|
|
175
220
|
<div class="py-8 border-t border-[rgb(var(--ec-page-border))] mt-8">
|
|
176
221
|
<div class="flex flex-col sm:flex-row justify-between w-full gap-4">
|
|
@@ -10,6 +10,8 @@ import Footer from '@layouts/Footer.astro';
|
|
|
10
10
|
|
|
11
11
|
import components from '@components/MDX/components';
|
|
12
12
|
import NodeGraph from '@components/MDX/NodeGraph/NodeGraph.astro';
|
|
13
|
+
import ArchitectureGraph from '@components/MDX/ArchitectureGraph/ArchitectureGraph.astro';
|
|
14
|
+
import { architectureGraphPortalId } from '@components/MDX/ArchitectureGraph/ArchitectureGraphPortal';
|
|
13
15
|
import SchemaViewer from '@components/MDX/SchemaViewer/SchemaViewerRoot.astro';
|
|
14
16
|
import Admonition from '@components/MDX/Admonition';
|
|
15
17
|
import VersionList from '@components/Lists/VersionList.astro';
|
|
@@ -65,6 +67,7 @@ import {
|
|
|
65
67
|
isVisualiserEnabled,
|
|
66
68
|
isRSSEnabled,
|
|
67
69
|
isEventCatalogMCPEnabled,
|
|
70
|
+
isArchitectureGraphEnabled,
|
|
68
71
|
} from '@utils/feature';
|
|
69
72
|
import { getMDXComponentsByName, parseMdxBooleanProp } from '@utils/markdown';
|
|
70
73
|
|
|
@@ -361,6 +364,20 @@ const contextDiagrams = contextDiagramSupported
|
|
|
361
364
|
})
|
|
362
365
|
: [];
|
|
363
366
|
|
|
367
|
+
// Architecture graphs embedded via <ArchitectureGraph />. Renders the D3
|
|
368
|
+
// catalog-wide force graph focused on the current resource's neighbourhood;
|
|
369
|
+
// `id` + `type` focus another resource instead. Each renders into its own
|
|
370
|
+
// portal, so it never collides with the page's NodeGraph.
|
|
371
|
+
const architectureGraphs = (getMDXComponentsByName(props.body || '', 'ArchitectureGraph') || []).map((graph: any) => ({
|
|
372
|
+
id: graph.id ?? props.data.id,
|
|
373
|
+
collection: graph.type ? resourceToCollectionMap[graph.type as keyof typeof resourceToCollectionMap] : props.collection,
|
|
374
|
+
depth: graph.depth ? Number(graph.depth) : undefined,
|
|
375
|
+
lens: graph.lens,
|
|
376
|
+
search: parseMdxBooleanProp(graph.search, true),
|
|
377
|
+
legend: parseMdxBooleanProp(graph.legend, true),
|
|
378
|
+
lensPicker: parseMdxBooleanProp(graph.lensPicker, false),
|
|
379
|
+
}));
|
|
380
|
+
|
|
364
381
|
// Get props for the node graph (when no id is passed, we assume its the current page)
|
|
365
382
|
const hasCurrentFlowEmbed =
|
|
366
383
|
props.collection === 'flows' &&
|
|
@@ -653,6 +670,30 @@ if (!isAdrPage && !hasCurrentFlowEmbed && !hasCurrentPageNodeGraph) {
|
|
|
653
670
|
/>
|
|
654
671
|
))
|
|
655
672
|
}
|
|
673
|
+
{
|
|
674
|
+
architectureGraphs.length > 0 &&
|
|
675
|
+
architectureGraphs.map((graph: any, occurrenceIndex: number) => (
|
|
676
|
+
<ArchitectureGraph
|
|
677
|
+
id={graph.id}
|
|
678
|
+
collection={graph.collection}
|
|
679
|
+
depth={graph.depth}
|
|
680
|
+
lens={graph.lens}
|
|
681
|
+
showSearch={graph.search}
|
|
682
|
+
showLegend={graph.legend}
|
|
683
|
+
showLensPicker={graph.lensPicker}
|
|
684
|
+
portalId={architectureGraphPortalId(graph.id, occurrenceIndex)}
|
|
685
|
+
href={
|
|
686
|
+
isArchitectureGraphEnabled()
|
|
687
|
+
? {
|
|
688
|
+
label: 'Open full screen',
|
|
689
|
+
// Base URL only — the graph appends its live view state (focus, depth, lens)
|
|
690
|
+
url: buildUrl('/visualiser/graph'),
|
|
691
|
+
}
|
|
692
|
+
: undefined
|
|
693
|
+
}
|
|
694
|
+
/>
|
|
695
|
+
))
|
|
696
|
+
}
|
|
656
697
|
</div>
|
|
657
698
|
<Footer />
|
|
658
699
|
<!-- Add edit this page button with icon and text-->
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
---
|
|
2
2
|
import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro';
|
|
3
|
-
import { getMDXComponentsByName } from '@utils/markdown';
|
|
3
|
+
import { getMDXComponentsByName, parseMdxBooleanProp } from '@utils/markdown';
|
|
4
|
+
import { architectureGraphPortalId } from '@components/MDX/ArchitectureGraph/ArchitectureGraphPortal';
|
|
4
5
|
import { resourceToCollectionMap } from '@utils/collections/util';
|
|
5
6
|
import { buildUrl } from '@utils/url-builder';
|
|
6
|
-
import { isCustomLandingPageEnabled, isVisualiserEnabled } from '@utils/feature';
|
|
7
|
+
import { isCustomLandingPageEnabled, isVisualiserEnabled, isArchitectureGraphEnabled } from '@utils/feature';
|
|
7
8
|
import DefaultAstroLandingPage from './_index.astro';
|
|
8
9
|
import config from '@config';
|
|
9
10
|
|
|
10
11
|
let nodeGraphs: any[] = [];
|
|
11
12
|
let systemContextMaps: any[] = [];
|
|
13
|
+
let architectureGraphs: any[] = [];
|
|
12
14
|
let CustomContent = null;
|
|
13
15
|
let NodeGraph = null;
|
|
16
|
+
let ArchitectureGraph = null;
|
|
14
17
|
let customHomepageComponents: Record<string, any> = {};
|
|
15
18
|
|
|
16
19
|
import path from 'path';
|
|
@@ -37,11 +40,19 @@ if (existsSync(pathToUserDefinedLandingPage) && isCustomLandingPageEnabled()) {
|
|
|
37
40
|
// The homepage can also embed the global System Context Map via <SystemContextMap />.
|
|
38
41
|
systemContextMaps = getMDXComponentsByName(rawContent, 'SystemContextMap') || [];
|
|
39
42
|
|
|
43
|
+
// …and the catalog-wide Architecture Graph via <ArchitectureGraph />. With no
|
|
44
|
+
// `id` + `type` the whole catalog is rendered.
|
|
45
|
+
architectureGraphs = getMDXComponentsByName(rawContent, 'ArchitectureGraph') || [];
|
|
46
|
+
|
|
40
47
|
// Avoid pulling visualizer CSS into the default homepage bundle.
|
|
41
48
|
// Only load the NodeGraph component when the custom homepage actually uses a graph.
|
|
42
49
|
if (nodeGraphs.length > 0 || systemContextMaps.length > 0) {
|
|
43
50
|
NodeGraph = (await import('@components/MDX/NodeGraph/NodeGraph.astro')).default;
|
|
44
51
|
}
|
|
52
|
+
// Only build the catalog-wide graph data when the homepage actually embeds it.
|
|
53
|
+
if (architectureGraphs.length > 0) {
|
|
54
|
+
ArchitectureGraph = (await import('@components/MDX/ArchitectureGraph/ArchitectureGraph.astro')).default;
|
|
55
|
+
}
|
|
45
56
|
}
|
|
46
57
|
|
|
47
58
|
if (config.landingPage) {
|
|
@@ -104,6 +115,27 @@ if (config.landingPage) {
|
|
|
104
115
|
}
|
|
105
116
|
/>
|
|
106
117
|
))}
|
|
118
|
+
{ArchitectureGraph &&
|
|
119
|
+
architectureGraphs.map((graph: any, occurrenceIndex: number) => (
|
|
120
|
+
<ArchitectureGraph
|
|
121
|
+
id={graph.id}
|
|
122
|
+
collection={graph.type ? resourceToCollectionMap[graph.type as keyof typeof resourceToCollectionMap] : undefined}
|
|
123
|
+
depth={graph.depth ? Number(graph.depth) : undefined}
|
|
124
|
+
lens={graph.lens}
|
|
125
|
+
showSearch={parseMdxBooleanProp(graph.search, true)}
|
|
126
|
+
showLegend={parseMdxBooleanProp(graph.legend, true)}
|
|
127
|
+
showLensPicker={parseMdxBooleanProp(graph.lensPicker, false)}
|
|
128
|
+
portalId={architectureGraphPortalId(graph.id, occurrenceIndex)}
|
|
129
|
+
href={
|
|
130
|
+
isArchitectureGraphEnabled()
|
|
131
|
+
? {
|
|
132
|
+
label: 'Open full screen',
|
|
133
|
+
url: buildUrl('/visualiser/graph'),
|
|
134
|
+
}
|
|
135
|
+
: undefined
|
|
136
|
+
}
|
|
137
|
+
/>
|
|
138
|
+
))}
|
|
107
139
|
</main>
|
|
108
140
|
</VerticalSideBarLayout>
|
|
109
141
|
) : (
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import VisualiserLayout from '@layouts/VisualiserLayout.astro';
|
|
3
3
|
import CatalogForceGraph from '@components/CatalogGraph/CatalogForceGraph';
|
|
4
|
-
import
|
|
5
|
-
import { getCatalogForceGraph } from '@utils/node-graphs/catalog-force-graph';
|
|
4
|
+
import { getCatalogForceGraph, toCatalogForceGraphWire } from '@utils/node-graphs/catalog-force-graph';
|
|
6
5
|
import { isArchitectureGraphEnabled } from '@utils/feature';
|
|
7
6
|
|
|
8
7
|
// Bail before building the graph — on very large catalogs the whole point of
|
|
@@ -11,29 +10,9 @@ if (!isArchitectureGraphEnabled()) {
|
|
|
11
10
|
return new Response(null, { status: 404 });
|
|
12
11
|
}
|
|
13
12
|
|
|
14
|
-
const { nodes, links } = await getCatalogForceGraph();
|
|
15
|
-
|
|
16
13
|
// Compact wire format: index-based links and client-derived URLs keep the
|
|
17
14
|
// island props Astro serialises into the page small for large catalogs.
|
|
18
|
-
const
|
|
19
|
-
const linkLabels = [...new Set(links.map((link) => link.label))];
|
|
20
|
-
const labelIndexByLabel = new Map(linkLabels.map((label, index) => [label, index]));
|
|
21
|
-
|
|
22
|
-
const wireNodes = nodes.map(({ id, label, collection, version }) => ({
|
|
23
|
-
// Node ids are `${collection}/${resourceId}` — send only the resource id, the client rebuilds the key
|
|
24
|
-
id: id.slice(collection.length + 1),
|
|
25
|
-
label,
|
|
26
|
-
collection,
|
|
27
|
-
version,
|
|
28
|
-
}));
|
|
29
|
-
|
|
30
|
-
const wireLinks = links.map(
|
|
31
|
-
(link): CatalogGraphWireLink => [
|
|
32
|
-
nodeIndexById.get(link.source)!,
|
|
33
|
-
nodeIndexById.get(link.target)!,
|
|
34
|
-
labelIndexByLabel.get(link.label)!,
|
|
35
|
-
]
|
|
36
|
-
);
|
|
15
|
+
const { nodes: wireNodes, links: wireLinks, linkLabels } = toCatalogForceGraphWire(await getCatalogForceGraph());
|
|
37
16
|
---
|
|
38
17
|
|
|
39
18
|
<VisualiserLayout title="Visualiser | Architecture Graph" description="Force-directed graph of every resource in the catalog">
|
|
@@ -35,6 +35,47 @@ export interface CatalogGraphLink {
|
|
|
35
35
|
label: string;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** Compact wire format — index-based links keep the payload Astro serialises into the island small. */
|
|
39
|
+
export interface CatalogGraphWireNode {
|
|
40
|
+
/** Resource id, e.g. `InventoryService` — the client rebuilds the `${collection}/${id}` key */
|
|
41
|
+
id: string;
|
|
42
|
+
label: string;
|
|
43
|
+
collection: string;
|
|
44
|
+
version?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** [sourceIndex, targetIndex, labelIndex] into the nodes / linkLabels arrays */
|
|
48
|
+
export type CatalogGraphWireLink = [number, number, number];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compacts the built graph for serialisation into a client island: node ids are
|
|
52
|
+
* stripped of their `${collection}/` prefix, link labels are deduped into one
|
|
53
|
+
* array, and each link becomes an index tuple. URLs are dropped server-side and
|
|
54
|
+
* rebuilt client-side.
|
|
55
|
+
*/
|
|
56
|
+
export const toCatalogForceGraphWire = ({ nodes, links }: { nodes: CatalogGraphNode[]; links: CatalogGraphLink[] }) => {
|
|
57
|
+
const nodeIndexById = new Map(nodes.map((node, index) => [node.id, index]));
|
|
58
|
+
const linkLabels = [...new Set(links.map((link) => link.label))];
|
|
59
|
+
const labelIndexByLabel = new Map(linkLabels.map((label, index) => [label, index]));
|
|
60
|
+
|
|
61
|
+
const wireNodes: CatalogGraphWireNode[] = nodes.map(({ id, label, collection, version }) => ({
|
|
62
|
+
id: id.slice(collection.length + 1),
|
|
63
|
+
label,
|
|
64
|
+
collection,
|
|
65
|
+
version,
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
const wireLinks = links.map(
|
|
69
|
+
(link): CatalogGraphWireLink => [
|
|
70
|
+
nodeIndexById.get(link.source)!,
|
|
71
|
+
nodeIndexById.get(link.target)!,
|
|
72
|
+
labelIndexByLabel.get(link.label)!,
|
|
73
|
+
]
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
return { nodes: wireNodes, links: wireLinks, linkLabels };
|
|
77
|
+
};
|
|
78
|
+
|
|
38
79
|
type AnyEntry = {
|
|
39
80
|
collection?: string;
|
|
40
81
|
data: { id: string; name?: string; version?: string; latestVersion?: string; visualiser?: boolean };
|
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"license": "SEE LICENSE IN LICENSE",
|
|
9
9
|
"type": "module",
|
|
10
|
-
"version": "4.10.
|
|
10
|
+
"version": "4.10.1",
|
|
11
11
|
"publishConfig": {
|
|
12
12
|
"access": "public"
|
|
13
13
|
},
|
|
@@ -119,9 +119,9 @@
|
|
|
119
119
|
"update-notifier": "^7.3.1",
|
|
120
120
|
"uuid": "^11.1.1",
|
|
121
121
|
"zod": "^4.3.6",
|
|
122
|
+
"@eventcatalog/visualiser": "^4.1.3",
|
|
122
123
|
"@eventcatalog/linter": "1.1.17",
|
|
123
|
-
"@eventcatalog/sdk": "2.28.1"
|
|
124
|
-
"@eventcatalog/visualiser": "^4.1.3"
|
|
124
|
+
"@eventcatalog/sdk": "2.28.1"
|
|
125
125
|
},
|
|
126
126
|
"devDependencies": {
|
|
127
127
|
"@astrojs/check": "^0.9.10",
|