@opendata-ai/openchart-engine 7.4.1 → 7.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "7.4.1",
3
+ "version": "7.6.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",
@@ -48,7 +48,7 @@
48
48
  "typecheck": "tsc --noEmit"
49
49
  },
50
50
  "dependencies": {
51
- "@opendata-ai/openchart-core": "7.4.1",
51
+ "@opendata-ai/openchart-core": "7.6.0",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
3
3
  import type { NormalizedChartSpec } from '../../../compiler/types';
4
4
  import { computeScales } from '../../../layout/scales';
5
5
  import { computeScatterMarks } from '../compute';
6
+ import { scatterRenderer } from '../index';
6
7
  import { computeTrendLine } from '../trendline';
7
8
 
8
9
  // ---------------------------------------------------------------------------
@@ -179,6 +180,40 @@ describe('computeScatterMarks', () => {
179
180
 
180
181
  expect(marks[0].aria.label).toContain('population=');
181
182
  });
183
+
184
+ it('explicit size domain keeps the largest datum below the max radius', () => {
185
+ const base = makeBubbleSpec();
186
+ // Widen the domain past the data max (8000) so nothing reaches radius 30.
187
+ const spec: NormalizedChartSpec = {
188
+ ...base,
189
+ encoding: {
190
+ ...base.encoding,
191
+ size: { field: 'population', type: 'quantitative', scale: { domain: [0, 32000] } },
192
+ },
193
+ };
194
+ const scales = computeScales(spec, chartArea, spec.data);
195
+ const marks = computeScatterMarks(spec, scales, chartArea, fullStrategy);
196
+
197
+ const largest = marks.find((m) => m.data.population === 8000)!;
198
+ // sqrt(8000/32000) * 30 = 0.5 * 30 = 15, well under the 30px cap.
199
+ expect(largest.r).toBeLessThan(20);
200
+ });
201
+
202
+ it('explicit size range caps the radii', () => {
203
+ const base = makeBubbleSpec();
204
+ const spec: NormalizedChartSpec = {
205
+ ...base,
206
+ encoding: {
207
+ ...base.encoding,
208
+ size: { field: 'population', type: 'quantitative', scale: { range: [2, 12] } },
209
+ },
210
+ };
211
+ const scales = computeScales(spec, chartArea, spec.data);
212
+ const marks = computeScatterMarks(spec, scales, chartArea, fullStrategy);
213
+
214
+ const maxRadius = Math.max(...marks.map((m) => m.r));
215
+ expect(maxRadius).toBeLessThanOrEqual(12);
216
+ });
182
217
  });
183
218
 
184
219
  describe('color encoding', () => {
@@ -428,3 +463,26 @@ describe('computeTrendLine', () => {
428
463
  expect(computeTrendLine([])).toBeNull();
429
464
  });
430
465
  });
466
+
467
+ describe('trendline opt-out', () => {
468
+ it('renders a trend line by default', () => {
469
+ const spec = makeBasicScatterSpec();
470
+ const scales = computeScales(spec, chartArea, spec.data);
471
+ const marks = scatterRenderer(spec, scales, chartArea, fullStrategy, undefined as never);
472
+
473
+ expect(marks.some((m) => m.type === 'line')).toBe(true);
474
+ });
475
+
476
+ it('suppresses the trend line when mark.trendline is false', () => {
477
+ const base = makeBasicScatterSpec();
478
+ const spec: NormalizedChartSpec = {
479
+ ...base,
480
+ markDef: { type: 'point', trendline: false },
481
+ };
482
+ const scales = computeScales(spec, chartArea, spec.data);
483
+ const marks = scatterRenderer(spec, scales, chartArea, fullStrategy, undefined as never);
484
+
485
+ expect(marks.some((m) => m.type === 'line')).toBe(false);
486
+ expect(marks.every((m) => m.type === 'point')).toBe(true);
487
+ });
488
+ });
@@ -113,19 +113,24 @@ export function computeScatterMarks(
113
113
  const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
114
114
  const isSequentialColor = colorEnc?.type === 'quantitative';
115
115
  const colorField = colorEnc?.field;
116
- const sizeField = encoding.size && 'field' in encoding.size ? encoding.size.field : undefined;
116
+ const sizeEnc = encoding.size && 'field' in encoding.size ? encoding.size : undefined;
117
+ const sizeField = sizeEnc?.field;
117
118
 
118
- // Build a size scale for bubble variant
119
+ // Build a size scale for bubble variant. Domain and radius range are
120
+ // author-overridable via `encoding.size.scale.{domain,range}`: an explicit
121
+ // domain (e.g. [0, 900]) keeps the largest datum below the max radius so
122
+ // dense clusters don't blob together, and an explicit range caps the radii.
119
123
  let sizeScale: ((v: number) => number) | undefined;
120
124
  if (sizeField) {
121
125
  const sizeValues = spec.data.map((d) => Number(d[sizeField])).filter((v) => Number.isFinite(v));
122
126
 
123
- const sizeMin = min(sizeValues) ?? 0;
124
- const sizeMax = max(sizeValues) ?? 1;
127
+ const explicitDomain = sizeEnc?.scale?.domain as [number, number] | undefined;
128
+ const [sizeMin, sizeMax] = explicitDomain ?? [min(sizeValues) ?? 0, max(sizeValues) ?? 1];
125
129
 
126
- sizeScale = scaleSqrt()
127
- .domain([sizeMin, sizeMax])
128
- .range([MIN_BUBBLE_RADIUS, MAX_BUBBLE_RADIUS]);
130
+ const explicitRange = sizeEnc?.scale?.range as [number, number] | undefined;
131
+ const [radiusMin, radiusMax] = explicitRange ?? [MIN_BUBBLE_RADIUS, MAX_BUBBLE_RADIUS];
132
+
133
+ sizeScale = scaleSqrt().domain([sizeMin, sizeMax]).range([radiusMin, radiusMax]).clamp(true);
129
134
  }
130
135
 
131
136
  const marks: PointMark[] = [];
@@ -23,11 +23,16 @@ export const scatterRenderer: ChartRenderer = (spec, scales, chartArea, strategy
23
23
  const pointMarks = computeScatterMarks(spec, scales, chartArea, strategy);
24
24
  const marks: Mark[] = [...pointMarks];
25
25
 
26
- // Add trend line if there are enough points
27
- const trendLine = computeTrendLine(pointMarks);
28
- if (trendLine) {
29
- // Trend line goes behind points
30
- marks.unshift(trendLine);
26
+ // Regression trend line renders by default but is opt-out via
27
+ // `mark: { type: 'point', trendline: false }`. Rendering it unconditionally
28
+ // competed with author-drawn reference lines (e.g. a manual x=y diagonal),
29
+ // so a chart could show two diagonals.
30
+ if (spec.markDef?.trendline !== false) {
31
+ const trendLine = computeTrendLine(pointMarks);
32
+ if (trendLine) {
33
+ // Trend line goes behind points
34
+ marks.unshift(trendLine);
35
+ }
31
36
  }
32
37
 
33
38
  return marks;