@opendata-ai/openchart-engine 7.2.0 → 7.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +32 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/compile-chart.test.ts +27 -0
- package/src/__tests__/scales.test.ts +34 -0
- package/src/charts/line/index.ts +53 -3
- package/src/layout/scales.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "7.2.
|
|
3
|
+
"version": "7.2.1",
|
|
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",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"typecheck": "tsc --noEmit"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@opendata-ai/openchart-core": "7.2.
|
|
51
|
+
"@opendata-ai/openchart-core": "7.2.1",
|
|
52
52
|
"d3-array": "^3.2.0",
|
|
53
53
|
"d3-format": "^3.1.2",
|
|
54
54
|
"d3-interpolate": "^3.0.0",
|
|
@@ -205,6 +205,33 @@ describe('compileChart', () => {
|
|
|
205
205
|
expect(pointMarks.length).toBeGreaterThan(0);
|
|
206
206
|
});
|
|
207
207
|
|
|
208
|
+
it('produces PointMarks on a multi-series area when mark.point is true', () => {
|
|
209
|
+
// Multi-series areas derive their lines from area tops, which bypasses the
|
|
210
|
+
// line renderer's point emission. mark.point must still place dots.
|
|
211
|
+
const areaSpec = {
|
|
212
|
+
...lineSpec,
|
|
213
|
+
mark: { type: 'area' as const, point: true as const },
|
|
214
|
+
};
|
|
215
|
+
const layout = compileChart(areaSpec, { width: 600, height: 400 });
|
|
216
|
+
|
|
217
|
+
const areaMarks = layout.marks.filter((m) => m.type === 'area');
|
|
218
|
+
const pointMarks = layout.marks.filter((m) => m.type === 'point');
|
|
219
|
+
expect(areaMarks.length).toBe(2); // US + UK
|
|
220
|
+
// One point per data row across both series (2 points x 2 series).
|
|
221
|
+
expect(pointMarks.length).toBe(4);
|
|
222
|
+
for (const p of pointMarks) {
|
|
223
|
+
if (p.type === 'point') expect(p.r).toBeGreaterThan(0);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('does not produce PointMarks on a multi-series area by default', () => {
|
|
228
|
+
const areaSpec = { ...lineSpec, mark: 'area' as const };
|
|
229
|
+
const layout = compileChart(areaSpec, { width: 600, height: 400 });
|
|
230
|
+
|
|
231
|
+
const pointMarks = layout.marks.filter((m) => m.type === 'point');
|
|
232
|
+
expect(pointMarks.length).toBe(0);
|
|
233
|
+
});
|
|
234
|
+
|
|
208
235
|
it('includes accessibility metadata with meaningful content', () => {
|
|
209
236
|
const layout = compileChart(lineSpec, { width: 600, height: 400 });
|
|
210
237
|
expect(layout.a11y.altText).toContain('Line chart');
|
|
@@ -269,6 +269,40 @@ describe('scale config properties', () => {
|
|
|
269
269
|
expect(paddedBandwidth).toBeGreaterThan(defaultBandwidth);
|
|
270
270
|
});
|
|
271
271
|
|
|
272
|
+
it('point scale honors paddingOuter as a padding alias', () => {
|
|
273
|
+
const ordinalBase = {
|
|
274
|
+
...lineSpec,
|
|
275
|
+
data: [
|
|
276
|
+
{ year: 'A', value: 10 },
|
|
277
|
+
{ year: 'B', value: 20 },
|
|
278
|
+
{ year: 'C', value: 30 },
|
|
279
|
+
],
|
|
280
|
+
};
|
|
281
|
+
const wide: NormalizedChartSpec = {
|
|
282
|
+
...ordinalBase,
|
|
283
|
+
encoding: {
|
|
284
|
+
x: { field: 'year', type: 'ordinal', scale: { paddingOuter: 0.5 } },
|
|
285
|
+
y: { field: 'value', type: 'quantitative' },
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
const tight: NormalizedChartSpec = {
|
|
289
|
+
...ordinalBase,
|
|
290
|
+
encoding: {
|
|
291
|
+
x: { field: 'year', type: 'ordinal', scale: { paddingOuter: 0.04 } },
|
|
292
|
+
y: { field: 'value', type: 'quantitative' },
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const wideScales = computeScales(wide, chartArea, wide.data);
|
|
297
|
+
const tightScales = computeScales(tight, chartArea, tight.data);
|
|
298
|
+
expect(wideScales.x!.type).toBe('point');
|
|
299
|
+
|
|
300
|
+
// Less outer padding pushes the first point closer to the left edge.
|
|
301
|
+
const wideFirst = wideScales.x!.scale('A') as number;
|
|
302
|
+
const tightFirst = tightScales.x!.scale('A') as number;
|
|
303
|
+
expect(tightFirst).toBeLessThan(wideFirst);
|
|
304
|
+
});
|
|
305
|
+
|
|
272
306
|
it('backward compatible: existing specs still work', () => {
|
|
273
307
|
// lineSpec and barSpec from before should still produce valid scales
|
|
274
308
|
const lineScales = computeScales(lineSpec, chartArea, lineSpec.data);
|
package/src/charts/line/index.ts
CHANGED
|
@@ -10,13 +10,17 @@
|
|
|
10
10
|
* `'normalize'`, or `'center'`.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type { AreaMark, LineMark, Mark } from '@opendata-ai/openchart-core';
|
|
13
|
+
import type { AreaMark, LineMark, Mark, PointMark } from '@opendata-ai/openchart-core';
|
|
14
14
|
import { getRepresentativeColor } from '@opendata-ai/openchart-core';
|
|
15
|
+
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
15
16
|
import type { ChartRenderer } from '../registry';
|
|
16
17
|
import { computeAreaMarks } from './area';
|
|
17
18
|
import { computeLineMarks } from './compute';
|
|
18
19
|
import { computeLineLabels } from './labels';
|
|
19
20
|
|
|
21
|
+
/** Radius for area-chart data points, matching the line renderer. */
|
|
22
|
+
const AREA_POINT_RADIUS = 3;
|
|
23
|
+
|
|
20
24
|
// ---------------------------------------------------------------------------
|
|
21
25
|
// Line chart renderer
|
|
22
26
|
// ---------------------------------------------------------------------------
|
|
@@ -84,8 +88,14 @@ export const areaRenderer: ChartRenderer = (spec, scales, chartArea, strategy, t
|
|
|
84
88
|
? linesFromAreas(areas)
|
|
85
89
|
: computeLineMarks(spec, scales, chartArea, strategy);
|
|
86
90
|
|
|
87
|
-
//
|
|
88
|
-
|
|
91
|
+
// For multi-series areas the lines are derived from area tops, which skips
|
|
92
|
+
// the point-emission path in computeLineMarks. Emit the data-point dots here
|
|
93
|
+
// so `mark.point` works on area charts the same way it does on lines.
|
|
94
|
+
// Single-series areas already get their points from computeLineMarks above.
|
|
95
|
+
const points = hasColor && spec.markDef.point ? pointsFromAreas(areas, spec.markDef.point) : [];
|
|
96
|
+
|
|
97
|
+
// Areas go first (rendered behind lines), then lines, then points on top
|
|
98
|
+
return [...areas, ...lines, ...points] as Mark[];
|
|
89
99
|
};
|
|
90
100
|
|
|
91
101
|
// ---------------------------------------------------------------------------
|
|
@@ -117,6 +127,46 @@ function linesFromAreas(areas: AreaMark[]): LineMark[] {
|
|
|
117
127
|
}));
|
|
118
128
|
}
|
|
119
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Derive PointMark[] sitting on each area's top boundary. Honors the same
|
|
132
|
+
* `mark.point` modes as the line renderer: `true` (filled dots), `'transparent'`
|
|
133
|
+
* (invisible hover targets), and `'endpoints'` (hollow dots at first/last only).
|
|
134
|
+
*/
|
|
135
|
+
function pointsFromAreas(
|
|
136
|
+
areas: AreaMark[],
|
|
137
|
+
pointMode: NonNullable<NormalizedChartSpec['markDef']['point']>,
|
|
138
|
+
): PointMark[] {
|
|
139
|
+
const isTransparent = pointMode === 'transparent';
|
|
140
|
+
const isEndpoints = pointMode === 'endpoints';
|
|
141
|
+
const points: PointMark[] = [];
|
|
142
|
+
|
|
143
|
+
for (const a of areas) {
|
|
144
|
+
const stroke = getRepresentativeColor(a.fill);
|
|
145
|
+
const lastIdx = a.topPoints.length - 1;
|
|
146
|
+
|
|
147
|
+
for (let i = 0; i < a.topPoints.length; i++) {
|
|
148
|
+
const pt = a.topPoints[i];
|
|
149
|
+
const isEndpoint = i === 0 || i === lastIdx;
|
|
150
|
+
const visible = !isTransparent && (!isEndpoints || isEndpoint);
|
|
151
|
+
const hollow = isEndpoints && visible;
|
|
152
|
+
points.push({
|
|
153
|
+
type: 'point',
|
|
154
|
+
cx: pt.x,
|
|
155
|
+
cy: pt.y,
|
|
156
|
+
r: visible ? AREA_POINT_RADIUS : 0,
|
|
157
|
+
fill: hollow ? 'transparent' : stroke,
|
|
158
|
+
stroke: hollow ? stroke : visible ? '#ffffff' : 'transparent',
|
|
159
|
+
strokeWidth: visible ? 1.5 : 0,
|
|
160
|
+
fillOpacity: isTransparent ? 0 : 1,
|
|
161
|
+
data: a.data[i] ?? {},
|
|
162
|
+
aria: { decorative: true },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return points;
|
|
168
|
+
}
|
|
169
|
+
|
|
120
170
|
// ---------------------------------------------------------------------------
|
|
121
171
|
// Public exports
|
|
122
172
|
// ---------------------------------------------------------------------------
|
package/src/layout/scales.ts
CHANGED
|
@@ -500,7 +500,11 @@ function buildPointScale(
|
|
|
500
500
|
? (channel.scale.domain as string[])
|
|
501
501
|
: applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
|
|
502
502
|
|
|
503
|
-
|
|
503
|
+
// Point scales have a single padding knob (outer padding only -- there are no
|
|
504
|
+
// bands, so paddingInner is meaningless). Accept `paddingOuter` as an alias so
|
|
505
|
+
// a spec written for a band scale doesn't silently no-op when the mark is a
|
|
506
|
+
// line; explicit `padding` wins if both are set.
|
|
507
|
+
const padding = channel.scale?.padding ?? channel.scale?.paddingOuter ?? 0.5;
|
|
504
508
|
const scale = scalePoint().domain(values).range([rangeStart, rangeEnd]).padding(padding);
|
|
505
509
|
|
|
506
510
|
if (channel.scale?.reverse) {
|