@zombie-mermaid/svg-renderer 3.2.0 → 4.1.0
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/dist/index.cjs +48 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +42 -4
- package/dist/index.d.ts +42 -4
- package/dist/index.js +2567 -1766
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/c4-label-clearance-1290.test.ts +112 -0
- package/src/__tests__/c4-line-ends-1208.test.ts +52 -0
- package/src/__tests__/c4-mermaid-reference.test.ts +56 -17
- package/src/__tests__/c4-text-width-1210.test.ts +43 -0
- package/src/__tests__/class-component-order-1249.test.ts +40 -0
- package/src/__tests__/class-member-right-padding-1238.test.ts +43 -0
- package/src/__tests__/class-namespace-1196.test.ts +165 -0
- package/src/__tests__/class-namespace-sparse-elk-1196.test.ts +96 -0
- package/src/__tests__/class-visibility-markers-1237.test.ts +27 -0
- package/src/__tests__/er-attribute-right-padding-1262.test.ts +47 -0
- package/src/__tests__/er-default-direction-1250.test.ts +69 -0
- package/src/__tests__/er-edge-label-contrast-1244.test.ts +58 -0
- package/src/__tests__/flowchart-parallel-edge-lanes-1308.test.ts +168 -0
- package/src/__tests__/sequence-label-clearance-1242.test.ts +162 -0
- package/src/__tests__/sequence-nested-activation-1241.test.ts +37 -0
- package/src/__tests__/svg-edge-to-own-cluster-1310.test.ts +32 -0
- package/src/__tests__/xychart-x-label-overlap-1243.test.ts +62 -0
- package/src/c4/arial-widths.ts +59 -0
- package/src/c4/layout.ts +234 -44
- package/src/c4/metrics.ts +56 -30
- package/src/c4/renderer.ts +1 -1
- package/src/class/layout.ts +178 -13
- package/src/class/renderer.ts +33 -4
- package/src/er/layout.ts +20 -4
- package/src/er/renderer.ts +12 -3
- package/src/generated/mono-font-subset.ts +32 -0
- package/src/layout-engine/back-edges.ts +73 -0
- package/src/layout-engine/compound-flat.ts +916 -0
- package/src/layout-engine/edge-bundling.ts +79 -0
- package/src/layout-engine/elk-graph-builder.ts +5 -4
- package/src/layout-engine/from-elk.ts +7 -2
- package/src/layout-engine/inner-edges.ts +329 -0
- package/src/layout-engine/layout-hints.ts +27 -0
- package/src/layout-engine/to-elk.ts +185 -25
- package/src/layout-engine.ts +42 -1
- package/src/registry.ts +3 -3
- package/src/renderer.ts +26 -2
- package/src/sequence/layout.ts +72 -4
- package/src/sequence/renderer.ts +155 -15
- package/src/styles.ts +28 -1
- package/src/title-gaps.ts +161 -0
- package/src/xychart/layout.ts +31 -2
- package/src/xychart/renderer.ts +1 -0
package/src/sequence/renderer.ts
CHANGED
|
@@ -8,12 +8,13 @@ import type {
|
|
|
8
8
|
PositionedNote,
|
|
9
9
|
PositionedParticipantBox,
|
|
10
10
|
} from '@zombie-mermaid/mermaid-parser'
|
|
11
|
-
import { boxLabelHeight } from './layout.ts'
|
|
11
|
+
import { ACTOR_LABEL_OFFSET, boxLabelHeight } from './layout.ts'
|
|
12
12
|
import type { DiagramColors, SvgEmitOptions } from '@zombie-mermaid/core'
|
|
13
13
|
import {
|
|
14
14
|
svgOpenTag,
|
|
15
15
|
buildStyleBlock,
|
|
16
16
|
renderMultilineText,
|
|
17
|
+
measureMultilineText,
|
|
17
18
|
escapeAttr,
|
|
18
19
|
f,
|
|
19
20
|
} from '@zombie-mermaid/core'
|
|
@@ -104,9 +105,11 @@ export function renderSequenceSvg(
|
|
|
104
105
|
parts.push(renderBlock(block, fontSizes))
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
// 2. Lifelines (dashed vertical lines from actor to bottom)
|
|
108
|
+
// 2. Lifelines (dashed vertical lines from actor to bottom), interrupted
|
|
109
|
+
// where a message / block label sits on them so no line runs through text
|
|
110
|
+
const labelBoxes = collectLabelBoxes(diagram, fontSizes)
|
|
108
111
|
for (const lifeline of diagram.lifelines) {
|
|
109
|
-
parts.push(renderLifeline(lifeline))
|
|
112
|
+
parts.push(renderLifeline(lifeline, labelBoxes))
|
|
110
113
|
}
|
|
111
114
|
|
|
112
115
|
// 3. Activation boxes
|
|
@@ -195,7 +198,7 @@ function renderActor(actor: PositionedActor, fontSizes: FontSizes): string {
|
|
|
195
198
|
renderMultilineText(
|
|
196
199
|
label,
|
|
197
200
|
x,
|
|
198
|
-
y + height +
|
|
201
|
+
y + height + ACTOR_LABEL_OFFSET,
|
|
199
202
|
fontSizes.nodeLabel,
|
|
200
203
|
f`font-size="${fontSizes.nodeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.nodeLabel}" fill="var(--_text)"`,
|
|
201
204
|
),
|
|
@@ -223,15 +226,152 @@ function renderActor(actor: PositionedActor, fontSizes: FontSizes): string {
|
|
|
223
226
|
return parts.join('\n')
|
|
224
227
|
}
|
|
225
228
|
|
|
229
|
+
/** Self-message loop width, height, and the gap before its label, in px. */
|
|
230
|
+
const SELF_LOOP_WIDTH = 30
|
|
231
|
+
const SELF_LOOP_HEIGHT = 20
|
|
232
|
+
const SELF_LABEL_PADDING = 8
|
|
233
|
+
|
|
234
|
+
/** How far above its arrow a message label's centre sits, in px. */
|
|
235
|
+
const MESSAGE_LABEL_RISE = 10
|
|
236
|
+
|
|
237
|
+
/** Height of a block's type-label tab, in px. */
|
|
238
|
+
const BLOCK_TAB_HEIGHT = 18
|
|
239
|
+
|
|
240
|
+
/** A rectangle some text occupies; lifelines are cut around it. */
|
|
241
|
+
interface LabelBox {
|
|
242
|
+
x0: number
|
|
243
|
+
x1: number
|
|
244
|
+
y0: number
|
|
245
|
+
y1: number
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Breathing room kept between a label and the lifeline segments around it. */
|
|
249
|
+
const LIFELINE_LABEL_PAD_X = 3
|
|
250
|
+
const LIFELINE_LABEL_PAD_Y = 2
|
|
251
|
+
|
|
252
|
+
/** Lifeline fragments shorter than this are dropped rather than drawn as a stub. */
|
|
253
|
+
const LIFELINE_MIN_SEGMENT = 3
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Footprints of every text label a lifeline can run through: message labels
|
|
257
|
+
* (above the arrow, or beside a self-message loop), the block type tab, and
|
|
258
|
+
* block divider (`else` / `and`) labels. Mirrors the positions
|
|
259
|
+
* {@link renderMessage} and {@link renderBlock} draw them at (#1242).
|
|
260
|
+
*/
|
|
261
|
+
function collectLabelBoxes(
|
|
262
|
+
diagram: PositionedSequenceDiagram,
|
|
263
|
+
fontSizes: FontSizes,
|
|
264
|
+
): LabelBox[] {
|
|
265
|
+
const boxes: LabelBox[] = []
|
|
266
|
+
const measure = (text: string) =>
|
|
267
|
+
measureMultilineText(text, fontSizes.edgeLabel, FONT_WEIGHTS.edgeLabel)
|
|
268
|
+
const centred = (text: string, cx: number, cy: number) => {
|
|
269
|
+
const m = measure(text)
|
|
270
|
+
boxes.push({
|
|
271
|
+
x0: cx - m.width / 2,
|
|
272
|
+
x1: cx + m.width / 2,
|
|
273
|
+
y0: cy - m.height / 2,
|
|
274
|
+
y1: cy + m.height / 2,
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
const startAnchored = (text: string, x: number, cy: number) => {
|
|
278
|
+
const m = measure(text)
|
|
279
|
+
boxes.push({
|
|
280
|
+
x0: x,
|
|
281
|
+
x1: x + m.width,
|
|
282
|
+
y0: cy - m.height / 2,
|
|
283
|
+
y1: cy + m.height / 2,
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
for (const msg of diagram.messages) {
|
|
288
|
+
if (!msg.label) continue
|
|
289
|
+
if (msg.isSelf) {
|
|
290
|
+
startAnchored(
|
|
291
|
+
msg.label,
|
|
292
|
+
msg.x1 + SELF_LOOP_WIDTH + SELF_LABEL_PADDING,
|
|
293
|
+
msg.y + SELF_LOOP_HEIGHT / 2,
|
|
294
|
+
)
|
|
295
|
+
} else {
|
|
296
|
+
centred(msg.label, (msg.x1 + msg.x2) / 2, msg.y - MESSAGE_LABEL_RISE)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
for (const block of diagram.blocks) {
|
|
301
|
+
const tab = `${block.type}${block.label ? ` [${block.label}]` : ''}`
|
|
302
|
+
const tabWidth =
|
|
303
|
+
estimateTextWidth(
|
|
304
|
+
tab.split('\n')[0] ?? '',
|
|
305
|
+
fontSizes.edgeLabel,
|
|
306
|
+
FONT_WEIGHTS.groupHeader,
|
|
307
|
+
) + 16
|
|
308
|
+
boxes.push({
|
|
309
|
+
x0: block.x,
|
|
310
|
+
x1: block.x + tabWidth,
|
|
311
|
+
y0: block.y,
|
|
312
|
+
y1: block.y + BLOCK_TAB_HEIGHT,
|
|
313
|
+
})
|
|
314
|
+
for (const divider of block.dividers) {
|
|
315
|
+
if (!divider.label) continue
|
|
316
|
+
startAnchored(`[${divider.label}]`, block.x + 8, divider.y + 14)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return boxes
|
|
320
|
+
}
|
|
321
|
+
|
|
226
322
|
/**
|
|
227
|
-
*
|
|
323
|
+
* The y-ranges of `lifeline` left after cutting out every label box it
|
|
324
|
+
* crosses, as `[y0, y1]` pairs top to bottom.
|
|
325
|
+
*/
|
|
326
|
+
function lifelineSegments(
|
|
327
|
+
lifeline: Lifeline,
|
|
328
|
+
labelBoxes: readonly LabelBox[],
|
|
329
|
+
): Array<[number, number]> {
|
|
330
|
+
const cuts = labelBoxes
|
|
331
|
+
.filter(
|
|
332
|
+
(b) =>
|
|
333
|
+
lifeline.x > b.x0 - LIFELINE_LABEL_PAD_X &&
|
|
334
|
+
lifeline.x < b.x1 + LIFELINE_LABEL_PAD_X &&
|
|
335
|
+
b.y1 + LIFELINE_LABEL_PAD_Y > lifeline.topY &&
|
|
336
|
+
b.y0 - LIFELINE_LABEL_PAD_Y < lifeline.bottomY,
|
|
337
|
+
)
|
|
338
|
+
.map((b): [number, number] => [
|
|
339
|
+
b.y0 - LIFELINE_LABEL_PAD_Y,
|
|
340
|
+
b.y1 + LIFELINE_LABEL_PAD_Y,
|
|
341
|
+
])
|
|
342
|
+
.sort((a, b) => a[0] - b[0])
|
|
343
|
+
|
|
344
|
+
const segments: Array<[number, number]> = []
|
|
345
|
+
let cursor = lifeline.topY
|
|
346
|
+
for (const [c0, c1] of cuts) {
|
|
347
|
+
if (c0 - cursor >= LIFELINE_MIN_SEGMENT) segments.push([cursor, c0])
|
|
348
|
+
cursor = Math.max(cursor, c1)
|
|
349
|
+
}
|
|
350
|
+
if (lifeline.bottomY - cursor >= LIFELINE_MIN_SEGMENT) {
|
|
351
|
+
segments.push([cursor, lifeline.bottomY])
|
|
352
|
+
}
|
|
353
|
+
// A lifeline entirely under a label (or otherwise shorter than a stub)
|
|
354
|
+
// would vanish; keep it whole rather than drop the participant's line.
|
|
355
|
+
return segments.length > 0 ? segments : [[lifeline.topY, lifeline.bottomY]]
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Render a lifeline (dashed vertical line from actor to bottom), as one
|
|
360
|
+
* `<line>` per stretch between label boxes it would otherwise pass through.
|
|
228
361
|
* Includes data-actor to link to its actor.
|
|
229
362
|
*/
|
|
230
|
-
function renderLifeline(
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
363
|
+
function renderLifeline(
|
|
364
|
+
lifeline: Lifeline,
|
|
365
|
+
labelBoxes: readonly LabelBox[],
|
|
366
|
+
): string {
|
|
367
|
+
const line = lifelineSegments(lifeline, labelBoxes)
|
|
368
|
+
.map(
|
|
369
|
+
([y0, y1]) =>
|
|
370
|
+
f`<line class="lifeline" data-actor="${escapeAttr(lifeline.actorId)}" ` +
|
|
371
|
+
f`x1="${lifeline.x}" y1="${y0}" x2="${lifeline.x}" y2="${y1}" ` +
|
|
372
|
+
`stroke="var(--_line)" stroke-width="0.75" stroke-dasharray="6 4" />`,
|
|
373
|
+
)
|
|
374
|
+
.join('\n')
|
|
235
375
|
if (!lifeline.destroyed) return line
|
|
236
376
|
// `destroy X`: the lifeline ends at the destroying message's row, marked
|
|
237
377
|
// with a cross centred on it — the same glyph Mermaid uses. Drawn in the
|
|
@@ -287,9 +427,9 @@ function renderMessage(msg: PositionedMessage, fontSizes: FontSizes): string {
|
|
|
287
427
|
if (msg.isSelf) {
|
|
288
428
|
// Self-message: curved loop going right and back
|
|
289
429
|
// Loop dimensions - loopH is fixed, loopW provides minimum clearance
|
|
290
|
-
const loopW =
|
|
291
|
-
const loopH =
|
|
292
|
-
const labelPadding =
|
|
430
|
+
const loopW = SELF_LOOP_WIDTH
|
|
431
|
+
const loopH = SELF_LOOP_HEIGHT
|
|
432
|
+
const labelPadding = SELF_LABEL_PADDING // Space between loop and label
|
|
293
433
|
parts.push(
|
|
294
434
|
f` <polyline points="${msg.x1},${msg.y} ${msg.x1 + loopW},${msg.y} ${msg.x1 + loopW},${msg.y + loopH} ${msg.x2},${msg.y + loopH}" ` +
|
|
295
435
|
f`fill="none" stroke="var(--_line)" stroke-width="${STROKE_WIDTHS.connector}"${dashArray} marker-end="url(#${markerId})"${markerStart} />`,
|
|
@@ -318,7 +458,7 @@ function renderMessage(msg: PositionedMessage, fontSizes: FontSizes): string {
|
|
|
318
458
|
renderMultilineText(
|
|
319
459
|
msg.label,
|
|
320
460
|
midX,
|
|
321
|
-
msg.y -
|
|
461
|
+
msg.y - MESSAGE_LABEL_RISE,
|
|
322
462
|
fontSizes.edgeLabel,
|
|
323
463
|
f`font-size="${fontSizes.edgeLabel}" text-anchor="middle" font-weight="${FONT_WEIGHTS.edgeLabel}" fill="var(--_text-muted)"`,
|
|
324
464
|
),
|
|
@@ -455,7 +595,7 @@ function renderBlock(block: PositionedBlock, fontSizes: FontSizes): string {
|
|
|
455
595
|
fontSizes.edgeLabel,
|
|
456
596
|
FONT_WEIGHTS.groupHeader,
|
|
457
597
|
) + 16
|
|
458
|
-
const tabHeight =
|
|
598
|
+
const tabHeight = BLOCK_TAB_HEIGHT
|
|
459
599
|
|
|
460
600
|
parts.push(
|
|
461
601
|
f` <rect x="${block.x}" y="${block.y}" width="${tabWidth}" height="${tabHeight}" ` +
|
package/src/styles.ts
CHANGED
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
// contains font metrics, spacing constants, and stroke widths.
|
|
8
8
|
// ============================================================================
|
|
9
9
|
|
|
10
|
-
import { measureTextWidth } from '@zombie-mermaid/core'
|
|
10
|
+
import { buildStyleBlock, measureTextWidth } from '@zombie-mermaid/core'
|
|
11
|
+
import {
|
|
12
|
+
MONO_FONT_FAMILY as EMBEDDED_MONO_FONT_FAMILY,
|
|
13
|
+
MONO_FONT_FACE_CSS as EMBEDDED_MONO_FONT_FACE_CSS,
|
|
14
|
+
} from './generated/mono-font-subset.ts'
|
|
11
15
|
|
|
12
16
|
/** Average character width in px at the given font size and weight (proportional font) */
|
|
13
17
|
export function estimateTextWidth(
|
|
@@ -116,3 +120,26 @@ export const ARROW_HEAD = {
|
|
|
116
120
|
width: 8,
|
|
117
121
|
height: 5,
|
|
118
122
|
} as const
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* SVG `<style>` block for a diagram: core's `buildStyleBlock` plus, when
|
|
126
|
+
* `hasMonoFont` is set, the self-hosted mono font subset for the `.mono`
|
|
127
|
+
* rule. The subset lives here rather than in `core` because only SVG output
|
|
128
|
+
* embeds it (#1319).
|
|
129
|
+
*/
|
|
130
|
+
export function buildSvgStyleBlock(
|
|
131
|
+
font: string,
|
|
132
|
+
hasMonoFont: boolean,
|
|
133
|
+
nonce?: string,
|
|
134
|
+
): string {
|
|
135
|
+
return buildStyleBlock(
|
|
136
|
+
font,
|
|
137
|
+
hasMonoFont
|
|
138
|
+
? {
|
|
139
|
+
family: EMBEDDED_MONO_FONT_FAMILY,
|
|
140
|
+
faceCss: EMBEDDED_MONO_FONT_FACE_CSS,
|
|
141
|
+
}
|
|
142
|
+
: false,
|
|
143
|
+
nonce,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Break an edge where it crosses a subgraph's title text.
|
|
3
|
+
*
|
|
4
|
+
* Layout treats a subgraph's title bar as plain padding, so an edge that
|
|
5
|
+
* enters a box from above can run straight over the title. mermaid.js draws
|
|
6
|
+
* such an edge through the text as well; here the edge is simply not painted
|
|
7
|
+
* over the text itself, and picks up again on the other side. The edge stays
|
|
8
|
+
* one element with its geometry untouched: a `<mask>` hides the stroke inside
|
|
9
|
+
* the text box (#1239).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { measureMultilineText } from '@zombie-mermaid/core'
|
|
13
|
+
import type {
|
|
14
|
+
PositionedEdge,
|
|
15
|
+
PositionedGroup,
|
|
16
|
+
Point,
|
|
17
|
+
} from '@zombie-mermaid/core'
|
|
18
|
+
import type { FontSizes } from './styles.ts'
|
|
19
|
+
import { FONT_WEIGHTS } from './styles.ts'
|
|
20
|
+
|
|
21
|
+
/** Left inset of the title text inside the box, matching `renderGroup`. */
|
|
22
|
+
const TITLE_TEXT_INSET = 12
|
|
23
|
+
|
|
24
|
+
/** Space left clear around the title text, in px. */
|
|
25
|
+
const GAP_PADDING = 2
|
|
26
|
+
|
|
27
|
+
export interface TitleTextBox {
|
|
28
|
+
x: number
|
|
29
|
+
y: number
|
|
30
|
+
width: number
|
|
31
|
+
height: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function flattenGroups(groups: PositionedGroup[]): PositionedGroup[] {
|
|
35
|
+
return groups.flatMap((g) => [g, ...flattenGroups(g.children)])
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The rectangle each subgraph's title text occupies, padded by `GAP_PADDING`. */
|
|
39
|
+
export function titleTextBoxes(
|
|
40
|
+
groups: PositionedGroup[],
|
|
41
|
+
fontSizes: FontSizes,
|
|
42
|
+
): TitleTextBox[] {
|
|
43
|
+
const headerHeight = fontSizes.groupHeader + 16
|
|
44
|
+
return flattenGroups(groups)
|
|
45
|
+
.filter((g) => g.label !== '')
|
|
46
|
+
.map((g) => {
|
|
47
|
+
const text = measureMultilineText(
|
|
48
|
+
g.label,
|
|
49
|
+
fontSizes.groupHeader,
|
|
50
|
+
FONT_WEIGHTS.groupHeader,
|
|
51
|
+
)
|
|
52
|
+
return {
|
|
53
|
+
x: g.x + TITLE_TEXT_INSET - GAP_PADDING,
|
|
54
|
+
y: g.y + headerHeight / 2 - text.height / 2 - GAP_PADDING,
|
|
55
|
+
width: text.width + 2 * GAP_PADDING,
|
|
56
|
+
height: text.height + 2 * GAP_PADDING,
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether the segment a-b touches the rectangle (Liang-Barsky clipping). */
|
|
62
|
+
function segmentHitsBox(a: Point, b: Point, box: TitleTextBox): boolean {
|
|
63
|
+
const dx = b.x - a.x
|
|
64
|
+
const dy = b.y - a.y
|
|
65
|
+
const p = [-dx, dx, -dy, dy]
|
|
66
|
+
const q = [
|
|
67
|
+
a.x - box.x,
|
|
68
|
+
box.x + box.width - a.x,
|
|
69
|
+
a.y - box.y,
|
|
70
|
+
box.y + box.height - a.y,
|
|
71
|
+
]
|
|
72
|
+
let t0 = 0
|
|
73
|
+
let t1 = 1
|
|
74
|
+
for (let i = 0; i < 4; i++) {
|
|
75
|
+
const pi = p[i]!
|
|
76
|
+
const qi = q[i]!
|
|
77
|
+
if (pi === 0) {
|
|
78
|
+
if (qi < 0) return false
|
|
79
|
+
} else {
|
|
80
|
+
const t = qi / pi
|
|
81
|
+
if (pi < 0) t0 = Math.max(t0, t)
|
|
82
|
+
else t1 = Math.min(t1, t)
|
|
83
|
+
if (t0 > t1) return false
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The box without its `GAP_PADDING` clearance: the text itself. */
|
|
90
|
+
function textOnly(box: TitleTextBox): TitleTextBox {
|
|
91
|
+
return {
|
|
92
|
+
x: box.x + GAP_PADDING,
|
|
93
|
+
y: box.y + GAP_PADDING,
|
|
94
|
+
width: box.width - 2 * GAP_PADDING,
|
|
95
|
+
height: box.height - 2 * GAP_PADDING,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Whether any segment of `edge` passes through the text of any of the `boxes`.
|
|
101
|
+
* The padding only widens the hole that is cut; an edge that merely passes
|
|
102
|
+
* through the clearance beside the text does not need a gap.
|
|
103
|
+
*/
|
|
104
|
+
export function edgeCrossesTitle(
|
|
105
|
+
edge: PositionedEdge,
|
|
106
|
+
boxes: TitleTextBox[],
|
|
107
|
+
): boolean {
|
|
108
|
+
const pts = edge.points
|
|
109
|
+
const texts = boxes.map(textOnly)
|
|
110
|
+
for (let i = 0; i + 1 < pts.length; i++) {
|
|
111
|
+
if (texts.some((box) => segmentHitsBox(pts[i]!, pts[i + 1]!, box))) {
|
|
112
|
+
return true
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Small stable hash (FNV-1a) for a mask id that is the same for the same boxes. */
|
|
119
|
+
function hashBoxes(boxes: TitleTextBox[]): string {
|
|
120
|
+
let h = 0x811c9dc5
|
|
121
|
+
for (const ch of boxes
|
|
122
|
+
.map((b) => `${b.x},${b.y},${b.width},${b.height}`)
|
|
123
|
+
.join(';')) {
|
|
124
|
+
h = Math.imul(h ^ ch.charCodeAt(0), 0x01000193)
|
|
125
|
+
}
|
|
126
|
+
return (h >>> 0).toString(36)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface TitleGapMask {
|
|
130
|
+
id: string
|
|
131
|
+
/** The `<mask>` element, for `<defs>`. */
|
|
132
|
+
markup: string
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The mask that hides edge strokes inside `boxes`. The id comes from the box
|
|
137
|
+
* positions, so two inline diagrams never share an id with different content.
|
|
138
|
+
*
|
|
139
|
+
* `maskUnits="userSpaceOnUse"` is required: the default bounding-box units
|
|
140
|
+
* give a straight vertical or horizontal edge a zero-size box, which masks the
|
|
141
|
+
* whole edge away.
|
|
142
|
+
*/
|
|
143
|
+
export function titleGapMask(
|
|
144
|
+
boxes: TitleTextBox[],
|
|
145
|
+
width: number,
|
|
146
|
+
height: number,
|
|
147
|
+
): TitleGapMask {
|
|
148
|
+
const id = `zm-title-gap-${hashBoxes(boxes)}`
|
|
149
|
+
const holes = boxes
|
|
150
|
+
.map(
|
|
151
|
+
(b) =>
|
|
152
|
+
` <rect x="${b.x}" y="${b.y}" width="${b.width}" height="${b.height}" fill="#000" />`,
|
|
153
|
+
)
|
|
154
|
+
.join('\n')
|
|
155
|
+
const markup =
|
|
156
|
+
` <mask id="${id}" maskUnits="userSpaceOnUse" x="0" y="0" width="${width}" height="${height}">\n` +
|
|
157
|
+
` <rect x="0" y="0" width="${width}" height="${height}" fill="#fff" />\n` +
|
|
158
|
+
`${holes}\n` +
|
|
159
|
+
` </mask>`
|
|
160
|
+
return { id, markup }
|
|
161
|
+
}
|
package/src/xychart/layout.ts
CHANGED
|
@@ -489,16 +489,45 @@ function getCategoryLabels(chart: XYChart, count: number): string[] {
|
|
|
489
489
|
return Array.from({ length: count }, (_, i) => String(i + 1))
|
|
490
490
|
}
|
|
491
491
|
|
|
492
|
+
/** Minimum clear space between two neighbouring x-axis labels. */
|
|
493
|
+
const X_LABEL_MIN_GAP = 8
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Step between shown x-axis labels: 1 when every pair of neighbouring labels
|
|
497
|
+
* keeps `X_LABEL_MIN_GAP` of clear space, otherwise the smallest n for which
|
|
498
|
+
* every pair of shown (every n-th) labels does. Checked pairwise on the real
|
|
499
|
+
* widths so one long label doesn't thin out an otherwise roomy axis.
|
|
500
|
+
*/
|
|
501
|
+
export function xLabelStep(labels: string[], bandWidth: number): number {
|
|
502
|
+
const widths = labels.map((l) =>
|
|
503
|
+
estimateTextWidth(l, XY.axisLabelFontSize, XY.axisLabelFontWeight),
|
|
504
|
+
)
|
|
505
|
+
for (let step = 1; step < labels.length; step++) {
|
|
506
|
+
let fits = true
|
|
507
|
+
for (let i = 0; i + step < labels.length; i += step) {
|
|
508
|
+
const gap = step * bandWidth - (widths[i]! + widths[i + step]!) / 2
|
|
509
|
+
if (gap < X_LABEL_MIN_GAP) {
|
|
510
|
+
fits = false
|
|
511
|
+
break
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (fits) return step
|
|
515
|
+
}
|
|
516
|
+
return Math.max(1, labels.length)
|
|
517
|
+
}
|
|
518
|
+
|
|
492
519
|
function buildXTicks(
|
|
493
520
|
chart: XYChart,
|
|
494
521
|
xScale: (i: number) => number,
|
|
495
522
|
axisY: number,
|
|
496
|
-
|
|
523
|
+
bandWidth: number,
|
|
497
524
|
): AxisTick[] {
|
|
498
525
|
const count = getDataCount(chart)
|
|
499
526
|
const labels = getCategoryLabels(chart, count)
|
|
527
|
+
const step = xLabelStep(labels, bandWidth)
|
|
528
|
+
// Dropped labels keep their tick mark but render no text.
|
|
500
529
|
return labels.map((label, i) => ({
|
|
501
|
-
label,
|
|
530
|
+
label: i % step === 0 ? label : '',
|
|
502
531
|
x: xScale(i),
|
|
503
532
|
y: axisY,
|
|
504
533
|
tx: xScale(i),
|
package/src/xychart/renderer.ts
CHANGED
|
@@ -293,6 +293,7 @@ export function renderXYChartSvg(
|
|
|
293
293
|
|
|
294
294
|
// 5. Axis labels (no axis lines, no tick marks — just floating labels)
|
|
295
295
|
for (const tick of chart.xAxis.ticks) {
|
|
296
|
+
if (tick.label === '') continue // dropped by the layout's overlap step
|
|
296
297
|
parts.push(
|
|
297
298
|
`<text x="${tick.labelX}" y="${tick.labelY}" text-anchor="${tick.textAnchor}" ` +
|
|
298
299
|
`font-size="${CHART_FONT.labelSize}" font-weight="${CHART_FONT.labelWeight}" ` +
|