@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "7.2.0",
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.0",
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);
@@ -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
- // Areas go first (rendered behind lines), then lines on top
88
- return [...areas, ...lines] as Mark[];
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
  // ---------------------------------------------------------------------------
@@ -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
- const padding = channel.scale?.padding ?? 0.5;
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) {