@opendata-ai/openchart-engine 6.9.0 → 6.11.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 +2 -10
- package/dist/index.js +198 -28
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/charts/bar/__tests__/compute.test.ts +92 -0
- package/src/charts/bar/compute.ts +92 -5
- package/src/charts/bar/labels.ts +2 -1
- package/src/charts/column/__tests__/compute.test.ts +66 -0
- package/src/charts/column/compute.ts +98 -6
- package/src/charts/column/labels.ts +2 -1
- package/src/charts/dot/labels.ts +6 -2
- package/src/charts/line/area.ts +3 -2
- package/src/charts/line/compute.ts +5 -2
- package/src/charts/pie/compute.ts +24 -3
- package/src/charts/rule/index.ts +6 -3
- package/src/charts/scatter/compute.ts +2 -1
- package/src/charts/text/index.ts +6 -3
- package/src/charts/tick/index.ts +6 -3
- package/src/charts/utils.ts +3 -3
- package/src/compile.ts +3 -2
- package/src/layout/scales.ts +12 -4
- package/src/tooltips/compute.ts +11 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.11.0",
|
|
4
4
|
"description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Riley Hilliard",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"typecheck": "tsc --noEmit"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@opendata-ai/openchart-core": "6.
|
|
48
|
+
"@opendata-ai/openchart-core": "6.11.0",
|
|
49
49
|
"d3-array": "^3.2.0",
|
|
50
50
|
"d3-format": "^3.1.2",
|
|
51
51
|
"d3-interpolate": "^3.0.0",
|
|
@@ -203,6 +203,98 @@ describe('computeBarMarks', () => {
|
|
|
203
203
|
});
|
|
204
204
|
});
|
|
205
205
|
|
|
206
|
+
describe('grouped bars (stack: null)', () => {
|
|
207
|
+
function makeDodgedBarSpec(): NormalizedChartSpec {
|
|
208
|
+
const spec = makeGroupedBarSpec();
|
|
209
|
+
(spec.encoding.x as { stack?: boolean | null }).stack = null;
|
|
210
|
+
return spec;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
it('produces marks for all data rows', () => {
|
|
214
|
+
const spec = makeDodgedBarSpec();
|
|
215
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
216
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
217
|
+
|
|
218
|
+
expect(marks).toHaveLength(6);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('grouped bars within a category have different y positions', () => {
|
|
222
|
+
const spec = makeDodgedBarSpec();
|
|
223
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
224
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
225
|
+
|
|
226
|
+
const q1East = marks.find(
|
|
227
|
+
(m) => m.aria.label.includes('Q1') && m.aria.label.includes('East'),
|
|
228
|
+
)!;
|
|
229
|
+
const q1West = marks.find(
|
|
230
|
+
(m) => m.aria.label.includes('Q1') && m.aria.label.includes('West'),
|
|
231
|
+
)!;
|
|
232
|
+
|
|
233
|
+
expect(q1East.y).not.toBe(q1West.y);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('grouped bars all start from baseline (not cumulative)', () => {
|
|
237
|
+
const spec = makeDodgedBarSpec();
|
|
238
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
239
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
240
|
+
|
|
241
|
+
const q1East = marks.find(
|
|
242
|
+
(m) => m.aria.label.includes('Q1') && m.aria.label.includes('East'),
|
|
243
|
+
)!;
|
|
244
|
+
const q1West = marks.find(
|
|
245
|
+
(m) => m.aria.label.includes('Q1') && m.aria.label.includes('West'),
|
|
246
|
+
)!;
|
|
247
|
+
|
|
248
|
+
// Both bars start at the same x position (baseline)
|
|
249
|
+
expect(q1East.x).toBe(q1West.x);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('grouped bars have cornerRadius 2', () => {
|
|
253
|
+
const spec = makeDodgedBarSpec();
|
|
254
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
255
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
256
|
+
|
|
257
|
+
for (const mark of marks) {
|
|
258
|
+
expect(mark.cornerRadius).toBe(2);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('grouped bars do not set stackGroup', () => {
|
|
263
|
+
const spec = makeDodgedBarSpec();
|
|
264
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
265
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
266
|
+
|
|
267
|
+
for (const mark of marks) {
|
|
268
|
+
expect(mark.stackGroup).toBeUndefined();
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('sub-band heights are smaller than full bandwidth', () => {
|
|
273
|
+
const spec = makeDodgedBarSpec();
|
|
274
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
275
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
276
|
+
|
|
277
|
+
// With 2 groups, each sub-bar should be less than the full bandwidth
|
|
278
|
+
const stackedSpec = makeGroupedBarSpec();
|
|
279
|
+
const stackedScales = computeScales(stackedSpec, chartArea, stackedSpec.data);
|
|
280
|
+
const stackedMarks = computeBarMarks(stackedSpec, stackedScales, chartArea, fullStrategy);
|
|
281
|
+
|
|
282
|
+
expect(marks[0].height).toBeLessThan(stackedMarks[0].height);
|
|
283
|
+
expect(marks[0].height).toBeGreaterThan(0);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('scale domain covers max individual value, not stacked sum', () => {
|
|
287
|
+
const spec = makeDodgedBarSpec();
|
|
288
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
289
|
+
|
|
290
|
+
// Max individual value is 70 (Q3 West), not 115 (Q3 stacked sum)
|
|
291
|
+
const xScale = scales.x!.scale;
|
|
292
|
+
const domain = xScale.domain() as number[];
|
|
293
|
+
// Domain should not extend to the stacked sum (115)
|
|
294
|
+
expect(domain[1]).toBeLessThanOrEqual(80); // some nice rounding above 70
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
206
298
|
describe('colored (non-stacked) bars', () => {
|
|
207
299
|
it('renders colored bars when each category has one row with color encoding', () => {
|
|
208
300
|
const spec: NormalizedChartSpec = {
|
|
@@ -10,12 +10,13 @@ import type {
|
|
|
10
10
|
ConditionalValueDef,
|
|
11
11
|
DataRow,
|
|
12
12
|
Encoding,
|
|
13
|
+
GradientDef,
|
|
13
14
|
LayoutStrategy,
|
|
14
15
|
MarkAria,
|
|
15
16
|
Rect,
|
|
16
17
|
RectMark,
|
|
17
18
|
} from '@opendata-ai/openchart-core';
|
|
18
|
-
import { abbreviateNumber, formatNumber } from '@opendata-ai/openchart-core';
|
|
19
|
+
import { abbreviateNumber, formatNumber, isGradientDef } from '@opendata-ai/openchart-core';
|
|
19
20
|
import type { ScaleBand, ScaleLinear } from 'd3-scale';
|
|
20
21
|
|
|
21
22
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
@@ -99,6 +100,22 @@ export function computeBarMarks(
|
|
|
99
100
|
const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
|
|
100
101
|
|
|
101
102
|
if (needsStacking) {
|
|
103
|
+
const stackDisabled = xChannel.stack === null || xChannel.stack === false;
|
|
104
|
+
|
|
105
|
+
if (stackDisabled) {
|
|
106
|
+
return computeGroupedBars(
|
|
107
|
+
spec.data,
|
|
108
|
+
xChannel.field,
|
|
109
|
+
yChannel.field,
|
|
110
|
+
colorField,
|
|
111
|
+
xScale,
|
|
112
|
+
yScale,
|
|
113
|
+
bandwidth,
|
|
114
|
+
baseline,
|
|
115
|
+
scales,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
102
119
|
return computeStackedBars(
|
|
103
120
|
spec.data,
|
|
104
121
|
xChannel.field,
|
|
@@ -184,6 +201,73 @@ function computeStackedBars(
|
|
|
184
201
|
return marks;
|
|
185
202
|
}
|
|
186
203
|
|
|
204
|
+
/** Compute grouped (dodged) horizontal bars -- side-by-side within each category band. */
|
|
205
|
+
function computeGroupedBars(
|
|
206
|
+
data: DataRow[],
|
|
207
|
+
valueField: string,
|
|
208
|
+
categoryField: string,
|
|
209
|
+
colorField: string,
|
|
210
|
+
xScale: ScaleLinear<number, number>,
|
|
211
|
+
yScale: ScaleBand<string>,
|
|
212
|
+
bandwidth: number,
|
|
213
|
+
baseline: number,
|
|
214
|
+
scales: ResolvedScales,
|
|
215
|
+
): RectMark[] {
|
|
216
|
+
const marks: RectMark[] = [];
|
|
217
|
+
const categoryGroups = groupByField(data, categoryField);
|
|
218
|
+
|
|
219
|
+
// Build a stable group order from first appearance in data (Map for O(1) lookup)
|
|
220
|
+
const groupIndexMap = new Map<string, number>();
|
|
221
|
+
for (const row of data) {
|
|
222
|
+
const key = String(row[colorField] ?? '');
|
|
223
|
+
if (!groupIndexMap.has(key)) {
|
|
224
|
+
groupIndexMap.set(key, groupIndexMap.size);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const groupCount = groupIndexMap.size;
|
|
228
|
+
if (groupCount === 0) return marks;
|
|
229
|
+
|
|
230
|
+
// Subdivide the band height by group count with a small gap
|
|
231
|
+
const gap = Math.min(1, bandwidth * 0.05);
|
|
232
|
+
const subBandHeight = Math.max((bandwidth - gap * (groupCount - 1)) / groupCount, MIN_BAR_WIDTH);
|
|
233
|
+
|
|
234
|
+
for (const [category, rows] of categoryGroups) {
|
|
235
|
+
const bandY = yScale(category);
|
|
236
|
+
if (bandY === undefined) continue;
|
|
237
|
+
|
|
238
|
+
for (const row of rows) {
|
|
239
|
+
const groupKey = String(row[colorField] ?? '');
|
|
240
|
+
const value = Number(row[valueField] ?? 0);
|
|
241
|
+
if (!Number.isFinite(value)) continue;
|
|
242
|
+
|
|
243
|
+
const groupIndex = groupIndexMap.get(groupKey) ?? 0;
|
|
244
|
+
const color = getColor(scales, groupKey);
|
|
245
|
+
const xPos = value >= 0 ? baseline : xScale(value);
|
|
246
|
+
const barWidth = Math.max(Math.abs(xScale(value) - baseline), MIN_BAR_WIDTH);
|
|
247
|
+
const subY = bandY + groupIndex * (subBandHeight + gap);
|
|
248
|
+
|
|
249
|
+
const aria: MarkAria = {
|
|
250
|
+
label: `${category}, ${groupKey}: ${formatBarValue(value)}`,
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
marks.push({
|
|
254
|
+
type: 'rect',
|
|
255
|
+
x: xPos,
|
|
256
|
+
y: subY,
|
|
257
|
+
width: barWidth,
|
|
258
|
+
height: subBandHeight,
|
|
259
|
+
fill: color,
|
|
260
|
+
cornerRadius: 2,
|
|
261
|
+
data: row as Record<string, unknown>,
|
|
262
|
+
aria,
|
|
263
|
+
orient: 'horizontal',
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return marks;
|
|
269
|
+
}
|
|
270
|
+
|
|
187
271
|
/** Compute colored (non-stacked) horizontal bars. Used when color encoding
|
|
188
272
|
* is present but each category has only one row (e.g., diverging charts). */
|
|
189
273
|
function computeColoredBars(
|
|
@@ -256,11 +340,14 @@ function computeSimpleBars(
|
|
|
256
340
|
const bandY = yScale(category);
|
|
257
341
|
if (bandY === undefined) continue;
|
|
258
342
|
|
|
259
|
-
let color: string;
|
|
343
|
+
let color: string | GradientDef;
|
|
260
344
|
if (conditionalColor) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
345
|
+
const resolved = resolveConditionalValue(row, conditionalColor);
|
|
346
|
+
if (resolved != null) {
|
|
347
|
+
color = isGradientDef(resolved) ? resolved : String(resolved);
|
|
348
|
+
} else {
|
|
349
|
+
color = getColor(scales, '__default__');
|
|
350
|
+
}
|
|
264
351
|
} else if (sequentialColor) {
|
|
265
352
|
color = getSequentialColor(scales, value);
|
|
266
353
|
} else {
|
package/src/charts/bar/labels.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
import {
|
|
21
21
|
buildD3Formatter,
|
|
22
22
|
estimateTextWidth,
|
|
23
|
+
getRepresentativeColor,
|
|
23
24
|
resolveCollisions,
|
|
24
25
|
} from '@opendata-ai/openchart-core';
|
|
25
26
|
|
|
@@ -141,7 +142,7 @@ export function computeBarLabels(
|
|
|
141
142
|
} else {
|
|
142
143
|
// Outside: just past the bar's right edge
|
|
143
144
|
anchorX = mark.x + mark.width + LABEL_PADDING;
|
|
144
|
-
fill = mark.fill;
|
|
145
|
+
fill = getRepresentativeColor(mark.fill);
|
|
145
146
|
textAnchor = 'start';
|
|
146
147
|
}
|
|
147
148
|
|
|
@@ -187,6 +187,72 @@ describe('computeColumnMarks', () => {
|
|
|
187
187
|
});
|
|
188
188
|
});
|
|
189
189
|
|
|
190
|
+
describe('grouped columns (stack: null)', () => {
|
|
191
|
+
function makeDodgedColumnSpec(): NormalizedChartSpec {
|
|
192
|
+
const spec = makeGroupedColumnSpec();
|
|
193
|
+
(spec.encoding.y as { stack?: boolean | null }).stack = null;
|
|
194
|
+
return spec;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
it('produces marks for all data rows', () => {
|
|
198
|
+
const spec = makeDodgedColumnSpec();
|
|
199
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
200
|
+
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
201
|
+
|
|
202
|
+
expect(marks).toHaveLength(6);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('grouped columns within a category have different x positions', () => {
|
|
206
|
+
const spec = makeDodgedColumnSpec();
|
|
207
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
208
|
+
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
209
|
+
|
|
210
|
+
const janNorth = marks.find(
|
|
211
|
+
(m) => m.aria.label.includes('Jan') && m.aria.label.includes('North'),
|
|
212
|
+
)!;
|
|
213
|
+
const janSouth = marks.find(
|
|
214
|
+
(m) => m.aria.label.includes('Jan') && m.aria.label.includes('South'),
|
|
215
|
+
)!;
|
|
216
|
+
|
|
217
|
+
expect(janNorth.x).not.toBe(janSouth.x);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('grouped columns have subdivided widths', () => {
|
|
221
|
+
const spec = makeDodgedColumnSpec();
|
|
222
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
223
|
+
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
224
|
+
|
|
225
|
+
// With 2 groups, each sub-column should be narrower than full bandwidth
|
|
226
|
+
const stackedSpec = makeGroupedColumnSpec();
|
|
227
|
+
const stackedScales = computeScales(stackedSpec, chartArea, stackedSpec.data);
|
|
228
|
+
const stackedMarks = computeColumnMarks(stackedSpec, stackedScales, chartArea, fullStrategy);
|
|
229
|
+
|
|
230
|
+
expect(marks[0].width).toBeLessThan(stackedMarks[0].width);
|
|
231
|
+
expect(marks[0].width).toBeGreaterThan(0);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('grouped columns have cornerRadius 2 and no stackGroup', () => {
|
|
235
|
+
const spec = makeDodgedColumnSpec();
|
|
236
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
237
|
+
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
238
|
+
|
|
239
|
+
for (const mark of marks) {
|
|
240
|
+
expect(mark.cornerRadius).toBe(2);
|
|
241
|
+
expect(mark.stackGroup).toBeUndefined();
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('scale domain covers max individual value, not stacked sum', () => {
|
|
246
|
+
const spec = makeDodgedColumnSpec();
|
|
247
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
248
|
+
|
|
249
|
+
// Max individual value is 150 (Mar North), not 280 (Mar stacked sum)
|
|
250
|
+
const yScale = scales.y!.scale;
|
|
251
|
+
const domain = yScale.domain() as number[];
|
|
252
|
+
expect(domain[1]).toBeLessThanOrEqual(170); // some nice rounding above 150
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
190
256
|
describe('negative values', () => {
|
|
191
257
|
it('negative columns extend downward from baseline', () => {
|
|
192
258
|
const spec = makeNegativeColumnSpec();
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Takes a normalized chart spec with resolved scales and produces
|
|
5
5
|
* RectMark[] for rendering vertical columns. When a color encoding
|
|
6
|
-
* is present, columns are stacked (cumulative heights
|
|
6
|
+
* is present, columns are either stacked (cumulative heights) or grouped
|
|
7
|
+
* (side-by-side) based on the `stack` property of the quantitative channel.
|
|
7
8
|
*
|
|
8
9
|
* Shares conceptual logic with bar chart but axes are swapped:
|
|
9
10
|
* x-axis is categorical (band scale), y-axis is quantitative.
|
|
@@ -13,12 +14,13 @@ import type {
|
|
|
13
14
|
ConditionalValueDef,
|
|
14
15
|
DataRow,
|
|
15
16
|
Encoding,
|
|
17
|
+
GradientDef,
|
|
16
18
|
LayoutStrategy,
|
|
17
19
|
MarkAria,
|
|
18
20
|
Rect,
|
|
19
21
|
RectMark,
|
|
20
22
|
} from '@opendata-ai/openchart-core';
|
|
21
|
-
import { abbreviateNumber, formatNumber } from '@opendata-ai/openchart-core';
|
|
23
|
+
import { abbreviateNumber, formatNumber, isGradientDef } from '@opendata-ai/openchart-core';
|
|
22
24
|
import type { ScaleBand, ScaleLinear } from 'd3-scale';
|
|
23
25
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
24
26
|
import type { ResolvedScales } from '../../layout/scales';
|
|
@@ -88,6 +90,22 @@ export function computeColumnMarks(
|
|
|
88
90
|
const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
|
|
89
91
|
|
|
90
92
|
if (needsStacking) {
|
|
93
|
+
const stackDisabled = yChannel.stack === null || yChannel.stack === false;
|
|
94
|
+
|
|
95
|
+
if (stackDisabled) {
|
|
96
|
+
return computeGroupedColumns(
|
|
97
|
+
spec.data,
|
|
98
|
+
xChannel.field,
|
|
99
|
+
yChannel.field,
|
|
100
|
+
colorField,
|
|
101
|
+
xScale,
|
|
102
|
+
yScale,
|
|
103
|
+
bandwidth,
|
|
104
|
+
baseline,
|
|
105
|
+
scales,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
91
109
|
return computeStackedColumns(
|
|
92
110
|
spec.data,
|
|
93
111
|
xChannel.field,
|
|
@@ -152,11 +170,14 @@ function computeSimpleColumns(
|
|
|
152
170
|
const bandX = xScale(category);
|
|
153
171
|
if (bandX === undefined) continue;
|
|
154
172
|
|
|
155
|
-
let color: string;
|
|
173
|
+
let color: string | GradientDef;
|
|
156
174
|
if (conditionalColor) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
175
|
+
const resolved = resolveConditionalValue(row, conditionalColor);
|
|
176
|
+
if (resolved != null) {
|
|
177
|
+
color = isGradientDef(resolved) ? resolved : String(resolved);
|
|
178
|
+
} else {
|
|
179
|
+
color = getColor(scales, '__default__');
|
|
180
|
+
}
|
|
160
181
|
} else if (sequentialColor) {
|
|
161
182
|
color = getSequentialColor(scales, value);
|
|
162
183
|
} else {
|
|
@@ -241,6 +262,77 @@ function computeColoredColumns(
|
|
|
241
262
|
return marks;
|
|
242
263
|
}
|
|
243
264
|
|
|
265
|
+
/** Compute grouped (dodged) vertical columns -- side-by-side within each category band. */
|
|
266
|
+
function computeGroupedColumns(
|
|
267
|
+
data: DataRow[],
|
|
268
|
+
categoryField: string,
|
|
269
|
+
valueField: string,
|
|
270
|
+
colorField: string,
|
|
271
|
+
xScale: ScaleBand<string>,
|
|
272
|
+
yScale: ScaleLinear<number, number>,
|
|
273
|
+
bandwidth: number,
|
|
274
|
+
baseline: number,
|
|
275
|
+
scales: ResolvedScales,
|
|
276
|
+
): RectMark[] {
|
|
277
|
+
const marks: RectMark[] = [];
|
|
278
|
+
const categoryGroups = groupByField(data, categoryField);
|
|
279
|
+
|
|
280
|
+
// Build a stable group order from first appearance in data (Map for O(1) lookup)
|
|
281
|
+
const groupIndexMap = new Map<string, number>();
|
|
282
|
+
for (const row of data) {
|
|
283
|
+
const key = String(row[colorField] ?? '');
|
|
284
|
+
if (!groupIndexMap.has(key)) {
|
|
285
|
+
groupIndexMap.set(key, groupIndexMap.size);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const groupCount = groupIndexMap.size;
|
|
289
|
+
if (groupCount === 0) return marks;
|
|
290
|
+
|
|
291
|
+
// Subdivide the band width by group count with a small gap
|
|
292
|
+
const gap = Math.min(1, bandwidth * 0.05);
|
|
293
|
+
const subBandWidth = Math.max(
|
|
294
|
+
(bandwidth - gap * (groupCount - 1)) / groupCount,
|
|
295
|
+
MIN_COLUMN_HEIGHT,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
for (const [category, rows] of categoryGroups) {
|
|
299
|
+
const bandX = xScale(category);
|
|
300
|
+
if (bandX === undefined) continue;
|
|
301
|
+
|
|
302
|
+
for (const row of rows) {
|
|
303
|
+
const groupKey = String(row[colorField] ?? '');
|
|
304
|
+
const value = Number(row[valueField] ?? 0);
|
|
305
|
+
if (!Number.isFinite(value)) continue;
|
|
306
|
+
|
|
307
|
+
const groupIndex = groupIndexMap.get(groupKey) ?? 0;
|
|
308
|
+
const color = getColor(scales, groupKey);
|
|
309
|
+
const yPos = yScale(value);
|
|
310
|
+
const columnHeight = Math.max(Math.abs(baseline - yPos), MIN_COLUMN_HEIGHT);
|
|
311
|
+
const y = value >= 0 ? yPos : baseline;
|
|
312
|
+
const subX = bandX + groupIndex * (subBandWidth + gap);
|
|
313
|
+
|
|
314
|
+
const aria: MarkAria = {
|
|
315
|
+
label: `${category}, ${groupKey}: ${formatColumnValue(value)}`,
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
marks.push({
|
|
319
|
+
type: 'rect',
|
|
320
|
+
x: subX,
|
|
321
|
+
y,
|
|
322
|
+
width: subBandWidth,
|
|
323
|
+
height: columnHeight,
|
|
324
|
+
fill: color,
|
|
325
|
+
cornerRadius: 2,
|
|
326
|
+
data: row as Record<string, unknown>,
|
|
327
|
+
aria,
|
|
328
|
+
orient: 'vertical',
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return marks;
|
|
334
|
+
}
|
|
335
|
+
|
|
244
336
|
/** Compute stacked vertical columns. */
|
|
245
337
|
function computeStackedColumns(
|
|
246
338
|
data: DataRow[],
|
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
import {
|
|
21
21
|
buildD3Formatter,
|
|
22
22
|
estimateTextWidth,
|
|
23
|
+
getRepresentativeColor,
|
|
23
24
|
resolveCollisions,
|
|
24
25
|
} from '@opendata-ai/openchart-core';
|
|
25
26
|
|
|
@@ -99,7 +100,7 @@ export function computeColumnLabels(
|
|
|
99
100
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
100
101
|
fontSize: LABEL_FONT_SIZE,
|
|
101
102
|
fontWeight: LABEL_FONT_WEIGHT,
|
|
102
|
-
fill: mark.fill,
|
|
103
|
+
fill: getRepresentativeColor(mark.fill),
|
|
103
104
|
lineHeight: 1.2,
|
|
104
105
|
textAnchor: 'middle',
|
|
105
106
|
dominantBaseline: isNegative ? 'hanging' : 'auto',
|
package/src/charts/dot/labels.ts
CHANGED
|
@@ -17,7 +17,11 @@ import type {
|
|
|
17
17
|
Rect,
|
|
18
18
|
ResolvedLabel,
|
|
19
19
|
} from '@opendata-ai/openchart-core';
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
estimateTextWidth,
|
|
22
|
+
getRepresentativeColor,
|
|
23
|
+
resolveCollisions,
|
|
24
|
+
} from '@opendata-ai/openchart-core';
|
|
21
25
|
|
|
22
26
|
// ---------------------------------------------------------------------------
|
|
23
27
|
// Constants
|
|
@@ -74,7 +78,7 @@ export function computeDotLabels(
|
|
|
74
78
|
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
75
79
|
fontSize: LABEL_FONT_SIZE,
|
|
76
80
|
fontWeight: LABEL_FONT_WEIGHT,
|
|
77
|
-
fill: mark.fill,
|
|
81
|
+
fill: getRepresentativeColor(mark.fill),
|
|
78
82
|
lineHeight: 1.2,
|
|
79
83
|
textAnchor: 'start',
|
|
80
84
|
dominantBaseline: 'central',
|
package/src/charts/line/area.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { AreaMark, DataRow, Encoding, MarkAria, Rect } from '@opendata-ai/openchart-core';
|
|
10
|
+
import { getRepresentativeColor } from '@opendata-ai/openchart-core';
|
|
10
11
|
import type { ScaleLinear } from 'd3-scale';
|
|
11
12
|
import { area, line, stack, stackOffsetNone, stackOrderNone } from 'd3-shape';
|
|
12
13
|
|
|
@@ -122,7 +123,7 @@ function computeSingleArea(
|
|
|
122
123
|
topPath: topPathStr,
|
|
123
124
|
fill: color,
|
|
124
125
|
fillOpacity: DEFAULT_FILL_OPACITY,
|
|
125
|
-
stroke: color,
|
|
126
|
+
stroke: getRepresentativeColor(color),
|
|
126
127
|
strokeWidth: 2,
|
|
127
128
|
seriesKey: seriesKey === '__default__' ? undefined : seriesKey,
|
|
128
129
|
data: validPoints.map((p) => p.row),
|
|
@@ -258,7 +259,7 @@ function computeStackedArea(
|
|
|
258
259
|
topPath: topPathStr,
|
|
259
260
|
fill: color,
|
|
260
261
|
fillOpacity: 0.7, // Higher opacity for stacked so layers are visible
|
|
261
|
-
stroke: color,
|
|
262
|
+
stroke: getRepresentativeColor(color),
|
|
262
263
|
strokeWidth: 1,
|
|
263
264
|
seriesKey,
|
|
264
265
|
data: layer.map((d) => {
|
|
@@ -10,12 +10,14 @@
|
|
|
10
10
|
import type {
|
|
11
11
|
DataRow,
|
|
12
12
|
Encoding,
|
|
13
|
+
GradientDef,
|
|
13
14
|
LayoutStrategy,
|
|
14
15
|
LineMark,
|
|
15
16
|
MarkAria,
|
|
16
17
|
PointMark,
|
|
17
18
|
Rect,
|
|
18
19
|
} from '@opendata-ai/openchart-core';
|
|
20
|
+
import { getRepresentativeColor } from '@opendata-ai/openchart-core';
|
|
19
21
|
import { line } from 'd3-shape';
|
|
20
22
|
|
|
21
23
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
@@ -68,9 +70,10 @@ export function computeLineMarks(
|
|
|
68
70
|
|
|
69
71
|
for (const [seriesKey, rows] of groups) {
|
|
70
72
|
// For sequential color, use a mid-range color for the line stroke
|
|
71
|
-
const color = isSequentialColor
|
|
73
|
+
const color: string | GradientDef = isSequentialColor
|
|
72
74
|
? getSequentialColor(scales, _getMidValue(rows, sequentialColorField!))
|
|
73
75
|
: getColor(scales, seriesKey);
|
|
76
|
+
const strokeColor = getRepresentativeColor(color);
|
|
74
77
|
|
|
75
78
|
// Sort rows by x-axis field so lines draw left-to-right
|
|
76
79
|
const sortedRows = sortByField(rows, xChannel.field);
|
|
@@ -165,7 +168,7 @@ export function computeLineMarks(
|
|
|
165
168
|
type: 'line',
|
|
166
169
|
points: allPoints,
|
|
167
170
|
path: combinedPath,
|
|
168
|
-
stroke:
|
|
171
|
+
stroke: strokeColor,
|
|
169
172
|
strokeWidth: styleOverride?.strokeWidth ?? DEFAULT_STROKE_WIDTH,
|
|
170
173
|
strokeDasharray,
|
|
171
174
|
opacity: styleOverride?.opacity,
|
|
@@ -8,17 +8,21 @@
|
|
|
8
8
|
|
|
9
9
|
import type {
|
|
10
10
|
ArcMark,
|
|
11
|
+
ConditionalValueDef,
|
|
11
12
|
DataRow,
|
|
12
13
|
Encoding,
|
|
14
|
+
GradientDef,
|
|
13
15
|
LayoutStrategy,
|
|
14
16
|
MarkAria,
|
|
15
17
|
Rect,
|
|
16
18
|
} from '@opendata-ai/openchart-core';
|
|
19
|
+
import { isConditionalDef, isGradientDef } from '@opendata-ai/openchart-core';
|
|
17
20
|
import type { PieArcDatum } from 'd3-shape';
|
|
18
21
|
import { arc as d3Arc, pie as d3Pie } from 'd3-shape';
|
|
19
22
|
|
|
20
23
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
21
24
|
import type { ResolvedScales } from '../../layout/scales';
|
|
25
|
+
import { resolveConditionalValue } from '../../transforms/conditional';
|
|
22
26
|
|
|
23
27
|
// ---------------------------------------------------------------------------
|
|
24
28
|
// Constants
|
|
@@ -109,6 +113,10 @@ export function computePieMarks(
|
|
|
109
113
|
const valueChannel = encoding.y ?? encoding.x;
|
|
110
114
|
const categoryField =
|
|
111
115
|
encoding.color && 'field' in encoding.color ? encoding.color.field : undefined;
|
|
116
|
+
const conditionalColor =
|
|
117
|
+
encoding.color && isConditionalDef(encoding.color)
|
|
118
|
+
? (encoding.color as ConditionalValueDef)
|
|
119
|
+
: undefined;
|
|
112
120
|
|
|
113
121
|
if (!valueChannel) return [];
|
|
114
122
|
|
|
@@ -190,9 +198,22 @@ export function computePieMarks(
|
|
|
190
198
|
const arcDatum = arcs[i];
|
|
191
199
|
const slice = arcDatum.data;
|
|
192
200
|
|
|
193
|
-
// Get color
|
|
194
|
-
let color: string;
|
|
195
|
-
if (
|
|
201
|
+
// Get color: conditional (supports gradients) > scale > default palette
|
|
202
|
+
let color: string | GradientDef;
|
|
203
|
+
if (conditionalColor) {
|
|
204
|
+
const resolved = resolveConditionalValue(
|
|
205
|
+
slice.originalRow as Record<string, unknown>,
|
|
206
|
+
conditionalColor,
|
|
207
|
+
);
|
|
208
|
+
if (resolved != null) {
|
|
209
|
+
color = isGradientDef(resolved) ? resolved : String(resolved);
|
|
210
|
+
} else if (scales.color && categoryField) {
|
|
211
|
+
const colorScale = scales.color.scale as (v: string) => string;
|
|
212
|
+
color = colorScale(slice.label);
|
|
213
|
+
} else {
|
|
214
|
+
color = DEFAULT_PALETTE[i % DEFAULT_PALETTE.length];
|
|
215
|
+
}
|
|
216
|
+
} else if (scales.color && categoryField) {
|
|
196
217
|
const colorScale = scales.color.scale as (v: string) => string;
|
|
197
218
|
color = colorScale(slice.label);
|
|
198
219
|
} else {
|
package/src/charts/rule/index.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { Encoding, Mark, MarkAria, Rect, RuleMarkLayout } from '@opendata-ai/openchart-core';
|
|
10
|
+
import { getRepresentativeColor } from '@opendata-ai/openchart-core';
|
|
10
11
|
|
|
11
12
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
12
13
|
import type { ResolvedScales } from '../../layout/scales';
|
|
@@ -84,9 +85,11 @@ export function computeRuleMarks(
|
|
|
84
85
|
if (y2Val != null) y2 = y2Val;
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
const color =
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
const color = getRepresentativeColor(
|
|
89
|
+
colorField
|
|
90
|
+
? getColor(scales, String(row[colorField] ?? '__default__'))
|
|
91
|
+
: getColor(scales, '__default__'),
|
|
92
|
+
);
|
|
90
93
|
|
|
91
94
|
const strokeDashEncoding =
|
|
92
95
|
encoding.strokeDash && 'field' in encoding.strokeDash ? encoding.strokeDash : undefined;
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import type {
|
|
11
11
|
Encoding,
|
|
12
12
|
FieldType,
|
|
13
|
+
GradientDef,
|
|
13
14
|
LayoutStrategy,
|
|
14
15
|
MarkAria,
|
|
15
16
|
PointMark,
|
|
@@ -139,7 +140,7 @@ export function computeScatterMarks(
|
|
|
139
140
|
if (cx === undefined || cy === undefined) continue;
|
|
140
141
|
|
|
141
142
|
const category = colorField && !isSequentialColor ? String(row[colorField] ?? '') : undefined;
|
|
142
|
-
let color: string;
|
|
143
|
+
let color: string | GradientDef;
|
|
143
144
|
if (isSequentialColor && colorField) {
|
|
144
145
|
const val = Number(row[colorField]);
|
|
145
146
|
color = Number.isFinite(val)
|