feat(arch): improved positioning system to better handle edge cases

This commit is contained in:
NicolasNewman
2024-04-03 13:45:41 -05:00
parent f47bbee24a
commit 36f52be3bf
4 changed files with 279 additions and 116 deletions

View File

@@ -5,9 +5,11 @@ import type {
ArchitectureGroup,
ArchitectureDirection,
ArchitectureLine,
ArchitectureDirectionPairMap,
ArchitectureDirectionPair,
} from './architectureTypes.js';
import { getConfig } from '../../diagram-api/diagramAPI.js';
import { isArchitectureDirection } from './architectureTypes.js';
import { getArchitectureDirectionPair, isArchitectureDirection, shiftPositionByArchitectureDirectionPair } from './architectureTypes.js';
import {
setAccTitle,
getAccTitle,
@@ -24,7 +26,7 @@ import type { D3Element } from '../../mermaidAPI.js';
export const DEFAULT_ARCHITECTURE_CONFIG: Required<ArchitectureDiagramConfig> =
DEFAULT_CONFIG.architecture;
export const DEFAULT_ARCHITECTURE_DB: ArchitectureFields = {
services: [],
services: {},
groups: [],
lines: [],
registeredIds: {},
@@ -35,18 +37,20 @@ let services = DEFAULT_ARCHITECTURE_DB.services;
let groups = DEFAULT_ARCHITECTURE_DB.groups;
let lines = DEFAULT_ARCHITECTURE_DB.lines;
let registeredIds = DEFAULT_ARCHITECTURE_DB.registeredIds;
let datastructures = DEFAULT_ARCHITECTURE_DB.datastructures;
let elements: Record<string, D3Element> = {};
const clear = (): void => {
services = structuredClone(DEFAULT_ARCHITECTURE_DB.services);
groups = structuredClone(DEFAULT_ARCHITECTURE_DB.groups);
lines = structuredClone(DEFAULT_ARCHITECTURE_DB.lines);
registeredIds = structuredClone(DEFAULT_ARCHITECTURE_DB.registeredIds)
registeredIds = structuredClone(DEFAULT_ARCHITECTURE_DB.registeredIds);
datastructures = undefined;
elements = {};
commonClear();
};
const addService = function (id: string, opts: Omit<ArchitectureService, 'id'> = {}) {
const addService = function (id: string, opts: Omit<ArchitectureService, 'id' | 'edges'> = {}) {
const { icon, in: inside, title } = opts;
if (registeredIds[id] !== undefined) {
throw new Error(`The service id [${id}] is already in use by another ${registeredIds[id]}`)
@@ -65,14 +69,16 @@ const addService = function (id: string, opts: Omit<ArchitectureService, 'id'> =
registeredIds[id] = 'service';
services.push({
services[id] = {
id,
icon,
title,
edges: [],
in: inside,
});
};
};
const getServices = (): ArchitectureService[] => services;
const getServices = (): ArchitectureService[] => Object.values(services);
const addGroup = function (id: string, opts: Omit<ArchitectureGroup, 'id'> = {}) {
const { icon, in: inside, title } = opts;
@@ -100,7 +106,63 @@ const addGroup = function (id: string, opts: Omit<ArchitectureGroup, 'id'> = {})
in: inside,
});
};
const getGroups = (): ArchitectureGroup[] => groups;
const getGroups = (): ArchitectureGroup[] => {
return groups
};
const getDataStructures = () => {
console.log('===== createSpatialMap =====')
if (datastructures === undefined) {
// 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(services).reduce<{[id: string]: ArchitectureDirectionPairMap}>((prev, [id, service]) => {
prev[id] = service.edges.reduce<ArchitectureDirectionPairMap>((prev, edge) => {
if (edge.lhs_id === id) { // source is LHS
const pair = getArchitectureDirectionPair(edge.lhs_dir, edge.rhs_dir);
if (pair) {
prev[pair] = edge.rhs_id
}
} else { // source is RHS
const pair = getArchitectureDirectionPair(edge.rhs_dir, edge.lhs_dir);
if (pair) {
prev[pair] = edge.lhs_id
}
}
return prev;
}, {})
return prev
}, {});
const [firstId, _] = Object.entries(adjList)[0];
const spatialMap = {[firstId]: [0,0]};
const visited = {[firstId]: 1};
const queue = [firstId];
// Perform BFS on adjacency list
while(queue.length > 0) {
const id = queue.shift();
if (id) {
visited[id] = 1
const adj = adjList[id];
const [posX, posY] = spatialMap[id];
Object.entries(adj).forEach(([dir, rhsId]) => {
if (!visited[rhsId]) {
console.log(`${id} -- ${rhsId}`);
spatialMap[rhsId] = shiftPositionByArchitectureDirectionPair([posX, posY], dir as ArchitectureDirectionPair)
queue.push(rhsId);
}
})
}
}
datastructures = {
adjList,
spatialMap
}
console.log(datastructures)
}
return datastructures;
}
const addLine = function (
lhs_id: string,
@@ -110,7 +172,6 @@ const addLine = function (
opts: Omit<ArchitectureLine, 'lhs_id' | 'lhs_dir' | 'rhs_id' | 'rhs_dir'> = {}
) {
const { title, lhs_into, rhs_into } = opts;
if (!isArchitectureDirection(lhs_dir)) {
throw new Error(
`Invalid direction given for left hand side of line ${lhs_id}--${rhs_id}. Expected (L,R,T,B) got ${lhs_dir}`
@@ -121,8 +182,18 @@ const addLine = function (
`Invalid direction given for right hand side of line ${lhs_id}--${rhs_id}. Expected (L,R,T,B) got ${rhs_dir}`
);
}
if (services[lhs_id] === undefined) {
throw new Error(
`The left-hand service [${lhs_id}] does not yet exist. Please create the service before declaring an edge to it.`
);
}
if (services[rhs_id] === undefined) {
throw new Error(
`The right-hand service [${rhs_id}] does not yet exist. Please create the service before declaring an edge to it.`
);
}
lines.push({
const edge = {
lhs_id,
lhs_dir,
rhs_id,
@@ -130,7 +201,12 @@ const addLine = function (
title,
lhs_into,
rhs_into,
});
}
lines.push(edge);
services[lhs_id].edges.push(lines[lines.length - 1])
services[rhs_id].edges.push(lines[lines.length - 1])
};
const getLines = (): ArchitectureLine[] => lines;
@@ -156,6 +232,7 @@ export const db: ArchitectureDB = {
getLines,
setElementForId,
getElementById,
getDataStructures,
};
function getConfigField<T extends keyof ArchitectureDiagramConfig>(field: T): Required<ArchitectureDiagramConfig>[T] {

View File

@@ -14,12 +14,18 @@ import {
type ArchitectureLine,
type ArchitectureService,
isArchitectureDirectionY,
ArchitectureDataStructures,
ArchitectureDirectionPair,
isArchitectureDirectionXY,
ArchitectureDirectionName,
getOppositeArchitectureDirection,
} from './architectureTypes.js';
import { select } from 'd3';
import { setupGraphViewbox } from '../../setupGraphViewbox.js';
import type { D3Element } from '../../mermaidAPI.js';
import { drawEdges, drawGroups, drawService } from './svgDraw.js';
import { getConfigField } from './architectureDb.js';
import { X } from 'vitest/dist/reporters-5f784f42.js';
cytoscape.use(fcose);
@@ -97,7 +103,8 @@ function addEdges(lines: ArchitectureLine[], cy: cytoscape.Core) {
function layoutArchitecture(
services: ArchitectureService[],
groups: ArchitectureGroup[],
lines: ArchitectureLine[]
lines: ArchitectureLine[],
{adjList, spatialMap}: ArchitectureDataStructures
): Promise<cytoscape.Core> {
return new Promise((resolve) => {
const renderEl = select('body').append('div').attr('id', 'cy').attr('style', 'display:none');
@@ -151,72 +158,76 @@ function layoutArchitecture(
addServices(services, cy);
addEdges(lines, cy);
/**
* Merge alignment pairs together if they share a common node.
*
* Example: [["a", "b"], ["b", "c"], ["d", "e"]] -> [["a", "b", "c"], ["d", "e"]]
*/
const mergeAlignments = (orig: string[][]): string[][] => {
if (orig.length < 1) return orig;
console.log('===== mergeAlignments =====');
console.log('Start: ', orig);
// Mapping of discovered ids to their index in the new alignment array
const map: Record<string, number> = {};
const newAlignments: string[][] = [orig[0]];
map[orig[0][0]] = 0;
map[orig[0][1]] = 0;
orig = orig.slice(1);
while (orig.length > 0) {
const pair = orig[0];
const pairLHSIdx = map[pair[0]];
const pairRHSIdx = map[pair[1]];
console.log(pair);
console.log(map);
console.log(newAlignments);
// If neither id appears in the new array, add the pair to the new array
if (pairLHSIdx === undefined && pairRHSIdx === undefined) {
newAlignments.push(pair);
map[pair[0]] = newAlignments.length - 1;
map[pair[1]] = newAlignments.length - 1;
// If the LHS of the pair doesn't appear in the new array, add the LHS to the existing array it shares an id with
} else if (pairLHSIdx === undefined) {
newAlignments[pairRHSIdx].push(pair[0]);
map[pair[0]] = pairRHSIdx;
// If the RHS of the pair doesn't appear in the new array, add the RHS to the existing array it shares an id with
} else if (pairRHSIdx === undefined) {
newAlignments[pairLHSIdx].push(pair[1]);
map[pair[1]] = pairLHSIdx;
// If both ids already have been added to the new array and their index is different, merge all 3 arrays
} else if (pairLHSIdx != pairRHSIdx) {
console.log('ELSE');
newAlignments.push(pair);
}
orig = orig.slice(1);
// Use the spatial map to create alignment arrays for fcose
const [horizontalAlignments, verticalAlignments] = (() => {
const _horizontalAlignments: Record<number, string[]> = {}
const _verticalAlignments: Record<number, string[]> = {}
// Group service ids in an object with their x and y coordinate as the key
Object.entries(spatialMap).forEach(([id, [x, y]]) => {
if (!_horizontalAlignments[y]) _horizontalAlignments[y] = [];
if (!_verticalAlignments[x]) _verticalAlignments[x] = [];
_horizontalAlignments[y].push(id);
_verticalAlignments[x].push(id);
})
// Merge the values of each object into a list if the inner list has at least 2 elements
return [
Object.values(_horizontalAlignments).filter(arr => arr.length > 1),
Object.values(_verticalAlignments).filter(arr => arr.length > 1)
]
})();
// Create the relative constraints for fcose by using an inverse of the spatial map and performing BFS on it
const relativeConstraints = (() => {
const _relativeConstraints: fcose.FcoseRelativePlacementConstraint[] = []
const posToStr = (pos: number[]) => `${pos[0]},${pos[1]}`
const strToPos = (pos: string) => pos.split(',').map(p => parseInt(p));
const invSpatialMap = Object.fromEntries(Object.entries(spatialMap).map(([id, pos]) => [posToStr(pos), id]))
console.log('===== invSpatialMap =====')
console.log(invSpatialMap);
// perform BFS
const queue = [posToStr([0,0])];
const visited: Record<string, number> = {};
const directions: Record<ArchitectureDirection, number[]> = {
"L": [-1, 0],
"R": [1, 0],
"T": [0, 1],
"B": [0, -1]
}
console.log('End: ', newAlignments);
console.log('===========================');
return newAlignments;
};
const horizontalAlignments = cy
.edges()
.filter(
(edge) =>
isArchitectureDirectionX(edge.data('sourceDir')) &&
isArchitectureDirectionX(edge.data('targetDir'))
)
.map((edge) => [edge.data('source'), edge.data('target')]);
const verticalAlignments = cy
.edges()
.filter(
(edge) =>
isArchitectureDirectionY(edge.data('sourceDir')) &&
isArchitectureDirectionY(edge.data('targetDir'))
)
.map((edge) => [edge.data('source'), edge.data('target')]);
while (queue.length > 0) {
const curr = queue.shift();
if (curr) {
visited[curr] = 1
const currId = invSpatialMap[curr];
if (currId) {
const currPos = strToPos(curr);
Object.entries(directions).forEach(([dir, shift]) => {
const newPos = posToStr([(currPos[0]+shift[0]), (currPos[1]+shift[1])]);
const newId = invSpatialMap[newPos];
// If there is an adjacent service to the current one and it has not yet been visited
if (newId && !visited[newPos]) {
queue.push(newPos);
// @ts-ignore cannot determine if left/right or top/bottom are paired together
_relativeConstraints.push({
[ArchitectureDirectionName[dir as ArchitectureDirection]]: newId,
[ArchitectureDirectionName[getOppositeArchitectureDirection(dir as ArchitectureDirection)]]: currId,
gap: 100
})
}
})
}
}
}
return _relativeConstraints;
})();
console.log(`Horizontal Alignments:`)
console.log(horizontalAlignments);
console.log(`Vertical Alignments:`)
console.log(verticalAlignments);
console.log(`Relative Alignments:`)
console.log(relativeConstraints);
cy.layout({
name: 'fcose',
@@ -242,34 +253,10 @@ function layoutArchitecture(
return elasticity
},
alignmentConstraint: {
horizontal: mergeAlignments(horizontalAlignments),
vertical: mergeAlignments(verticalAlignments),
horizontal: horizontalAlignments,
vertical: verticalAlignments,
},
relativePlacementConstraint: cy.edges().map((edge) => {
const sourceDir = edge.data('sourceDir') as ArchitectureDirection;
const targetDir = edge.data('targetDir') as ArchitectureDirection;
const sourceId = edge.data('source') as string;
const targetId = edge.data('target') as string;
let gap = 1.25*getConfigField('iconSize');
console.log(`relativeConstraint: ${sourceId} ${sourceDir}--${targetDir} ${targetId} (gap=${gap})`);
if (isArchitectureDirectionX(sourceDir) && isArchitectureDirectionX(targetDir)) {
return {
left: sourceDir === 'R' ? sourceId : targetId,
right: sourceDir === 'L' ? sourceId : targetId,
gap,
};
} else if (isArchitectureDirectionY(sourceDir) && isArchitectureDirectionY(targetDir)) {
return {
top: sourceDir === 'B' ? sourceId : targetId,
bottom: sourceDir === 'T' ? sourceId : targetId,
gap,
};
} else {
console.log('FALLBACK CASE NEEDED')
}
// TODO: fallback case + RB, TL, etc
}),
relativePlacementConstraint: relativeConstraints
} as FcoseLayoutOptions).run();
cy.ready((e) => {
log.info('Ready', e);
@@ -285,6 +272,7 @@ export const draw: DrawDefinition = async (text, id, _version, diagObj: Diagram)
const services = db.getServices();
const groups = db.getGroups();
const lines = db.getLines();
const ds = db.getDataStructures();
console.log('Services: ', services);
console.log('Lines: ', lines);
console.log('Groups: ', groups);
@@ -302,7 +290,7 @@ export const draw: DrawDefinition = async (text, id, _version, diagObj: Diagram)
drawServices(db, servicesElem, services, conf);
const cy = await layoutArchitecture(services, groups, lines);
const cy = await layoutArchitecture(services, groups, lines, ds);
console.log(cy.nodes().map(node => ({a: node.data()})));
drawEdges(edgesElem, cy);

View File

@@ -3,29 +3,104 @@ import type { ArchitectureDiagramConfig } from '../../config.type.js';
import type { D3Element } from '../../mermaidAPI.js';
export type ArchitectureDirection = 'L' | 'R' | 'T' | 'B';
export type ArchitectureDirectionX = Extract<ArchitectureDirection, 'L' | 'R'>
export type ArchitectureDirectionY = Extract<ArchitectureDirection, 'T' | 'B'>
export const ArchitectureDirectionName = {
'L': 'left',
'R': 'right',
'T': 'top',
'B': 'bottom',
} as const;
export const getOppositeArchitectureDirection = function(x: ArchitectureDirection): ArchitectureDirection {
if (isArchitectureDirectionX(x)) {
return x === 'L' ? 'R' : 'L'
} else {
return x === 'T' ? 'B' : 'T'
}
}
export const isArchitectureDirection = function (x: unknown): x is ArchitectureDirection {
const temp = x as ArchitectureDirection;
return temp === 'L' || temp === 'R' || temp === 'T' || temp === 'B';
};
export const isArchitectureDirectionX = function (
x: ArchitectureDirection
): x is Extract<ArchitectureDirection, 'L' | 'R'> {
const temp = x as Extract<ArchitectureDirection, 'L' | 'R'>;
): x is ArchitectureDirectionX {
const temp = x as ArchitectureDirectionX;
return temp === 'L' || temp === 'R';
};
export const isArchitectureDirectionY = function (
x: ArchitectureDirection
): x is Extract<ArchitectureDirection, 'T' | 'B'> {
const temp = x as Extract<ArchitectureDirection, 'T' | 'B'>;
): x is ArchitectureDirectionY {
const temp = x as ArchitectureDirectionY;
return temp === 'T' || temp === 'B';
};
export const isArchitectureDirectionXY = function (
a: ArchitectureDirection,
b: ArchitectureDirection,
) {
const aX_bY = isArchitectureDirectionX(a) && isArchitectureDirectionY(b);
const aY_bX = isArchitectureDirectionY(a) && isArchitectureDirectionX(b);
return aX_bY || aY_bX;
};
/**
* Contains LL, RR, TT, BB which are impossible conections
*/
export type InvalidArchitectureDirectionPair = `${ArchitectureDirection}${ArchitectureDirection}`
export type ArchitectureDirectionPair = Exclude<InvalidArchitectureDirectionPair, 'LL' | 'RR' | 'TT' | 'BB'>
export const isValidArchitectureDirectionPair = function(x: InvalidArchitectureDirectionPair): x is ArchitectureDirectionPair {
return x !== 'LL' && x !== 'RR' && x !== 'TT' && x !== 'BB'
}
export type ArchitectureDirectionPairMap = {
[key in ArchitectureDirectionPair]?: string
}
/**
* Creates a pair of the directions of each side of an edge. This function should be used instead of manually creating it to ensure that the source is always the first character.
*
* Note: Undefined is returned when sourceDir and targetDir are the same. In theory this should never happen since the diagram parser throws an error if a user defines it as such.
* @param sourceDir
* @param targetDir
* @returns
*/
export const getArchitectureDirectionPair = function (sourceDir: ArchitectureDirection, targetDir: ArchitectureDirection): ArchitectureDirectionPair | undefined {
const pair: `${ArchitectureDirection}${ArchitectureDirection}` = `${sourceDir}${targetDir}`;
return isValidArchitectureDirectionPair(pair) ? pair : undefined
}
export const shiftPositionByArchitectureDirectionPair = function ([x, y]: number[], pair: ArchitectureDirectionPair): number[] {
const lhs = pair[0] as ArchitectureDirection;
const rhs = pair[1] as ArchitectureDirection;
console.log(`${pair}: (${x},${y})`);
if (isArchitectureDirectionX(lhs)) {
if (isArchitectureDirectionY(rhs)) {
return [x + (lhs === 'L' ? -1 : 1), y + (rhs === 'T' ? 1 : -1)]
} else {
return [x + (lhs === 'L' ? -1 : 1), y]
}
} else {
if (isArchitectureDirectionX(rhs)) {
return [x + (rhs === 'L' ? 1 : -1), y + (lhs === 'T' ? 1 : -1)]
} else {
return [x, y + (lhs === 'T' ? 1 : -1)]
}
}
}
export interface ArchitectureStyleOptions {
fontFamily: string;
}
export interface ArchitectureService {
id: string;
edges: ArchitectureLine[];
icon?: string;
title?: string;
in?: string;
@@ -66,12 +141,21 @@ export interface ArchitectureDB extends DiagramDB {
getLines: () => ArchitectureLine[];
setElementForId: (id: string, element: D3Element) => void;
getElementById: (id: string) => D3Element;
getDataStructures: () => ArchitectureDataStructures;
}
export type ArchitectureAdjacencyList = {[id: string]: ArchitectureDirectionPairMap}
export type ArchitectureSpatialMap = Record<string, number[]>
export type ArchitectureDataStructures = {
adjList: ArchitectureAdjacencyList;
spatialMap: ArchitectureSpatialMap;
}
export interface ArchitectureFields {
services: ArchitectureService[];
services: Record<string, ArchitectureService>;
groups: ArchitectureGroup[];
lines: ArchitectureLine[];
registeredIds: Record<string, 'service' | 'group'>;
datastructures?: ArchitectureDataStructures;
config: ArchitectureDiagramConfig;
}

View File

@@ -2,6 +2,7 @@ import type { D3Element } from '../../mermaidAPI.js';
import { createText } from '../../rendering-util/createText.js';
import type {
ArchitectureDB,
ArchitectureDirection,
ArchitectureService,
} from './architectureTypes.js';
import type { MermaidConfig } from '../../config.type.js';
@@ -10,7 +11,16 @@ import { log } from '../../logger.js';
import { getIcon, isIconNameInUse } from '../../rendering-util/svgRegister.js';
import { getConfigField } from './architectureDb.js';
declare module 'cytoscape' {
type _EdgeSingularData = {
id: string;
source: string;
sourceDir: ArchitectureDirection;
target: string;
targetDir: ArchitectureDirection;
[key: string]: any;
}
interface EdgeSingular {
_private: {
bodyBounds: unknown;
@@ -23,6 +33,9 @@ declare module 'cytoscape' {
endY: number;
};
};
// data: (() => _EdgeSingularData) | (<T extends keyof _EdgeSingularData>(key: T) => _EdgeSingularData[T])
data(): _EdgeSingularData
data<T extends keyof _EdgeSingularData>(key: T): _EdgeSingularData[T]
}
interface NodeSingular {
_private: {
@@ -84,6 +97,7 @@ export const drawGroups = function (groupsEl: D3Element, cy: cytoscape.Core) {
const data = node.data();
if (data.type === 'group') {
const { h, w, x1, x2, y1, y2 } = node.boundingBox();
console.log(`Draw group (${data.id}): pos=(${x1}, ${y1}), dim=(${w}, ${h})`)
let bkgElem = groupsEl
.append('rect')
.attr('x', x1 + halfIconSize)
@@ -122,7 +136,7 @@ export const drawService = function (
const textElem = serviceElem.append('g');
createText(textElem, service.title, {
useHtmlLabels: false,
width: 110,
width: iconSize * 1.5,
classes: 'architecture-service-label',
});
textElem
@@ -133,16 +147,16 @@ export const drawService = function (
textElem.attr(
'transform',
// TODO: dynamic size
'translate(' + (iconSize / 2) + ', ' + iconSize + ')'
);
}
let bkgElem = serviceElem.append('g');
if (service.icon) {
if (!isIconNameInUse(service.icon)) {
throw new Error(`Invalid SVG Icon name: "${service.icon}"`);
}
// TODO: should a warning be given to end-users saying which icon names are available?
// if (!isIconNameInUse(service.icon)) {
// throw new Error(`Invalid SVG Icon name: "${service.icon}"`);
// }
bkgElem = getIcon(service.icon)?.(bkgElem, iconSize);
} else {
bkgElem
@@ -157,7 +171,7 @@ export const drawService = function (
const { width, height } = serviceElem._groups[0][0].getBBox();
service.width = width;
service.height = height;
console.log(`Draw service (${service.id})`)
db.setElementForId(service.id, serviceElem);
return 0;
};