feat(arch): implemented junction nodes

This commit is contained in:
NicolasNewman 2024-05-10 10:10:19 -05:00
parent 0049127bb7
commit b09dc5db67
7 changed files with 213 additions and 27 deletions

View File

@ -16,7 +16,6 @@
<body>
<h1>Architecture diagram demo</h1>
<h2>Simple diagram with groups</h2>
<pre class="mermaid">
architecture
@ -182,6 +181,50 @@
</pre>
<hr />
<h2>Junction Demo</h2>
<pre class="mermaid">
architecture
service left_disk(disk)[Disk]
service top_disk(disk)[Disk]
service bottom_disk(disk)[Disk]
service top_gateway(internet)[Gateway]
service bottom_gateway(internet)[Gateway]
junction juncC
junction juncR
left_disk R--L juncC
top_disk B--T juncC
bottom_disk T--B juncC
juncC R--L juncR
top_gateway B--T juncR
bottom_gateway T--B juncR
</pre>
<hr />
<h2>Junction Demo Groups</h2>
<pre class="mermaid">
architecture
group left
group right
service left_disk(disk)[Disk] in left
service top_disk(disk)[Disk] in left
service bottom_disk(disk)[Disk] in left
service top_gateway(internet)[Gateway] in right
service bottom_gateway(internet)[Gateway] in right
junction juncC in left
junction juncR in right
left_disk R--L juncC
top_disk B--T juncC
bottom_disk T--B juncC
top_gateway (B--T juncR
bottom_gateway (T--B juncR
juncC{group} R--L) juncR{group}
</pre>
<hr />
<script type="module">
import mermaid from './mermaid.esm.mjs';

View File

@ -9,11 +9,15 @@ import type {
ArchitectureDirectionPairMap,
ArchitectureDirectionPair,
ArchitectureSpatialMap,
ArchitectureNode,
ArchitectureJunction,
} from './architectureTypes.js';
import { getConfig } from '../../diagram-api/diagramAPI.js';
import {
getArchitectureDirectionPair,
isArchitectureDirection,
isArchitectureJunction,
isArchitectureService,
shiftPositionByArchitectureDirectionPair,
} from './architectureTypes.js';
import {
@ -34,7 +38,7 @@ const DEFAULT_ARCHITECTURE_CONFIG: Required<ArchitectureDiagramConfig> =
DEFAULT_CONFIG.architecture;
const state = new ImperativeState<ArchitectureState>(() => ({
services: {},
nodes: {},
groups: {},
edges: [],
registeredIds: {},
@ -69,15 +73,16 @@ const addService = function ({
`The service [${id}]'s parent does not exist. Please make sure the parent is created before this service`
);
}
if (state.records.registeredIds[parent] === 'service') {
if (state.records.registeredIds[parent] === 'node') {
throw new Error(`The service [${id}]'s parent is not a group`);
}
}
state.records.registeredIds[id] = 'service';
state.records.registeredIds[id] = 'node';
state.records.services[id] = {
state.records.nodes[id] = {
id,
type: 'service',
icon,
iconText,
title,
@ -86,7 +91,27 @@ const addService = function ({
};
};
const getServices = (): ArchitectureService[] => Object.values(state.records.services);
const getServices = (): ArchitectureService[] => Object.values(state.records.nodes).filter<ArchitectureService>(isArchitectureService);
const addJunction = function ({
id, in: parent
}: Omit<ArchitectureJunction, 'edges'>) {
state.records.registeredIds[id] = 'node';
state.records.nodes[id] = {
id,
type: 'junction',
edges: [],
in: parent,
};
}
const getJunctions = (): ArchitectureJunction[] => Object.values(state.records.nodes).filter<ArchitectureJunction>(isArchitectureJunction);
const getNodes = (): ArchitectureNode[] => Object.values(state.records.nodes);
const getNode = (id: string): ArchitectureNode | null => state.records.nodes[id];
const addGroup = function ({ id, icon, in: parent, title }: ArchitectureGroup) {
if (state.records.registeredIds[id] !== undefined) {
@ -103,7 +128,7 @@ const addGroup = function ({ id, icon, in: parent, title }: ArchitectureGroup) {
`The group [${id}]'s parent does not exist. Please make sure the parent is created before this group`
);
}
if (state.records.registeredIds[parent] === 'service') {
if (state.records.registeredIds[parent] === 'node') {
throw new Error(`The group [${id}]'s parent is not a group`);
}
}
@ -143,19 +168,19 @@ const addEdge = function ({
);
}
if (state.records.services[lhsId] === undefined && state.records.groups[lhsId] === undefined) {
if (state.records.nodes[lhsId] === undefined && state.records.groups[lhsId] === undefined) {
throw new Error(
`The left-hand id [${lhsId}] does not yet exist. Please create the service/group before declaring an edge to it.`
);
}
if (state.records.services[rhsId] === undefined && state.records.groups[lhsId] === undefined) {
if (state.records.nodes[rhsId] === undefined && state.records.groups[lhsId] === undefined) {
throw new Error(
`The right-hand id [${rhsId}] does not yet exist. Please create the service/group before declaring an edge to it.`
);
}
const lhsGroupId = state.records.services[lhsId].in
const rhsGroupId = state.records.services[rhsId].in
const lhsGroupId = state.records.nodes[lhsId].in
const rhsGroupId = state.records.nodes[rhsId].in
if (lhsGroup && lhsGroupId && rhsGroupId && lhsGroupId == rhsGroupId) {
throw new Error(
`The left-hand id [${lhsId}] is modified to traverse the group boundary, but the edge does not pass through two groups.`
@ -180,10 +205,9 @@ const addEdge = function ({
};
state.records.edges.push(edge);
if (state.records.services[lhsId] && state.records.services[rhsId]) {
state.records.services[lhsId].edges.push(state.records.edges[state.records.edges.length - 1]);
state.records.services[rhsId].edges.push(state.records.edges[state.records.edges.length - 1]);
} else if (state.records.groups[lhsId] && state.records.groups[rhsId]) {
if (state.records.nodes[lhsId] && state.records.nodes[rhsId]) {
state.records.nodes[lhsId].edges.push(state.records.edges[state.records.edges.length - 1]);
state.records.nodes[rhsId].edges.push(state.records.edges[state.records.edges.length - 1]);
}
};
@ -199,7 +223,7 @@ const getDataStructures = () => {
// Create an adjacency list of the diagram to perform BFS on
// Outer reduce applied on all services
// Inner reduce applied on the edges for a service
const adjList = Object.entries(state.records.services).reduce<{
const adjList = Object.entries(state.records.nodes).reduce<{
[id: string]: ArchitectureDirectionPairMap;
}>((prevOuter, [id, service]) => {
prevOuter[id] = service.edges.reduce<ArchitectureDirectionPairMap>((prevInner, edge) => {
@ -284,6 +308,10 @@ export const db: ArchitectureDB = {
addService,
getServices,
addJunction,
getJunctions,
getNodes,
getNode,
addGroup,
getGroups,
addEdge,

View File

@ -9,7 +9,8 @@ import { db } from './architectureDb.js';
const populateDb = (ast: Architecture, db: ArchitectureDB) => {
populateCommonDb(ast, db);
ast.groups.map(db.addGroup);
ast.services.map(db.addService);
ast.services.map((service) => db.addService({ ...service, type: 'service' }));
ast.junctions.map((service) => db.addJunction({ ...service, type: 'junction' }));
// @ts-ignore TODO our parser guarantees the type is L/R/T/B and not string. How to change to union type?
ast.edges.map(db.addEdge);
};

View File

@ -13,6 +13,8 @@ import type {
ArchitectureSpatialMap,
EdgeSingularData,
EdgeSingular,
ArchitectureJunction,
NodeSingularData,
} from './architectureTypes.js';
import {
type ArchitectureDB,
@ -29,7 +31,7 @@ import {
} from './architectureTypes.js';
import { select } from 'd3';
import { setupGraphViewbox } from '../../setupGraphViewbox.js';
import { drawEdges, drawGroups, drawServices } from './svgDraw.js';
import { drawEdges, drawGroups, drawJunctions, drawServices } from './svgDraw.js';
import { getConfigField } from './architectureDb.js';
cytoscape.use(fcose);
@ -46,13 +48,29 @@ function addServices(services: ArchitectureService[], cy: cytoscape.Core) {
parent: service.in,
width: getConfigField('iconSize'),
height: getConfigField('iconSize'),
},
} as NodeSingularData,
classes: 'node-service',
});
});
}
function positionServices(db: ArchitectureDB, cy: cytoscape.Core) {
function addJunctions(junctions: ArchitectureJunction[], cy: cytoscape.Core) {
junctions.forEach((junction) => {
cy.add({
group: 'nodes',
data: {
type: 'junction',
id: junction.id,
parent: junction.in,
width: getConfigField('iconSize'),
height: getConfigField('iconSize'),
} as NodeSingularData,
classes: 'node-junction',
});
});
}
function positionNodes(db: ArchitectureDB, cy: cytoscape.Core) {
cy.nodes().map((node) => {
const data = nodeData(node);
if (data.type === 'group') {
@ -76,7 +94,7 @@ function addGroups(groups: ArchitectureGroup[], cy: cytoscape.Core) {
icon: group.icon,
label: group.title,
parent: group.in,
},
} as NodeSingularData,
classes: 'node-group',
});
});
@ -216,6 +234,7 @@ function getRelativeConstraints(
function layoutArchitecture(
services: ArchitectureService[],
junctions: ArchitectureJunction[],
groups: ArchitectureGroup[],
edges: ArchitectureEdge[],
{ spatialMaps }: ArchitectureDataStructures
@ -269,6 +288,13 @@ function layoutArchitecture(
height: 'data(height)',
},
},
{
selector: '.node-junction',
style: {
width: 'data(width)',
height: 'data(height)',
},
},
{
selector: '.node-group',
style: {
@ -283,6 +309,7 @@ function layoutArchitecture(
addGroups(groups, cy);
addServices(services, cy);
addJunctions(junctions, cy);
addEdges(edges, cy);
// Use the spatial map to create alignment arrays for fcose
@ -408,6 +435,7 @@ export const draw: DrawDefinition = async (text, id, _version, diagObj: Diagram)
const db = diagObj.db as ArchitectureDB;
const services = db.getServices();
const junctions = db.getJunctions();
const groups = db.getGroups();
const edges = db.getEdges();
const ds = db.getDataStructures();
@ -427,12 +455,13 @@ export const draw: DrawDefinition = async (text, id, _version, diagObj: Diagram)
groupElem.attr('class', 'architecture-groups');
drawServices(db, servicesElem, services);
drawJunctions(db, servicesElem, junctions);
const cy = await layoutArchitecture(services, groups, edges, ds);
const cy = await layoutArchitecture(services, junctions, groups, edges, ds);
drawEdges(edgesElem, cy);
drawGroups(groupElem, cy);
positionServices(db, cy);
positionNodes(db, cy);
setupGraphViewbox(undefined, svg, getConfigField('padding'), getConfigField('useMaxWidth'));

View File

@ -180,6 +180,7 @@ export interface ArchitectureStyleOptions {
export interface ArchitectureService {
id: string;
type: 'service';
edges: ArchitectureEdge[];
icon?: string;
iconText?: string;
@ -189,6 +190,27 @@ export interface ArchitectureService {
height?: number;
}
export interface ArchitectureJunction {
id: string;
type: 'junction';
edges: ArchitectureEdge[];
in?: string;
width?: number;
height?: number;
}
export type ArchitectureNode = ArchitectureService | ArchitectureJunction;
export const isArchitectureService = function (x: ArchitectureNode): x is ArchitectureService {
const temp = x as ArchitectureService;
return temp.type === 'service';
};
export const isArchitectureJunction = function (x: ArchitectureNode): x is ArchitectureJunction {
const temp = x as ArchitectureJunction;
return temp.type === 'junction';
};
export interface ArchitectureGroup {
id: string;
icon?: string;
@ -212,6 +234,10 @@ export interface ArchitectureDB extends DiagramDB {
clear: () => void;
addService: (service: Omit<ArchitectureService, 'edges'>) => void;
getServices: () => ArchitectureService[];
addJunction: (service: Omit<ArchitectureJunction, 'edges'>) => void;
getJunctions: () => ArchitectureJunction[];
getNodes: () => ArchitectureNode[];
getNode: (id: string) => ArchitectureNode | null;
addGroup: (group: ArchitectureGroup) => void;
getGroups: () => ArchitectureGroup[];
addEdge: (edge: ArchitectureEdge) => void;
@ -229,10 +255,10 @@ export type ArchitectureDataStructures = {
};
export interface ArchitectureState extends Record<string, unknown> {
services: Record<string, ArchitectureService>;
nodes: Record<string, ArchitectureNode>;
groups: Record<string, ArchitectureGroup>;
edges: ArchitectureEdge[];
registeredIds: Record<string, 'service' | 'group'>;
registeredIds: Record<string, 'node' | 'group'>;
dataStructures?: ArchitectureDataStructures;
elements: Record<string, D3Element>;
config: ArchitectureDiagramConfig;
@ -287,6 +313,14 @@ export type NodeSingularData =
height: number;
[key: string]: any;
}
| {
type: 'junction';
id: string;
parent?: string;
width: number;
height: number;
[key: string]: any;
}
| {
type: 'group';
id: string;

View File

@ -15,24 +15,27 @@ import {
getArchitectureDirectionPair,
getArchitectureDirectionXYFactors,
isArchitecturePairXY,
ArchitectureJunction,
} from './architectureTypes.js';
import type cytoscape from 'cytoscape';
import { getIcon } from '../../rendering-util/svgRegister.js';
import { getConfigField } from './architectureDb.js';
import { db, getConfigField } from './architectureDb.js';
import { getConfig } from '../../diagram-api/diagramAPI.js';
export const drawEdges = function (edgesEl: D3Element, cy: cytoscape.Core) {
const padding = getConfigField('padding');
const iconSize = getConfigField('iconSize');
const halfIconSize = iconSize / 2;
const arrowSize = iconSize / 6;
const halfArrowSize = arrowSize / 2;
cy.edges().map((edge, id) => {
const { sourceDir, sourceArrow, sourceGroup, targetDir, targetArrow, targetGroup, label } = edgeData(edge);
const { source, sourceDir, sourceArrow, sourceGroup, target, targetDir, targetArrow, targetGroup, label } = edgeData(edge);
let { x: startX, y: startY } = edge[0].sourceEndpoint();
const { x: midX, y: midY } = edge[0].midpoint();
let { x: endX, y: endY } = edge[0].targetEndpoint();
// Adjust the edge distance if it has the {group} modifier
const groupEdgeShift = padding + 4;
// +18 comes from the service label height that extends the padding on the bottom side of each group
if (sourceGroup) {
@ -51,6 +54,22 @@ export const drawEdges = function (edgesEl: D3Element, cy: cytoscape.Core) {
}
}
// Adjust the edge distance if it doesn't have the {group} modifier and the endpoint is a junction node
if (!sourceGroup && db.getNode(source)?.type === 'junction') {
if (isArchitectureDirectionX(sourceDir)) {
sourceDir === 'L' ? startX += halfIconSize : startX -= halfIconSize;
} else {
sourceDir === 'T' ? startY += halfIconSize : startY -= halfIconSize;
}
}
if (!targetGroup && db.getNode(target)?.type === 'junction') {
if (isArchitectureDirectionX(targetDir)) {
targetDir === 'L' ? endX += halfIconSize : endX -= halfIconSize;
} else {
targetDir === 'T' ? endY += halfIconSize : endY -= halfIconSize;
}
}
if (edge[0]._private.rscratch) {
// const bounds = edge[0]._private.rscratch;
@ -305,3 +324,30 @@ export const drawServices = function (
});
return 0;
};
export const drawJunctions = function (
db: ArchitectureDB,
elem: D3Element,
junctions: ArchitectureJunction[]
) {
junctions.forEach((junction) => {
const junctionElem = elem.append('g');
const iconSize = getConfigField('iconSize');
let bkgElem = junctionElem.append('g');
bkgElem
.append('rect')
.attr('id', 'node-' + junction.id)
.attr('fill-opacity', '0')
.attr('width', iconSize)
.attr('height', iconSize);
junctionElem.attr('class', 'architecture-junction');
const { width, height } = junctionElem._groups[0][0].getBBox();
junctionElem.width = width;
junctionElem.height = height;
db.setElementForId(junction.id, junctionElem);
});
}

View File

@ -14,6 +14,7 @@ entry Architecture:
fragment Statement:
groups+=Group
| services+=Service
| junctions+=Junction
| edges+=Edge
;
@ -29,6 +30,10 @@ Service:
'service' id=ARCH_ID (iconText=ARCH_TEXT_ICON | icon=ARCH_ICON)? title=ARCH_TITLE? ('in' in=ARCH_ID)? EOL
;
Junction:
'junction' id=ARCH_ID ('in' in=ARCH_ID)? EOL
;
Edge:
lhsId=ARCH_ID lhsGroup?=ARROW_GROUP? Arrow rhsId=ARCH_ID rhsGroup?=ARROW_GROUP? EOL
;