@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,682 @@
1
+ import type {
2
+ XYChart,
3
+ PositionedXYChart,
4
+ PositionedAxis,
5
+ AxisTick,
6
+ PositionedBar,
7
+ PositionedLine,
8
+ GridLine,
9
+ PlotArea,
10
+ LegendItem,
11
+ } from '@zombie-mermaid/mermaid-parser'
12
+ import type { XyChartRenderOptions } from '@zombie-mermaid/core'
13
+ import { estimateTextWidth } from '../styles.ts'
14
+
15
+ // ============================================================================
16
+ // XY Chart layout engine
17
+ //
18
+ // Computes pixel coordinates for all chart elements. No dagre needed —
19
+ // direct coordinate-space mapping for axes, bars, lines, and grid.
20
+ // ============================================================================
21
+
22
+ /** Layout constants — aligned with Chart.js default proportions */
23
+ const XY = {
24
+ plotWidth: 600,
25
+ plotHeight: 340,
26
+ padding: 22,
27
+ titleFontSize: 18,
28
+ titleFontWeight: 600,
29
+ titleHeight: 42,
30
+ axisLabelFontSize: 14,
31
+ axisLabelFontWeight: 400,
32
+ axisTitleFontSize: 15,
33
+ axisTitleFontWeight: 500,
34
+ xLabelHeight: 38,
35
+ yLabelWidth: 58,
36
+ yLabelGap: 18,
37
+ axisTitlePad: 30,
38
+ tickLength: 4,
39
+ barPadRatio: 0.2,
40
+ barGroupGap: 0,
41
+ maxBarWidth: 40,
42
+ legendFontSize: 14,
43
+ legendFontWeight: 400,
44
+ legendHeight: 28,
45
+ legendSwatchW: 14,
46
+ legendSwatchH: 14,
47
+ legendGap: 6,
48
+ legendItemGap: 16,
49
+ } as const
50
+
51
+ /**
52
+ * Lay out a parsed XY chart by computing pixel coordinates.
53
+ */
54
+ export function layoutXYChart(
55
+ chart: XYChart,
56
+ _options: XyChartRenderOptions = {},
57
+ ): PositionedXYChart {
58
+ if (chart.horizontal) return layoutHorizontal(chart)
59
+ return layoutVertical(chart)
60
+ }
61
+
62
+ // ============================================================================
63
+ // Vertical layout (default)
64
+ // ============================================================================
65
+
66
+ function layoutVertical(chart: XYChart): PositionedXYChart {
67
+ const hasTitle = !!chart.title
68
+ const hasXTitle = !!chart.xAxis.title
69
+ const hasYTitle = !!chart.yAxis.title
70
+ const hasLegend = chart.series.length > 1
71
+
72
+ // Compute y-axis label width from tick labels
73
+ //
74
+ // parseXYChart always derives a y-axis range (from data or a 0-100
75
+ // fallback — see xychart/parser.ts) before returning, so this is never
76
+ // actually undefined for parsed input. That guarantee lives in a
77
+ // different module, so narrow it here explicitly instead of trusting it
78
+ // silently across the boundary.
79
+ const yRange = chart.yAxis.range
80
+ if (!yRange) {
81
+ /* v8 ignore next */
82
+ throw new Error('XY chart: y-axis range was not set by the parser')
83
+ }
84
+ const yTicks = niceTickValues(yRange.min, yRange.max)
85
+ const maxYLabelWidth = Math.max(
86
+ ...yTicks.map((v) =>
87
+ estimateTextWidth(
88
+ formatTickValue(v),
89
+ XY.axisLabelFontSize,
90
+ XY.axisLabelFontWeight,
91
+ ),
92
+ ),
93
+ XY.yLabelWidth,
94
+ )
95
+
96
+ // Margins
97
+ const top =
98
+ XY.padding +
99
+ (hasTitle ? XY.titleHeight : 0) +
100
+ (hasLegend ? XY.legendHeight : 0)
101
+ const bottom =
102
+ XY.padding + XY.xLabelHeight + (hasXTitle ? XY.axisTitlePad : 0)
103
+ const left =
104
+ XY.padding +
105
+ maxYLabelWidth +
106
+ XY.yLabelGap +
107
+ (hasYTitle ? XY.axisTitlePad : 0)
108
+ const right = XY.padding
109
+
110
+ const plotW = XY.plotWidth
111
+ const plotH = XY.plotHeight
112
+ const totalW = left + plotW + right
113
+ const totalH = top + plotH + bottom
114
+
115
+ const plotArea: PlotArea = { x: left, y: top, width: plotW, height: plotH }
116
+
117
+ // Scales
118
+ const dataCount = getDataCount(chart)
119
+ const xScale = (i: number) => left + (i + 0.5) * (plotW / dataCount)
120
+ const bandWidth = plotW / dataCount
121
+ const yScale = (v: number) => {
122
+ const t = (v - yRange.min) / (yRange.max - yRange.min || 1)
123
+ return top + plotH - t * plotH
124
+ }
125
+
126
+ // X-axis ticks
127
+ const xTicks = buildXTicks(chart, xScale, top + plotH, bandWidth)
128
+
129
+ // Y-axis ticks
130
+ const yAxisTicks: AxisTick[] = yTicks.map((v) => ({
131
+ label: formatTickValue(v),
132
+ x: left,
133
+ y: yScale(v),
134
+ tx: left - XY.tickLength,
135
+ ty: yScale(v),
136
+ labelX: left - XY.yLabelGap,
137
+ labelY: yScale(v),
138
+ textAnchor: 'end' as const,
139
+ }))
140
+
141
+ // Grid lines (horizontal at each y tick)
142
+ const gridLines: GridLine[] = yTicks.map((v) => ({
143
+ x1: left,
144
+ y1: yScale(v),
145
+ x2: left + plotW,
146
+ y2: yScale(v),
147
+ }))
148
+
149
+ // Category labels for data attributes
150
+ const catLabels = getCategoryLabels(chart, dataCount)
151
+
152
+ // Global color index map: each series gets a unique color index regardless of type
153
+ const colorMap = chart.series.map((_, i) => i)
154
+
155
+ // Bars
156
+ const bars = layoutBars(
157
+ chart,
158
+ xScale,
159
+ yScale,
160
+ bandWidth,
161
+ yRange.min,
162
+ catLabels,
163
+ colorMap,
164
+ )
165
+
166
+ // Lines
167
+ const lines = layoutLines(chart, xScale, yScale, catLabels, colorMap)
168
+
169
+ // Legend
170
+ const legendY =
171
+ XY.padding + (hasTitle ? XY.titleHeight : 0) + XY.legendHeight / 2
172
+ const legend = hasLegend
173
+ ? buildLegendItems(chart, totalW / 2, legendY, colorMap)
174
+ : []
175
+
176
+ // Axis lines
177
+ const xAxisLine = {
178
+ x1: left,
179
+ y1: top + plotH,
180
+ x2: left + plotW,
181
+ y2: top + plotH,
182
+ }
183
+ const yAxisLine = { x1: left, y1: top, x2: left, y2: top + plotH }
184
+
185
+ // Axis titles
186
+ const xAxisObj: PositionedAxis = {
187
+ ticks: xTicks,
188
+ line: xAxisLine,
189
+ ...(chart.xAxis.title
190
+ ? {
191
+ title: {
192
+ text: chart.xAxis.title,
193
+ x: left + plotW / 2,
194
+ y: totalH - XY.padding,
195
+ },
196
+ }
197
+ : {}),
198
+ }
199
+ const yAxisObj: PositionedAxis = {
200
+ ticks: yAxisTicks,
201
+ line: yAxisLine,
202
+ ...(chart.yAxis.title
203
+ ? {
204
+ title: {
205
+ text: chart.yAxis.title,
206
+ x: XY.padding + 4,
207
+ y: top + plotH / 2,
208
+ rotate: -90,
209
+ },
210
+ }
211
+ : {}),
212
+ }
213
+
214
+ // Title
215
+ const titleObj = chart.title
216
+ ? { text: chart.title, x: totalW / 2, y: XY.padding + XY.titleFontSize }
217
+ : undefined
218
+
219
+ return {
220
+ width: totalW,
221
+ height: totalH,
222
+ title: titleObj,
223
+ xAxis: xAxisObj,
224
+ yAxis: yAxisObj,
225
+ plotArea,
226
+ bars,
227
+ lines,
228
+ gridLines,
229
+ legend,
230
+ }
231
+ }
232
+
233
+ // ============================================================================
234
+ // Horizontal layout
235
+ // ============================================================================
236
+
237
+ function layoutHorizontal(chart: XYChart): PositionedXYChart {
238
+ const hasTitle = !!chart.title
239
+ const hasXTitle = !!chart.xAxis.title
240
+ const hasYTitle = !!chart.yAxis.title
241
+ const hasLegend = chart.series.length > 1
242
+
243
+ // In horizontal mode: categories go on y-axis (left side), values go on x-axis (bottom)
244
+ //
245
+ // See the matching comment in layoutVertical: the parser always derives a
246
+ // y-axis range before returning, but that guarantee lives in a different
247
+ // module, so it's narrowed here explicitly rather than trusted silently.
248
+ const yRange = chart.yAxis.range
249
+ if (!yRange) {
250
+ /* v8 ignore next */
251
+ throw new Error('XY chart: y-axis range was not set by the parser')
252
+ }
253
+ const valueTicks = niceTickValues(yRange.min, yRange.max)
254
+
255
+ // Compute category label widths for left margin
256
+ const dataCount = getDataCount(chart)
257
+ const catLabels = getCategoryLabels(chart, dataCount)
258
+ const maxCatLabelWidth = Math.max(
259
+ ...catLabels.map((l) =>
260
+ estimateTextWidth(l, XY.axisLabelFontSize, XY.axisLabelFontWeight),
261
+ ),
262
+ 40,
263
+ )
264
+
265
+ const top =
266
+ XY.padding +
267
+ (hasTitle ? XY.titleHeight : 0) +
268
+ (hasLegend ? XY.legendHeight : 0)
269
+ const bottom =
270
+ XY.padding + XY.xLabelHeight + (hasYTitle ? XY.axisTitlePad : 0)
271
+ const left =
272
+ XY.padding +
273
+ maxCatLabelWidth +
274
+ XY.yLabelGap +
275
+ (hasXTitle ? XY.axisTitlePad : 0)
276
+ const right = XY.padding
277
+
278
+ const plotW = XY.plotWidth
279
+ const plotH = XY.plotHeight
280
+ const totalW = left + plotW + right
281
+ const totalH = top + plotH + bottom
282
+
283
+ const plotArea: PlotArea = { x: left, y: top, width: plotW, height: plotH }
284
+
285
+ // Value scale (horizontal: left to right)
286
+ const valueScale = (v: number) => {
287
+ const t = (v - yRange.min) / (yRange.max - yRange.min || 1)
288
+ return left + t * plotW
289
+ }
290
+
291
+ // Category scale (vertical: top to bottom)
292
+ const bandHeight = plotH / dataCount
293
+ const catScale = (i: number) => top + (i + 0.5) * bandHeight
294
+
295
+ // X-axis (bottom): value ticks
296
+ const xTicks: AxisTick[] = valueTicks.map((v) => ({
297
+ label: formatTickValue(v),
298
+ x: valueScale(v),
299
+ y: top + plotH,
300
+ tx: valueScale(v),
301
+ ty: top + plotH + XY.tickLength,
302
+ labelX: valueScale(v),
303
+ labelY: top + plotH + 18,
304
+ textAnchor: 'middle' as const,
305
+ }))
306
+
307
+ // Y-axis (left): category ticks
308
+ const yTicks: AxisTick[] = catLabels.map((label, i) => ({
309
+ label,
310
+ x: left,
311
+ y: catScale(i),
312
+ tx: left - XY.tickLength,
313
+ ty: catScale(i),
314
+ labelX: left - XY.yLabelGap,
315
+ labelY: catScale(i),
316
+ textAnchor: 'end' as const,
317
+ }))
318
+
319
+ // Grid lines (vertical at each value tick)
320
+ const gridLines: GridLine[] = valueTicks.map((v) => ({
321
+ x1: valueScale(v),
322
+ y1: top,
323
+ x2: valueScale(v),
324
+ y2: top + plotH,
325
+ }))
326
+
327
+ // Global color index map
328
+ const colorMap = chart.series.map((_, i) => i)
329
+
330
+ // Bars (horizontal)
331
+ const barSeries = chart.series.filter((s) => s.type === 'bar')
332
+ const barCount = barSeries.length
333
+ const bars: PositionedBar[] = []
334
+ if (barCount > 0) {
335
+ const usable = bandHeight * (1 - XY.barPadRatio)
336
+ const rawBarH =
337
+ barCount > 1
338
+ ? (usable - (barCount - 1) * XY.barGroupGap) / barCount
339
+ : usable
340
+ const singleBarH = Math.min(rawBarH, XY.maxBarWidth)
341
+ const groupH =
342
+ barCount > 1
343
+ ? singleBarH * barCount + XY.barGroupGap * (barCount - 1)
344
+ : singleBarH
345
+ let bIdx = 0
346
+ let seriesArrayIdx = 0
347
+ for (const s of chart.series) {
348
+ if (s.type !== 'bar') {
349
+ seriesArrayIdx++
350
+ continue
351
+ }
352
+ for (let i = 0; i < s.data.length; i++) {
353
+ const cy = catScale(i)
354
+ const groupTop = cy - groupH / 2
355
+ const by = groupTop + bIdx * (singleBarH + XY.barGroupGap)
356
+ const valX = valueScale(Math.max(s.data[i]!, yRange.min))
357
+ const baseX = valueScale(Math.max(0, yRange.min))
358
+ bars.push({
359
+ x: Math.min(baseX, valX),
360
+ y: by,
361
+ width: Math.abs(valX - baseX),
362
+ height: singleBarH,
363
+ value: s.data[i]!,
364
+ // A series can carry more data points than the chart has category
365
+ // labels for (e.g. a bar/line series longer than x-axis.categories
366
+ // or the series that determined dataCount), so catLabels[i] is not
367
+ // guaranteed to exist just because s.data[i] does. `label` is
368
+ // already optional on PositionedBar, so let a missing entry flow
369
+ // through as undefined instead of asserting past a real gap.
370
+ label: catLabels[i],
371
+ seriesIndex: bIdx,
372
+ colorIndex: colorMap[seriesArrayIdx]!,
373
+ })
374
+ }
375
+ bIdx++
376
+ seriesArrayIdx++
377
+ }
378
+ }
379
+
380
+ // Lines (horizontal: value on x, category index on y)
381
+ const lines: PositionedLine[] = []
382
+ let lineIdx = 0
383
+ let lineSeriesIdx = 0
384
+ for (const s of chart.series) {
385
+ if (s.type !== 'line') {
386
+ lineSeriesIdx++
387
+ continue
388
+ }
389
+ const points = s.data.map((v, i) => ({
390
+ x: valueScale(v),
391
+ y: catScale(i),
392
+ value: v,
393
+ // See the matching comment in the bars loop above: a line series can
394
+ // outrun catLabels, and the point's `label` is already optional.
395
+ label: catLabels[i],
396
+ }))
397
+ lines.push({
398
+ points,
399
+ seriesIndex: lineIdx,
400
+ colorIndex: colorMap[lineSeriesIdx]!,
401
+ })
402
+ lineIdx++
403
+ lineSeriesIdx++
404
+ }
405
+
406
+ const xAxisLine = {
407
+ x1: left,
408
+ y1: top + plotH,
409
+ x2: left + plotW,
410
+ y2: top + plotH,
411
+ }
412
+ const yAxisLine = { x1: left, y1: top, x2: left, y2: top + plotH }
413
+
414
+ // In horizontal mode, the "y-axis" title describes values (bottom) and "x-axis" title describes categories (left)
415
+ const xAxisObj: PositionedAxis = {
416
+ ticks: xTicks,
417
+ line: xAxisLine,
418
+ ...(chart.yAxis.title
419
+ ? {
420
+ title: {
421
+ text: chart.yAxis.title,
422
+ x: left + plotW / 2,
423
+ y: totalH - XY.padding,
424
+ },
425
+ }
426
+ : {}),
427
+ }
428
+ const yAxisObj: PositionedAxis = {
429
+ ticks: yTicks,
430
+ line: yAxisLine,
431
+ ...(chart.xAxis.title
432
+ ? {
433
+ title: {
434
+ text: chart.xAxis.title,
435
+ x: XY.padding + 4,
436
+ y: top + plotH / 2,
437
+ rotate: -90,
438
+ },
439
+ }
440
+ : {}),
441
+ }
442
+
443
+ const titleObj = chart.title
444
+ ? { text: chart.title, x: totalW / 2, y: XY.padding + XY.titleFontSize }
445
+ : undefined
446
+
447
+ // Legend
448
+ const legendY =
449
+ XY.padding + (hasTitle ? XY.titleHeight : 0) + XY.legendHeight / 2
450
+ const legend = hasLegend
451
+ ? buildLegendItems(chart, totalW / 2, legendY, colorMap)
452
+ : []
453
+
454
+ return {
455
+ width: totalW,
456
+ height: totalH,
457
+ horizontal: true,
458
+ title: titleObj,
459
+ xAxis: xAxisObj,
460
+ yAxis: yAxisObj,
461
+ plotArea,
462
+ bars,
463
+ lines,
464
+ gridLines,
465
+ legend,
466
+ }
467
+ }
468
+
469
+ // ============================================================================
470
+ // Helpers
471
+ // ============================================================================
472
+
473
+ function getDataCount(chart: XYChart): number {
474
+ if (chart.xAxis.categories) return chart.xAxis.categories.length
475
+ // For numeric range, use the length of the first series
476
+ for (const s of chart.series) {
477
+ if (s.data.length > 0) return s.data.length
478
+ }
479
+ return 1
480
+ }
481
+
482
+ function getCategoryLabels(chart: XYChart, count: number): string[] {
483
+ if (chart.xAxis.categories) return chart.xAxis.categories
484
+ if (chart.xAxis.range) {
485
+ const { min, max } = chart.xAxis.range
486
+ const step = count > 1 ? (max - min) / (count - 1) : 0
487
+ return Array.from({ length: count }, (_, i) =>
488
+ formatTickValue(min + step * i),
489
+ )
490
+ }
491
+ return Array.from({ length: count }, (_, i) => String(i + 1))
492
+ }
493
+
494
+ function buildXTicks(
495
+ chart: XYChart,
496
+ xScale: (i: number) => number,
497
+ axisY: number,
498
+ _bandWidth: number,
499
+ ): AxisTick[] {
500
+ const count = getDataCount(chart)
501
+ const labels = getCategoryLabels(chart, count)
502
+ return labels.map((label, i) => ({
503
+ label,
504
+ x: xScale(i),
505
+ y: axisY,
506
+ tx: xScale(i),
507
+ ty: axisY + XY.tickLength,
508
+ labelX: xScale(i),
509
+ labelY: axisY + 18,
510
+ textAnchor: 'middle' as const,
511
+ }))
512
+ }
513
+
514
+ function layoutBars(
515
+ chart: XYChart,
516
+ xScale: (i: number) => number,
517
+ yScale: (v: number) => number,
518
+ bandWidth: number,
519
+ yMin: number,
520
+ catLabels: string[],
521
+ colorMap: number[],
522
+ ): PositionedBar[] {
523
+ const barSeries = chart.series.filter((s) => s.type === 'bar')
524
+ const barCount = barSeries.length
525
+ if (barCount === 0) return []
526
+
527
+ const usable = bandWidth * (1 - XY.barPadRatio)
528
+ const rawBarW =
529
+ barCount > 1
530
+ ? (usable - (barCount - 1) * XY.barGroupGap) / barCount
531
+ : usable
532
+ const singleBarW = Math.min(rawBarW, XY.maxBarWidth)
533
+ const groupW =
534
+ barCount > 1
535
+ ? singleBarW * barCount + XY.barGroupGap * (barCount - 1)
536
+ : singleBarW
537
+ const bars: PositionedBar[] = []
538
+
539
+ let bIdx = 0
540
+ let seriesArrayIdx = 0
541
+ for (const s of chart.series) {
542
+ if (s.type !== 'bar') {
543
+ seriesArrayIdx++
544
+ continue
545
+ }
546
+ for (let i = 0; i < s.data.length; i++) {
547
+ const cx = xScale(i)
548
+ const groupLeft = cx - groupW / 2
549
+ const bx = groupLeft + bIdx * (singleBarW + XY.barGroupGap)
550
+ const valY = yScale(s.data[i]!)
551
+ const baseY = yScale(Math.max(0, yMin))
552
+ bars.push({
553
+ x: bx,
554
+ y: Math.min(valY, baseY),
555
+ width: singleBarW,
556
+ height: Math.abs(baseY - valY),
557
+ value: s.data[i]!,
558
+ // catLabels has one entry per dataCount, which is not necessarily
559
+ // s.data.length for every series. `label` is already optional on
560
+ // PositionedBar, so let a missing entry flow through as undefined
561
+ // instead of asserting past a real (if unusual) length mismatch.
562
+ // See layoutHorizontal's matching comment.
563
+ label: catLabels[i],
564
+ seriesIndex: bIdx,
565
+ colorIndex: colorMap[seriesArrayIdx]!,
566
+ })
567
+ }
568
+ bIdx++
569
+ seriesArrayIdx++
570
+ }
571
+ return bars
572
+ }
573
+
574
+ function layoutLines(
575
+ chart: XYChart,
576
+ xScale: (i: number) => number,
577
+ yScale: (v: number) => number,
578
+ catLabels: string[],
579
+ colorMap: number[],
580
+ ): PositionedLine[] {
581
+ const lines: PositionedLine[] = []
582
+ let lineIdx = 0
583
+ let seriesArrayIdx = 0
584
+ for (const s of chart.series) {
585
+ if (s.type !== 'line') {
586
+ seriesArrayIdx++
587
+ continue
588
+ }
589
+ const points = s.data.map((v, i) => ({
590
+ x: xScale(i),
591
+ y: yScale(v),
592
+ value: v,
593
+ // See layoutBars' matching comment: a line series can outrun
594
+ // catLabels, and the point's `label` is already optional.
595
+ label: catLabels[i],
596
+ }))
597
+ lines.push({
598
+ points,
599
+ seriesIndex: lineIdx,
600
+ colorIndex: colorMap[seriesArrayIdx]!,
601
+ })
602
+ lineIdx++
603
+ seriesArrayIdx++
604
+ }
605
+ return lines
606
+ }
607
+
608
+ /** Generate "nice" tick values for a numeric range */
609
+ function niceTickValues(min: number, max: number): number[] {
610
+ const range = max - min
611
+ if (range <= 0) return [min]
612
+
613
+ // Find nice interval
614
+ const rawInterval = range / 6
615
+ const magnitude = Math.pow(10, Math.floor(Math.log10(rawInterval)))
616
+ const residual = rawInterval / magnitude
617
+ let niceInterval: number
618
+ if (residual <= 1.5) niceInterval = magnitude
619
+ else if (residual <= 3) niceInterval = 2 * magnitude
620
+ else if (residual <= 7) niceInterval = 5 * magnitude
621
+ else niceInterval = 10 * magnitude
622
+
623
+ const start = Math.ceil(min / niceInterval) * niceInterval
624
+ const ticks: number[] = []
625
+ for (let v = start; v <= max + niceInterval * 0.001; v += niceInterval) {
626
+ ticks.push(Math.round(v * 1e10) / 1e10) // avoid floating-point noise
627
+ }
628
+ return ticks
629
+ }
630
+
631
+ function formatTickValue(v: number): string {
632
+ if (Number.isInteger(v)) return String(v)
633
+ // Limit decimal places
634
+ return v.toFixed(Math.abs(v) < 10 ? 1 : 0)
635
+ }
636
+
637
+ /** Build centered legend items for multi-series charts */
638
+ function buildLegendItems(
639
+ chart: XYChart,
640
+ centerX: number,
641
+ y: number,
642
+ colorMap: number[],
643
+ ): LegendItem[] {
644
+ const items: LegendItem[] = []
645
+ let barIdx = 0,
646
+ lineIdx = 0
647
+ for (let si = 0; si < chart.series.length; si++) {
648
+ const s = chart.series[si]!
649
+ const label = s.type === 'bar' ? `Bar ${barIdx + 1}` : `Line ${lineIdx + 1}`
650
+ items.push({
651
+ label,
652
+ x: 0,
653
+ y,
654
+ type: s.type,
655
+ seriesIndex: s.type === 'bar' ? barIdx : lineIdx,
656
+ colorIndex: colorMap[si]!,
657
+ })
658
+ if (s.type === 'bar') barIdx++
659
+ else lineIdx++
660
+ }
661
+
662
+ // Measure total width, then center
663
+ const itemWidths = items.map((item) => {
664
+ const textW = estimateTextWidth(
665
+ item.label,
666
+ XY.legendFontSize,
667
+ XY.legendFontWeight,
668
+ )
669
+ return XY.legendSwatchW + XY.legendGap + textW
670
+ })
671
+ const totalWidth =
672
+ itemWidths.reduce((a, b) => a + b, 0) +
673
+ (items.length - 1) * XY.legendItemGap
674
+ let x = centerX - totalWidth / 2
675
+
676
+ for (let i = 0; i < items.length; i++) {
677
+ items[i]!.x = x
678
+ x += itemWidths[i]! + XY.legendItemGap
679
+ }
680
+
681
+ return items
682
+ }