@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.
- package/LICENSE +22 -0
- package/dist/index.cjs +56 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +524 -0
- package/dist/index.d.ts +524 -0
- package/dist/index.js +2994 -0
- package/dist/index.js.map +1 -0
- package/package.json +37 -0
- package/src/__tests__/elk-adapter-utils.test.ts +166 -0
- package/src/class/layout.ts +360 -0
- package/src/class/renderer.ts +636 -0
- package/src/edge-curves.ts +204 -0
- package/src/elk-instance.ts +292 -0
- package/src/er/layout.ts +200 -0
- package/src/er/renderer.ts +493 -0
- package/src/index.ts +58 -0
- package/src/layout-engine/constants.ts +19 -0
- package/src/layout-engine/edge-bundling.ts +379 -0
- package/src/layout-engine/elk-adapter-utils.ts +81 -0
- package/src/layout-engine/elk-graph-builder.ts +240 -0
- package/src/layout-engine/from-elk.ts +685 -0
- package/src/layout-engine/layer-alignment.ts +174 -0
- package/src/layout-engine/to-elk.ts +695 -0
- package/src/layout-engine.ts +74 -0
- package/src/layout.ts +8 -0
- package/src/renderer.ts +1485 -0
- package/src/resolve-colors.ts +339 -0
- package/src/sequence/layout.ts +698 -0
- package/src/sequence/renderer.ts +546 -0
- package/src/shape-clipping.ts +197 -0
- package/src/styles.ts +118 -0
- package/src/xychart/layout.ts +682 -0
- package/src/xychart/renderer.ts +684 -0
|
@@ -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
|
+
}
|