@octane-xplat/charts 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/dist/macos/Chart.macos.tsrx.js +351 -0
- package/dist/macos/core/axes.js +146 -0
- package/dist/macos/core/hit.js +131 -0
- package/dist/macos/core/marks.js +262 -0
- package/dist/macos/core/scales.js +99 -0
- package/dist/macos/core/scene.js +120 -0
- package/dist/macos/core/svg.js +40 -0
- package/dist/macos/index.js +2 -0
- package/dist/macos/octane-client-build.json +8 -0
- package/dist/macos/svg-image.macos.js +14 -0
- package/dist/native/Chart.tsrx.js +340 -0
- package/dist/native/core/axes.js +146 -0
- package/dist/native/core/hit.js +131 -0
- package/dist/native/core/marks.js +262 -0
- package/dist/native/core/scales.js +99 -0
- package/dist/native/core/scene.js +120 -0
- package/dist/native/core/svg.js +40 -0
- package/dist/native/index.js +2 -0
- package/dist/native/octane-client-build.json +8 -0
- package/dist/web/Chart.web.tsrx.js +488 -0
- package/dist/web/core/axes.js +146 -0
- package/dist/web/core/hit.js +131 -0
- package/dist/web/core/marks.js +262 -0
- package/dist/web/core/scales.js +99 -0
- package/dist/web/core/scene.js +120 -0
- package/dist/web/core/svg.js +40 -0
- package/dist/web/index.js +2 -0
- package/dist/web/octane-client-build.json +8 -0
- package/package.json +91 -3
- package/src/Chart.macos.tsrx +134 -0
- package/src/Chart.tsrx +174 -0
- package/src/Chart.web.tsrx +215 -0
- package/src/core/axes.ts +211 -0
- package/src/core/hit.ts +201 -0
- package/src/core/marks.ts +380 -0
- package/src/core/scales.ts +168 -0
- package/src/core/scene.ts +145 -0
- package/src/core/svg.ts +84 -0
- package/src/index.macos.ts +12 -0
- package/src/index.ts +12 -0
- package/src/index.web.ts +12 -0
- package/src/props.ts +90 -0
- package/src/svg-image.macos.ts +34 -0
- package/types/index.d.ts +17 -0
- package/types/props.d.ts +83 -0
- package/README.md +0 -3
|
@@ -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
|
+
}
|