@octane-xplat/charts 0.0.1 → 0.10.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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/dist/macos/Chart.macos.tsrx.js +351 -0
  3. package/dist/macos/core/axes.js +146 -0
  4. package/dist/macos/core/hit.js +131 -0
  5. package/dist/macos/core/marks.js +262 -0
  6. package/dist/macos/core/scales.js +99 -0
  7. package/dist/macos/core/scene.js +120 -0
  8. package/dist/macos/core/svg.js +40 -0
  9. package/dist/macos/index.js +2 -0
  10. package/dist/macos/octane-client-build.json +8 -0
  11. package/dist/macos/svg-image.macos.js +14 -0
  12. package/dist/native/Chart.tsrx.js +340 -0
  13. package/dist/native/core/axes.js +146 -0
  14. package/dist/native/core/hit.js +131 -0
  15. package/dist/native/core/marks.js +262 -0
  16. package/dist/native/core/scales.js +99 -0
  17. package/dist/native/core/scene.js +120 -0
  18. package/dist/native/core/svg.js +40 -0
  19. package/dist/native/index.js +2 -0
  20. package/dist/native/octane-client-build.json +8 -0
  21. package/dist/web/Chart.web.tsrx.js +488 -0
  22. package/dist/web/core/axes.js +146 -0
  23. package/dist/web/core/hit.js +131 -0
  24. package/dist/web/core/marks.js +262 -0
  25. package/dist/web/core/scales.js +99 -0
  26. package/dist/web/core/scene.js +120 -0
  27. package/dist/web/core/svg.js +40 -0
  28. package/dist/web/index.js +2 -0
  29. package/dist/web/octane-client-build.json +8 -0
  30. package/package.json +86 -3
  31. package/src/Chart.macos.tsrx +134 -0
  32. package/src/Chart.tsrx +174 -0
  33. package/src/Chart.web.tsrx +215 -0
  34. package/src/core/axes.ts +211 -0
  35. package/src/core/hit.ts +201 -0
  36. package/src/core/marks.ts +380 -0
  37. package/src/core/scales.ts +168 -0
  38. package/src/core/scene.ts +145 -0
  39. package/src/core/svg.ts +84 -0
  40. package/src/index.macos.ts +12 -0
  41. package/src/index.ts +12 -0
  42. package/src/index.web.ts +12 -0
  43. package/src/props.ts +90 -0
  44. package/src/svg-image.macos.ts +34 -0
  45. package/types/index.d.ts +17 -0
  46. package/types/props.d.ts +83 -0
  47. package/README.md +0 -4
@@ -0,0 +1,380 @@
1
+ import { scaleBand } from 'd3-scale'
2
+ import {
3
+ arc as arcPath,
4
+ area as areaPath,
5
+ curveMonotoneX,
6
+ line as linePath,
7
+ pie as pieLayout,
8
+ } from 'd3-shape'
9
+
10
+ import type { ChartDatum, ChartType } from '../props'
11
+ import type { ScaleBand } from 'd3-scale'
12
+ import type { PlotBox, ResolvedSeries, XScale, XScaleKind, YScale } from './scales'
13
+ import { categoryKey, xCenter } from './scales'
14
+
15
+ /** Presentation attributes — the androidsvg ∩ SVGKit subset only (fills,
16
+ * strokes, opacity). No filters, no text, no radial gradients. */
17
+ export interface Paint {
18
+ fill?: string
19
+ fillOpacity?: number
20
+ stroke?: string
21
+ strokeOpacity?: number
22
+ strokeWidth?: number
23
+ strokeLinecap?: 'butt' | 'round' | 'square'
24
+ }
25
+
26
+ /** Datum reference — lets the overlay and hit-test map marks back to data. */
27
+ interface DatumRef {
28
+ series?: number
29
+ index?: number
30
+ }
31
+
32
+ export type Mark = DatumRef &
33
+ Paint & { transform?: string } & (
34
+ | { kind: 'path'; d: string }
35
+ | { kind: 'rect'; x: number; y: number; width: number; height: number }
36
+ | { kind: 'circle'; cx: number; cy: number; r: number }
37
+ | { kind: 'line'; x1: number; y1: number; x2: number; y2: number }
38
+ )
39
+
40
+ /** One hit-testable datum position in chart space. */
41
+ export interface ScenePoint {
42
+ series: number
43
+ index: number
44
+ /** Snap-to-x group — String(datum.x). */
45
+ key: string
46
+ cx: number
47
+ cy: number
48
+ x: number | string | Date
49
+ y: number
50
+ }
51
+
52
+ export interface PieSlice {
53
+ series: number
54
+ index: number
55
+ label: string
56
+ value: number
57
+ color: string
58
+ startAngle: number
59
+ endAngle: number
60
+ /** Slice centroid relative to the pie center — the tooltip anchor. */
61
+ cx: number
62
+ cy: number
63
+ }
64
+
65
+ export interface PieScene {
66
+ /** Pie center in chart space (plot offset applied by scene.ts). */
67
+ cx: number
68
+ cy: number
69
+ rOuter: number
70
+ rInner: number
71
+ slices: PieSlice[]
72
+ }
73
+
74
+ export interface ChartLayout {
75
+ plot: PlotBox
76
+ xKind: XScaleKind
77
+ x: XScale
78
+ y: YScale
79
+ }
80
+
81
+ export interface BuiltMarks {
82
+ marks: Mark[]
83
+ points: ScenePoint[]
84
+ pie?: PieScene
85
+ }
86
+
87
+ export function buildMarks(
88
+ type: ChartType,
89
+ layout: ChartLayout,
90
+ series: ResolvedSeries[],
91
+ options: { stacked?: boolean; innerRadiusRatio?: number; palette: string[] },
92
+ ): BuiltMarks {
93
+ switch (type) {
94
+ case 'line':
95
+ return lineMarks(layout, series)
96
+ case 'area':
97
+ return areaMarks(layout, series)
98
+ case 'scatter':
99
+ return scatterMarks(layout, series)
100
+ case 'bar':
101
+ return options.stacked ? stackedBarMarks(layout, series) : groupedBarMarks(layout, series)
102
+ case 'pie':
103
+ return pieMarks(layout, series, options)
104
+ }
105
+ }
106
+
107
+ function lineMarks(layout: ChartLayout, series: ResolvedSeries[]): BuiltMarks {
108
+ const { xKind, x, y } = layout
109
+ const gen = linePath<ChartDatum>()
110
+ .defined((d) => Number.isFinite(d.y))
111
+ .x((d) => xCenter(xKind, x, d.x))
112
+ .y((d) => y(d.y))
113
+ .curve(curveMonotoneX)
114
+
115
+ const marks: Mark[] = []
116
+ const points: ScenePoint[] = []
117
+ series.forEach((s, si) => {
118
+ marks.push({
119
+ kind: 'path',
120
+ d: gen(s.values) ?? '',
121
+ fill: 'none',
122
+ stroke: s.color,
123
+ strokeWidth: 2,
124
+ strokeLinecap: 'round',
125
+ series: si,
126
+ })
127
+
128
+ s.values.forEach((d, i) => {
129
+ points.push({
130
+ series: si,
131
+ index: i,
132
+ key: categoryKey(d.x),
133
+ cx: xCenter(xKind, x, d.x),
134
+ cy: y(d.y),
135
+ x: d.x,
136
+ y: d.y,
137
+ })
138
+ })
139
+ })
140
+
141
+ return { marks, points }
142
+ }
143
+
144
+ function areaMarks(layout: ChartLayout, series: ResolvedSeries[]): BuiltMarks {
145
+ const { xKind, x, y } = layout
146
+ const zero = y(Math.max(y.domain()[0], Math.min(0, y.domain()[1])))
147
+ const gen = areaPath<ChartDatum>()
148
+ .defined((d) => Number.isFinite(d.y))
149
+ .x((d) => xCenter(xKind, x, d.x))
150
+ .y0(zero)
151
+ .y1((d) => y(d.y))
152
+ .curve(curveMonotoneX)
153
+
154
+ const stroke = linePath<ChartDatum>()
155
+ .defined((d) => Number.isFinite(d.y))
156
+ .x((d) => xCenter(xKind, x, d.x))
157
+ .y((d) => y(d.y))
158
+ .curve(curveMonotoneX)
159
+
160
+ const marks: Mark[] = []
161
+ const points: ScenePoint[] = []
162
+ series.forEach((s, si) => {
163
+ marks.push({
164
+ kind: 'path',
165
+ d: gen(s.values) ?? '',
166
+ fill: s.color,
167
+ fillOpacity: 0.25,
168
+ series: si,
169
+ })
170
+
171
+ marks.push({
172
+ kind: 'path',
173
+ d: stroke(s.values) ?? '',
174
+ fill: 'none',
175
+ stroke: s.color,
176
+ strokeWidth: 2,
177
+ series: si,
178
+ })
179
+
180
+ s.values.forEach((d, i) => {
181
+ points.push({
182
+ series: si,
183
+ index: i,
184
+ key: categoryKey(d.x),
185
+ cx: xCenter(xKind, x, d.x),
186
+ cy: y(d.y),
187
+ x: d.x,
188
+ y: d.y,
189
+ })
190
+ })
191
+ })
192
+
193
+ return { marks, points }
194
+ }
195
+
196
+ function scatterMarks(layout: ChartLayout, series: ResolvedSeries[]): BuiltMarks {
197
+ const { xKind, x, y } = layout
198
+ const marks: Mark[] = []
199
+ const points: ScenePoint[] = []
200
+ series.forEach((s, si) => {
201
+ s.values.forEach((d, i) => {
202
+ const cx = xCenter(xKind, x, d.x)
203
+ const cy = y(d.y)
204
+ marks.push({
205
+ kind: 'circle',
206
+ cx,
207
+ cy,
208
+ r: 3.5,
209
+ fill: d.color ?? s.color,
210
+ fillOpacity: 0.85,
211
+ series: si,
212
+ index: i,
213
+ })
214
+
215
+ points.push({ series: si, index: i, key: categoryKey(d.x), cx, cy, x: d.x, y: d.y })
216
+ })
217
+ })
218
+
219
+ return { marks, points }
220
+ }
221
+
222
+ function groupedBarMarks(layout: ChartLayout, series: ResolvedSeries[]): BuiltMarks {
223
+ const { x, y } = layout
224
+ const outer = x as ScaleBand<string>
225
+ const inner = scaleBand<string>()
226
+ .domain(series.map((_, i) => String(i)))
227
+ .range([0, outer.bandwidth()])
228
+ .padding(0.08)
229
+
230
+ const marks: Mark[] = []
231
+ const points: ScenePoint[] = []
232
+ series.forEach((s, si) => {
233
+ const offset = inner(String(si)) ?? 0
234
+ const width = inner.bandwidth()
235
+ s.values.forEach((d, i) => {
236
+ const band = outer(String(d.x))
237
+ if (band === undefined || !Number.isFinite(d.y)) {
238
+ return
239
+ }
240
+
241
+ const top = y(Math.max(0, d.y))
242
+ const bottom = y(Math.min(0, d.y))
243
+ marks.push({
244
+ kind: 'rect',
245
+ x: band + offset,
246
+ y: top,
247
+ width,
248
+ height: Math.max(0, bottom - top),
249
+ fill: d.color ?? s.color,
250
+ series: si,
251
+ index: i,
252
+ })
253
+
254
+ points.push({
255
+ series: si,
256
+ index: i,
257
+ key: categoryKey(d.x),
258
+ cx: band + offset + width / 2,
259
+ cy: y(d.y),
260
+ x: d.x,
261
+ y: d.y,
262
+ })
263
+ })
264
+ })
265
+
266
+ return { marks, points }
267
+ }
268
+
269
+ /** Stacked bars accumulate per category — positive values stack up from the
270
+ * baseline, negatives down, so mixed-sign data stays correct. */
271
+ function stackedBarMarks(layout: ChartLayout, series: ResolvedSeries[]): BuiltMarks {
272
+ const { x, y } = layout
273
+ const outer = x as ScaleBand<string>
274
+ const posBase = new Map<string, number>()
275
+ const negBase = new Map<string, number>()
276
+
277
+ const marks: Mark[] = []
278
+ const points: ScenePoint[] = []
279
+ series.forEach((s, si) => {
280
+ s.values.forEach((d, i) => {
281
+ const cat = String(d.x)
282
+ const band = outer(cat)
283
+ if (band === undefined || !Number.isFinite(d.y)) {
284
+ return
285
+ }
286
+
287
+ const v0 = d.y >= 0 ? (posBase.get(cat) ?? 0) : (negBase.get(cat) ?? 0)
288
+ const v1 = v0 + d.y
289
+ if (d.y >= 0) {
290
+ posBase.set(cat, v1)
291
+ } else {
292
+ negBase.set(cat, v1)
293
+ }
294
+
295
+ const top = y(Math.max(v0, v1))
296
+ const bottom = y(Math.min(v0, v1))
297
+ marks.push({
298
+ kind: 'rect',
299
+ x: band,
300
+ y: top,
301
+ width: outer.bandwidth(),
302
+ height: Math.max(0, bottom - top),
303
+ fill: d.color ?? s.color,
304
+ series: si,
305
+ index: i,
306
+ })
307
+
308
+ points.push({
309
+ series: si,
310
+ index: i,
311
+ key: cat,
312
+ cx: band + outer.bandwidth() / 2,
313
+ cy: y(v1),
314
+ x: d.x,
315
+ y: d.y,
316
+ })
317
+ })
318
+ })
319
+
320
+ return { marks, points }
321
+ }
322
+
323
+ /** Pie consumes every datum across series — each {x: label, y: value} is a
324
+ * slice; slice colors are categorical (palette by order) with datum.color
325
+ * overrides. A series color is ignored on pie — it would be monochrome. */
326
+ function pieMarks(
327
+ layout: ChartLayout,
328
+ series: ResolvedSeries[],
329
+ options: { innerRadiusRatio?: number; palette: string[] },
330
+ ): BuiltMarks {
331
+ const { plot } = layout
332
+ const flat: { d: ChartDatum; si: number; i: number }[] = []
333
+ series.forEach((s, si) => s.values.forEach((d, i) => flat.push({ d, si, i })))
334
+
335
+ // Marks are plot-space; d3 arcs generate around (0,0), so each slice path
336
+ // carries its own translate to the pie center and scene.ts folds the plot
337
+ // offset into the published pie center.
338
+ const cx = plot.width / 2
339
+ const cy = plot.height / 2
340
+ const rOuter = Math.max(0, Math.min(plot.width, plot.height) / 2)
341
+ const ratio = options.innerRadiusRatio ?? 0
342
+ const rInner = rOuter * Math.min(Math.max(ratio, 0), 0.95)
343
+
344
+ const arcs = pieLayout<{ d: ChartDatum; si: number; i: number }>()
345
+ .value((v) => Math.max(0, v.d.y))
346
+ .sort(null)(flat)
347
+
348
+ const gen = arcPath<any, any>().innerRadius(rInner).outerRadius(rOuter)
349
+
350
+ const marks: Mark[] = []
351
+ const slices: PieSlice[] = []
352
+ arcs.forEach((a, order) => {
353
+ // Pie slices are categorical — palette by slice order; a series color on
354
+ // pie would monochrome every slice. datum.color overrides.
355
+ const color = a.data.d.color ?? options.palette[order % options.palette.length]
356
+ const centroid = gen.centroid(a)
357
+ marks.push({
358
+ kind: 'path',
359
+ d: gen(a) ?? '',
360
+ transform: `translate(${cx} ${cy})`,
361
+ fill: color,
362
+ series: a.data.si,
363
+ index: a.data.i,
364
+ })
365
+
366
+ slices.push({
367
+ series: a.data.si,
368
+ index: a.data.i,
369
+ label: String(a.data.d.x),
370
+ value: a.data.d.y,
371
+ color,
372
+ startAngle: a.startAngle,
373
+ endAngle: a.endAngle,
374
+ cx: centroid[0],
375
+ cy: centroid[1],
376
+ })
377
+ })
378
+
379
+ return { marks, points: [], pie: { cx, cy, rOuter, rInner, slices } }
380
+ }
@@ -0,0 +1,168 @@
1
+ import { extent } from 'd3-array'
2
+ import { scaleBand, scaleLinear, scalePoint, scaleTime } from 'd3-scale'
3
+ import type { ScaleBand, ScaleLinear, ScalePoint, ScaleTime } from 'd3-scale'
4
+ import type { ChartType, SeriesSpec } from '../props'
5
+
6
+ export type XScaleKind = 'band' | 'point' | 'linear' | 'time'
7
+
8
+ export type XScale =
9
+ | ScaleBand<string>
10
+ | ScalePoint<string>
11
+ | ScaleLinear<number, number>
12
+ | ScaleTime<number, number>
13
+
14
+ export type YScale = ScaleLinear<number, number>
15
+
16
+ export interface PlotBox {
17
+ x: number
18
+ y: number
19
+ width: number
20
+ height: number
21
+ }
22
+
23
+ export interface ResolvedSeries {
24
+ name: string
25
+ color: string
26
+ values: { x: number | string | Date; y: number; color?: string }[]
27
+ }
28
+
29
+ /** Categorical x values across all series, first-seen order — the band/point
30
+ * domain. */
31
+ export function categoryDomain(data: SeriesSpec[]): string[] {
32
+ const seen = new Set<string>()
33
+ const out: string[] = []
34
+ for (const series of data) {
35
+ for (const d of series.values) {
36
+ const key = String(d.x)
37
+ if (!seen.has(key)) {
38
+ seen.add(key)
39
+ out.push(key)
40
+ }
41
+ }
42
+ }
43
+
44
+ return out
45
+ }
46
+
47
+ /** A categorical x lookup — band/point domains key on String(x). */
48
+ export function categoryKey(x: number | string | Date): string {
49
+ return String(x)
50
+ }
51
+
52
+ /** Pick the x scale family: bar is always band, other cartesian types infer
53
+ * from the first datum — Date → time, string → point, number → linear. */
54
+ export function inferXKind(type: ChartType, data: SeriesSpec[]): XScaleKind {
55
+ if (type === 'bar') {
56
+ return 'band'
57
+ }
58
+
59
+ const first = data[0]?.values[0]?.x
60
+ if (first instanceof Date) {
61
+ return 'time'
62
+ }
63
+
64
+ if (typeof first === 'string') {
65
+ return 'point'
66
+ }
67
+
68
+ return 'linear'
69
+ }
70
+
71
+ export function buildXScale(kind: XScaleKind, data: SeriesSpec[], width: number): XScale {
72
+ switch (kind) {
73
+ case 'band':
74
+ return scaleBand<string>()
75
+ .domain(categoryDomain(data))
76
+ .range([0, width])
77
+ .paddingInner(0.25)
78
+ .paddingOuter(0.15)
79
+ case 'point': {
80
+ const domain = categoryDomain(data)
81
+ return scalePoint<string>()
82
+ .domain(domain)
83
+ .range([0, width])
84
+ .padding(domain.length <= 1 ? 0 : 0.5)
85
+ }
86
+ case 'time': {
87
+ const values = data.flatMap((s) => s.values.map((d) => d.x as Date))
88
+ const [lo, hi] = extent(values)
89
+ if (lo === undefined || hi === undefined) {
90
+ const now = new Date()
91
+ return scaleTime().domain([now, now]).range([0, width])
92
+ }
93
+
94
+ return scaleTime()
95
+ .domain(padDomain(+lo, +hi).map((v) => new Date(v)))
96
+ .range([0, width])
97
+ .nice()
98
+ }
99
+ case 'linear': {
100
+ const values = data.flatMap((s) => s.values.map((d) => Number(d.x)))
101
+ const [lo, hi] = extent(values)
102
+ return scaleLinear()
103
+ .domain(padDomain(lo ?? 0, hi ?? 1))
104
+ .range([0, width])
105
+ .nice()
106
+ }
107
+ }
108
+ }
109
+
110
+ /** Degenerate domains (one value) get a unit pad so the scale still maps
111
+ * somewhere sensible. */
112
+ function padDomain(lo: number, hi: number): [number, number] {
113
+ return lo === hi ? [lo - 0.5, hi + 0.5] : [lo, hi]
114
+ }
115
+
116
+ /** Y domain: bar/area pin the zero baseline; line/scatter take the data
117
+ * extent; stacked bars sum per category (positive stack only — negative
118
+ * values in stacked mode stack below zero independently). */
119
+ export function yDomain(data: SeriesSpec[], type: ChartType, stacked: boolean): [number, number] {
120
+ if (stacked && type === 'bar') {
121
+ const pos = new Map<string, number>()
122
+ const neg = new Map<string, number>()
123
+ for (const series of data) {
124
+ for (const d of series.values) {
125
+ const key = String(d.x)
126
+ if (d.y >= 0) {
127
+ pos.set(key, (pos.get(key) ?? 0) + d.y)
128
+ } else {
129
+ neg.set(key, (neg.get(key) ?? 0) + d.y)
130
+ }
131
+ }
132
+ }
133
+
134
+ const hi = Math.max(0, ...pos.values())
135
+ const lo = Math.min(0, ...neg.values())
136
+ return lo === hi ? [0, 1] : [lo, hi]
137
+ }
138
+
139
+ const values = data.flatMap((s) => s.values.map((d) => d.y))
140
+ const [lo, hi] = extent(values)
141
+ const min = lo ?? 0
142
+ const max = hi ?? 1
143
+ if (type === 'bar' || type === 'area') {
144
+ const domain: [number, number] = [Math.min(0, min), Math.max(0, max)]
145
+ return domain[0] === domain[1] ? [0, 1] : domain
146
+ }
147
+
148
+ return padDomain(min, max)
149
+ }
150
+
151
+ export function buildYScale(domain: [number, number], height: number): YScale {
152
+ return scaleLinear().domain(domain).range([height, 0]).nice()
153
+ }
154
+
155
+ /** Pixel center of an x value — band offsets by half the band, point and
156
+ * continuous scales map directly. */
157
+ export function xCenter(kind: XScaleKind, scale: XScale, value: number | string | Date): number {
158
+ if (kind === 'band') {
159
+ const band = scale as ScaleBand<string>
160
+ return (band(String(value)) ?? NaN) + band.bandwidth() / 2
161
+ }
162
+
163
+ if (kind === 'point') {
164
+ return (scale as ScalePoint<string>)(String(value)) ?? NaN
165
+ }
166
+
167
+ return (scale as ScaleLinear<number, number> | ScaleTime<number, number>)(value as never)
168
+ }
@@ -0,0 +1,145 @@
1
+ import type { ChartProps } from '../props'
2
+ import { xAxisModel, yAxisModel } from './axes'
3
+ import type { LabelSpec } from './axes'
4
+ import { buildMarks } from './marks'
5
+ import type { Mark, PieScene, ScenePoint } from './marks'
6
+ import { buildXScale, buildYScale, inferXKind, yDomain } from './scales'
7
+ import type { PlotBox } from './scales'
8
+ import { svgDocument } from './svg'
9
+
10
+ /** Default categorical palette — Tableau 10, embedded so the leaf stays free
11
+ * of d3-scale-chromatic. */
12
+ export const PALETTE = [
13
+ '#4e79a7',
14
+ '#f28e2b',
15
+ '#e15759',
16
+ '#76b7b2',
17
+ '#59a14f',
18
+ '#edc948',
19
+ '#b07aa1',
20
+ '#ff9da7',
21
+ '#9c755f',
22
+ '#bab0ac',
23
+ ]
24
+
25
+ export interface LegendItem {
26
+ color: string
27
+ text: string
28
+ }
29
+
30
+ /** The fully-resolved chart: svg markup, overlay label boxes, legend items,
31
+ * and per-datum hit targets in chart space. `computeScene` is the core's one
32
+ * entry point — the leaves only render it. */
33
+ export interface ChartScene {
34
+ type: ChartProps['type']
35
+ width: number
36
+ height: number
37
+ plot: PlotBox
38
+ markup: string
39
+ labels: LabelSpec[]
40
+ legend: LegendItem[]
41
+ series: { name: string; color: string }[]
42
+ points: ScenePoint[]
43
+ pie: PieScene | null
44
+ }
45
+
46
+ export function computeScene(props: ChartProps, width: number, height: number): ChartScene | null {
47
+ if (!(width > 0) || !(height > 0) || !props.data?.length) {
48
+ return null
49
+ }
50
+
51
+ const pie = props.type === 'pie'
52
+ const xLabels = !pie && props.xAxis?.labels !== false
53
+ const yLabels = !pie && props.yAxis?.labels !== false
54
+ const margin = {
55
+ top: 8,
56
+ right: 10,
57
+ bottom: pie ? 8 : xLabels ? 26 : 10,
58
+ left: pie ? 8 : yLabels ? 46 : 10,
59
+ ...props.margin,
60
+ }
61
+
62
+ const plot: PlotBox = {
63
+ x: margin.left,
64
+ y: margin.top,
65
+ width: Math.max(0, width - margin.left - margin.right),
66
+ height: Math.max(0, height - margin.top - margin.bottom),
67
+ }
68
+
69
+ if (!(plot.width > 0) || !(plot.height > 0)) {
70
+ return null
71
+ }
72
+
73
+ const series = props.data.map((s, i) => ({
74
+ name: s.name,
75
+ color: s.color ?? PALETTE[i % PALETTE.length],
76
+ values: s.values,
77
+ }))
78
+
79
+ let marks: Mark[]
80
+ let points: ScenePoint[]
81
+ let pieScene: PieScene | null = null
82
+ let labels: LabelSpec[] = []
83
+ let grid: Mark[] = []
84
+ let frame: Mark[] = []
85
+
86
+ if (pie) {
87
+ const built = buildMarks(
88
+ 'pie',
89
+ // Pie doesn't consume the cartesian scales — placeholders only.
90
+ { plot, xKind: 'band', x: buildXScale('band', [], 0), y: buildYScale([0, 1], 1) },
91
+ series,
92
+ { innerRadiusRatio: props.innerRadiusRatio, palette: PALETTE },
93
+ )
94
+
95
+ marks = built.marks
96
+ points = []
97
+ const center = { cx: built.pie!.cx + plot.x, cy: built.pie!.cy + plot.y }
98
+ pieScene = {
99
+ ...built.pie!,
100
+ ...center,
101
+ slices: built.pie!.slices.map((s) => ({ ...s, cx: s.cx + center.cx, cy: s.cy + center.cy })),
102
+ }
103
+ } else {
104
+ const xKind = inferXKind(props.type, props.data)
105
+ const layout = {
106
+ plot,
107
+ xKind,
108
+ x: buildXScale(xKind, props.data, plot.width),
109
+ y: buildYScale(yDomain(props.data, props.type, props.stacked === true), plot.height),
110
+ }
111
+
112
+ const built = buildMarks(props.type, layout, series, {
113
+ stacked: props.stacked,
114
+ palette: PALETTE,
115
+ })
116
+
117
+ marks = built.marks
118
+ // Marks stay plot-space in the markup (the serializer wraps them in a
119
+ // translate group); hit targets publish chart space.
120
+ points = built.points.map((p) => ({ ...p, cx: p.cx + plot.x, cy: p.cy + plot.y }))
121
+
122
+ const xAxis = xAxisModel(xKind, layout.x, props.xAxis, plot)
123
+ const yAxis = yAxisModel(layout.y, props.yAxis, plot)
124
+ labels = [...xAxis.labels, ...yAxis.labels]
125
+ grid = [...xAxis.grid, ...yAxis.grid]
126
+ frame = [...xAxis.frame, ...yAxis.frame]
127
+ }
128
+
129
+ const legend = pieScene
130
+ ? pieScene.slices.map((s) => ({ color: s.color, text: s.label }))
131
+ : series.map((s) => ({ color: s.color, text: s.name }))
132
+
133
+ return {
134
+ type: props.type,
135
+ width,
136
+ height,
137
+ plot,
138
+ markup: svgDocument(marks, grid, frame, plot, width, height),
139
+ labels,
140
+ legend,
141
+ series,
142
+ points,
143
+ pie: pieScene,
144
+ }
145
+ }