Restructure to skill-manager conventions
- Move the TypeScript project under scripts/ (src, tsconfig, package.json, pnpm lockfile/workspace, canonical .gitignore); drop the npm package-lock - Add scripts/Taskfile.yml aggregator plus .scripts modules (loggers, base, cli) with build, run, and validate tasks - Move the six SKILL-*.md docs into references/ with kebab names and extract the connector-routing sections from SKILL.md into references/routing-best-practices.md (SKILL.md 666 -> ~310 lines) - Add license/metadata/compatibility frontmatter, an Available scripts section, and update all CLI paths in README and references skill-manager validate: 13/13 passed, 0 warnings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* connector-router — computes the actual routed path for draw.io connectors.
|
||||
*
|
||||
* Strategy: load the diagram XML into a real maxGraph Graph+GraphView instance
|
||||
* via graph-loader, which calls GraphView.validate() to compute all cell states
|
||||
* exactly as draw.io does at render time. Edge absolutePoints are taken directly
|
||||
* from those states — no synthetic geometry approximation.
|
||||
*
|
||||
* This is the only correct approach: draw.io's routing algorithms (especially
|
||||
* OrthConnector for edgeStyle=orthogonalEdgeStyle) depend on the full graph
|
||||
* state including parent container bounds, so they cannot be reproduced
|
||||
* accurately without loading the full model.
|
||||
*/
|
||||
|
||||
import { loadGraphStates } from "../maxgraph-loader/graph-loader.js";
|
||||
import type { Edge } from "../drawio-parser/parser.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RoutedPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a map of edgeId → routed points for ALL edges in the diagram.
|
||||
*
|
||||
* Call this once per diagram page and reuse the result for all edges.
|
||||
* Using `routeAllEdges` is significantly more efficient than calling
|
||||
* `routeEdge` in a loop because it runs `GraphView.validate()` only once.
|
||||
*
|
||||
* @param graphModelXml - Raw <mxGraphModel> XML for the page
|
||||
* @returns Map of edge id → array of canvas-absolute routed points
|
||||
*/
|
||||
export function routeAllEdges(graphModelXml: string): Map<string, RoutedPoint[]> {
|
||||
const { edgeRoutes } = loadGraphStates(graphModelXml);
|
||||
const result = new Map<string, RoutedPoint[]>();
|
||||
for (const [id, route] of edgeRoutes) {
|
||||
result.set(id, route.points);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get routed points for a single edge from a pre-computed route map.
|
||||
*
|
||||
* For efficiency, pre-compute the map once using `routeAllEdges` and pass it here.
|
||||
* Falls back to a straight line between shape centers if the edge is not found.
|
||||
*
|
||||
* @param edge - The edge to route
|
||||
* @param routeMap - Pre-computed map from `routeAllEdges`
|
||||
* @param shapeMidpoints - Optional fallback: map of shapeId → {midX, midY}
|
||||
*/
|
||||
export function getEdgeRoute(
|
||||
edge: Edge,
|
||||
routeMap: Map<string, RoutedPoint[]>,
|
||||
shapeMidpoints?: Map<string, { midX: number; midY: number }>
|
||||
): RoutedPoint[] {
|
||||
const pts = routeMap.get(edge.id);
|
||||
if (pts && pts.length >= 2) return pts;
|
||||
|
||||
// Fallback: straight line using shape midpoints
|
||||
if (shapeMidpoints && edge.sourceId && edge.targetId) {
|
||||
const src = shapeMidpoints.get(edge.sourceId);
|
||||
const tgt = shapeMidpoints.get(edge.targetId);
|
||||
if (src && tgt) {
|
||||
return [{ x: src.midX, y: src.midY }, { x: tgt.midX, y: tgt.midY }];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Shared helper — build a plain serialisable summary object for a single ParsedPage.
|
||||
* Used by both diagram-page-summary and diagram-summary actions.
|
||||
*/
|
||||
|
||||
import type { ParsedPage } from "./parser.js";
|
||||
|
||||
export interface PageSummaryResult {
|
||||
pageIndex: number;
|
||||
pageName: string;
|
||||
page: { width: number; height: number };
|
||||
summary: {
|
||||
shapeCount: number;
|
||||
edgeCount: number;
|
||||
contentRight: number;
|
||||
contentBottom: number;
|
||||
};
|
||||
shapes: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
midX: number;
|
||||
midY: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}>;
|
||||
edges: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
sourceId: string | null;
|
||||
targetId: string | null;
|
||||
waypointCount: number;
|
||||
waypoints: Array<{ x: number; y: number }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function buildPageSummary(page: ParsedPage): PageSummaryResult {
|
||||
const shapes = Array.from(page.shapes.values()).map((s) => ({
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
x: s.x,
|
||||
y: s.y,
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
midX: Math.round(s.midX * 10) / 10,
|
||||
midY: Math.round(s.midY * 10) / 10,
|
||||
x2: Math.round(s.x2 * 10) / 10,
|
||||
y2: Math.round(s.y2 * 10) / 10,
|
||||
}));
|
||||
|
||||
const edges = page.edges.map((e) => ({
|
||||
id: e.id,
|
||||
label: e.label,
|
||||
sourceId: e.sourceId,
|
||||
targetId: e.targetId,
|
||||
waypointCount: e.waypoints.length,
|
||||
waypoints: e.waypoints.map((w) => ({ x: w.x, y: w.y })),
|
||||
}));
|
||||
|
||||
const contentRight = shapes.reduce((max, s) => Math.max(max, s.x2), 0);
|
||||
const contentBottom = shapes.reduce((max, s) => Math.max(max, s.y2), 0);
|
||||
|
||||
return {
|
||||
pageIndex: page.pageIndex,
|
||||
pageName: page.pageName,
|
||||
page: { width: page.pageWidth, height: page.pageHeight },
|
||||
summary: {
|
||||
shapeCount: shapes.length,
|
||||
edgeCount: edges.length,
|
||||
contentRight: Math.round(contentRight * 10) / 10,
|
||||
contentBottom: Math.round(contentBottom * 10) / 10,
|
||||
},
|
||||
shapes,
|
||||
edges,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* DrawioParser — parse a .drawio file into shapes and edges with absolute coordinates.
|
||||
*
|
||||
* Uses @maxgraph/core (the TypeScript successor to mxGraph) with a jsdom DOM polyfill
|
||||
* so that maxGraph can run in Node.js.
|
||||
*
|
||||
* Supports both:
|
||||
* - bare <mxGraphModel> XML files (single page)
|
||||
* - <mxfile><diagram>…</diagram></mxfile> wrappers (draw.io desktop format,
|
||||
* where each <diagram> body is base64+deflate encoded, multi-page supported)
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { inflateRaw } from "pako";
|
||||
import { JSDOM } from "jsdom";
|
||||
import {
|
||||
GraphDataModel,
|
||||
ModelXmlSerializer,
|
||||
type Cell,
|
||||
type CellStyle,
|
||||
} from "@maxgraph/core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// One-time jsdom DOM polyfill — maxGraph calls addEventListener, DOMParser, etc.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
(function setupDomPolyfill() {
|
||||
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
|
||||
const w = dom.window as unknown as Record<string, unknown>;
|
||||
|
||||
// Assign globals that maxGraph expects in a browser environment
|
||||
(globalThis as Record<string, unknown>)["window"] = w;
|
||||
(globalThis as Record<string, unknown>)["document"] = dom.window.document;
|
||||
(globalThis as Record<string, unknown>)["DOMParser"] = dom.window.DOMParser;
|
||||
(globalThis as Record<string, unknown>)["XMLSerializer"] = dom.window.XMLSerializer;
|
||||
(globalThis as Record<string, unknown>)["Element"] = dom.window.Element;
|
||||
(globalThis as Record<string, unknown>)["HTMLElement"] = dom.window.HTMLElement;
|
||||
(globalThis as Record<string, unknown>)["Node"] = dom.window.Node;
|
||||
(globalThis as Record<string, unknown>)["Event"] = dom.window.Event;
|
||||
|
||||
// globalThis.navigator is non-configurable in some Node versions — use defineProperty
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: dom.window.navigator,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Shape {
|
||||
id: string;
|
||||
label: string;
|
||||
style: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
parentId: string;
|
||||
/**
|
||||
* Header bar height for swimlane/container shapes (extracted from startSize= in style).
|
||||
* 0 for non-swimlane shapes.
|
||||
* The label of a swimlane lives in the band y..y+startSize.
|
||||
*/
|
||||
startSize: number;
|
||||
// computed
|
||||
readonly x2: number;
|
||||
readonly y2: number;
|
||||
readonly midX: number;
|
||||
readonly midY: number;
|
||||
}
|
||||
|
||||
export interface Waypoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Edge {
|
||||
id: string;
|
||||
label: string;
|
||||
style: string;
|
||||
sourceId: string | null;
|
||||
targetId: string | null;
|
||||
waypoints: Waypoint[];
|
||||
}
|
||||
|
||||
export interface ParsedPage {
|
||||
pageIndex: number; // 0-based
|
||||
pageName: string; // value of the name="" attribute on <diagram>, or "Page N"
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
shapes: Map<string, Shape>;
|
||||
edges: Edge[];
|
||||
/** Raw <mxGraphModel> XML string for this page — used by graph-loader for full routing */
|
||||
graphModelXml: string;
|
||||
}
|
||||
|
||||
/** Single-page result (first page) — kept for backward compatibility. */
|
||||
export interface ParsedDiagram extends ParsedPage {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decode <mxfile> base64+deflate diagram content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function decodeDiagramContent(content: string): string {
|
||||
try {
|
||||
const decoded = Buffer.from(content.trim(), "base64");
|
||||
const decompressed = inflateRaw(decoded);
|
||||
const text = new TextDecoder("utf-8").decode(decompressed);
|
||||
return decodeURIComponent(text);
|
||||
} catch {
|
||||
// Already plain XML
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — lightweight regex for attributes / element text
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getAttrFromTag(tagStr: string, attr: string): string {
|
||||
const re = new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]*?)"|'([^']*?)')`, "i");
|
||||
const m = re.exec(tagStr);
|
||||
return m ? (m[1] ?? m[2] ?? "") : "";
|
||||
}
|
||||
|
||||
/** Convert a maxGraph CellStyle object back to a semicolon-delimited style string. */
|
||||
function cellStyleToString(style: CellStyle): string {
|
||||
const { baseStyleNames = [], ...props } = style;
|
||||
const parts: string[] = [...baseStyleNames];
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
if (v !== undefined && v !== null) {
|
||||
parts.push(`${k}=${String(v)}`);
|
||||
}
|
||||
}
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
function extractPageDimensions(graphModelXml: string): { pageWidth: number; pageHeight: number } {
|
||||
const m = /<mxGraphModel([^>]*)>/i.exec(graphModelXml);
|
||||
if (!m) return { pageWidth: 0, pageHeight: 0 };
|
||||
const attrs = m[1];
|
||||
const pw = getAttrFromTag(attrs, "pageWidth");
|
||||
const ph = getAttrFromTag(attrs, "pageHeight");
|
||||
return {
|
||||
pageWidth: pw ? parseFloat(pw) : 0,
|
||||
pageHeight: ph ? parseFloat(ph) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build Shape from a maxGraph Cell (vertex)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Extract startSize from a CellStyle (swimlane header bar height). Default 0. */
|
||||
function extractStartSize(style: CellStyle): number {
|
||||
// maxGraph stores startSize as a numeric property on the style object
|
||||
const raw = (style as Record<string, unknown>)["startSize"];
|
||||
if (typeof raw === "number" && raw > 0) return raw;
|
||||
if (typeof raw === "string") {
|
||||
const n = parseFloat(raw);
|
||||
if (!isNaN(n) && n > 0) return n;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function cellToShape(cell: Cell): Shape | null {
|
||||
const id = cell.id;
|
||||
if (!id || id === "0" || id === "1") return null;
|
||||
if (!cell.isVertex()) return null;
|
||||
|
||||
const geo = cell.getGeometry();
|
||||
if (!geo) return null;
|
||||
|
||||
// getOrigin() walks the parent chain and returns canvas-absolute top-left
|
||||
const origin = cell.getOrigin();
|
||||
const x = origin.x;
|
||||
const y = origin.y;
|
||||
const width = geo.width ?? 0;
|
||||
const height = geo.height ?? 0;
|
||||
const parentId = cell.parent?.id ?? "1";
|
||||
const startSize = extractStartSize(cell.style);
|
||||
|
||||
const shape: Shape = {
|
||||
id,
|
||||
label: (cell.value as string) ?? "",
|
||||
style: cellStyleToString(cell.style),
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
parentId,
|
||||
startSize,
|
||||
get x2() { return this.x + this.width; },
|
||||
get y2() { return this.y + this.height; },
|
||||
get midX() { return this.x + this.width / 2; },
|
||||
get midY() { return this.y + this.height / 2; },
|
||||
};
|
||||
return shape;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build Edge from a maxGraph Cell (edge)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cellToEdge(cell: Cell): Edge | null {
|
||||
const id = cell.id;
|
||||
if (!id) return null;
|
||||
if (!cell.isEdge()) return null;
|
||||
|
||||
const geo = cell.getGeometry();
|
||||
const waypoints: Waypoint[] = [];
|
||||
if (geo?.points) {
|
||||
for (const pt of geo.points) {
|
||||
waypoints.push({ x: pt.x, y: pt.y });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
label: (cell.value as string) ?? "",
|
||||
style: cellStyleToString(cell.style),
|
||||
sourceId: cell.source?.id ?? null,
|
||||
targetId: cell.target?.id ?? null,
|
||||
waypoints,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse a single <mxGraphModel> XML string into a ParsedPage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseGraphModelXml(graphModelXml: string, pageIndex: number, pageName: string): ParsedPage {
|
||||
const { pageWidth, pageHeight } = extractPageDimensions(graphModelXml);
|
||||
|
||||
const model = new GraphDataModel();
|
||||
new ModelXmlSerializer(model).import(graphModelXml);
|
||||
|
||||
const shapes = new Map<string, Shape>();
|
||||
const edges: Edge[] = [];
|
||||
|
||||
for (const cell of Object.values(model.cells ?? {})) {
|
||||
if (cell.isVertex()) {
|
||||
const shape = cellToShape(cell);
|
||||
if (shape) shapes.set(shape.id, shape);
|
||||
} else if (cell.isEdge()) {
|
||||
const edge = cellToEdge(cell);
|
||||
if (edge) edges.push(edge);
|
||||
}
|
||||
}
|
||||
|
||||
return { pageIndex, pageName, pageWidth, pageHeight, shapes, edges, graphModelXml };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extract all <diagram> blocks from an <mxfile> string
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DiagramBlock {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
function extractDiagramBlocks(mxfileXml: string): DiagramBlock[] {
|
||||
const blocks: DiagramBlock[] = [];
|
||||
// Match each <diagram ...>...</diagram> element
|
||||
const re = /<diagram([^>]*)>([\s\S]*?)<\/diagram>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(mxfileXml)) !== null) {
|
||||
const attrs = match[1];
|
||||
const content = match[2];
|
||||
const name = getAttrFromTag(attrs, "name") || "";
|
||||
blocks.push({ name, content: content.trim() });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse all pages in a .drawio file.
|
||||
* Returns one ParsedPage per diagram/tab found.
|
||||
*/
|
||||
export function parseAllPages(filePath: string): ParsedPage[] {
|
||||
const raw = readFileSync(filePath, "utf-8").trim();
|
||||
|
||||
if (/^<mxfile/i.test(raw)) {
|
||||
const blocks = extractDiagramBlocks(raw);
|
||||
if (blocks.length === 0) throw new Error("No <diagram> elements found in mxfile");
|
||||
|
||||
return blocks.map((block, i) => {
|
||||
const graphModelXml = decodeDiagramContent(block.content);
|
||||
const pageName = block.name || `Page ${i + 1}`;
|
||||
return parseGraphModelXml(graphModelXml, i, pageName);
|
||||
});
|
||||
}
|
||||
|
||||
if (/^<mxGraphModel/i.test(raw)) {
|
||||
return [parseGraphModelXml(raw, 0, "Page 1")];
|
||||
}
|
||||
|
||||
throw new Error(`Unrecognised draw.io file format in ${filePath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the first page of a .drawio file.
|
||||
* Kept for backward compatibility with connectors-check and page-recommendations.
|
||||
*/
|
||||
export function parseDiagram(filePath: string): ParsedDiagram {
|
||||
return parseAllPages(filePath)[0];
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Shared hierarchy builder — used by page-hierarchy and page-hierarchy-full.
|
||||
*
|
||||
* Given a ParsedPage, builds a BFS depth map and a recursive containment tree.
|
||||
*/
|
||||
|
||||
import type { ParsedPage, Shape } from "../drawio-parser/parser.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HierarchyNode {
|
||||
id: string;
|
||||
label: string;
|
||||
depth: number;
|
||||
childCount: number;
|
||||
children: HierarchyNode[];
|
||||
}
|
||||
|
||||
export interface HierarchyResult {
|
||||
tree: HierarchyNode[];
|
||||
allNodes: HierarchyNode[];
|
||||
depthMap: Map<string, number>; // shape id → depth
|
||||
childrenOf: Map<string, Shape[]>; // parentId → direct children (Shape objects)
|
||||
maxDepth: number;
|
||||
totalLevels: number; // maxDepth + 1 — total nesting levels
|
||||
depthCounts: Record<number, number>; // depth → count of shapes at that depth
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildTree(
|
||||
parentId: string,
|
||||
childrenMap: Map<string, string[]>,
|
||||
shapeLabels: Map<string, string>,
|
||||
depth: number
|
||||
): HierarchyNode[] {
|
||||
const ids = childrenMap.get(parentId) ?? [];
|
||||
return ids.map((id) => {
|
||||
const children = buildTree(id, childrenMap, shapeLabels, depth + 1);
|
||||
return {
|
||||
id,
|
||||
label: shapeLabels.get(id) ?? "",
|
||||
depth,
|
||||
childCount: children.length,
|
||||
children,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildHierarchy(page: ParsedPage): HierarchyResult {
|
||||
const allShapes = Array.from(page.shapes.values());
|
||||
|
||||
// Build parent → [children ids] map and label map (for tree)
|
||||
const childrenMap = new Map<string, string[]>();
|
||||
const shapeLabels = new Map<string, string>();
|
||||
for (const s of allShapes) {
|
||||
shapeLabels.set(s.id, s.label);
|
||||
const parent = s.parentId ?? "1";
|
||||
if (!childrenMap.has(parent)) childrenMap.set(parent, []);
|
||||
childrenMap.get(parent)!.push(s.id);
|
||||
}
|
||||
|
||||
// Build parent → [children Shape] map (for geometry access)
|
||||
const childrenOf = new Map<string, Shape[]>();
|
||||
for (const s of allShapes) {
|
||||
const parent = s.parentId ?? "1";
|
||||
if (!childrenOf.has(parent)) childrenOf.set(parent, []);
|
||||
childrenOf.get(parent)!.push(s);
|
||||
}
|
||||
|
||||
// Build recursive tree
|
||||
const tree = buildTree("1", childrenMap, shapeLabels, 0);
|
||||
|
||||
// Flatten tree for stats + depth map
|
||||
const allNodes: HierarchyNode[] = [];
|
||||
const depthMap = new Map<string, number>();
|
||||
const stack = [...tree];
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop()!;
|
||||
allNodes.push(node);
|
||||
depthMap.set(node.id, node.depth);
|
||||
stack.push(...node.children);
|
||||
}
|
||||
|
||||
const maxDepth = allNodes.reduce((m, n) => Math.max(m, n.depth), 0);
|
||||
const depthCounts: Record<number, number> = {};
|
||||
for (const n of allNodes) {
|
||||
depthCounts[n.depth] = (depthCounts[n.depth] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
tree,
|
||||
allNodes,
|
||||
depthMap,
|
||||
childrenOf,
|
||||
maxDepth,
|
||||
totalLevels: maxDepth + 1,
|
||||
depthCounts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* dom-polyfill.ts
|
||||
*
|
||||
* Sets up (or extends) the global DOM environment required by @maxgraph/core
|
||||
* when running in Node.js. Safe to import multiple times — idempotent.
|
||||
*
|
||||
* The parser (drawio-parser/parser.ts) already sets up a partial DOM polyfill
|
||||
* for the basic maxGraph model/serializer classes. This module extends it with
|
||||
* the additional globals needed by Graph+GraphView (rendering pipeline):
|
||||
* - location
|
||||
* - SVGElement, MouseEvent, KeyboardEvent, TouchEvent
|
||||
* - requestAnimationFrame / cancelAnimationFrame
|
||||
*
|
||||
* Must be imported before any maxGraph Graph/GraphView usage.
|
||||
*/
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
// Use the same DOM that may already be set up by parser.ts, or create a new one.
|
||||
// We detect by checking whether global.document already exists.
|
||||
let win: Record<string, unknown>;
|
||||
|
||||
if ((globalThis as Record<string, unknown>)["document"]) {
|
||||
// Parser already set up a DOM — re-use the existing window
|
||||
win = (globalThis as Record<string, unknown>)["window"] as Record<string, unknown>;
|
||||
} else {
|
||||
const dom = new JSDOM(
|
||||
'<!DOCTYPE html><html><body></body></html>',
|
||||
{ pretendToBeVisual: true }
|
||||
);
|
||||
win = dom.window as unknown as Record<string, unknown>;
|
||||
(globalThis as Record<string, unknown>)["document"] = win["document"];
|
||||
(globalThis as Record<string, unknown>)["window"] = win;
|
||||
(globalThis as Record<string, unknown>)["DOMParser"] = win["DOMParser"];
|
||||
(globalThis as Record<string, unknown>)["XMLSerializer"] = win["XMLSerializer"];
|
||||
(globalThis as Record<string, unknown>)["HTMLElement"] = win["HTMLElement"];
|
||||
(globalThis as Record<string, unknown>)["Element"] = win["Element"];
|
||||
(globalThis as Record<string, unknown>)["Node"] = win["Node"];
|
||||
(globalThis as Record<string, unknown>)["Event"] = win["Event"];
|
||||
try {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: win["navigator"],
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
} catch { /* already defined */ }
|
||||
}
|
||||
|
||||
// Add/overwrite globals needed specifically for Graph+GraphView rendering
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
|
||||
if (!g["SVGElement"]) g["SVGElement"] = win["SVGElement"] ?? class SVGElement {};
|
||||
if (!g["MouseEvent"]) g["MouseEvent"] = win["MouseEvent"] ?? class MouseEvent {};
|
||||
if (!g["KeyboardEvent"]) g["KeyboardEvent"] = win["KeyboardEvent"] ?? class KeyboardEvent {};
|
||||
if (!g["TouchEvent"]) g["TouchEvent"] = win["TouchEvent"] ?? class TouchEvent {};
|
||||
if (!g["requestAnimationFrame"]) {
|
||||
g["requestAnimationFrame"] = (fn: () => void) => setTimeout(fn, 0);
|
||||
}
|
||||
if (!g["cancelAnimationFrame"]) {
|
||||
g["cancelAnimationFrame"] = clearTimeout;
|
||||
}
|
||||
// location is needed by UrlConverter (used when rendering image shapes)
|
||||
if (!g["location"]) {
|
||||
g["location"] = {
|
||||
protocol: "http:",
|
||||
host: "localhost",
|
||||
href: "http://localhost/",
|
||||
pathname: "/",
|
||||
};
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* graph-loader.ts
|
||||
*
|
||||
* Loads a draw.io diagram XML into a full maxGraph Graph+GraphView instance
|
||||
* and computes all cell states (including edge absolutePoints) exactly as
|
||||
* draw.io does at render time.
|
||||
*
|
||||
* This is the authoritative source for edge routing — do NOT hand-compute
|
||||
* orthogonal paths; use the routed points returned here instead.
|
||||
*
|
||||
* Usage:
|
||||
* import { loadGraphStates } from "./graph-loader.js";
|
||||
* const { edgePoints, vertexBounds } = loadGraphStates(graphModelXml);
|
||||
*/
|
||||
|
||||
// DOM polyfill MUST be imported first so globals are set before maxGraph loads
|
||||
import "./dom-polyfill.js";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import { Graph, GraphDataModel, ModelXmlSerializer } from "@maxgraph/core";
|
||||
import type { Cell, CellState } from "@maxgraph/core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface EdgeRoute {
|
||||
/** Canvas-absolute points for the full routed path (including endpoints) */
|
||||
points: Point[];
|
||||
}
|
||||
|
||||
export interface VertexBounds {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface GraphStates {
|
||||
/** Map of edge cell ID → routed absolute points */
|
||||
edgeRoutes: Map<string, EdgeRoute>;
|
||||
/** Map of vertex cell ID → absolute bounding box */
|
||||
vertexBounds: Map<string, VertexBounds>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: create a fresh DOM container for each graph instance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createContainer(): HTMLElement {
|
||||
const dom = new JSDOM(
|
||||
'<!DOCTYPE html><html><body><div id="g" style="width:4000px;height:4000px;"></div></body></html>',
|
||||
{ pretendToBeVisual: true }
|
||||
);
|
||||
return dom.window.document.getElementById("g") as HTMLElement;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Load a single <mxGraphModel> XML string into a maxGraph Graph instance,
|
||||
* run GraphView.validate() to compute all cell states, and return the
|
||||
* resulting edge routes and vertex bounds.
|
||||
*
|
||||
* @param graphModelXml - Raw <mxGraphModel ...>...</mxGraphModel> XML string
|
||||
*/
|
||||
export function loadGraphStates(graphModelXml: string): GraphStates {
|
||||
const dataModel = new GraphDataModel();
|
||||
const serializer = new ModelXmlSerializer(dataModel);
|
||||
serializer.import(graphModelXml);
|
||||
|
||||
const container = createContainer();
|
||||
const graph = new Graph(container, dataModel);
|
||||
|
||||
// Compute all cell states (geometry, routing, labels…)
|
||||
graph.view.validate();
|
||||
|
||||
const states = graph.view.getStates() as Map<Cell, CellState>;
|
||||
|
||||
const edgeRoutes = new Map<string, EdgeRoute>();
|
||||
const vertexBounds = new Map<string, VertexBounds>();
|
||||
|
||||
for (const [cell, state] of states) {
|
||||
const id = cell.id;
|
||||
if (!id || id === "0" || id === "1") continue;
|
||||
|
||||
if (cell.isEdge()) {
|
||||
const pts = state.absolutePoints as Array<Point | null> | null;
|
||||
if (pts && pts.length >= 2) {
|
||||
const resolved: Point[] = pts
|
||||
.filter((p): p is Point => p != null)
|
||||
.map((p) => ({ x: Math.round(p.x * 10) / 10, y: Math.round(p.y * 10) / 10 }));
|
||||
edgeRoutes.set(id, { points: resolved });
|
||||
}
|
||||
} else if (cell.isVertex()) {
|
||||
vertexBounds.set(id, {
|
||||
x: state.x,
|
||||
y: state.y,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { edgeRoutes, vertexBounds };
|
||||
}
|
||||
Reference in New Issue
Block a user