@eventcatalog/core 5.0.0-beta.4 → 5.0.0-beta.6
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 +2 -2
- package/dist/analytics/log-build.js +4 -4
- package/dist/{chunk-TOMV5YJH.js → chunk-56CD2NH6.js} +1 -1
- package/dist/{chunk-M7OVJHPV.js → chunk-DE3QHVJP.js} +1 -1
- package/dist/{chunk-Q7FBYUCZ.js → chunk-L2IRFOE7.js} +1 -1
- package/dist/{chunk-TQLRG35T.js → chunk-PIYJM642.js} +1 -1
- package/dist/{chunk-VA77ENFQ.js → chunk-QBP3BBSV.js} +1 -1
- package/dist/{chunk-PL3T5TWF.js → chunk-XMPFHLK4.js} +2 -2
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +2 -2
- package/dist/eventcatalog.js +6 -6
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/runtime-dependencies/@hocuspocus__provider.mjs +3 -0
- package/dist/runtime-dependencies/@hocuspocus__server.mjs +3 -0
- package/dist/runtime-dependencies/@mcp-b__webmcp-local-relay.mjs +3 -0
- package/dist/runtime-dependencies/@mcp-b__webmcp-polyfill.mjs +3 -0
- package/dist/runtime-dependencies/manifest.json +8 -0
- package/dist/runtime-dependencies/react-hotkeys-hook.mjs +3 -0
- package/dist/runtime-dependencies/y-protocols.mjs +3 -0
- package/dist/runtime-dependencies/y-protocols__awareness.mjs +3 -0
- package/dist/runtime-dependencies/yjs.mjs +3 -0
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/dist/utils/license-status.cjs +1 -1
- package/dist/utils/license-status.js +1 -1
- package/eventcatalog/astro.config.mjs +2 -0
- package/eventcatalog/integrations/studio-server.mjs +17 -0
- package/eventcatalog/src/features/integrations/eventcatalog-features.ts +20 -1
- package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +39 -0
- package/eventcatalog/src/features/mcp/apps/canvas/styles.css +22 -0
- package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +269 -0
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +209 -0
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +2 -2
- package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +2 -1
- package/eventcatalog/src/features/mcp/mcp-apps.ts +6 -3
- package/eventcatalog/src/features/mcp/mcp-server.ts +17 -4
- package/eventcatalog/src/features/studio/agent-choreography.ts +378 -0
- package/eventcatalog/src/features/studio/canvas-actions.ts +287 -0
- package/eventcatalog/src/features/studio/canvas-doc.ts +280 -0
- package/eventcatalog/src/features/studio/catalog-resources.ts +152 -0
- package/eventcatalog/src/features/studio/catalog.ts +171 -0
- package/eventcatalog/src/features/studio/components/CanvasControls.tsx +153 -0
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +332 -0
- package/eventcatalog/src/features/studio/components/Comments.tsx +455 -0
- package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +217 -0
- package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +145 -0
- package/eventcatalog/src/features/studio/components/JoinForm.tsx +58 -0
- package/eventcatalog/src/features/studio/components/LeftPanel.tsx +218 -0
- package/eventcatalog/src/features/studio/components/Presence.tsx +284 -0
- package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +75 -0
- package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +760 -0
- package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +79 -0
- package/eventcatalog/src/features/studio/components/icons.ts +48 -0
- package/eventcatalog/src/features/studio/components/status.ts +22 -0
- package/eventcatalog/src/features/studio/edges.ts +54 -0
- package/eventcatalog/src/features/studio/flow-state.ts +92 -0
- package/eventcatalog/src/features/studio/grouping.ts +133 -0
- package/eventcatalog/src/features/studio/hooks/presence-sender.ts +46 -0
- package/eventcatalog/src/features/studio/hooks/presence-store.ts +115 -0
- package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +337 -0
- package/eventcatalog/src/features/studio/hooks/use-comments.ts +47 -0
- package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +99 -0
- package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +423 -0
- package/eventcatalog/src/features/studio/layout.ts +39 -0
- package/eventcatalog/src/features/studio/levels.ts +216 -0
- package/eventcatalog/src/features/studio/node-types.ts +173 -0
- package/eventcatalog/src/features/studio/pages/[id].astro +38 -0
- package/eventcatalog/src/features/studio/pages/index.astro +8 -0
- package/eventcatalog/src/features/studio/pages/webmcp-relay.ts +27 -0
- package/eventcatalog/src/features/studio/placement.ts +197 -0
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +475 -0
- package/eventcatalog/src/features/studio/server/runtime.ts +215 -0
- package/eventcatalog/src/features/studio/server/tool-icons.ts +30 -0
- package/eventcatalog/src/features/studio/studio.css +89 -0
- package/eventcatalog/src/features/studio/tool-sync.ts +75 -0
- package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +9 -2
- package/eventcatalog/src/utils/feature.ts +4 -0
- package/eventcatalog/src/utils/trial.ts +1 -1
- package/package.json +9 -2
- package/eventcatalog/public/studio-bg.png +0 -0
- package/eventcatalog/src/components/Studio/StudioPageModal.tsx +0 -150
- package/eventcatalog/src/pages/studio.astro +0 -316
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { getDomains } from '@utils/collections/domains';
|
|
2
|
+
import { getSystems } from '@utils/collections/systems';
|
|
3
|
+
import { getServices } from '@utils/collections/services';
|
|
4
|
+
import { getEvents } from '@utils/collections/events';
|
|
5
|
+
import { getCommands } from '@utils/collections/commands';
|
|
6
|
+
import { getQueries } from '@utils/collections/queries';
|
|
7
|
+
import { getChannels } from '@utils/collections/channels';
|
|
8
|
+
import { getContainers } from '@utils/collections/containers';
|
|
9
|
+
import { buildUrl } from '@utils/url-builder';
|
|
10
|
+
|
|
11
|
+
/** A catalog resource that can be dropped onto the canvas (latest version only) */
|
|
12
|
+
export type CatalogResource = {
|
|
13
|
+
/** `${collection}:${id}`, stored on the canvas node so relationships can be found */
|
|
14
|
+
key: string;
|
|
15
|
+
collection: string;
|
|
16
|
+
id: string;
|
|
17
|
+
version: string;
|
|
18
|
+
name: string;
|
|
19
|
+
summary?: string;
|
|
20
|
+
url: string;
|
|
21
|
+
node: { type: string; data: Record<string, unknown> };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** A relationship between two catalog resources, by key */
|
|
25
|
+
export type CatalogRelation = { source: string; target: string; label?: string };
|
|
26
|
+
|
|
27
|
+
const keyOf = (collection: string, id: string) => `${collection}:${id}`;
|
|
28
|
+
|
|
29
|
+
/** The collection helpers hydrate references (e.g. a domain's services) into entries; their types describe references */
|
|
30
|
+
type HydratedEntry = { data: { id: string } };
|
|
31
|
+
const isHydratedEntry = (value: unknown): value is HydratedEntry => {
|
|
32
|
+
if (typeof value !== 'object' || value === null || !('data' in value)) return false;
|
|
33
|
+
const { data } = value;
|
|
34
|
+
return typeof data === 'object' && data !== null && 'id' in data && typeof data.id === 'string';
|
|
35
|
+
};
|
|
36
|
+
const entries = (value: unknown): HydratedEntry[] => (Array.isArray(value) ? value.filter(isHydratedEntry) : []);
|
|
37
|
+
const idsOf = (value: unknown) => entries(value).map((entry) => entry.data.id);
|
|
38
|
+
|
|
39
|
+
const toResource = (
|
|
40
|
+
collection: string,
|
|
41
|
+
entry: { data: { id: string; version: string; name?: string; summary?: string } },
|
|
42
|
+
node: CatalogResource['node']
|
|
43
|
+
): CatalogResource => ({
|
|
44
|
+
key: keyOf(collection, entry.data.id),
|
|
45
|
+
collection,
|
|
46
|
+
id: entry.data.id,
|
|
47
|
+
version: entry.data.version,
|
|
48
|
+
name: entry.data.name ?? entry.data.id,
|
|
49
|
+
summary: entry.data.summary,
|
|
50
|
+
url: buildUrl(`/docs/${collection}/${entry.data.id}/${entry.data.version}`),
|
|
51
|
+
node,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Visualiser node data needs plain fields (not collection entries)
|
|
55
|
+
const basics = ({ data }: { data: { id: string; version: string; name?: string; summary?: string } }) => ({
|
|
56
|
+
id: data.id,
|
|
57
|
+
version: data.version,
|
|
58
|
+
name: data.name ?? data.id,
|
|
59
|
+
summary: data.summary ?? '',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export const getCatalogResources = async (): Promise<{ resources: CatalogResource[]; relations: CatalogRelation[] }> => {
|
|
63
|
+
const [domains, systems, services, events, commands, queries, channels, containers] = await Promise.all([
|
|
64
|
+
getDomains({ getAllVersions: false }),
|
|
65
|
+
getSystems({ getAllVersions: false }),
|
|
66
|
+
getServices({ getAllVersions: false }),
|
|
67
|
+
getEvents({ getAllVersions: false, hydrateServices: false }),
|
|
68
|
+
getCommands({ getAllVersions: false, hydrateServices: false }),
|
|
69
|
+
getQueries({ getAllVersions: false, hydrateServices: false }),
|
|
70
|
+
getChannels({ getAllVersions: false }),
|
|
71
|
+
getContainers({ getAllVersions: false }),
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
const messages = [
|
|
75
|
+
...events.map((entry) => ({ entry, collection: 'events', type: 'event' })),
|
|
76
|
+
...commands.map((entry) => ({ entry, collection: 'commands', type: 'command' })),
|
|
77
|
+
...queries.map((entry) => ({ entry, collection: 'queries', type: 'query' })),
|
|
78
|
+
];
|
|
79
|
+
const messageCollection = new Map(messages.map(({ entry, collection }) => [entry.data.id, collection]));
|
|
80
|
+
|
|
81
|
+
const resources: CatalogResource[] = [
|
|
82
|
+
...domains.map((domain) =>
|
|
83
|
+
toResource('domains', domain, {
|
|
84
|
+
type: 'context-domain',
|
|
85
|
+
data: {
|
|
86
|
+
mode: 'full',
|
|
87
|
+
navigable: false,
|
|
88
|
+
domain: basics(domain),
|
|
89
|
+
systemsCount: idsOf(domain.data.systems).length,
|
|
90
|
+
servicesCount: idsOf(domain.data.services).length,
|
|
91
|
+
entitiesCount: idsOf(domain.data.entities).length,
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
),
|
|
95
|
+
...systems.map((system) =>
|
|
96
|
+
toResource('systems', system, {
|
|
97
|
+
type: 'system',
|
|
98
|
+
data: {
|
|
99
|
+
mode: 'full',
|
|
100
|
+
navigable: false,
|
|
101
|
+
system: { ...basics(system), scope: system.data.scope },
|
|
102
|
+
servicesCount: idsOf(system.data.services).length,
|
|
103
|
+
containersCount: idsOf(system.data.containers).length,
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
),
|
|
107
|
+
...services.map((service) =>
|
|
108
|
+
toResource('services', service, { type: 'service', data: { mode: 'full', service: basics(service) } })
|
|
109
|
+
),
|
|
110
|
+
...messages.map(({ entry, collection, type }) =>
|
|
111
|
+
toResource(collection, entry, { type, data: { mode: 'full', message: basics(entry) } })
|
|
112
|
+
),
|
|
113
|
+
...channels.map((channel) =>
|
|
114
|
+
toResource('channels', channel, {
|
|
115
|
+
type: 'channel',
|
|
116
|
+
data: { mode: 'full', channel: { ...basics(channel), protocols: channel.data.protocols ?? [] } },
|
|
117
|
+
})
|
|
118
|
+
),
|
|
119
|
+
...containers.map((container) =>
|
|
120
|
+
toResource('containers', container, {
|
|
121
|
+
type: 'data',
|
|
122
|
+
data: { mode: 'full', data: { ...basics(container), container_type: container.data.container_type } },
|
|
123
|
+
})
|
|
124
|
+
),
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
const relations: CatalogRelation[] = [];
|
|
128
|
+
const relate = (source: string, target: string, label?: string) => relations.push({ source, target, label });
|
|
129
|
+
|
|
130
|
+
for (const domain of domains) {
|
|
131
|
+
for (const id of idsOf(domain.data.systems)) relate(keyOf('domains', domain.data.id), keyOf('systems', id), 'contains');
|
|
132
|
+
for (const id of idsOf(domain.data.services)) relate(keyOf('domains', domain.data.id), keyOf('services', id), 'contains');
|
|
133
|
+
}
|
|
134
|
+
for (const system of systems) {
|
|
135
|
+
for (const id of idsOf(system.data.services)) relate(keyOf('systems', system.data.id), keyOf('services', id), 'contains');
|
|
136
|
+
}
|
|
137
|
+
for (const service of services) {
|
|
138
|
+
const serviceKey = keyOf('services', service.data.id);
|
|
139
|
+
for (const id of idsOf(service.data.sends)) {
|
|
140
|
+
const collection = messageCollection.get(id);
|
|
141
|
+
if (collection) relate(serviceKey, keyOf(collection, id));
|
|
142
|
+
}
|
|
143
|
+
for (const id of idsOf(service.data.receives)) {
|
|
144
|
+
const collection = messageCollection.get(id);
|
|
145
|
+
if (collection) relate(keyOf(collection, id), serviceKey);
|
|
146
|
+
}
|
|
147
|
+
for (const id of idsOf(service.data.writesTo)) relate(serviceKey, keyOf('containers', id));
|
|
148
|
+
for (const id of idsOf(service.data.readsFrom)) relate(keyOf('containers', id), serviceKey);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { resources, relations };
|
|
152
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { Edge, Node } from '@xyflow/react';
|
|
2
|
+
import type { CatalogRelation, CatalogResource } from './catalog-resources';
|
|
3
|
+
import { buildNode } from './canvas-doc';
|
|
4
|
+
import { createEdge } from './edges';
|
|
5
|
+
import { layoutGraph, withGroupSizes } from './layout';
|
|
6
|
+
import { GROUP_TYPES, isGroupType } from './node-types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Catalog resources on a canvas: the node for a resource, its connections to related resources already there,
|
|
10
|
+
* and domains and systems as containers with what they hold in the catalog.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type CatalogLink = { key: string; url: string; version: string };
|
|
14
|
+
|
|
15
|
+
/** The node data for a resource, tagged with the catalog resource it came from */
|
|
16
|
+
export const catalogNodeData = (resource: CatalogResource) => ({
|
|
17
|
+
...resource.node.data,
|
|
18
|
+
catalog: { key: resource.key, url: resource.url, version: resource.version } satisfies CatalogLink,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const getCatalogLink = (node: Pick<Node, 'data'>) => node.data?.catalog as CatalogLink | undefined;
|
|
22
|
+
|
|
23
|
+
export const indexRelations = (relations: CatalogRelation[]) => {
|
|
24
|
+
const byKey = new Map<string, CatalogRelation[]>();
|
|
25
|
+
for (const relation of relations) {
|
|
26
|
+
for (const key of [relation.source, relation.target]) byKey.set(key, [...(byKey.get(key) ?? []), relation]);
|
|
27
|
+
}
|
|
28
|
+
return byKey;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Edges from a newly dropped resource to the related resources already on the canvas. A container doesn't get
|
|
33
|
+
* "contains" edges: what it contains sits inside it.
|
|
34
|
+
*/
|
|
35
|
+
export const getRelatedEdges = (
|
|
36
|
+
resource: CatalogResource,
|
|
37
|
+
nodeId: string,
|
|
38
|
+
existing: Node[],
|
|
39
|
+
relationsByKey: Map<string, CatalogRelation[]>,
|
|
40
|
+
nodeType = resource.node.type
|
|
41
|
+
): Edge[] => {
|
|
42
|
+
const edges: Edge[] = [];
|
|
43
|
+
for (const relation of relationsByKey.get(resource.key) ?? []) {
|
|
44
|
+
const isSource = relation.source === resource.key;
|
|
45
|
+
const otherKey = isSource ? relation.target : relation.source;
|
|
46
|
+
|
|
47
|
+
for (const other of existing.filter((node) => getCatalogLink(node)?.key === otherKey)) {
|
|
48
|
+
if (relation.label === 'contains' && (isGroupType(nodeType) || isGroupType(other.type))) continue;
|
|
49
|
+
const [source, target] = isSource ? [{ id: nodeId, type: nodeType }, other] : [other, { id: nodeId, type: nodeType }];
|
|
50
|
+
const edge = createEdge(
|
|
51
|
+
{ source: source.id, target: target.id, sourceHandle: null, targetHandle: null },
|
|
52
|
+
source.type,
|
|
53
|
+
target.type
|
|
54
|
+
);
|
|
55
|
+
edges.push(relation.label ? { ...edge, label: relation.label } : edge);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return edges;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// ---- Domains and systems as containers ----
|
|
62
|
+
|
|
63
|
+
export const canBeContainer = (resource: CatalogResource) =>
|
|
64
|
+
resource.collection === 'domains' || resource.collection === 'systems';
|
|
65
|
+
|
|
66
|
+
export const containerTypeFor = (resource: CatalogResource) =>
|
|
67
|
+
resource.collection === 'domains' ? GROUP_TYPES.domain : GROUP_TYPES.system;
|
|
68
|
+
|
|
69
|
+
/** A container's data: the domain or system it stands for, and where it is in the catalog */
|
|
70
|
+
export const catalogGroupData = (resource: CatalogResource) => ({
|
|
71
|
+
[resource.collection === 'domains' ? 'domain' : 'system']: {
|
|
72
|
+
id: resource.id,
|
|
73
|
+
name: resource.name,
|
|
74
|
+
version: resource.version,
|
|
75
|
+
summary: resource.summary,
|
|
76
|
+
},
|
|
77
|
+
catalog: { key: resource.key, url: resource.url, version: resource.version } satisfies CatalogLink,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/** What a domain or system contains in the catalog: a domain's systems and services, a system's services */
|
|
81
|
+
export const getContents = (
|
|
82
|
+
resource: CatalogResource,
|
|
83
|
+
catalog: { resourcesByKey: Map<string, CatalogResource>; relationsByKey: Map<string, CatalogRelation[]> }
|
|
84
|
+
) =>
|
|
85
|
+
(catalog.relationsByKey.get(resource.key) ?? [])
|
|
86
|
+
.filter((relation) => relation.source === resource.key && relation.label === 'contains')
|
|
87
|
+
.map((relation) => catalog.resourcesByKey.get(relation.target))
|
|
88
|
+
.filter((child): child is CatalogResource => !!child);
|
|
89
|
+
|
|
90
|
+
/** A domain or system as an (empty) container, centred on a point */
|
|
91
|
+
export const buildContainer = (resource: CatalogResource, center: { x: number; y: number }): Node =>
|
|
92
|
+
buildNode(containerTypeFor(resource), catalogGroupData(resource), center);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A domain or system as a container with everything it holds in the catalog: its systems (as containers too)
|
|
96
|
+
* and services, and the messages and data stores of those services, so every level of detail (L1 to L3) has
|
|
97
|
+
* something to show. Laid out with the visualiser's layout and centred on a point, with the connections between
|
|
98
|
+
* them and to related resources already on the canvas. Resources already on the canvas aren't added again.
|
|
99
|
+
*/
|
|
100
|
+
export const buildContainerWithContents = async (
|
|
101
|
+
resource: CatalogResource,
|
|
102
|
+
center: { x: number; y: number },
|
|
103
|
+
catalog: { resourcesByKey: Map<string, CatalogResource>; relationsByKey: Map<string, CatalogRelation[]> },
|
|
104
|
+
existing: Node[]
|
|
105
|
+
): Promise<{ nodes: Node[]; edges: Edge[] }> => {
|
|
106
|
+
const onCanvas = new Set(existing.map((node) => getCatalogLink(node)?.key).filter(Boolean));
|
|
107
|
+
const root = buildContainer(resource, { x: 0, y: 0 });
|
|
108
|
+
const nodes: Node[] = [root];
|
|
109
|
+
const added = (key: string) => onCanvas.has(key) || nodes.some((node) => getCatalogLink(node)?.key === key);
|
|
110
|
+
const services: { resource: CatalogResource; parentId: string }[] = [];
|
|
111
|
+
|
|
112
|
+
// Its systems (as containers, with their services) and services
|
|
113
|
+
const fill = (parent: CatalogResource, parentId: string) => {
|
|
114
|
+
for (const child of getContents(parent, catalog)) {
|
|
115
|
+
if (added(child.key)) continue;
|
|
116
|
+
if (canBeContainer(child)) {
|
|
117
|
+
const container = { ...buildContainer(child, { x: 0, y: 0 }), parentId };
|
|
118
|
+
nodes.push(container);
|
|
119
|
+
fill(child, container.id);
|
|
120
|
+
} else {
|
|
121
|
+
nodes.push({ ...buildNode(child.node.type, catalogNodeData(child), { x: 0, y: 0 }), parentId });
|
|
122
|
+
if (child.collection === 'services') services.push({ resource: child, parentId });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
fill(resource, root.id);
|
|
127
|
+
|
|
128
|
+
// The messages its services send and receive (in the domain or system dropped), and their data stores (next to them)
|
|
129
|
+
for (const service of services) {
|
|
130
|
+
for (const relation of catalog.relationsByKey.get(service.resource.key) ?? []) {
|
|
131
|
+
if (relation.label === 'contains') continue;
|
|
132
|
+
const other = catalog.resourcesByKey.get(relation.source === service.resource.key ? relation.target : relation.source);
|
|
133
|
+
if (!other || added(other.key) || canBeContainer(other) || other.collection === 'services') continue;
|
|
134
|
+
const parentId = other.collection === 'containers' ? service.parentId : root.id;
|
|
135
|
+
nodes.push({ ...buildNode(other.node.type, catalogNodeData(other), { x: 0, y: 0 }), parentId });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (nodes.length === 1) return { nodes: [buildContainer(resource, center)], edges: [] };
|
|
140
|
+
|
|
141
|
+
// Connections between them, and to related resources already on the canvas
|
|
142
|
+
const inside: Edge[] = [];
|
|
143
|
+
const outside: Edge[] = [];
|
|
144
|
+
const placed = [...existing];
|
|
145
|
+
for (const node of nodes) {
|
|
146
|
+
const link = getCatalogLink(node);
|
|
147
|
+
const related = link && catalog.resourcesByKey.get(link.key);
|
|
148
|
+
if (related) {
|
|
149
|
+
for (const edge of getRelatedEdges(related, node.id, placed, catalog.relationsByKey, node.type)) {
|
|
150
|
+
(existing.some((other) => other.id === edge.source || other.id === edge.target) ? outside : inside).push(edge);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
placed.push(node);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Laid out with its connections; containers are sized to fit what's in them
|
|
157
|
+
const laidOut = withGroupSizes((await layoutGraph(nodes, inside)).nodes);
|
|
158
|
+
const top = laidOut.find((node) => node.id === root.id)!;
|
|
159
|
+
const offset = { x: center.x - (top.width ?? 0) / 2 - top.position.x, y: center.y - (top.height ?? 0) / 2 - top.position.y };
|
|
160
|
+
return {
|
|
161
|
+
nodes: laidOut.map((node) => {
|
|
162
|
+
const original = nodes.find((candidate) => candidate.id === node.id)!;
|
|
163
|
+
return {
|
|
164
|
+
...original,
|
|
165
|
+
position: node.parentId ? node.position : { x: node.position.x + offset.x, y: node.position.y + offset.y },
|
|
166
|
+
...(isGroupType(node.type) && { width: node.width, height: node.height }),
|
|
167
|
+
};
|
|
168
|
+
}),
|
|
169
|
+
edges: [...inside, ...outside],
|
|
170
|
+
};
|
|
171
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { memo, type ReactNode } from 'react';
|
|
2
|
+
import { useReactFlow, useStore } from '@xyflow/react';
|
|
3
|
+
import { Maximize, MessageCircle, Redo, SendToBack, StickyNote, Undo, ZoomIn, ZoomOut } from 'lucide-react';
|
|
4
|
+
import { LEVELS, type Level } from '../levels';
|
|
5
|
+
|
|
6
|
+
// The canvas's bottom bar: layout, view, levels of detail, undo, comments and notes
|
|
7
|
+
|
|
8
|
+
export const FIT_VIEW_OPTIONS = { padding: { top: '80px', right: '60px', bottom: '100px', left: '60px' } } as const;
|
|
9
|
+
|
|
10
|
+
function ControlButton({
|
|
11
|
+
label,
|
|
12
|
+
onClick,
|
|
13
|
+
disabled,
|
|
14
|
+
active,
|
|
15
|
+
children,
|
|
16
|
+
}: {
|
|
17
|
+
label: string;
|
|
18
|
+
onClick: () => void;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
active?: boolean;
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
}) {
|
|
23
|
+
return (
|
|
24
|
+
<button
|
|
25
|
+
title={label}
|
|
26
|
+
aria-label={label}
|
|
27
|
+
onClick={onClick}
|
|
28
|
+
disabled={disabled}
|
|
29
|
+
className={`rounded-full p-2 transition-colors ${
|
|
30
|
+
disabled
|
|
31
|
+
? 'cursor-not-allowed opacity-40'
|
|
32
|
+
: active
|
|
33
|
+
? 'bg-[rgb(var(--ec-accent-subtle))] text-[rgb(var(--ec-accent))]'
|
|
34
|
+
: 'text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-page-border)/0.5)] hover:text-[rgb(var(--ec-icon-hover))]'
|
|
35
|
+
}`}
|
|
36
|
+
>
|
|
37
|
+
{children}
|
|
38
|
+
</button>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Isolated, so the bar only re-renders when the zoom changes (not on every pan) */
|
|
43
|
+
function ZoomLevel() {
|
|
44
|
+
const zoom = useStore((state) => Math.round(state.transform[2] * 100));
|
|
45
|
+
return <div className="w-12 text-center text-xs font-medium tabular-nums text-[rgb(var(--ec-page-text-muted))]">{zoom}%</div>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const Divider = () => <div className="mx-1 h-6 w-px bg-[rgb(var(--ec-page-border))]" />;
|
|
49
|
+
|
|
50
|
+
function LevelSwitch({
|
|
51
|
+
level,
|
|
52
|
+
onChange,
|
|
53
|
+
unavailable,
|
|
54
|
+
}: {
|
|
55
|
+
level: Level;
|
|
56
|
+
onChange: (level: Level) => void;
|
|
57
|
+
unavailable: Partial<Record<Level, string | undefined>>;
|
|
58
|
+
}) {
|
|
59
|
+
return (
|
|
60
|
+
<div role="group" aria-label="Level of detail" className="flex items-center gap-0.5">
|
|
61
|
+
{LEVELS.map(({ level: value, description }) => {
|
|
62
|
+
const reason = unavailable[value];
|
|
63
|
+
const active = value === level;
|
|
64
|
+
return (
|
|
65
|
+
<button
|
|
66
|
+
key={value}
|
|
67
|
+
title={`Level ${value}: ${description}${reason ? `. ${reason}` : ''}`}
|
|
68
|
+
// Stays focusable when unavailable, so its title can say why
|
|
69
|
+
aria-disabled={!!reason}
|
|
70
|
+
aria-pressed={active}
|
|
71
|
+
onClick={() => !reason && onChange(value)}
|
|
72
|
+
className={`h-8 min-w-8 rounded-full px-2 text-xs font-semibold tabular-nums transition-colors ${
|
|
73
|
+
reason
|
|
74
|
+
? 'cursor-not-allowed opacity-40'
|
|
75
|
+
: active
|
|
76
|
+
? 'bg-[rgb(var(--ec-accent-subtle))] text-[rgb(var(--ec-accent))]'
|
|
77
|
+
: 'text-[rgb(var(--ec-page-text-muted))] hover:bg-[rgb(var(--ec-page-border)/0.5)]'
|
|
78
|
+
}`}
|
|
79
|
+
>
|
|
80
|
+
L{value}
|
|
81
|
+
</button>
|
|
82
|
+
);
|
|
83
|
+
})}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export default memo(function CanvasControls({
|
|
89
|
+
level,
|
|
90
|
+
onLevelChange,
|
|
91
|
+
unavailableLevels,
|
|
92
|
+
editable,
|
|
93
|
+
onReorder,
|
|
94
|
+
canUndo,
|
|
95
|
+
canRedo,
|
|
96
|
+
onUndo,
|
|
97
|
+
onRedo,
|
|
98
|
+
commentMode,
|
|
99
|
+
onToggleCommentMode,
|
|
100
|
+
onAddNote,
|
|
101
|
+
}: {
|
|
102
|
+
level: Level;
|
|
103
|
+
onLevelChange: (level: Level) => void;
|
|
104
|
+
unavailableLevels: Partial<Record<Level, string | undefined>>;
|
|
105
|
+
/** L1 and L2 are read only views of the canvas */
|
|
106
|
+
editable: boolean;
|
|
107
|
+
onReorder: () => void;
|
|
108
|
+
canUndo: boolean;
|
|
109
|
+
canRedo: boolean;
|
|
110
|
+
onUndo: () => void;
|
|
111
|
+
onRedo: () => void;
|
|
112
|
+
commentMode: boolean;
|
|
113
|
+
onToggleCommentMode: () => void;
|
|
114
|
+
onAddNote: () => void;
|
|
115
|
+
}) {
|
|
116
|
+
const { fitView, zoomIn, zoomOut } = useReactFlow();
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div className="absolute bottom-4 left-1/2 z-10 -translate-x-1/2">
|
|
120
|
+
<div className="flex items-center gap-1 rounded-full border px-2 py-1 shadow-lg bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))]">
|
|
121
|
+
<ControlButton label="Reorder diagram (the visualiser's layout)" onClick={onReorder} disabled={!editable}>
|
|
122
|
+
<SendToBack className="h-4 w-4" />
|
|
123
|
+
</ControlButton>
|
|
124
|
+
<ControlButton label="Fit view" onClick={() => void fitView({ ...FIT_VIEW_OPTIONS, duration: 500 })}>
|
|
125
|
+
<Maximize className="h-4 w-4" />
|
|
126
|
+
</ControlButton>
|
|
127
|
+
<ControlButton label="Zoom out" onClick={() => void zoomOut({ duration: 200 })}>
|
|
128
|
+
<ZoomOut className="h-4 w-4" />
|
|
129
|
+
</ControlButton>
|
|
130
|
+
<ZoomLevel />
|
|
131
|
+
<ControlButton label="Zoom in" onClick={() => void zoomIn({ duration: 200 })}>
|
|
132
|
+
<ZoomIn className="h-4 w-4" />
|
|
133
|
+
</ControlButton>
|
|
134
|
+
<Divider />
|
|
135
|
+
<LevelSwitch level={level} onChange={onLevelChange} unavailable={unavailableLevels} />
|
|
136
|
+
<Divider />
|
|
137
|
+
<ControlButton label="Undo (⌘Z)" onClick={onUndo} disabled={!editable || !canUndo}>
|
|
138
|
+
<Undo className="h-4 w-4" />
|
|
139
|
+
</ControlButton>
|
|
140
|
+
<ControlButton label="Redo (⇧⌘Z)" onClick={onRedo} disabled={!editable || !canRedo}>
|
|
141
|
+
<Redo className="h-4 w-4" />
|
|
142
|
+
</ControlButton>
|
|
143
|
+
<Divider />
|
|
144
|
+
<ControlButton label="Comment (C)" onClick={onToggleCommentMode} active={commentMode} disabled={!editable}>
|
|
145
|
+
<MessageCircle className="h-4 w-4" />
|
|
146
|
+
</ControlButton>
|
|
147
|
+
<ControlButton label="Add sticky note" onClick={onAddNote} disabled={!editable}>
|
|
148
|
+
<StickyNote className="h-4 w-4 text-yellow-500" />
|
|
149
|
+
</ControlButton>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
});
|