@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.
@@ -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