@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.
Files changed (50) hide show
  1. package/dist/index.cjs +48 -38
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +42 -4
  4. package/dist/index.d.ts +42 -4
  5. package/dist/index.js +2567 -1766
  6. package/dist/index.js.map +1 -1
  7. package/package.json +3 -3
  8. package/src/__tests__/c4-label-clearance-1290.test.ts +112 -0
  9. package/src/__tests__/c4-line-ends-1208.test.ts +52 -0
  10. package/src/__tests__/c4-mermaid-reference.test.ts +56 -17
  11. package/src/__tests__/c4-text-width-1210.test.ts +43 -0
  12. package/src/__tests__/class-component-order-1249.test.ts +40 -0
  13. package/src/__tests__/class-member-right-padding-1238.test.ts +43 -0
  14. package/src/__tests__/class-namespace-1196.test.ts +165 -0
  15. package/src/__tests__/class-namespace-sparse-elk-1196.test.ts +96 -0
  16. package/src/__tests__/class-visibility-markers-1237.test.ts +27 -0
  17. package/src/__tests__/er-attribute-right-padding-1262.test.ts +47 -0
  18. package/src/__tests__/er-default-direction-1250.test.ts +69 -0
  19. package/src/__tests__/er-edge-label-contrast-1244.test.ts +58 -0
  20. package/src/__tests__/flowchart-parallel-edge-lanes-1308.test.ts +168 -0
  21. package/src/__tests__/sequence-label-clearance-1242.test.ts +162 -0
  22. package/src/__tests__/sequence-nested-activation-1241.test.ts +37 -0
  23. package/src/__tests__/svg-edge-to-own-cluster-1310.test.ts +32 -0
  24. package/src/__tests__/xychart-x-label-overlap-1243.test.ts +62 -0
  25. package/src/c4/arial-widths.ts +59 -0
  26. package/src/c4/layout.ts +234 -44
  27. package/src/c4/metrics.ts +56 -30
  28. package/src/c4/renderer.ts +1 -1
  29. package/src/class/layout.ts +178 -13
  30. package/src/class/renderer.ts +33 -4
  31. package/src/er/layout.ts +20 -4
  32. package/src/er/renderer.ts +12 -3
  33. package/src/generated/mono-font-subset.ts +32 -0
  34. package/src/layout-engine/back-edges.ts +73 -0
  35. package/src/layout-engine/compound-flat.ts +916 -0
  36. package/src/layout-engine/edge-bundling.ts +79 -0
  37. package/src/layout-engine/elk-graph-builder.ts +5 -4
  38. package/src/layout-engine/from-elk.ts +7 -2
  39. package/src/layout-engine/inner-edges.ts +329 -0
  40. package/src/layout-engine/layout-hints.ts +27 -0
  41. package/src/layout-engine/to-elk.ts +185 -25
  42. package/src/layout-engine.ts +42 -1
  43. package/src/registry.ts +3 -3
  44. package/src/renderer.ts +26 -2
  45. package/src/sequence/layout.ts +72 -4
  46. package/src/sequence/renderer.ts +155 -15
  47. package/src/styles.ts +28 -1
  48. package/src/title-gaps.ts +161 -0
  49. package/src/xychart/layout.ts +31 -2
  50. package/src/xychart/renderer.ts +1 -0
@@ -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 + 14,
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
- * Render a lifeline (dashed vertical line from actor to bottom).
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(lifeline: Lifeline): string {
231
- const line =
232
- f`<line class="lifeline" data-actor="${escapeAttr(lifeline.actorId)}" ` +
233
- f`x1="${lifeline.x}" y1="${lifeline.topY}" x2="${lifeline.x}" y2="${lifeline.bottomY}" ` +
234
- `stroke="var(--_line)" stroke-width="0.75" stroke-dasharray="6 4" />`
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 = 30
291
- const loopH = 20
292
- const labelPadding = 8 // Space between loop and label
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 - 10,
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 = 18
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
+ }
@@ -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
- _bandWidth: number,
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),
@@ -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}" ` +