@zombie-mermaid/svg-renderer 4.2.3 → 5.0.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 (45) hide show
  1. package/README.md +15 -1
  2. package/dist/index.cjs +52 -52
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +23 -4
  5. package/dist/index.d.ts +23 -4
  6. package/dist/index.js +890 -850
  7. package/dist/index.js.map +1 -1
  8. package/package.json +14 -6
  9. package/src/c4/arial-widths.ts +0 -59
  10. package/src/c4/layout.ts +0 -680
  11. package/src/c4/metrics.ts +0 -261
  12. package/src/c4/renderer.ts +0 -366
  13. package/src/class/layout.ts +0 -525
  14. package/src/class/renderer.ts +0 -669
  15. package/src/edge-curves.ts +0 -205
  16. package/src/elk-instance.ts +0 -292
  17. package/src/er/layout.ts +0 -216
  18. package/src/er/renderer.ts +0 -516
  19. package/src/generated/mono-font-subset.ts +0 -32
  20. package/src/index.ts +0 -310
  21. package/src/layout-engine/back-edges.ts +0 -73
  22. package/src/layout-engine/compound-flat.ts +0 -916
  23. package/src/layout-engine/constants.ts +0 -19
  24. package/src/layout-engine/edge-bundling.ts +0 -458
  25. package/src/layout-engine/elk-adapter-utils.ts +0 -81
  26. package/src/layout-engine/elk-graph-builder.ts +0 -245
  27. package/src/layout-engine/from-elk.ts +0 -767
  28. package/src/layout-engine/inner-edges.ts +0 -329
  29. package/src/layout-engine/jog-straightening.ts +0 -52
  30. package/src/layout-engine/layer-alignment.ts +0 -174
  31. package/src/layout-engine/layout-hints.ts +0 -27
  32. package/src/layout-engine/to-elk.ts +0 -855
  33. package/src/layout-engine.ts +0 -115
  34. package/src/pie/layout.ts +0 -234
  35. package/src/pie/renderer.ts +0 -161
  36. package/src/registry.ts +0 -441
  37. package/src/renderer.ts +0 -1510
  38. package/src/resolve-colors.ts +0 -339
  39. package/src/sequence/layout.ts +0 -766
  40. package/src/sequence/renderer.ts +0 -687
  41. package/src/shape-clipping.ts +0 -197
  42. package/src/styles.ts +0 -145
  43. package/src/title-gaps.ts +0 -161
  44. package/src/xychart/layout.ts +0 -727
  45. package/src/xychart/renderer.ts +0 -676
@@ -1,727 +0,0 @@
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 yLabels = formatTickLabels(yTicks)
86
- const maxYLabelWidth = Math.max(
87
- ...yLabels.map((label) =>
88
- estimateTextWidth(label, XY.axisLabelFontSize, XY.axisLabelFontWeight),
89
- ),
90
- XY.yLabelWidth,
91
- )
92
-
93
- // Margins
94
- const top =
95
- XY.padding +
96
- (hasTitle ? XY.titleHeight : 0) +
97
- (hasLegend ? XY.legendHeight : 0)
98
- const bottom =
99
- XY.padding + XY.xLabelHeight + (hasXTitle ? XY.axisTitlePad : 0)
100
- const left =
101
- XY.padding +
102
- maxYLabelWidth +
103
- XY.yLabelGap +
104
- (hasYTitle ? XY.axisTitlePad : 0)
105
- const right = XY.padding
106
-
107
- const plotW = XY.plotWidth
108
- const plotH = XY.plotHeight
109
- const totalW = left + plotW + right
110
- const totalH = top + plotH + bottom
111
-
112
- const plotArea: PlotArea = { x: left, y: top, width: plotW, height: plotH }
113
-
114
- // Scales
115
- const dataCount = getDataCount(chart)
116
- const xScale = (i: number) => left + (i + 0.5) * (plotW / dataCount)
117
- const bandWidth = plotW / dataCount
118
- const yScale = (v: number) => {
119
- const t = (v - yRange.min) / (yRange.max - yRange.min || 1)
120
- return top + plotH - t * plotH
121
- }
122
-
123
- // X-axis ticks
124
- const xTicks = buildXTicks(chart, xScale, top + plotH, bandWidth)
125
-
126
- // Y-axis ticks
127
- const yAxisTicks: AxisTick[] = yTicks.map((v, i) => ({
128
- label: yLabels[i]!,
129
- x: left,
130
- y: yScale(v),
131
- tx: left - XY.tickLength,
132
- ty: yScale(v),
133
- labelX: left - XY.yLabelGap,
134
- labelY: yScale(v),
135
- textAnchor: 'end' as const,
136
- }))
137
-
138
- // Grid lines (horizontal at each y tick)
139
- const gridLines: GridLine[] = yTicks.map((v) => ({
140
- x1: left,
141
- y1: yScale(v),
142
- x2: left + plotW,
143
- y2: yScale(v),
144
- }))
145
-
146
- // Category labels for data attributes
147
- const catLabels = getCategoryLabels(chart, dataCount)
148
-
149
- // Global color index map: each series gets a unique color index regardless of type
150
- const colorMap = chart.series.map((_, i) => i)
151
-
152
- // Bars
153
- const bars = layoutBars(
154
- chart,
155
- xScale,
156
- yScale,
157
- bandWidth,
158
- yRange.min,
159
- catLabels,
160
- colorMap,
161
- )
162
-
163
- // Lines
164
- const lines = layoutLines(chart, xScale, yScale, catLabels, colorMap)
165
-
166
- // Legend
167
- const legendY =
168
- XY.padding + (hasTitle ? XY.titleHeight : 0) + XY.legendHeight / 2
169
- const legend = hasLegend
170
- ? buildLegendItems(chart, totalW / 2, legendY, colorMap)
171
- : []
172
-
173
- // Axis lines
174
- const xAxisLine = {
175
- x1: left,
176
- y1: top + plotH,
177
- x2: left + plotW,
178
- y2: top + plotH,
179
- }
180
- const yAxisLine = { x1: left, y1: top, x2: left, y2: top + plotH }
181
-
182
- // Axis titles
183
- const xAxisObj: PositionedAxis = {
184
- ticks: xTicks,
185
- line: xAxisLine,
186
- ...(chart.xAxis.title
187
- ? {
188
- title: {
189
- text: chart.xAxis.title,
190
- x: left + plotW / 2,
191
- y: totalH - XY.padding,
192
- },
193
- }
194
- : {}),
195
- }
196
- const yAxisObj: PositionedAxis = {
197
- ticks: yAxisTicks,
198
- line: yAxisLine,
199
- ...(chart.yAxis.title
200
- ? {
201
- title: {
202
- text: chart.yAxis.title,
203
- x: XY.padding + 4,
204
- y: top + plotH / 2,
205
- rotate: -90,
206
- },
207
- }
208
- : {}),
209
- }
210
-
211
- // Title
212
- const titleObj = chart.title
213
- ? { text: chart.title, x: totalW / 2, y: XY.padding + XY.titleFontSize }
214
- : undefined
215
-
216
- return {
217
- width: totalW,
218
- height: totalH,
219
- title: titleObj,
220
- xAxis: xAxisObj,
221
- yAxis: yAxisObj,
222
- plotArea,
223
- bars,
224
- lines,
225
- gridLines,
226
- legend,
227
- }
228
- }
229
-
230
- // ============================================================================
231
- // Horizontal layout
232
- // ============================================================================
233
-
234
- function layoutHorizontal(chart: XYChart): PositionedXYChart {
235
- const hasTitle = !!chart.title
236
- const hasXTitle = !!chart.xAxis.title
237
- const hasYTitle = !!chart.yAxis.title
238
- const hasLegend = chart.series.length > 1
239
-
240
- // In horizontal mode: categories go on y-axis (left side), values go on x-axis (bottom)
241
- //
242
- // See the matching comment in layoutVertical: the parser always derives a
243
- // y-axis range before returning, but that guarantee lives in a different
244
- // module, so it's narrowed here explicitly rather than trusted silently.
245
- const yRange = chart.yAxis.range
246
- if (!yRange) {
247
- /* v8 ignore next */
248
- throw new Error('XY chart: y-axis range was not set by the parser')
249
- }
250
- const valueTicks = niceTickValues(yRange.min, yRange.max)
251
- const valueLabels = formatTickLabels(valueTicks)
252
-
253
- // Compute category label widths for left margin
254
- const dataCount = getDataCount(chart)
255
- const catLabels = getCategoryLabels(chart, dataCount)
256
- const maxCatLabelWidth = Math.max(
257
- ...catLabels.map((l) =>
258
- estimateTextWidth(l, XY.axisLabelFontSize, XY.axisLabelFontWeight),
259
- ),
260
- 40,
261
- )
262
-
263
- const top =
264
- XY.padding +
265
- (hasTitle ? XY.titleHeight : 0) +
266
- (hasLegend ? XY.legendHeight : 0)
267
- const bottom =
268
- XY.padding + XY.xLabelHeight + (hasYTitle ? XY.axisTitlePad : 0)
269
- const left =
270
- XY.padding +
271
- maxCatLabelWidth +
272
- XY.yLabelGap +
273
- (hasXTitle ? XY.axisTitlePad : 0)
274
- const right = XY.padding
275
-
276
- const plotW = XY.plotWidth
277
- const plotH = XY.plotHeight
278
- const totalW = left + plotW + right
279
- const totalH = top + plotH + bottom
280
-
281
- const plotArea: PlotArea = { x: left, y: top, width: plotW, height: plotH }
282
-
283
- // Value scale (horizontal: left to right)
284
- const valueScale = (v: number) => {
285
- const t = (v - yRange.min) / (yRange.max - yRange.min || 1)
286
- return left + t * plotW
287
- }
288
-
289
- // Category scale (vertical: top to bottom)
290
- const bandHeight = plotH / dataCount
291
- const catScale = (i: number) => top + (i + 0.5) * bandHeight
292
-
293
- // X-axis (bottom): value ticks
294
- const xTicks: AxisTick[] = valueTicks.map((v, i) => ({
295
- label: valueLabels[i]!,
296
- x: valueScale(v),
297
- y: top + plotH,
298
- tx: valueScale(v),
299
- ty: top + plotH + XY.tickLength,
300
- labelX: valueScale(v),
301
- labelY: top + plotH + 18,
302
- textAnchor: 'middle' as const,
303
- }))
304
-
305
- // Y-axis (left): category ticks
306
- const yTicks: AxisTick[] = catLabels.map((label, i) => ({
307
- label,
308
- x: left,
309
- y: catScale(i),
310
- tx: left - XY.tickLength,
311
- ty: catScale(i),
312
- labelX: left - XY.yLabelGap,
313
- labelY: catScale(i),
314
- textAnchor: 'end' as const,
315
- }))
316
-
317
- // Grid lines (vertical at each value tick)
318
- const gridLines: GridLine[] = valueTicks.map((v) => ({
319
- x1: valueScale(v),
320
- y1: top,
321
- x2: valueScale(v),
322
- y2: top + plotH,
323
- }))
324
-
325
- // Global color index map
326
- const colorMap = chart.series.map((_, i) => i)
327
-
328
- // Bars (horizontal)
329
- const barSeries = chart.series.filter((s) => s.type === 'bar')
330
- const barCount = barSeries.length
331
- const bars: PositionedBar[] = []
332
- if (barCount > 0) {
333
- const usable = bandHeight * (1 - XY.barPadRatio)
334
- const rawBarH =
335
- barCount > 1
336
- ? (usable - (barCount - 1) * XY.barGroupGap) / barCount
337
- : usable
338
- const singleBarH = Math.min(rawBarH, XY.maxBarWidth)
339
- const groupH =
340
- barCount > 1
341
- ? singleBarH * barCount + XY.barGroupGap * (barCount - 1)
342
- : singleBarH
343
- let bIdx = 0
344
- let seriesArrayIdx = 0
345
- for (const s of chart.series) {
346
- if (s.type !== 'bar') {
347
- seriesArrayIdx++
348
- continue
349
- }
350
- for (let i = 0; i < s.data.length; i++) {
351
- const cy = catScale(i)
352
- const groupTop = cy - groupH / 2
353
- const by = groupTop + bIdx * (singleBarH + XY.barGroupGap)
354
- const valX = valueScale(Math.max(s.data[i]!, yRange.min))
355
- const baseX = valueScale(Math.max(0, yRange.min))
356
- bars.push({
357
- x: Math.min(baseX, valX),
358
- y: by,
359
- width: Math.abs(valX - baseX),
360
- height: singleBarH,
361
- value: s.data[i]!,
362
- // A series can carry more data points than the chart has category
363
- // labels for (e.g. a bar/line series longer than x-axis.categories
364
- // or the series that determined dataCount), so catLabels[i] is not
365
- // guaranteed to exist just because s.data[i] does. `label` is
366
- // already optional on PositionedBar, so let a missing entry flow
367
- // through as undefined instead of asserting past a real gap.
368
- label: catLabels[i],
369
- seriesIndex: bIdx,
370
- colorIndex: colorMap[seriesArrayIdx]!,
371
- })
372
- }
373
- bIdx++
374
- seriesArrayIdx++
375
- }
376
- }
377
-
378
- // Lines (horizontal: value on x, category index on y)
379
- const lines: PositionedLine[] = []
380
- let lineIdx = 0
381
- let lineSeriesIdx = 0
382
- for (const s of chart.series) {
383
- if (s.type !== 'line') {
384
- lineSeriesIdx++
385
- continue
386
- }
387
- const points = s.data.map((v, i) => ({
388
- x: valueScale(v),
389
- y: catScale(i),
390
- value: v,
391
- // See the matching comment in the bars loop above: a line series can
392
- // outrun catLabels, and the point's `label` is already optional.
393
- label: catLabels[i],
394
- }))
395
- lines.push({
396
- points,
397
- seriesIndex: lineIdx,
398
- colorIndex: colorMap[lineSeriesIdx]!,
399
- })
400
- lineIdx++
401
- lineSeriesIdx++
402
- }
403
-
404
- const xAxisLine = {
405
- x1: left,
406
- y1: top + plotH,
407
- x2: left + plotW,
408
- y2: top + plotH,
409
- }
410
- const yAxisLine = { x1: left, y1: top, x2: left, y2: top + plotH }
411
-
412
- // In horizontal mode, the "y-axis" title describes values (bottom) and "x-axis" title describes categories (left)
413
- const xAxisObj: PositionedAxis = {
414
- ticks: xTicks,
415
- line: xAxisLine,
416
- ...(chart.yAxis.title
417
- ? {
418
- title: {
419
- text: chart.yAxis.title,
420
- x: left + plotW / 2,
421
- y: totalH - XY.padding,
422
- },
423
- }
424
- : {}),
425
- }
426
- const yAxisObj: PositionedAxis = {
427
- ticks: yTicks,
428
- line: yAxisLine,
429
- ...(chart.xAxis.title
430
- ? {
431
- title: {
432
- text: chart.xAxis.title,
433
- x: XY.padding + 4,
434
- y: top + plotH / 2,
435
- rotate: -90,
436
- },
437
- }
438
- : {}),
439
- }
440
-
441
- const titleObj = chart.title
442
- ? { text: chart.title, x: totalW / 2, y: XY.padding + XY.titleFontSize }
443
- : undefined
444
-
445
- // Legend
446
- const legendY =
447
- XY.padding + (hasTitle ? XY.titleHeight : 0) + XY.legendHeight / 2
448
- const legend = hasLegend
449
- ? buildLegendItems(chart, totalW / 2, legendY, colorMap)
450
- : []
451
-
452
- return {
453
- width: totalW,
454
- height: totalH,
455
- horizontal: true,
456
- title: titleObj,
457
- xAxis: xAxisObj,
458
- yAxis: yAxisObj,
459
- plotArea,
460
- bars,
461
- lines,
462
- gridLines,
463
- legend,
464
- }
465
- }
466
-
467
- // ============================================================================
468
- // Helpers
469
- // ============================================================================
470
-
471
- function getDataCount(chart: XYChart): number {
472
- if (chart.xAxis.categories) return chart.xAxis.categories.length
473
- // For numeric range, use the length of the first series
474
- for (const s of chart.series) {
475
- if (s.data.length > 0) return s.data.length
476
- }
477
- return 1
478
- }
479
-
480
- function getCategoryLabels(chart: XYChart, count: number): string[] {
481
- if (chart.xAxis.categories) return chart.xAxis.categories
482
- if (chart.xAxis.range) {
483
- const { min, max } = chart.xAxis.range
484
- const step = count > 1 ? (max - min) / (count - 1) : 0
485
- return Array.from({ length: count }, (_, i) =>
486
- formatTickValue(min + step * i),
487
- )
488
- }
489
- return Array.from({ length: count }, (_, i) => String(i + 1))
490
- }
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
-
519
- function buildXTicks(
520
- chart: XYChart,
521
- xScale: (i: number) => number,
522
- axisY: number,
523
- bandWidth: number,
524
- ): AxisTick[] {
525
- const count = getDataCount(chart)
526
- const labels = getCategoryLabels(chart, count)
527
- const step = xLabelStep(labels, bandWidth)
528
- // Dropped labels keep their tick mark but render no text.
529
- return labels.map((label, i) => ({
530
- label: i % step === 0 ? label : '',
531
- x: xScale(i),
532
- y: axisY,
533
- tx: xScale(i),
534
- ty: axisY + XY.tickLength,
535
- labelX: xScale(i),
536
- labelY: axisY + 18,
537
- textAnchor: 'middle' as const,
538
- }))
539
- }
540
-
541
- function layoutBars(
542
- chart: XYChart,
543
- xScale: (i: number) => number,
544
- yScale: (v: number) => number,
545
- bandWidth: number,
546
- yMin: number,
547
- catLabels: string[],
548
- colorMap: number[],
549
- ): PositionedBar[] {
550
- const barSeries = chart.series.filter((s) => s.type === 'bar')
551
- const barCount = barSeries.length
552
- if (barCount === 0) return []
553
-
554
- const usable = bandWidth * (1 - XY.barPadRatio)
555
- const rawBarW =
556
- barCount > 1
557
- ? (usable - (barCount - 1) * XY.barGroupGap) / barCount
558
- : usable
559
- const singleBarW = Math.min(rawBarW, XY.maxBarWidth)
560
- const groupW =
561
- barCount > 1
562
- ? singleBarW * barCount + XY.barGroupGap * (barCount - 1)
563
- : singleBarW
564
- const bars: PositionedBar[] = []
565
-
566
- let bIdx = 0
567
- let seriesArrayIdx = 0
568
- for (const s of chart.series) {
569
- if (s.type !== 'bar') {
570
- seriesArrayIdx++
571
- continue
572
- }
573
- for (let i = 0; i < s.data.length; i++) {
574
- const cx = xScale(i)
575
- const groupLeft = cx - groupW / 2
576
- const bx = groupLeft + bIdx * (singleBarW + XY.barGroupGap)
577
- const valY = yScale(s.data[i]!)
578
- const baseY = yScale(Math.max(0, yMin))
579
- bars.push({
580
- x: bx,
581
- y: Math.min(valY, baseY),
582
- width: singleBarW,
583
- height: Math.abs(baseY - valY),
584
- value: s.data[i]!,
585
- // catLabels has one entry per dataCount, which is not necessarily
586
- // s.data.length for every series. `label` is already optional on
587
- // PositionedBar, so let a missing entry flow through as undefined
588
- // instead of asserting past a real (if unusual) length mismatch.
589
- // See layoutHorizontal's matching comment.
590
- label: catLabels[i],
591
- seriesIndex: bIdx,
592
- colorIndex: colorMap[seriesArrayIdx]!,
593
- })
594
- }
595
- bIdx++
596
- seriesArrayIdx++
597
- }
598
- return bars
599
- }
600
-
601
- function layoutLines(
602
- chart: XYChart,
603
- xScale: (i: number) => number,
604
- yScale: (v: number) => number,
605
- catLabels: string[],
606
- colorMap: number[],
607
- ): PositionedLine[] {
608
- const lines: PositionedLine[] = []
609
- let lineIdx = 0
610
- let seriesArrayIdx = 0
611
- for (const s of chart.series) {
612
- if (s.type !== 'line') {
613
- seriesArrayIdx++
614
- continue
615
- }
616
- const points = s.data.map((v, i) => ({
617
- x: xScale(i),
618
- y: yScale(v),
619
- value: v,
620
- // See layoutBars' matching comment: a line series can outrun
621
- // catLabels, and the point's `label` is already optional.
622
- label: catLabels[i],
623
- }))
624
- lines.push({
625
- points,
626
- seriesIndex: lineIdx,
627
- colorIndex: colorMap[seriesArrayIdx]!,
628
- })
629
- lineIdx++
630
- seriesArrayIdx++
631
- }
632
- return lines
633
- }
634
-
635
- /** Generate "nice" tick values for a numeric range */
636
- export function niceTickValues(min: number, max: number): number[] {
637
- const range = max - min
638
- if (range <= 0) return [min]
639
-
640
- // Find nice interval
641
- const rawInterval = range / 6
642
- const magnitude = Math.pow(10, Math.floor(Math.log10(rawInterval)))
643
- const residual = rawInterval / magnitude
644
- let niceInterval: number
645
- if (residual <= 1.5) niceInterval = magnitude
646
- else if (residual <= 3) niceInterval = 2 * magnitude
647
- else if (residual <= 7) niceInterval = 5 * magnitude
648
- else niceInterval = 10 * magnitude
649
-
650
- const start = Math.ceil(min / niceInterval) * niceInterval
651
- const ticks: number[] = []
652
- for (let v = start; v <= max + niceInterval * 0.001; v += niceInterval) {
653
- ticks.push(Math.round(v * 1e10) / 1e10) // avoid floating-point noise
654
- }
655
- return ticks
656
- }
657
-
658
- /**
659
- * Format a set of evenly-spaced tick values with enough decimal places to
660
- * keep every label distinct. Precision comes from the tick step, not the
661
- * magnitude of the value: a 0.5 step around 100 needs one decimal, or
662
- * 99.5 and 100.5 both collapse into neighbouring whole numbers.
663
- */
664
- export function formatTickLabels(ticks: number[]): string[] {
665
- const step = ticks.length > 1 ? Math.abs(ticks[1]! - ticks[0]!) : 0
666
- // The -1e-9 keeps an exact power-of-ten step (0.1) from rounding up
667
- const decimals =
668
- step > 0 && step < 1 ? Math.ceil(-Math.log10(step) - 1e-9) : 0
669
- return ticks.map((v) =>
670
- Number.isInteger(v) && decimals === 0
671
- ? String(v)
672
- : String(Number(v.toFixed(decimals))),
673
- )
674
- }
675
-
676
- function formatTickValue(v: number): string {
677
- if (Number.isInteger(v)) return String(v)
678
- // Limit decimal places
679
- return v.toFixed(Math.abs(v) < 10 ? 1 : 0)
680
- }
681
-
682
- /** Build centered legend items for multi-series charts */
683
- function buildLegendItems(
684
- chart: XYChart,
685
- centerX: number,
686
- y: number,
687
- colorMap: number[],
688
- ): LegendItem[] {
689
- const items: LegendItem[] = []
690
- let barIdx = 0,
691
- lineIdx = 0
692
- for (let si = 0; si < chart.series.length; si++) {
693
- const s = chart.series[si]!
694
- const label = s.type === 'bar' ? `Bar ${barIdx + 1}` : `Line ${lineIdx + 1}`
695
- items.push({
696
- label,
697
- x: 0,
698
- y,
699
- type: s.type,
700
- seriesIndex: s.type === 'bar' ? barIdx : lineIdx,
701
- colorIndex: colorMap[si]!,
702
- })
703
- if (s.type === 'bar') barIdx++
704
- else lineIdx++
705
- }
706
-
707
- // Measure total width, then center
708
- const itemWidths = items.map((item) => {
709
- const textW = estimateTextWidth(
710
- item.label,
711
- XY.legendFontSize,
712
- XY.legendFontWeight,
713
- )
714
- return XY.legendSwatchW + XY.legendGap + textW
715
- })
716
- const totalWidth =
717
- itemWidths.reduce((a, b) => a + b, 0) +
718
- (items.length - 1) * XY.legendItemGap
719
- let x = centerX - totalWidth / 2
720
-
721
- for (let i = 0; i < items.length; i++) {
722
- items[i]!.x = x
723
- x += itemWidths[i]! + XY.legendItemGap
724
- }
725
-
726
- return items
727
- }