@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.
Files changed (29) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/{chunk-BQXEWQ5J.js → chunk-OC2RGFPU.js} +1 -1
  6. package/dist/{chunk-HFAQXAG3.js → chunk-PHG7KEUH.js} +1 -1
  7. package/dist/{chunk-IC6XSXJO.js → chunk-QIV7LWVJ.js} +1 -1
  8. package/dist/{chunk-5XURY2JV.js → chunk-SQNVL7PI.js} +1 -1
  9. package/dist/{chunk-BDFTJVGN.js → chunk-T2HLSITU.js} +1 -1
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/eventcatalog.cjs +1 -1
  13. package/dist/eventcatalog.js +5 -5
  14. package/dist/generate.cjs +1 -1
  15. package/dist/generate.js +3 -3
  16. package/dist/utils/cli-logger.cjs +1 -1
  17. package/dist/utils/cli-logger.js +2 -2
  18. package/eventcatalog/astro.config.mjs +6 -0
  19. package/eventcatalog/src/components/CatalogGraph/CatalogForceGraph.tsx +260 -175
  20. package/eventcatalog/src/components/MDX/ArchitectureGraph/ArchitectureGraph.astro +62 -0
  21. package/eventcatalog/src/components/MDX/ArchitectureGraph/ArchitectureGraphPortal.tsx +22 -0
  22. package/eventcatalog/src/components/MDX/ArchitectureGraph/AstroArchitectureGraph.tsx +42 -0
  23. package/eventcatalog/src/components/MDX/components.tsx +13 -0
  24. package/eventcatalog/src/enterprise/custom-documentation/pages/docs/custom/index.astro +46 -1
  25. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro +41 -0
  26. package/eventcatalog/src/pages/index.astro +34 -2
  27. package/eventcatalog/src/pages/visualiser/graph/index.astro +2 -23
  28. package/eventcatalog/src/utils/node-graphs/catalog-force-graph.ts +41 -0
  29. package/package.json +3 -3
@@ -36,7 +36,7 @@ module.exports = __toCommonJS(analytics_exports);
36
36
  var import_os = __toESM(require("os"), 1);
37
37
 
38
38
  // package.json
39
- var version = "4.10.0";
39
+ var version = "4.10.1";
40
40
 
41
41
  // src/constants.ts
42
42
  var VERSION = version;
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  raiseEvent
3
- } from "../chunk-BQXEWQ5J.js";
4
- import "../chunk-BDFTJVGN.js";
3
+ } from "../chunk-OC2RGFPU.js";
4
+ import "../chunk-T2HLSITU.js";
5
5
  export {
6
6
  raiseEvent
7
7
  };
@@ -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.0";
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-HFAQXAG3.js";
4
- import "../chunk-BQXEWQ5J.js";
3
+ } from "../chunk-PHG7KEUH.js";
4
+ import "../chunk-OC2RGFPU.js";
5
5
  import "../chunk-K2XIENVT.js";
6
- import "../chunk-BDFTJVGN.js";
6
+ import "../chunk-T2HLSITU.js";
7
7
  import "../chunk-6QENHZZP.js";
8
8
  export {
9
9
  log_build_default as default
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  VERSION
3
- } from "./chunk-BDFTJVGN.js";
3
+ } from "./chunk-T2HLSITU.js";
4
4
 
5
5
  // src/analytics/analytics.js
6
6
  import os from "os";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  raiseEvent
3
- } from "./chunk-BQXEWQ5J.js";
3
+ } from "./chunk-OC2RGFPU.js";
4
4
  import {
5
5
  countResources,
6
6
  serializeCounts
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  VERSION
3
- } from "./chunk-BDFTJVGN.js";
3
+ } from "./chunk-T2HLSITU.js";
4
4
 
5
5
  // src/utils/cli-logger.ts
6
6
  import pc from "picocolors";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  logger
3
- } from "./chunk-IC6XSXJO.js";
3
+ } from "./chunk-QIV7LWVJ.js";
4
4
  import {
5
5
  cleanup,
6
6
  getEventCatalogConfigFile
@@ -1,5 +1,5 @@
1
1
  // package.json
2
- var version = "4.10.0";
2
+ var version = "4.10.1";
3
3
 
4
4
  // src/constants.ts
5
5
  var VERSION = version;
@@ -25,7 +25,7 @@ __export(constants_exports, {
25
25
  module.exports = __toCommonJS(constants_exports);
26
26
 
27
27
  // package.json
28
- var version = "4.10.0";
28
+ var version = "4.10.1";
29
29
 
30
30
  // src/constants.ts
31
31
  var VERSION = version;
package/dist/constants.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  VERSION
3
- } from "./chunk-BDFTJVGN.js";
3
+ } from "./chunk-T2HLSITU.js";
4
4
  export {
5
5
  VERSION
6
6
  };
@@ -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.0";
147
+ var version = "4.10.1";
148
148
 
149
149
  // src/constants.ts
150
150
  var VERSION = version;
@@ -4,8 +4,8 @@ import {
4
4
  import "./chunk-CA4U2JP7.js";
5
5
  import {
6
6
  log_build_default
7
- } from "./chunk-HFAQXAG3.js";
8
- import "./chunk-BQXEWQ5J.js";
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-5XURY2JV.js";
34
+ } from "./chunk-SQNVL7PI.js";
35
35
  import {
36
36
  logger
37
- } from "./chunk-IC6XSXJO.js";
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-BDFTJVGN.js";
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.0";
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-5XURY2JV.js";
4
- import "./chunk-IC6XSXJO.js";
5
- import "./chunk-BDFTJVGN.js";
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
@@ -36,7 +36,7 @@ module.exports = __toCommonJS(cli_logger_exports);
36
36
  var import_picocolors = __toESM(require("picocolors"), 1);
37
37
 
38
38
  // package.json
39
- var version = "4.10.0";
39
+ var version = "4.10.1";
40
40
 
41
41
  // src/constants.ts
42
42
  var VERSION = version;
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  logger
3
- } from "../chunk-IC6XSXJO.js";
4
- import "../chunk-BDFTJVGN.js";
3
+ } from "../chunk-QIV7LWVJ.js";
4
+ import "../chunk-T2HLSITU.js";
5
5
  export {
6
6
  logger
7
7
  };
@@ -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
- /** Compact wire format expanded client-side to keep the serialised page payload small. */
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 = ({ nodes, links, linkLabels }: Props) => {
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 = new URLSearchParams(window.location.search).get('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 = new URLSearchParams(window.location.search).get('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>(() => new URLSearchParams(window.location.search).get('focus'));
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(new URLSearchParams(window.location.search).get('depth')) || 1))
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
- // Reflect the view state back into the URL (replaceState, so no history spam);
277
- // unrelated params e.g. the stress page's ?nodes are preserved
278
- useEffect(() => {
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
- const query = params.toString();
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 ref={containerRef} className="relative h-full w-full overflow-hidden">
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
- <div className="absolute left-3 top-3 z-10 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">
1209
- <div className="flex items-center gap-2">
1210
- <label htmlFor="catalog-graph-lens" className="font-semibold text-[rgb(var(--ec-page-text))]">
1211
- Lens
1212
- </label>
1213
- <select
1214
- id="catalog-graph-lens"
1215
- value={lensKey}
1216
- onChange={(event) => {
1217
- // A new lens is a fresh view — drop any focus and legend filters
1218
- setLensKey(event.target.value);
1219
- setFocusKey(null);
1220
- setFocusDepth(1);
1221
- setHiddenCollections(new Set());
1222
- }}
1223
- title={lens.description}
1224
- 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))]"
1225
- >
1226
- {Object.entries(LENSES).map(([key, { label }]) => (
1227
- <option key={key} value={key}>
1228
- {label}
1229
- </option>
1230
- ))}
1231
- </select>
1232
- </div>
1233
- {lens.hubCollections && !focusedNode && (
1234
- <label
1235
- className="flex items-center gap-2 text-[rgb(var(--ec-page-text-muted))]"
1236
- title="How many relationship hops to show around the anchor nodes"
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
- Detail
1239
- <input
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
- <div className="absolute right-3 top-3 z-10 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">
1253
- <div ref={searchContainerRef} className="relative">
1254
- <input
1255
- type="text"
1256
- placeholder="Search &amp; focus…"
1257
- value={searchValue}
1258
- onChange={(event) => {
1259
- setSearchValue(event.target.value);
1260
- setShowSuggestions(true);
1261
- setSelectedSuggestionIndex(-1);
1262
- }}
1263
- onFocus={() => setShowSuggestions(true)}
1264
- onKeyDown={handleSearchKeyDown}
1265
- 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))]"
1266
- />
1267
- {searchValue && (
1268
- <button
1269
- type="button"
1270
- onClick={() => setSearchValue('')}
1271
- aria-label="Clear search"
1272
- 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))]"
1273
- >
1274
- ×
1275
- </button>
1276
- )}
1277
- {showSuggestions && searchSuggestions.length > 0 && (
1278
- <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">
1279
- {searchSuggestions.map((node, index) => {
1280
- const Icon = getIconForCollection(node.collection);
1281
- const chipStyle = collectionChipStyle(node.collection);
1282
- const isSelected = index === selectedSuggestionIndex;
1283
- return (
1284
- <button
1285
- key={node.key}
1286
- type="button"
1287
- onClick={() => selectSuggestion(node)}
1288
- onMouseEnter={() => setSelectedSuggestionIndex(index)}
1289
- className={`flex w-full items-center gap-2 px-2 py-1.5 text-left ${isSelected ? 'bg-[rgb(var(--ec-accent-subtle))]' : ''}`}
1290
- >
1291
- <span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md" style={chipStyle}>
1292
- <Icon width={13} height={13} />
1293
- </span>
1294
- <span className="min-w-0 flex-1">
1295
- <span className="block truncate font-medium text-[rgb(var(--ec-page-text))]">{node.label}</span>
1296
- {node.version && (
1297
- <span className="block truncate text-[10px] text-[rgb(var(--ec-page-text-muted))]">v{node.version}</span>
1298
- )}
1299
- </span>
1300
- <span className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium" style={chipStyle}>
1301
- {COLLECTION_LABELS[node.collection] ?? node.collection}
1302
- </span>
1303
- </button>
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 &amp; 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
- {focusedNode && (
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
- <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">
1381
- <div className="mb-2 font-semibold text-[rgb(var(--ec-page-text))]">Resources</div>
1382
- <ul className="space-y-1">
1383
- {collectionsInGraph.map(({ collection, count }) => {
1384
- const hidden = hiddenCollections.has(collection);
1385
- return (
1386
- <li key={collection}>
1387
- <button
1388
- type="button"
1389
- onClick={() => toggleCollection(collection)}
1390
- 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' : ''}`}
1391
- >
1392
- <span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: nodeColor(collection) }} />
1393
- <span className="text-[rgb(var(--ec-page-text))]">
1394
- {COLLECTION_LABELS[collection] ?? collection} ({count})
1395
- </span>
1396
- </button>
1397
- </li>
1398
- );
1399
- })}
1400
- </ul>
1401
- </div>
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 { isVisualiserEnabled, isMarkdownDownloadEnabled, isRSSEnabled, isEventCatalogChatEnabled } from '@utils/feature';
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 type { CatalogGraphWireLink } from '@components/CatalogGraph/CatalogForceGraph';
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 nodeIndexById = new Map(nodes.map((node, index) => [node.id, index]));
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.0",
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",