@graph-artifact/core 0.1.13 → 0.1.15

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.
@@ -2,6 +2,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
2
2
  import { memo } from 'react';
3
3
  import { Handle, Position } from '@xyflow/react';
4
4
  import { useTheme } from '../../ThemeContext.js';
5
+ import { getConfig } from '../../config.js';
5
6
  const invisibleHandle = {
6
7
  opacity: 0,
7
8
  width: 1,
@@ -27,31 +28,54 @@ function EntityHandles({ fwIn, fwOut }) {
27
28
  }
28
29
  export const EntityNode = memo(function EntityNode({ data }) {
29
30
  const theme = useTheme();
31
+ const { layout } = getConfig();
30
32
  const label = String(data.label ?? '');
31
33
  const attrs = data.attributes ?? [];
32
34
  const ns = theme.nodeStyles.entity;
33
35
  const fwIn = Math.max(1, data.fwIn ?? 1);
34
36
  const fwOut = Math.max(1, data.fwOut ?? 1);
35
37
  const layoutWidth = data.layoutWidth;
36
- const layoutHeight = data.layoutHeight;
38
+ const minWidth = Number.isFinite(layoutWidth) && layoutWidth > 0
39
+ ? layoutWidth
40
+ : layout.nodeSizing.common.minWidth;
37
41
  return (_jsxs("div", { style: {
38
42
  ...theme.nodeBase.card,
39
- width: layoutWidth,
40
- }, children: [_jsx("div", { style: { ...theme.nodeBase.header, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }, title: label, children: label }), attrs.length > 0 && (_jsx("div", { style: { padding: `${theme.space[2]} ${theme.space[3]}` }, children: attrs.map((attr, i) => {
41
- const parts = attr.split(' ');
42
- const type = parts[0] ?? '';
43
- const name = parts[1] ?? '';
44
- const badge = parts[2];
45
- const badgeVariant = badge === 'PK' ? 'primary' : badge === 'FK' ? 'secondary' : 'muted';
46
- return (_jsxs("div", { style: {
43
+ boxSizing: 'border-box',
44
+ width: 'fit-content',
45
+ minWidth,
46
+ maxWidth: layout.nodeSizing.common.maxWidth,
47
+ }, children: [_jsxs("div", { style: { padding: `${theme.space[2]} ${theme.space[3]}` }, children: [_jsx("div", { style: {
47
48
  display: 'flex', alignItems: 'center', gap: theme.space[2],
48
- padding: `${theme.space[1]} 0`,
49
- borderBottom: i < attrs.length - 1 ? `${theme.borderWidth.sm} solid ${theme.color.gray2}` : 'none',
50
49
  fontSize: theme.font.size.md,
51
- }, children: [_jsx("span", { style: {
52
- color: theme.color.gray3, minWidth: ns.typeColumnWidth,
53
- fontFamily: theme.font.mono, fontSize: theme.font.size.sm,
54
- flexShrink: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
55
- }, children: _jsx("span", { title: type, children: type }) }), _jsx("span", { style: { color: theme.color.gray4, flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }, title: name, children: name }), badge && _jsx("span", { style: theme.nodeBase.badge(badgeVariant), children: badge })] }, i));
56
- }) })), _jsx(EntityHandles, { fwIn: fwIn, fwOut: fwOut })] }));
50
+ backgroundColor: theme.color.orange1,
51
+ margin: `-${theme.space[2]} -${theme.space[3]}`,
52
+ marginBottom: attrs.length > 0 ? theme.space[1] : `-${theme.space[2]}`,
53
+ padding: `${theme.space[2]} ${theme.space[3]}`,
54
+ borderRadius: `${theme.radius.lg} ${theme.radius.lg} 0 0`,
55
+ }, children: _jsx("span", { style: {
56
+ color: theme.color.white,
57
+ fontWeight: theme.font.weight.semibold,
58
+ flex: 1,
59
+ minWidth: 0,
60
+ whiteSpace: 'nowrap',
61
+ overflow: 'hidden',
62
+ textOverflow: 'ellipsis',
63
+ textAlign: 'center',
64
+ }, title: label, children: label }) }), attrs.map((attr, i) => {
65
+ const parts = attr.split(' ');
66
+ const type = parts[0] ?? '';
67
+ const name = parts[1] ?? '';
68
+ const badge = parts[2];
69
+ const badgeVariant = badge === 'PK' ? 'primary' : badge === 'FK' ? 'secondary' : 'muted';
70
+ return (_jsxs("div", { style: {
71
+ display: 'flex', alignItems: 'center', gap: theme.space[2],
72
+ padding: `${theme.space[1]} 0`,
73
+ borderBottom: i < attrs.length - 1 ? `${theme.borderWidth.sm} solid ${theme.color.gray2}` : 'none',
74
+ fontSize: theme.font.size.md,
75
+ }, children: [_jsx("span", { style: {
76
+ color: theme.color.gray3, minWidth: ns.typeColumnWidth,
77
+ fontFamily: theme.font.mono, fontSize: theme.font.size.sm,
78
+ flexShrink: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
79
+ }, children: _jsx("span", { title: type, children: type }) }), _jsx("span", { style: { color: theme.color.gray4, flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }, title: name, children: name }), badge && _jsx("span", { style: theme.nodeBase.badge(badgeVariant), children: badge })] }, i));
80
+ })] }), _jsx(EntityHandles, { fwIn: fwIn, fwOut: fwOut })] }));
57
81
  });
@@ -72,13 +72,20 @@ function NodeHandles({ fwIn, fwOut }) {
72
72
  * source handles are needed at each position (bottom, left, right) based on
73
73
  * where each outgoing edge's target is located relative to the diamond center.
74
74
  *
75
+ * `fwIn` specifies how many incoming edges need target handles (for spreading).
76
+ *
75
77
  * Fallback: if no config is provided (single outgoing), uses bottom handle.
76
78
  */
77
- function DiamondHandles({ handleConfig }) {
79
+ function DiamondHandles({ handleConfig, fwIn = 1 }) {
78
80
  const bottom = handleConfig?.bottom ?? 1;
79
81
  const left = handleConfig?.left ?? 0;
80
82
  const right = handleConfig?.right ?? 0;
81
- return (_jsxs(_Fragment, { children: [_jsx(Handle, { type: "target", id: "fw-target-0", position: Position.Top, style: invisibleHandle }), Array.from({ length: bottom }, (_, i) => (_jsx(Handle, { type: "source", id: `fw-source-bottom-${i}`, position: Position.Bottom, style: invisibleHandle }, `db-${i}`))), Array.from({ length: left }, (_, i) => (_jsx(Handle, { type: "source", id: `fw-source-left-${i}`, position: Position.Left, style: invisibleHandle }, `dl-${i}`))), Array.from({ length: right }, (_, i) => (_jsx(Handle, { type: "source", id: `fw-source-right-${i}`, position: Position.Right, style: invisibleHandle }, `dr-${i}`))), _jsx(Handle, { type: "source", id: "fw-source-0", position: Position.Bottom, style: invisibleHandle }), _jsx(SideHandles, { side: "left" }), _jsx(SideHandles, { side: "right" }), _jsx(Handle, { type: "target", id: "lateral-target", position: Position.Left, style: invisibleHandle }), _jsx(Handle, { type: "source", id: "lateral-source", position: Position.Right, style: invisibleHandle })] }));
83
+ // Spread multiple incoming target handles across the top edge
84
+ const targetCount = Math.max(1, fwIn);
85
+ const targetPositions = targetCount === 1
86
+ ? [50]
87
+ : Array.from({ length: targetCount }, (_, i) => 25 + (50 * i) / (targetCount - 1));
88
+ return (_jsxs(_Fragment, { children: [targetPositions.map((pct, i) => (_jsx(Handle, { type: "target", id: `fw-target-${i}`, position: Position.Top, style: { ...invisibleHandle, left: `${pct}%` } }, `dt-${i}`))), Array.from({ length: bottom }, (_, i) => (_jsx(Handle, { type: "source", id: `fw-source-bottom-${i}`, position: Position.Bottom, style: invisibleHandle }, `db-${i}`))), Array.from({ length: left }, (_, i) => (_jsx(Handle, { type: "source", id: `fw-source-left-${i}`, position: Position.Left, style: invisibleHandle }, `dl-${i}`))), Array.from({ length: right }, (_, i) => (_jsx(Handle, { type: "source", id: `fw-source-right-${i}`, position: Position.Right, style: invisibleHandle }, `dr-${i}`))), _jsx(Handle, { type: "source", id: "fw-source-0", position: Position.Bottom, style: invisibleHandle }), _jsx(SideHandles, { side: "left" }), _jsx(SideHandles, { side: "right" }), _jsx(Handle, { type: "target", id: "lateral-target", position: Position.Left, style: invisibleHandle }), _jsx(Handle, { type: "source", id: "lateral-source", position: Position.Right, style: invisibleHandle })] }));
82
89
  }
83
90
  export const FlowNode = memo(function FlowNode({ data }) {
84
91
  const theme = useTheme();
@@ -94,7 +101,15 @@ export const FlowNode = memo(function FlowNode({ data }) {
94
101
  const nodeBgColor = data.nodeBgColor ?? theme.color.orange1;
95
102
  const nodeTextColor = data.nodeTextColor ?? theme.color.white;
96
103
  const markdownComponents = {
97
- p: ({ children }) => (_jsx("p", { style: { margin: 0, whiteSpace: 'pre-wrap', lineHeight: theme.lineHeight.normal }, children: children })),
104
+ p: ({ children }) => (_jsx("p", { style: {
105
+ margin: 0,
106
+ whiteSpace: 'pre-wrap',
107
+ lineHeight: theme.lineHeight.normal,
108
+ // Ensure long unbroken glyph runs (e.g. box-drawing lines) don't
109
+ // visually overflow fixed-width nodes, which breaks subgraph bounds.
110
+ overflowWrap: 'anywhere',
111
+ wordBreak: 'break-word',
112
+ }, children: children })),
98
113
  strong: ({ children }) => (_jsx("strong", { style: { fontWeight: theme.font.weight.bold }, children: children })),
99
114
  em: ({ children }) => (_jsx("em", { style: { fontStyle: 'italic' }, children: children })),
100
115
  code: ({ children }) => (_jsx("code", { style: {
@@ -122,11 +137,11 @@ export const FlowNode = memo(function FlowNode({ data }) {
122
137
  transform: 'rotate(45deg)', borderRadius: theme.radius.md,
123
138
  display: 'flex', alignItems: 'center', justifyContent: 'center',
124
139
  }, children: _jsx("span", { style: {
125
- transform: 'rotate(-45deg)', color: theme.color.white, fontSize: theme.font.size.base,
140
+ transform: 'rotate(-45deg)', color: theme.color.white, fontSize: theme.font.size.lg,
126
141
  fontWeight: theme.font.weight.bold, fontFamily: theme.font.family,
127
142
  textAlign: 'center', lineHeight: theme.lineHeight.tight,
128
143
  padding: theme.space[2], maxWidth: ns.diamond.labelMaxWidth, overflowWrap: 'break-word',
129
- }, children: _jsx(ReactMarkdown, { components: markdownComponents, children: labelMarkdown }) }) }), _jsx(DiamondHandles, { handleConfig: data.diamondHandleConfig })] }));
144
+ }, children: _jsx(ReactMarkdown, { components: markdownComponents, children: labelMarkdown }) }) }), _jsx(DiamondHandles, { handleConfig: data.diamondHandleConfig, fwIn: data.fwIn })] }));
130
145
  }
131
146
  if (shape === 'circle') {
132
147
  return (_jsxs("div", { style: {
@@ -135,8 +150,8 @@ export const FlowNode = memo(function FlowNode({ data }) {
135
150
  display: 'flex', alignItems: 'center', justifyContent: 'center',
136
151
  fontFamily: theme.font.family,
137
152
  }, children: [_jsx(Handle, { type: "target", id: "fw-target-0", position: Position.Top, style: invisibleHandle }), _jsx("span", { style: {
138
- color: nodeTextColor, fontSize: theme.font.size.md, fontWeight: theme.font.weight.bold,
139
- textAlign: 'center', padding: theme.space[1], maxWidth: '100%', overflowWrap: 'break-word',
153
+ color: nodeTextColor, fontSize: theme.font.size.lg, fontWeight: theme.font.weight.bold,
154
+ textAlign: 'center', padding: theme.space[2], maxWidth: '100%', overflowWrap: 'break-word',
140
155
  }, children: _jsx(ReactMarkdown, { components: markdownComponents, children: labelMarkdown }) }), _jsx(Handle, { type: "source", id: "fw-source-0", position: Position.Bottom, style: invisibleHandle }), _jsx(NodeHandles, { fwIn: fwIn, fwOut: fwOut })] }));
141
156
  }
142
157
  if (shape === 'cylinder') {
@@ -34,29 +34,25 @@ export const StateNode = memo(function StateNode({ data }) {
34
34
  const fwOut = Math.max(1, data.fwOut ?? 1);
35
35
  const layoutWidth = data.layoutWidth;
36
36
  const layoutHeight = data.layoutHeight;
37
- // Start state: filled circle
37
+ // Start state: ring with thick orange border and transparent center
38
38
  if (shape === 'circle') {
39
39
  return (_jsx("div", { style: {
40
- width: layoutWidth, height: layoutHeight,
41
- backgroundColor: theme.color.orange1, borderRadius: theme.radius.full,
40
+ width: layoutWidth,
41
+ height: layoutHeight,
42
+ boxSizing: 'border-box',
43
+ borderRadius: theme.radius.full,
44
+ border: `10px solid ${theme.color.orange1}`,
45
+ backgroundColor: 'transparent',
42
46
  }, children: _jsx(StateHandles, { fwIn: fwIn, fwOut: fwOut }) }));
43
47
  }
44
- // End state: bullseye (outer ring + inner filled dot).
45
- // layoutWidth/layoutHeight include the border, so use box-sizing: border-box.
48
+ // End state: solid filled circle
46
49
  if (shape === 'doublecircle') {
47
- const innerSize = layoutWidth - 16; // outer - padding(8) - border(3)*2
48
- return (_jsxs("div", { style: {
49
- width: layoutWidth, height: layoutHeight,
50
- boxSizing: 'border-box',
50
+ return (_jsx("div", { style: {
51
+ width: layoutWidth,
52
+ height: layoutHeight,
53
+ backgroundColor: theme.color.orange1,
51
54
  borderRadius: theme.radius.full,
52
- border: `${theme.borderWidth.md} solid ${theme.color.orange1}`,
53
- display: 'flex', alignItems: 'center', justifyContent: 'center',
54
- }, children: [_jsx("div", { style: {
55
- width: innerSize, height: innerSize,
56
- // Inner fill: dark in dark mode, dark gray in light mode.
57
- backgroundColor: theme.mode === 'dark' ? theme.color.darkBg2 : theme.color.gray5,
58
- borderRadius: theme.radius.full,
59
- } }), _jsx(StateHandles, { fwIn: fwIn, fwOut: fwOut })] }));
55
+ }, children: _jsx(StateHandles, { fwIn: fwIn, fwOut: fwOut }) }));
60
56
  }
61
57
  return (_jsxs("div", { style: {
62
58
  backgroundColor: theme.color.orange1,
@@ -6,10 +6,10 @@ export const SubgraphNode = memo(function SubgraphNode({ data }) {
6
6
  const label = String(data.label ?? '');
7
7
  const ns = theme.nodeStyles.subgraph;
8
8
  return (_jsx("div", { style: {
9
- fontSize: theme.font.size.sm,
9
+ fontSize: theme.font.size.base,
10
10
  fontWeight: theme.font.weight.semibold,
11
11
  fontFamily: theme.font.family,
12
- color: theme.color.gray3,
12
+ color: theme.mode === 'dark' ? theme.color.white : theme.color.gray5,
13
13
  padding: `${theme.space[1]} ${theme.space[2]}`,
14
14
  pointerEvents: ns.pointerEvents,
15
15
  }, children: label }));
package/dist/config.d.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * Consumers call `configure()` once at module load time. Every internal module
9
9
  * reads from `getConfig()`. Nothing is hardcoded elsewhere.
10
10
  */
11
- import type { ThemeOverride, NodeComponent, NodeChildComponent } from './types.js';
11
+ import type { NodeChildComponent, NodeComponent, ThemeOverride } from './types.js';
12
12
  export interface GraphConfig {
13
13
  /** Which theme to use as the base token set ('dark', 'light', or any registered name) */
14
14
  baseTheme: string;
package/dist/config.js CHANGED
@@ -15,11 +15,11 @@ function createDefaultConfig() {
15
15
  theme: {},
16
16
  layout: {
17
17
  rankSep: 30,
18
- erRankSep: 60,
19
- stateRankSep: 60,
18
+ erRankSep: 100,
19
+ stateRankSep: 25,
20
20
  nodeSep: 30,
21
- erNodeSep: 80,
22
- stateNodeSep: 40,
21
+ erNodeSep: 120,
22
+ stateNodeSep: 20,
23
23
  subgraphPadding: 16,
24
24
  nodeSizing: {
25
25
  charWidth: 9,
@@ -27,11 +27,11 @@ function createDefaultConfig() {
27
27
  er: { width: 220, headerHeight: 33, rowHeight: 23, borderExtra: 2 },
28
28
  class: { width: 240, headerHeight: 33, rowHeight: 20, dividerGap: 17 },
29
29
  state: {
30
- circleSize: 28, doublecirclePad: 8, doublecircleBorder: 3,
30
+ circleSize: 36, doublecirclePad: 0, doublecircleBorder: 0,
31
31
  minWidth: 100, height: 40, padding: 32, borderExtra: 6,
32
32
  },
33
33
  flow: {
34
- diamondSize: 120, circleSize: 80, cylinderWidth: 120, cylinderHeight: 70,
34
+ diamondSize: 150, circleSize: 100, cylinderWidth: 120, cylinderHeight: 70,
35
35
  minWidth: 120, height: 44, padding: 40,
36
36
  },
37
37
  },
@@ -45,7 +45,7 @@ function createDefaultConfig() {
45
45
  labelGapBelow: 6,
46
46
  topPadding: 20,
47
47
  leftPadding: 40,
48
- lifelineStartOffset: 40,
48
+ lifelineStartOffset: 0, // Lifelines connect directly to participant bottom
49
49
  bottomParticipantGap: 40,
50
50
  anchorWidth: 2,
51
51
  anchorHeight: 2,
@@ -112,6 +112,48 @@ export function createDagreGraph(parsed, layoutConfig, nodeSizeOverrides) {
112
112
  }
113
113
  }
114
114
  }
115
+ // Calculate actual depth (longest path) for each subgraph
116
+ const subgraphDepth = new Map();
117
+ if (parsed.subgraphs) {
118
+ for (const sg of parsed.subgraphs) {
119
+ const internalNodes = new Set(sg.nodeIds);
120
+ // Build adjacency list for internal edges
121
+ const adj = new Map();
122
+ for (const nodeId of sg.nodeIds) {
123
+ adj.set(nodeId, []);
124
+ }
125
+ for (const edge of parsed.edges) {
126
+ if (internalNodes.has(edge.source) && internalNodes.has(edge.target)) {
127
+ adj.get(edge.source)?.push(edge.target);
128
+ }
129
+ }
130
+ // Find longest path using DFS with cycle detection
131
+ const memo = new Map();
132
+ const visiting = new Set();
133
+ function longestPath(node) {
134
+ if (memo.has(node))
135
+ return memo.get(node);
136
+ if (visiting.has(node))
137
+ return 0; // Cycle detected, stop recursion
138
+ visiting.add(node);
139
+ const neighbors = adj.get(node) ?? [];
140
+ let maxDepth = 0;
141
+ for (const neighbor of neighbors) {
142
+ maxDepth = Math.max(maxDepth, 1 + longestPath(neighbor));
143
+ }
144
+ visiting.delete(node);
145
+ memo.set(node, maxDepth);
146
+ return maxDepth;
147
+ }
148
+ // Find max depth from any starting node
149
+ let maxDepth = 0;
150
+ for (const nodeId of sg.nodeIds) {
151
+ maxDepth = Math.max(maxDepth, longestPath(nodeId));
152
+ }
153
+ // Just use the depth + 1 for minimal clearance
154
+ subgraphDepth.set(sg.id, maxDepth + 1);
155
+ }
156
+ }
115
157
  parsed.edges.forEach((edge, i) => {
116
158
  // Pass label dimensions to dagre so it spaces edges apart
117
159
  // and generates better waypoints. Without this, dagre treats
@@ -126,12 +168,19 @@ export function createDagreGraph(parsed, layoutConfig, nodeSizeOverrides) {
126
168
  g.setNode(dagreSrc, fallbackNode);
127
169
  if (!g.hasNode(dagreTgt))
128
170
  g.setNode(dagreTgt, fallbackNode);
171
+ // For edges FROM a subgraph, add minlen based on actual subgraph depth
172
+ let minlen = 1;
173
+ if (edge.sourceSubgraphId) {
174
+ // Use calculated depth to push target just below subgraph
175
+ minlen = subgraphDepth.get(edge.sourceSubgraphId) ?? 2;
176
+ }
129
177
  // Use index as edge name for multigraph — prevents overwriting when
130
178
  // multiple edges exist between the same source→target pair.
131
179
  g.setEdge(dagreSrc, dagreTgt, {
132
180
  width: labelWidth,
133
181
  height: labelHeight,
134
182
  labelpos: 'c',
183
+ minlen,
135
184
  }, `e${i}`);
136
185
  });
137
186
  try {
@@ -134,13 +134,14 @@ function erNodeSize(node, sizing) {
134
134
  const attrCount = node.attributes?.length ?? 0;
135
135
  const attrs = node.attributes ?? [];
136
136
  // Height breakdown (mirrors EntityNode CSS):
137
- // headerHeight: header padding (space[2]*2) + font (size.lg) + line-height
138
- // sectionPad: attribute wrapper padding (space[2]*2 = 8+8 = 16px)
137
+ // sectionPad: wrapper padding (space[2]*2 = 8+8 = 16px)
138
+ // headerRow: header row height (same as attribute rows)
139
139
  // rowHeight: per-row padding (space[1]*2 = 4+4) + font (size.md) * line-height
140
- // dividers: 1px borderBottom between attribute rows
140
+ // dividers: 1px borderBottom between rows (header + attrs - 1)
141
141
  // borderExtra: card border (borderWidth.sm * 2 = 1+1 = 2px)
142
- const sectionPad = attrCount > 0 ? 16 : 0;
143
- const dividers = attrCount > 1 ? attrCount - 1 : 0;
142
+ const sectionPad = 16; // always have wrapper padding now
143
+ const totalRows = 1 + attrCount; // header + attributes
144
+ const dividers = totalRows > 1 ? totalRows - 1 : 0;
144
145
  const badgeRoom = attrs.some((attr) => {
145
146
  const t = attr.trim().split(/\s+/);
146
147
  return t[2] === 'PK' || t[2] === 'FK' || t[2] === 'UK';
@@ -157,9 +158,8 @@ function erNodeSize(node, sizing) {
157
158
  const estimatedWidth = Math.max(sizing.er.width, 24 + 90 + 8 + contentChars * sizing.charWidth + badgeRoom);
158
159
  return {
159
160
  width: clampWidth(estimatedWidth),
160
- height: sizing.er.headerHeight
161
- + sectionPad
162
- + attrCount * sizing.er.rowHeight
161
+ height: sectionPad
162
+ + totalRows * sizing.er.rowHeight
163
163
  + dividers
164
164
  + sizing.er.borderExtra,
165
165
  };