@zombie-mermaid/svg-renderer 2.2.1
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/LICENSE +22 -0
- package/dist/index.cjs +56 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +524 -0
- package/dist/index.d.ts +524 -0
- package/dist/index.js +2994 -0
- package/dist/index.js.map +1 -0
- package/package.json +37 -0
- package/src/__tests__/elk-adapter-utils.test.ts +166 -0
- package/src/class/layout.ts +360 -0
- package/src/class/renderer.ts +636 -0
- package/src/edge-curves.ts +204 -0
- package/src/elk-instance.ts +292 -0
- package/src/er/layout.ts +200 -0
- package/src/er/renderer.ts +493 -0
- package/src/index.ts +58 -0
- package/src/layout-engine/constants.ts +19 -0
- package/src/layout-engine/edge-bundling.ts +379 -0
- package/src/layout-engine/elk-adapter-utils.ts +81 -0
- package/src/layout-engine/elk-graph-builder.ts +240 -0
- package/src/layout-engine/from-elk.ts +685 -0
- package/src/layout-engine/layer-alignment.ts +174 -0
- package/src/layout-engine/to-elk.ts +695 -0
- package/src/layout-engine.ts +74 -0
- package/src/layout.ts +8 -0
- package/src/renderer.ts +1485 -0
- package/src/resolve-colors.ts +339 -0
- package/src/sequence/layout.ts +698 -0
- package/src/sequence/renderer.ts +546 -0
- package/src/shape-clipping.ts +197 -0
- package/src/styles.ts +118 -0
- package/src/xychart/layout.ts +682 -0
- package/src/xychart/renderer.ts +684 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shape-aware edge clipping utilities.
|
|
3
|
+
*
|
|
4
|
+
* ELK.js treats all nodes as rectangles for edge routing. For non-rectangular
|
|
5
|
+
* shapes like diamonds, this causes edges to terminate at the bounding box
|
|
6
|
+
* boundary instead of the actual shape vertices.
|
|
7
|
+
*
|
|
8
|
+
* This module provides utilities to clip edge endpoints to actual shape
|
|
9
|
+
* boundaries after ELK layout is complete.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Point, PositionedNode } from '@zombie-mermaid/core'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Clip an edge endpoint to the actual shape boundary of a node.
|
|
16
|
+
*
|
|
17
|
+
* @param points - The edge points array
|
|
18
|
+
* @param node - The node to clip to
|
|
19
|
+
* @param isStart - True if clipping the start point (source), false for end (target)
|
|
20
|
+
* @returns New points array with clipped endpoint
|
|
21
|
+
*/
|
|
22
|
+
export function clipEdgeToShape(
|
|
23
|
+
points: Point[],
|
|
24
|
+
node: PositionedNode,
|
|
25
|
+
isStart: boolean,
|
|
26
|
+
): Point[] {
|
|
27
|
+
if (points.length < 2) return points
|
|
28
|
+
|
|
29
|
+
// Only clip non-rectangular shapes
|
|
30
|
+
if (
|
|
31
|
+
node.shape === 'rectangle' ||
|
|
32
|
+
node.shape === 'rounded' ||
|
|
33
|
+
node.shape === 'stadium'
|
|
34
|
+
) {
|
|
35
|
+
return points
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const result = [...points]
|
|
39
|
+
|
|
40
|
+
if (node.shape === 'diamond') {
|
|
41
|
+
if (isStart) {
|
|
42
|
+
result[0] = clipToDiamond(points[0]!, points[1]!, node)
|
|
43
|
+
} else {
|
|
44
|
+
const lastIdx = points.length - 1
|
|
45
|
+
result[lastIdx] = clipToDiamond(
|
|
46
|
+
points[lastIdx]!,
|
|
47
|
+
points[lastIdx - 1]!,
|
|
48
|
+
node,
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Future: add clipping for hexagon, circle, etc.
|
|
53
|
+
|
|
54
|
+
return result
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Clip a point to the diamond shape boundary using ray-polygon intersection.
|
|
59
|
+
*
|
|
60
|
+
* Diamond vertices are at the midpoints of the bounding box sides:
|
|
61
|
+
* - Top: (cx, y)
|
|
62
|
+
* - Right: (x + w, cy)
|
|
63
|
+
* - Bottom: (cx, y + h)
|
|
64
|
+
* - Left: (x, cy)
|
|
65
|
+
*
|
|
66
|
+
* For orthogonal edges, we extend the final segment as a ray and find where
|
|
67
|
+
* it intersects the diamond boundary. This preserves orthogonality while
|
|
68
|
+
* ensuring the edge terminates at the actual diamond shape.
|
|
69
|
+
*
|
|
70
|
+
* @param endpoint - The edge endpoint to clip
|
|
71
|
+
* @param adjacent - The adjacent point (to determine ray direction)
|
|
72
|
+
* @param node - The diamond node
|
|
73
|
+
* @returns Clipped point on the diamond boundary
|
|
74
|
+
*/
|
|
75
|
+
function clipToDiamond(
|
|
76
|
+
endpoint: Point,
|
|
77
|
+
adjacent: Point,
|
|
78
|
+
node: PositionedNode,
|
|
79
|
+
): Point {
|
|
80
|
+
const cx = node.x + node.width / 2
|
|
81
|
+
const cy = node.y + node.height / 2
|
|
82
|
+
|
|
83
|
+
// Diamond vertices
|
|
84
|
+
const top: Point = { x: cx, y: node.y }
|
|
85
|
+
const right: Point = { x: node.x + node.width, y: cy }
|
|
86
|
+
const bottom: Point = { x: cx, y: node.y + node.height }
|
|
87
|
+
const left: Point = { x: node.x, y: cy }
|
|
88
|
+
|
|
89
|
+
// Determine approach direction from adjacent point
|
|
90
|
+
const dx = endpoint.x - adjacent.x
|
|
91
|
+
const dy = endpoint.y - adjacent.y
|
|
92
|
+
|
|
93
|
+
// For orthogonal edges, one of dx or dy will be ~0
|
|
94
|
+
const isVertical = Math.abs(dx) < Math.abs(dy)
|
|
95
|
+
|
|
96
|
+
if (isVertical) {
|
|
97
|
+
// Vertical ray at x = endpoint.x
|
|
98
|
+
const rayX = endpoint.x
|
|
99
|
+
|
|
100
|
+
if (dy > 0) {
|
|
101
|
+
// Coming from above (moving down) → intersect with top half of diamond
|
|
102
|
+
// Top half edges: left-top (left → top) and top-right (top → right)
|
|
103
|
+
if (rayX <= cx) {
|
|
104
|
+
// Intersect with left-top edge (from left vertex to top vertex)
|
|
105
|
+
return intersectVerticalRayWithEdge(rayX, left, top) ?? top
|
|
106
|
+
} else {
|
|
107
|
+
// Intersect with top-right edge (from top vertex to right vertex)
|
|
108
|
+
return intersectVerticalRayWithEdge(rayX, top, right) ?? top
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
// Coming from below (moving up) → intersect with bottom half of diamond
|
|
112
|
+
// Bottom half edges: left-bottom (bottom → left) and bottom-right (right → bottom)
|
|
113
|
+
if (rayX <= cx) {
|
|
114
|
+
// Intersect with bottom-left edge (from bottom vertex to left vertex)
|
|
115
|
+
return intersectVerticalRayWithEdge(rayX, bottom, left) ?? bottom
|
|
116
|
+
} else {
|
|
117
|
+
// Intersect with bottom-right edge (from right vertex to bottom vertex)
|
|
118
|
+
return intersectVerticalRayWithEdge(rayX, right, bottom) ?? bottom
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
// Horizontal ray at y = endpoint.y
|
|
123
|
+
const rayY = endpoint.y
|
|
124
|
+
|
|
125
|
+
if (dx > 0) {
|
|
126
|
+
// Coming from left (moving right) → intersect with left half of diamond
|
|
127
|
+
// Left half edges: top-left (top → left) and left-bottom (left → bottom)
|
|
128
|
+
if (rayY <= cy) {
|
|
129
|
+
// Intersect with top-left edge (from top vertex to left vertex)
|
|
130
|
+
return intersectHorizontalRayWithEdge(rayY, top, left) ?? left
|
|
131
|
+
} else {
|
|
132
|
+
// Intersect with left-bottom edge (from left vertex to bottom vertex)
|
|
133
|
+
return intersectHorizontalRayWithEdge(rayY, left, bottom) ?? left
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
// Coming from right (moving left) → intersect with right half of diamond
|
|
137
|
+
// Right half edges: top-right (top → right) and right-bottom (right → bottom)
|
|
138
|
+
if (rayY <= cy) {
|
|
139
|
+
// Intersect with top-right edge (from top vertex to right vertex)
|
|
140
|
+
return intersectHorizontalRayWithEdge(rayY, top, right) ?? right
|
|
141
|
+
} else {
|
|
142
|
+
// Intersect with right-bottom edge (from right vertex to bottom vertex)
|
|
143
|
+
return intersectHorizontalRayWithEdge(rayY, right, bottom) ?? right
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Find intersection of a horizontal ray (y = rayY) with a line segment.
|
|
151
|
+
* Returns the intersection point or null if no intersection.
|
|
152
|
+
*/
|
|
153
|
+
function intersectHorizontalRayWithEdge(
|
|
154
|
+
rayY: number,
|
|
155
|
+
p1: Point,
|
|
156
|
+
p2: Point,
|
|
157
|
+
): Point | null {
|
|
158
|
+
const dy = p2.y - p1.y
|
|
159
|
+
if (Math.abs(dy) < 0.001) {
|
|
160
|
+
// Edge is horizontal, no single intersection
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const t = (rayY - p1.y) / dy
|
|
165
|
+
if (t < 0 || t > 1) {
|
|
166
|
+
// Intersection outside the edge segment
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const x = p1.x + t * (p2.x - p1.x)
|
|
171
|
+
return { x, y: rayY }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Find intersection of a vertical ray (x = rayX) with a line segment.
|
|
176
|
+
* Returns the intersection point or null if no intersection.
|
|
177
|
+
*/
|
|
178
|
+
function intersectVerticalRayWithEdge(
|
|
179
|
+
rayX: number,
|
|
180
|
+
p1: Point,
|
|
181
|
+
p2: Point,
|
|
182
|
+
): Point | null {
|
|
183
|
+
const dx = p2.x - p1.x
|
|
184
|
+
if (Math.abs(dx) < 0.001) {
|
|
185
|
+
// Edge is vertical, no single intersection
|
|
186
|
+
return null
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const t = (rayX - p1.x) / dx
|
|
190
|
+
if (t < 0 || t > 1) {
|
|
191
|
+
// Intersection outside the edge segment
|
|
192
|
+
return null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const y = p1.y + t * (p2.y - p1.y)
|
|
196
|
+
return { x: rayX, y }
|
|
197
|
+
}
|
package/src/styles.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Font metrics — character width estimates for Inter at different sizes.
|
|
3
|
+
// Used to approximate text bounding boxes without DOM measurement.
|
|
4
|
+
// These are calibrated for Inter's typical glyph widths.
|
|
5
|
+
//
|
|
6
|
+
// NOTE: Theme/color system has moved to packages/core/src/theme.ts. This file only
|
|
7
|
+
// contains font metrics, spacing constants, and stroke widths.
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
import { measureTextWidth } from '@zombie-mermaid/core'
|
|
11
|
+
|
|
12
|
+
/** Average character width in px at the given font size and weight (proportional font) */
|
|
13
|
+
export function estimateTextWidth(
|
|
14
|
+
text: string,
|
|
15
|
+
fontSize: number,
|
|
16
|
+
fontWeight: number,
|
|
17
|
+
): number {
|
|
18
|
+
// Delegate to variable-width character measurement for better accuracy
|
|
19
|
+
// with mixed character sets (Latin narrow/wide, CJK, emoji, etc.)
|
|
20
|
+
return measureTextWidth(text, fontSize, fontWeight)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Average character width in px for monospace fonts (uniform glyph width) */
|
|
24
|
+
export function estimateMonoTextWidth(text: string, fontSize: number): number {
|
|
25
|
+
// Monospace fonts have uniform character width — 0.6 of fontSize matches actual
|
|
26
|
+
// glyph widths for JetBrains Mono / SF Mono / Fira Code at small sizes (11px).
|
|
27
|
+
// Previous value of 0.55 underestimated widths, causing class member labels to
|
|
28
|
+
// extend beyond their box boundaries.
|
|
29
|
+
return text.length * fontSize * 0.6
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Monospace font family used for code-like text (class members, types) */
|
|
33
|
+
export const MONO_FONT = "'JetBrains Mono'" as const
|
|
34
|
+
|
|
35
|
+
/** Full CSS fallback chain for monospace text */
|
|
36
|
+
export const MONO_FONT_STACK =
|
|
37
|
+
`${MONO_FONT}, 'SF Mono', 'Fira Code', ui-monospace, monospace` as const
|
|
38
|
+
|
|
39
|
+
/** Default font sizes used in the renderer (in px). Overridable via `RenderOptions.fontSizes`. */
|
|
40
|
+
export const FONT_SIZES = {
|
|
41
|
+
/** Node label text */
|
|
42
|
+
nodeLabel: 13,
|
|
43
|
+
/** Edge label text */
|
|
44
|
+
edgeLabel: 11,
|
|
45
|
+
/** Subgraph header text */
|
|
46
|
+
groupHeader: 12,
|
|
47
|
+
} as const
|
|
48
|
+
|
|
49
|
+
/** Resolved font-size set — same shape as {@link FONT_SIZES} but mutable numbers. */
|
|
50
|
+
export type FontSizes = { [K in keyof typeof FONT_SIZES]: number }
|
|
51
|
+
|
|
52
|
+
/** Partial font-size overrides, as accepted by `RenderOptions.fontSizes`. */
|
|
53
|
+
export type FontSizeOptions = Partial<FontSizes>
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Merge user-provided font-size overrides over the {@link FONT_SIZES} defaults.
|
|
57
|
+
* Any field left unspecified (or `undefined`) falls back to its default.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveFontSizes(overrides?: FontSizeOptions): FontSizes {
|
|
60
|
+
return {
|
|
61
|
+
nodeLabel: overrides?.nodeLabel ?? FONT_SIZES.nodeLabel,
|
|
62
|
+
edgeLabel: overrides?.edgeLabel ?? FONT_SIZES.edgeLabel,
|
|
63
|
+
groupHeader: overrides?.groupHeader ?? FONT_SIZES.groupHeader,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Font weights used per element type */
|
|
68
|
+
export const FONT_WEIGHTS = {
|
|
69
|
+
nodeLabel: 500,
|
|
70
|
+
edgeLabel: 400,
|
|
71
|
+
groupHeader: 600,
|
|
72
|
+
} as const
|
|
73
|
+
|
|
74
|
+
// ============================================================================
|
|
75
|
+
// Spacing & sizing constants
|
|
76
|
+
// ============================================================================
|
|
77
|
+
|
|
78
|
+
/** Vertical gap between a subgraph header band and the content area below it (px).
|
|
79
|
+
* Without this, nested subgraph headers sit flush against their parent's header band.
|
|
80
|
+
* Increased from 8 to 12 to provide more clearance for edges routing near headers. */
|
|
81
|
+
export const GROUP_HEADER_CONTENT_PAD = 12
|
|
82
|
+
|
|
83
|
+
/** Padding inside node shapes */
|
|
84
|
+
export const NODE_PADDING = {
|
|
85
|
+
/** Horizontal padding inside rectangles/rounded/stadium (increased from 16 for better label fit) */
|
|
86
|
+
horizontal: 20,
|
|
87
|
+
/** Vertical padding inside rectangles/rounded/stadium */
|
|
88
|
+
vertical: 10,
|
|
89
|
+
/** Extra padding for diamond shapes (they need more space due to rotation) */
|
|
90
|
+
diamondExtra: 24,
|
|
91
|
+
} as const
|
|
92
|
+
|
|
93
|
+
/** Stroke widths per element type (in px) */
|
|
94
|
+
export const STROKE_WIDTHS = {
|
|
95
|
+
outerBox: 1,
|
|
96
|
+
innerBox: 0.75,
|
|
97
|
+
/** Edge connector stroke (increased from 0.75 for better visibility) */
|
|
98
|
+
connector: 1,
|
|
99
|
+
} as const
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Vertical shift applied to all text elements for font-agnostic centering.
|
|
103
|
+
*
|
|
104
|
+
* Instead of relying on `dominant-baseline="central"` (which each font interprets
|
|
105
|
+
* differently based on its own ascent/descent metrics), we use the default alphabetic
|
|
106
|
+
* baseline and shift down by 0.35em. This places the optical center of text at the
|
|
107
|
+
* y coordinate, regardless of font family (Inter, JetBrains Mono, system fallbacks).
|
|
108
|
+
*
|
|
109
|
+
* The 0.35em value approximates the distance from alphabetic baseline to visual
|
|
110
|
+
* center of Latin text. Using `em` units ensures it scales with font size.
|
|
111
|
+
*/
|
|
112
|
+
export const TEXT_BASELINE_SHIFT = '0.35em' as const
|
|
113
|
+
|
|
114
|
+
/** Arrow head dimensions — matches spec: 8px wide × ~5px tall */
|
|
115
|
+
export const ARROW_HEAD = {
|
|
116
|
+
width: 8,
|
|
117
|
+
height: 5,
|
|
118
|
+
} as const
|