@opendata-ai/openchart-engine 1.2.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/dist/index.d.ts +366 -0
- package/dist/index.js +4227 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -0
- package/src/__test-fixtures__/specs.ts +124 -0
- package/src/__tests__/axes.test.ts +114 -0
- package/src/__tests__/compile-chart.test.ts +337 -0
- package/src/__tests__/dimensions.test.ts +151 -0
- package/src/__tests__/legend.test.ts +113 -0
- package/src/__tests__/scales.test.ts +109 -0
- package/src/annotations/__tests__/compute.test.ts +454 -0
- package/src/annotations/compute.ts +603 -0
- package/src/charts/__tests__/registry.test.ts +110 -0
- package/src/charts/bar/__tests__/compute.test.ts +294 -0
- package/src/charts/bar/__tests__/labels.test.ts +75 -0
- package/src/charts/bar/compute.ts +205 -0
- package/src/charts/bar/index.ts +33 -0
- package/src/charts/bar/labels.ts +132 -0
- package/src/charts/column/__tests__/compute.test.ts +277 -0
- package/src/charts/column/compute.ts +282 -0
- package/src/charts/column/index.ts +33 -0
- package/src/charts/column/labels.ts +108 -0
- package/src/charts/dot/__tests__/compute.test.ts +344 -0
- package/src/charts/dot/compute.ts +257 -0
- package/src/charts/dot/index.ts +46 -0
- package/src/charts/dot/labels.ts +97 -0
- package/src/charts/line/__tests__/compute.test.ts +437 -0
- package/src/charts/line/__tests__/labels.test.ts +93 -0
- package/src/charts/line/area.ts +288 -0
- package/src/charts/line/compute.ts +177 -0
- package/src/charts/line/index.ts +68 -0
- package/src/charts/line/labels.ts +144 -0
- package/src/charts/pie/__tests__/compute.test.ts +276 -0
- package/src/charts/pie/compute.ts +234 -0
- package/src/charts/pie/index.ts +49 -0
- package/src/charts/pie/labels.ts +142 -0
- package/src/charts/registry.ts +64 -0
- package/src/charts/scatter/__tests__/compute.test.ts +304 -0
- package/src/charts/scatter/__tests__/trendline.test.ts +191 -0
- package/src/charts/scatter/compute.ts +124 -0
- package/src/charts/scatter/index.ts +41 -0
- package/src/charts/scatter/trendline.ts +100 -0
- package/src/charts/utils.ts +120 -0
- package/src/compile.ts +368 -0
- package/src/compiler/__tests__/compile.test.ts +87 -0
- package/src/compiler/__tests__/normalize.test.ts +210 -0
- package/src/compiler/__tests__/validate.test.ts +440 -0
- package/src/compiler/index.ts +47 -0
- package/src/compiler/normalize.ts +269 -0
- package/src/compiler/types.ts +148 -0
- package/src/compiler/validate.ts +581 -0
- package/src/graphs/__tests__/community.test.ts +228 -0
- package/src/graphs/__tests__/compile-graph.test.ts +315 -0
- package/src/graphs/__tests__/encoding.test.ts +314 -0
- package/src/graphs/community.ts +92 -0
- package/src/graphs/compile-graph.ts +291 -0
- package/src/graphs/encoding.ts +302 -0
- package/src/graphs/types.ts +98 -0
- package/src/index.ts +74 -0
- package/src/layout/axes.ts +194 -0
- package/src/layout/dimensions.ts +199 -0
- package/src/layout/gridlines.ts +84 -0
- package/src/layout/scales.ts +426 -0
- package/src/legend/compute.ts +186 -0
- package/src/tables/__tests__/bar-column.test.ts +147 -0
- package/src/tables/__tests__/category-colors.test.ts +153 -0
- package/src/tables/__tests__/compile-table.test.ts +208 -0
- package/src/tables/__tests__/format-cells.test.ts +126 -0
- package/src/tables/__tests__/heatmap.test.ts +124 -0
- package/src/tables/__tests__/pagination.test.ts +78 -0
- package/src/tables/__tests__/search.test.ts +94 -0
- package/src/tables/__tests__/sort.test.ts +107 -0
- package/src/tables/__tests__/sparkline.test.ts +122 -0
- package/src/tables/bar-column.ts +94 -0
- package/src/tables/category-colors.ts +67 -0
- package/src/tables/compile-table.ts +420 -0
- package/src/tables/format-cells.ts +110 -0
- package/src/tables/heatmap.ts +121 -0
- package/src/tables/pagination.ts +46 -0
- package/src/tables/search.ts +66 -0
- package/src/tables/sort.ts +69 -0
- package/src/tables/sparkline.ts +113 -0
- package/src/tables/utils.ts +16 -0
- package/src/tooltips/__tests__/compute.test.ts +328 -0
- package/src/tooltips/compute.ts +231 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dimension computation for the chart layout.
|
|
3
|
+
*
|
|
4
|
+
* Takes the normalized spec + compile options + legend layout and produces
|
|
5
|
+
* LayoutDimensions with the total area, chrome layout, chart drawing area,
|
|
6
|
+
* and margins. The chart area is what's left after subtracting chrome,
|
|
7
|
+
* legend space, and axis margins.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
CompileOptions,
|
|
12
|
+
Encoding,
|
|
13
|
+
LegendLayout,
|
|
14
|
+
Margins,
|
|
15
|
+
Rect,
|
|
16
|
+
ResolvedChrome,
|
|
17
|
+
ResolvedTheme,
|
|
18
|
+
} from '@opendata-ai/openchart-core';
|
|
19
|
+
import { computeChrome, estimateTextWidth } from '@opendata-ai/openchart-core';
|
|
20
|
+
|
|
21
|
+
import type { NormalizedChartSpec, NormalizedChrome } from '../compiler/types';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Types
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/** The complete dimension layout for a chart. */
|
|
28
|
+
export interface LayoutDimensions {
|
|
29
|
+
/** Total available space. */
|
|
30
|
+
total: Rect;
|
|
31
|
+
/** Resolved chrome (title, subtitle, source, etc.). */
|
|
32
|
+
chrome: ResolvedChrome;
|
|
33
|
+
/** The chart drawing area (after subtracting chrome, legend, margins). */
|
|
34
|
+
chartArea: Rect;
|
|
35
|
+
/** Margins around the chart area. */
|
|
36
|
+
margins: Margins;
|
|
37
|
+
/** Resolved theme used for this layout. */
|
|
38
|
+
theme: ResolvedTheme;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Helpers
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/** Convert NormalizedChrome back to a Chrome-compatible shape for computeChrome. */
|
|
46
|
+
function chromeToInput(chrome: NormalizedChrome): import('@opendata-ai/openchart-core').Chrome {
|
|
47
|
+
return {
|
|
48
|
+
title: chrome.title,
|
|
49
|
+
subtitle: chrome.subtitle,
|
|
50
|
+
source: chrome.source,
|
|
51
|
+
byline: chrome.byline,
|
|
52
|
+
footer: chrome.footer,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Public API
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Compute chart dimensions, reserving space for chrome, legend, and axes.
|
|
62
|
+
*
|
|
63
|
+
* @param spec - Normalized chart spec.
|
|
64
|
+
* @param options - Compile options (width, height, theme, darkMode).
|
|
65
|
+
* @param legendLayout - Pre-computed legend layout (used to reserve space).
|
|
66
|
+
* @param theme - Already-resolved theme (resolved once in compileChart).
|
|
67
|
+
* @returns LayoutDimensions with chart area rect.
|
|
68
|
+
*/
|
|
69
|
+
export function computeDimensions(
|
|
70
|
+
spec: NormalizedChartSpec,
|
|
71
|
+
options: CompileOptions,
|
|
72
|
+
legendLayout: LegendLayout,
|
|
73
|
+
theme: ResolvedTheme,
|
|
74
|
+
): LayoutDimensions {
|
|
75
|
+
const { width, height } = options;
|
|
76
|
+
|
|
77
|
+
const padding = theme.spacing.padding;
|
|
78
|
+
const axisMargin = theme.spacing.axisMargin;
|
|
79
|
+
|
|
80
|
+
// Compute chrome
|
|
81
|
+
const chrome = computeChrome(chromeToInput(spec.chrome), theme, width, options.measureText);
|
|
82
|
+
|
|
83
|
+
// Start with the total rect
|
|
84
|
+
const total: Rect = { x: 0, y: 0, width, height };
|
|
85
|
+
|
|
86
|
+
// Radial charts (pie/donut) don't have axes, so skip axis space
|
|
87
|
+
const isRadial = spec.type === 'pie' || spec.type === 'donut';
|
|
88
|
+
const encoding = spec.encoding as Encoding;
|
|
89
|
+
|
|
90
|
+
// Estimate x-axis height below chart area: tick labels sit 14px below,
|
|
91
|
+
// axis title sits 35px below. These extend past the chart area bottom
|
|
92
|
+
// and source/footer chrome must be positioned below them.
|
|
93
|
+
const hasXAxisLabel = !!(encoding.x?.axis as Record<string, unknown> | undefined)?.label;
|
|
94
|
+
const xAxisHeight = isRadial ? 0 : hasXAxisLabel ? 48 : 26;
|
|
95
|
+
|
|
96
|
+
// Build margins: padding + chrome + axis space
|
|
97
|
+
const margins: Margins = {
|
|
98
|
+
top: padding + chrome.topHeight + axisMargin,
|
|
99
|
+
right: padding + (isRadial ? padding : axisMargin),
|
|
100
|
+
bottom: padding + chrome.bottomHeight + xAxisHeight,
|
|
101
|
+
left: padding + (isRadial ? padding : axisMargin),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Dynamic right margin for line/area end-of-line labels
|
|
105
|
+
if (spec.type === 'line' || spec.type === 'area') {
|
|
106
|
+
// Estimate label width from longest series name (color encoding domain)
|
|
107
|
+
const colorField = encoding.color?.field;
|
|
108
|
+
if (colorField) {
|
|
109
|
+
let maxLabelWidth = 0;
|
|
110
|
+
const seen = new Set<string>();
|
|
111
|
+
for (const row of spec.data) {
|
|
112
|
+
const label = String(row[colorField] ?? '');
|
|
113
|
+
if (!seen.has(label)) {
|
|
114
|
+
seen.add(label);
|
|
115
|
+
const w = estimateTextWidth(label, 11, 600);
|
|
116
|
+
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (maxLabelWidth > 0) {
|
|
120
|
+
margins.right = Math.max(margins.right, padding + maxLabelWidth + 16);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Dynamic left margin for y-axis labels
|
|
126
|
+
if (encoding.y && !isRadial) {
|
|
127
|
+
if (
|
|
128
|
+
spec.type === 'bar' ||
|
|
129
|
+
spec.type === 'dot' ||
|
|
130
|
+
encoding.y.type === 'nominal' ||
|
|
131
|
+
encoding.y.type === 'ordinal'
|
|
132
|
+
) {
|
|
133
|
+
// Category labels on the left for bar/dot charts
|
|
134
|
+
const yField = encoding.y.field;
|
|
135
|
+
let maxLabelWidth = 0;
|
|
136
|
+
for (const row of spec.data) {
|
|
137
|
+
const label = String(row[yField] ?? '');
|
|
138
|
+
const w = estimateTextWidth(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
|
|
139
|
+
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
140
|
+
}
|
|
141
|
+
if (maxLabelWidth > 0) {
|
|
142
|
+
margins.left = Math.max(margins.left, padding + maxLabelWidth + 12);
|
|
143
|
+
}
|
|
144
|
+
} else if (encoding.y.type === 'quantitative' || encoding.y.type === 'temporal') {
|
|
145
|
+
// Numeric tick labels on the left. Estimate width from the data range.
|
|
146
|
+
const yField = encoding.y.field;
|
|
147
|
+
let maxAbsVal = 0;
|
|
148
|
+
for (const row of spec.data) {
|
|
149
|
+
const v = Number(row[yField]);
|
|
150
|
+
if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
|
|
151
|
+
}
|
|
152
|
+
// Estimate the formatted label: abbreviateNumber for >= 1000, formatNumber otherwise
|
|
153
|
+
let sampleLabel: string;
|
|
154
|
+
if (maxAbsVal >= 1_000_000_000) sampleLabel = '1.5B';
|
|
155
|
+
else if (maxAbsVal >= 1_000_000) sampleLabel = '1.5M';
|
|
156
|
+
else if (maxAbsVal >= 1_000) sampleLabel = '1.5K';
|
|
157
|
+
else if (maxAbsVal >= 100) sampleLabel = '100';
|
|
158
|
+
else if (maxAbsVal >= 10) sampleLabel = '10';
|
|
159
|
+
else sampleLabel = '0.0';
|
|
160
|
+
// Account for negative sign
|
|
161
|
+
const negPrefix = spec.data.some((r) => Number(r[yField]) < 0) ? '-' : '';
|
|
162
|
+
const labelEst = negPrefix + sampleLabel;
|
|
163
|
+
const labelWidth = estimateTextWidth(
|
|
164
|
+
labelEst,
|
|
165
|
+
theme.fonts.sizes.axisTick,
|
|
166
|
+
theme.fonts.weights.normal,
|
|
167
|
+
);
|
|
168
|
+
// 6px gap between label and chart area edge
|
|
169
|
+
margins.left = Math.max(margins.left, padding + labelWidth + 10);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Rotated y-axis label needs extra left margin (rendered at area.x - 45 in SVG)
|
|
174
|
+
if (encoding.y?.axis && (encoding.y.axis as Record<string, unknown>).label && !isRadial) {
|
|
175
|
+
const rotatedLabelMargin = 45 + Math.ceil(theme.fonts.sizes.body / 2) + 4;
|
|
176
|
+
margins.left = Math.max(margins.left, padding + rotatedLabelMargin);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Reserve legend space
|
|
180
|
+
if (legendLayout.entries.length > 0) {
|
|
181
|
+
if (legendLayout.position === 'right' || legendLayout.position === 'bottom-right') {
|
|
182
|
+
margins.right += legendLayout.bounds.width + 8;
|
|
183
|
+
} else if (legendLayout.position === 'top') {
|
|
184
|
+
margins.top += legendLayout.bounds.height + 4;
|
|
185
|
+
} else if (legendLayout.position === 'bottom') {
|
|
186
|
+
margins.bottom += legendLayout.bounds.height + 4;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Chart area is what's left after margins
|
|
191
|
+
const chartArea: Rect = {
|
|
192
|
+
x: margins.left,
|
|
193
|
+
y: margins.top,
|
|
194
|
+
width: Math.max(0, width - margins.left - margins.right),
|
|
195
|
+
height: Math.max(0, height - margins.top - margins.bottom),
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
return { total, chrome, chartArea, margins, theme };
|
|
199
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gridline computation.
|
|
3
|
+
*
|
|
4
|
+
* Produces horizontal gridlines at y-axis tick positions and optional
|
|
5
|
+
* vertical gridlines at x-axis tick positions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Rect } from '@opendata-ai/openchart-core';
|
|
9
|
+
import type { AxesResult } from './axes';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Types
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/** A single gridline with start/end positions. */
|
|
16
|
+
export interface GridlineSpec {
|
|
17
|
+
/** Start x. */
|
|
18
|
+
x1: number;
|
|
19
|
+
/** Start y. */
|
|
20
|
+
y1: number;
|
|
21
|
+
/** End x. */
|
|
22
|
+
x2: number;
|
|
23
|
+
/** End y. */
|
|
24
|
+
y2: number;
|
|
25
|
+
/** Whether this is a major gridline. */
|
|
26
|
+
major: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Complete gridline layout. */
|
|
30
|
+
export interface GridlineLayout {
|
|
31
|
+
horizontal: GridlineSpec[];
|
|
32
|
+
vertical: GridlineSpec[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Public API
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Compute gridlines from axis layouts.
|
|
41
|
+
*
|
|
42
|
+
* Horizontal gridlines span the chart width at y-axis tick positions.
|
|
43
|
+
* Vertical gridlines span the chart height at x-axis tick positions.
|
|
44
|
+
*
|
|
45
|
+
* @param axes - Computed axis layouts.
|
|
46
|
+
* @param chartArea - The chart drawing area.
|
|
47
|
+
* @param showVertical - Whether to include vertical gridlines (default: false).
|
|
48
|
+
*/
|
|
49
|
+
export function computeGridlines(
|
|
50
|
+
axes: AxesResult,
|
|
51
|
+
chartArea: Rect,
|
|
52
|
+
showVertical = false,
|
|
53
|
+
): GridlineLayout {
|
|
54
|
+
const horizontal: GridlineSpec[] = [];
|
|
55
|
+
const vertical: GridlineSpec[] = [];
|
|
56
|
+
|
|
57
|
+
// Horizontal gridlines at y-axis ticks
|
|
58
|
+
if (axes.y) {
|
|
59
|
+
for (const gridline of axes.y.gridlines) {
|
|
60
|
+
horizontal.push({
|
|
61
|
+
x1: chartArea.x,
|
|
62
|
+
y1: gridline.position,
|
|
63
|
+
x2: chartArea.x + chartArea.width,
|
|
64
|
+
y2: gridline.position,
|
|
65
|
+
major: gridline.major,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Vertical gridlines at x-axis ticks (off by default)
|
|
71
|
+
if (showVertical && axes.x) {
|
|
72
|
+
for (const gridline of axes.x.gridlines) {
|
|
73
|
+
vertical.push({
|
|
74
|
+
x1: gridline.position,
|
|
75
|
+
y1: chartArea.y,
|
|
76
|
+
x2: gridline.position,
|
|
77
|
+
y2: chartArea.y + chartArea.height,
|
|
78
|
+
major: gridline.major,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { horizontal, vertical };
|
|
84
|
+
}
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scale computation from encoding spec + data.
|
|
3
|
+
*
|
|
4
|
+
* Creates D3 scales that map data values to pixel positions.
|
|
5
|
+
* Temporal -> scaleTime(), quantitative -> scaleLinear(),
|
|
6
|
+
* nominal/ordinal -> scaleBand() or scaleOrdinal(), depending on context.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { DataRow, Encoding, EncodingChannel, Rect } from '@opendata-ai/openchart-core';
|
|
10
|
+
import { extent, max, min } from 'd3-array';
|
|
11
|
+
import type {
|
|
12
|
+
ScaleBand,
|
|
13
|
+
ScaleLinear,
|
|
14
|
+
ScaleLogarithmic,
|
|
15
|
+
ScaleOrdinal,
|
|
16
|
+
ScalePoint,
|
|
17
|
+
ScaleTime,
|
|
18
|
+
} from 'd3-scale';
|
|
19
|
+
import { scaleBand, scaleLinear, scaleLog, scaleOrdinal, scalePoint, scaleTime } from 'd3-scale';
|
|
20
|
+
|
|
21
|
+
import type { NormalizedChartSpec } from '../compiler/types';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Types
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/** Continuous D3 scales (linear, time, log) that support .ticks() and .nice(). */
|
|
28
|
+
export type D3ContinuousScale =
|
|
29
|
+
| ScaleLinear<number, number>
|
|
30
|
+
| ScaleTime<number, number>
|
|
31
|
+
| ScaleLogarithmic<number, number>;
|
|
32
|
+
|
|
33
|
+
/** Categorical D3 scales (band, point, ordinal) that support .domain() as string[]. */
|
|
34
|
+
export type D3CategoricalScale =
|
|
35
|
+
| ScaleBand<string>
|
|
36
|
+
| ScalePoint<string>
|
|
37
|
+
| ScaleOrdinal<string, string>;
|
|
38
|
+
|
|
39
|
+
/** Union of all D3 scale types used by the engine. */
|
|
40
|
+
export type D3Scale = D3ContinuousScale | D3CategoricalScale;
|
|
41
|
+
|
|
42
|
+
/** A sequential color scale mapping numbers to color strings. */
|
|
43
|
+
export type D3SequentialColorScale = ScaleLinear<string, string>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A resolved scale wrapping a d3 scale with type metadata.
|
|
47
|
+
* We need to carry the scale type around so axes and marks know
|
|
48
|
+
* how to interpret the domain/range. Consumers use the `type` discriminant
|
|
49
|
+
* to determine which D3 methods are available on the scale.
|
|
50
|
+
*/
|
|
51
|
+
export interface ResolvedScale {
|
|
52
|
+
/** The d3 scale function. Maps domain value -> pixel position or color. */
|
|
53
|
+
scale: D3Scale;
|
|
54
|
+
/** The scale type for downstream use. */
|
|
55
|
+
type: 'linear' | 'time' | 'band' | 'ordinal' | 'point' | 'log' | 'sequential';
|
|
56
|
+
/** The encoding channel this scale was derived from. */
|
|
57
|
+
channel: EncodingChannel;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** All resolved scales for a chart. */
|
|
61
|
+
export interface ResolvedScales {
|
|
62
|
+
x?: ResolvedScale;
|
|
63
|
+
y?: ResolvedScale;
|
|
64
|
+
color?: ResolvedScale;
|
|
65
|
+
size?: ResolvedScale;
|
|
66
|
+
/** Default color for single-series charts (first categorical palette color). */
|
|
67
|
+
defaultColor?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Helpers
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
/** Extract all non-null values for a field from data. */
|
|
75
|
+
function fieldValues(data: DataRow[], field: string): unknown[] {
|
|
76
|
+
return data.map((d) => d[field]).filter((v) => v != null);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Parse values to dates. */
|
|
80
|
+
function parseDates(values: unknown[]): Date[] {
|
|
81
|
+
return values
|
|
82
|
+
.map((v) => (v instanceof Date ? v : new Date(String(v))))
|
|
83
|
+
.filter((d) => !Number.isNaN(d.getTime()));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Parse values to numbers. */
|
|
87
|
+
function parseNumbers(values: unknown[]): number[] {
|
|
88
|
+
return values
|
|
89
|
+
.map((v) => (typeof v === 'number' ? v : Number(v)))
|
|
90
|
+
.filter((n) => Number.isFinite(n));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Get unique string values preserving order. */
|
|
94
|
+
function uniqueStrings(values: unknown[]): string[] {
|
|
95
|
+
const seen = new Set<string>();
|
|
96
|
+
const result: string[] = [];
|
|
97
|
+
for (const v of values) {
|
|
98
|
+
const s = String(v);
|
|
99
|
+
if (!seen.has(s)) {
|
|
100
|
+
seen.add(s);
|
|
101
|
+
result.push(s);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Scale builders
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
function buildTimeScale(
|
|
112
|
+
channel: EncodingChannel,
|
|
113
|
+
data: DataRow[],
|
|
114
|
+
rangeStart: number,
|
|
115
|
+
rangeEnd: number,
|
|
116
|
+
): ResolvedScale {
|
|
117
|
+
const values = parseDates(fieldValues(data, channel.field));
|
|
118
|
+
const domain = channel.scale?.domain
|
|
119
|
+
? [new Date(channel.scale.domain[0] as string), new Date(channel.scale.domain[1] as string)]
|
|
120
|
+
: (extent(values) as [Date, Date]);
|
|
121
|
+
|
|
122
|
+
const scale = scaleTime().domain(domain).range([rangeStart, rangeEnd]);
|
|
123
|
+
|
|
124
|
+
if (channel.scale?.nice !== false) {
|
|
125
|
+
scale.nice();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { scale, type: 'time', channel };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function buildLinearScale(
|
|
132
|
+
channel: EncodingChannel,
|
|
133
|
+
data: DataRow[],
|
|
134
|
+
rangeStart: number,
|
|
135
|
+
rangeEnd: number,
|
|
136
|
+
): ResolvedScale {
|
|
137
|
+
const values = parseNumbers(fieldValues(data, channel.field));
|
|
138
|
+
|
|
139
|
+
let domainMin: number;
|
|
140
|
+
let domainMax: number;
|
|
141
|
+
|
|
142
|
+
if (channel.scale?.domain) {
|
|
143
|
+
const [d0, d1] = channel.scale.domain as [number, number];
|
|
144
|
+
domainMin = d0;
|
|
145
|
+
domainMax = d1;
|
|
146
|
+
} else {
|
|
147
|
+
domainMin = min(values) ?? 0;
|
|
148
|
+
domainMax = max(values) ?? 1;
|
|
149
|
+
|
|
150
|
+
// Include zero by default for quantitative scales
|
|
151
|
+
if (channel.scale?.zero !== false) {
|
|
152
|
+
domainMin = Math.min(0, domainMin);
|
|
153
|
+
domainMax = Math.max(0, domainMax);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const scale = scaleLinear().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
|
|
158
|
+
|
|
159
|
+
if (channel.scale?.nice !== false) {
|
|
160
|
+
scale.nice();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { scale, type: 'linear', channel };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function buildLogScale(
|
|
167
|
+
channel: EncodingChannel,
|
|
168
|
+
data: DataRow[],
|
|
169
|
+
rangeStart: number,
|
|
170
|
+
rangeEnd: number,
|
|
171
|
+
): ResolvedScale {
|
|
172
|
+
const values = parseNumbers(fieldValues(data, channel.field)).filter((v) => v > 0);
|
|
173
|
+
const domainMin = min(values) ?? 1;
|
|
174
|
+
const domainMax = max(values) ?? 10;
|
|
175
|
+
|
|
176
|
+
const scale = scaleLog().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]).nice();
|
|
177
|
+
|
|
178
|
+
return { scale, type: 'log', channel };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function buildBandScale(
|
|
182
|
+
channel: EncodingChannel,
|
|
183
|
+
data: DataRow[],
|
|
184
|
+
rangeStart: number,
|
|
185
|
+
rangeEnd: number,
|
|
186
|
+
): ResolvedScale {
|
|
187
|
+
const values = channel.scale?.domain
|
|
188
|
+
? (channel.scale.domain as string[])
|
|
189
|
+
: uniqueStrings(fieldValues(data, channel.field));
|
|
190
|
+
|
|
191
|
+
const scale = scaleBand().domain(values).range([rangeStart, rangeEnd]).padding(0.35);
|
|
192
|
+
|
|
193
|
+
return { scale, type: 'band', channel };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function buildPointScale(
|
|
197
|
+
channel: EncodingChannel,
|
|
198
|
+
data: DataRow[],
|
|
199
|
+
rangeStart: number,
|
|
200
|
+
rangeEnd: number,
|
|
201
|
+
): ResolvedScale {
|
|
202
|
+
const values = channel.scale?.domain
|
|
203
|
+
? (channel.scale.domain as string[])
|
|
204
|
+
: uniqueStrings(fieldValues(data, channel.field));
|
|
205
|
+
|
|
206
|
+
const scale = scalePoint().domain(values).range([rangeStart, rangeEnd]).padding(0.5);
|
|
207
|
+
|
|
208
|
+
return { scale, type: 'point', channel };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function buildOrdinalColorScale(
|
|
212
|
+
channel: EncodingChannel,
|
|
213
|
+
data: DataRow[],
|
|
214
|
+
palette: string[],
|
|
215
|
+
): ResolvedScale {
|
|
216
|
+
const values = uniqueStrings(fieldValues(data, channel.field));
|
|
217
|
+
|
|
218
|
+
const scale = scaleOrdinal<string>().domain(values).range(palette);
|
|
219
|
+
|
|
220
|
+
return { scale, type: 'ordinal', channel };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function buildSequentialColorScale(
|
|
224
|
+
channel: EncodingChannel,
|
|
225
|
+
data: DataRow[],
|
|
226
|
+
palette: string[],
|
|
227
|
+
): ResolvedScale {
|
|
228
|
+
const values = parseNumbers(fieldValues(data, channel.field));
|
|
229
|
+
const domainMin = min(values) ?? 0;
|
|
230
|
+
const domainMax = max(values) ?? 1;
|
|
231
|
+
|
|
232
|
+
const scale = scaleLinear<string>()
|
|
233
|
+
.domain([domainMin, domainMax])
|
|
234
|
+
.range([palette[0], palette[palette.length - 1]])
|
|
235
|
+
.clamp(true);
|
|
236
|
+
|
|
237
|
+
// Cast: sequential color scale (number -> string) is structurally incompatible
|
|
238
|
+
// with D3Scale (number -> number), but is only ever accessed via scales.color
|
|
239
|
+
// where consumers already cast appropriately.
|
|
240
|
+
return { scale: scale as unknown as D3Scale, type: 'sequential', channel };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Positional scale selection
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Choose the right scale type for a positional channel (x or y).
|
|
249
|
+
* Respects explicit scale.type overrides from the spec.
|
|
250
|
+
*/
|
|
251
|
+
function buildPositionalScale(
|
|
252
|
+
channel: EncodingChannel,
|
|
253
|
+
data: DataRow[],
|
|
254
|
+
rangeStart: number,
|
|
255
|
+
rangeEnd: number,
|
|
256
|
+
chartType: string,
|
|
257
|
+
axis: 'x' | 'y',
|
|
258
|
+
): ResolvedScale {
|
|
259
|
+
// Explicit scale type override
|
|
260
|
+
if (channel.scale?.type) {
|
|
261
|
+
switch (channel.scale.type) {
|
|
262
|
+
case 'time':
|
|
263
|
+
return buildTimeScale(channel, data, rangeStart, rangeEnd);
|
|
264
|
+
case 'linear':
|
|
265
|
+
return buildLinearScale(channel, data, rangeStart, rangeEnd);
|
|
266
|
+
case 'log':
|
|
267
|
+
return buildLogScale(channel, data, rangeStart, rangeEnd);
|
|
268
|
+
case 'band':
|
|
269
|
+
return buildBandScale(channel, data, rangeStart, rangeEnd);
|
|
270
|
+
case 'point':
|
|
271
|
+
return buildPointScale(channel, data, rangeStart, rangeEnd);
|
|
272
|
+
case 'ordinal':
|
|
273
|
+
return buildBandScale(channel, data, rangeStart, rangeEnd);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Infer from field type
|
|
278
|
+
switch (channel.type) {
|
|
279
|
+
case 'temporal':
|
|
280
|
+
return buildTimeScale(channel, data, rangeStart, rangeEnd);
|
|
281
|
+
case 'quantitative':
|
|
282
|
+
return buildLinearScale(channel, data, rangeStart, rangeEnd);
|
|
283
|
+
case 'nominal':
|
|
284
|
+
case 'ordinal':
|
|
285
|
+
// Bar/column charts use band scales for their categorical axis
|
|
286
|
+
if (
|
|
287
|
+
(chartType === 'bar' && axis === 'y') ||
|
|
288
|
+
(chartType === 'column' && axis === 'x') ||
|
|
289
|
+
(chartType === 'dot' && axis === 'y')
|
|
290
|
+
) {
|
|
291
|
+
return buildBandScale(channel, data, rangeStart, rangeEnd);
|
|
292
|
+
}
|
|
293
|
+
return buildPointScale(channel, data, rangeStart, rangeEnd);
|
|
294
|
+
default:
|
|
295
|
+
return buildLinearScale(channel, data, rangeStart, rangeEnd);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// Public API
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Compute D3 scales from encoding channels and data.
|
|
305
|
+
*
|
|
306
|
+
* @param spec - Normalized chart spec.
|
|
307
|
+
* @param chartArea - The computed chart drawing area.
|
|
308
|
+
* @param data - Data rows.
|
|
309
|
+
* @returns ResolvedScales with d3 scale instances.
|
|
310
|
+
*/
|
|
311
|
+
export function computeScales(
|
|
312
|
+
spec: NormalizedChartSpec,
|
|
313
|
+
chartArea: Rect,
|
|
314
|
+
data: DataRow[],
|
|
315
|
+
): ResolvedScales {
|
|
316
|
+
const result: ResolvedScales = {};
|
|
317
|
+
const encoding = spec.encoding as Encoding;
|
|
318
|
+
|
|
319
|
+
// Scatter/bubble charts should NOT include zero by default (tight domain fits data range)
|
|
320
|
+
if (spec.type === 'scatter') {
|
|
321
|
+
if (encoding.x?.type === 'quantitative' && encoding.x.scale?.zero === undefined) {
|
|
322
|
+
if (!encoding.x.scale) {
|
|
323
|
+
(encoding.x as { scale?: Record<string, unknown> }).scale = { zero: false };
|
|
324
|
+
} else {
|
|
325
|
+
(encoding.x.scale as Record<string, unknown>).zero = false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (encoding.y?.type === 'quantitative' && encoding.y.scale?.zero === undefined) {
|
|
329
|
+
if (!encoding.y.scale) {
|
|
330
|
+
(encoding.y as { scale?: Record<string, unknown> }).scale = { zero: false };
|
|
331
|
+
} else {
|
|
332
|
+
(encoding.y.scale as Record<string, unknown>).zero = false;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (encoding.x) {
|
|
338
|
+
// For stacked bars, the x-domain needs the max category sum, not max individual value.
|
|
339
|
+
// Without this, stacked bars would clip past the chart area.
|
|
340
|
+
let xData = data;
|
|
341
|
+
if (spec.type === 'bar' && encoding.color && encoding.x.type === 'quantitative') {
|
|
342
|
+
const yField = encoding.y?.field;
|
|
343
|
+
const xField = encoding.x.field;
|
|
344
|
+
if (yField) {
|
|
345
|
+
const sums = new Map<string, number>();
|
|
346
|
+
for (const row of data) {
|
|
347
|
+
const cat = String(row[yField] ?? '');
|
|
348
|
+
const val = Number(row[xField] ?? 0);
|
|
349
|
+
if (Number.isFinite(val) && val > 0) {
|
|
350
|
+
sums.set(cat, (sums.get(cat) ?? 0) + val);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const maxSum = Math.max(...sums.values(), 0);
|
|
354
|
+
// Create a synthetic row with the max stack sum so buildLinearScale sees it
|
|
355
|
+
xData = [...data, { [xField]: maxSum } as DataRow];
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
result.x = buildPositionalScale(
|
|
360
|
+
encoding.x,
|
|
361
|
+
xData,
|
|
362
|
+
chartArea.x,
|
|
363
|
+
chartArea.x + chartArea.width,
|
|
364
|
+
spec.type,
|
|
365
|
+
'x',
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (encoding.y) {
|
|
370
|
+
// For stacked columns, the y-domain needs the max category sum, not max individual value.
|
|
371
|
+
// Without this, stacked bars would clip above the chart area.
|
|
372
|
+
let yData = data;
|
|
373
|
+
if (spec.type === 'column' && encoding.color && encoding.y.type === 'quantitative') {
|
|
374
|
+
const xField = encoding.x?.field;
|
|
375
|
+
const yField = encoding.y.field;
|
|
376
|
+
if (xField) {
|
|
377
|
+
const sums = new Map<string, number>();
|
|
378
|
+
for (const row of data) {
|
|
379
|
+
const cat = String(row[xField] ?? '');
|
|
380
|
+
const val = Number(row[yField] ?? 0);
|
|
381
|
+
if (Number.isFinite(val) && val > 0) {
|
|
382
|
+
sums.set(cat, (sums.get(cat) ?? 0) + val);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const maxSum = Math.max(...sums.values(), 0);
|
|
386
|
+
// Create a synthetic row with the max stack sum so buildLinearScale sees it
|
|
387
|
+
yData = [...data, { [yField]: maxSum } as DataRow];
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Y axis: range is inverted (SVG y goes down, data y goes up)
|
|
392
|
+
result.y = buildPositionalScale(
|
|
393
|
+
encoding.y,
|
|
394
|
+
yData,
|
|
395
|
+
chartArea.y + chartArea.height,
|
|
396
|
+
chartArea.y,
|
|
397
|
+
spec.type,
|
|
398
|
+
'y',
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (encoding.color) {
|
|
403
|
+
const defaultPalette = [
|
|
404
|
+
'#1b7fa3',
|
|
405
|
+
'#c44e52',
|
|
406
|
+
'#6a9f58',
|
|
407
|
+
'#d47215',
|
|
408
|
+
'#507e79',
|
|
409
|
+
'#9a6a8d',
|
|
410
|
+
'#c4636b',
|
|
411
|
+
'#9c755f',
|
|
412
|
+
'#a88f22',
|
|
413
|
+
'#858078',
|
|
414
|
+
];
|
|
415
|
+
|
|
416
|
+
if (encoding.color.type === 'quantitative') {
|
|
417
|
+
// Sequential color scale for value-based coloring
|
|
418
|
+
result.color = buildSequentialColorScale(encoding.color, data, defaultPalette);
|
|
419
|
+
} else {
|
|
420
|
+
// Categorical color scale for nominal/ordinal grouping
|
|
421
|
+
result.color = buildOrdinalColorScale(encoding.color, data, defaultPalette);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return result;
|
|
426
|
+
}
|