Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this

### Features

- **scatter:** bubble size scale now honors `encoding.size.scale.domain` and `encoding.size.scale.range`. An explicit domain wider than the data keeps the largest datum below the max radius (so dense clusters stop blobbing together) and an explicit range caps the radii outright. The scale also clamps, so an author-narrowed domain never produces out-of-range radii.
- **endpoint-labels:** right-side per-series label column for multi-series line/area charts. Renders a chip+bar swatch (matching the redesigned legend), the colored series name, and a muted formatted value below. Auto-takes over from the traditional legend on ≥2-series line/area charts unless the user forces `legend: { show: true }`. Bidirectional collision sweep keeps labels stacked without overlap. Optional open-ring marker terminates the line at the chart's right edge; decorative point marks at the same coordinate are auto-suppressed to avoid double-circles.
- **legend:** redesigned categorical swatch as a rounded chip with a colored bar through its midline. Shared between the traditional legend and the endpoint-labels column so a chart never shows two swatch idioms.
- **annotations:** add `dot` and `subtitle` fields to `TextAnnotation` for editorial pull-quote callouts (open-ring marker at the data point + muted second-tone supporting text).
Expand Down
34 changes: 34 additions & 0 deletions packages/engine/src/charts/scatter/__tests__/compute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,40 @@ describe('computeScatterMarks', () => {

expect(marks[0].aria.label).toContain('population=');
});

it('explicit size domain keeps the largest datum below the max radius', () => {
const base = makeBubbleSpec();
// Widen the domain past the data max (8000) so nothing reaches radius 30.
const spec: NormalizedChartSpec = {
...base,
encoding: {
...base.encoding,
size: { field: 'population', type: 'quantitative', scale: { domain: [0, 32000] } },
},
};
const scales = computeScales(spec, chartArea, spec.data);
const marks = computeScatterMarks(spec, scales, chartArea, fullStrategy);

const largest = marks.find((m) => m.data.population === 8000)!;
// sqrt(8000/32000) * 30 = 0.5 * 30 = 15, well under the 30px cap.
expect(largest.r).toBeLessThan(20);
});

it('explicit size range caps the radii', () => {
const base = makeBubbleSpec();
const spec: NormalizedChartSpec = {
...base,
encoding: {
...base.encoding,
size: { field: 'population', type: 'quantitative', scale: { range: [2, 12] } },
},
};
const scales = computeScales(spec, chartArea, spec.data);
const marks = computeScatterMarks(spec, scales, chartArea, fullStrategy);

const maxRadius = Math.max(...marks.map((m) => m.r));
expect(maxRadius).toBeLessThanOrEqual(12);
});
});

describe('color encoding', () => {
Expand Down
19 changes: 12 additions & 7 deletions packages/engine/src/charts/scatter/compute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,19 +113,24 @@ export function computeScatterMarks(
const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
const isSequentialColor = colorEnc?.type === 'quantitative';
const colorField = colorEnc?.field;
const sizeField = encoding.size && 'field' in encoding.size ? encoding.size.field : undefined;
const sizeEnc = encoding.size && 'field' in encoding.size ? encoding.size : undefined;
const sizeField = sizeEnc?.field;

// Build a size scale for bubble variant
// Build a size scale for bubble variant. Domain and radius range are
// author-overridable via `encoding.size.scale.{domain,range}`: an explicit
// domain (e.g. [0, 900]) keeps the largest datum below the max radius so
// dense clusters don't blob together, and an explicit range caps the radii.
let sizeScale: ((v: number) => number) | undefined;
if (sizeField) {
const sizeValues = spec.data.map((d) => Number(d[sizeField])).filter((v) => Number.isFinite(v));

const sizeMin = min(sizeValues) ?? 0;
const sizeMax = max(sizeValues) ?? 1;
const explicitDomain = sizeEnc?.scale?.domain as [number, number] | undefined;
const [sizeMin, sizeMax] = explicitDomain ?? [min(sizeValues) ?? 0, max(sizeValues) ?? 1];

sizeScale = scaleSqrt()
.domain([sizeMin, sizeMax])
.range([MIN_BUBBLE_RADIUS, MAX_BUBBLE_RADIUS]);
const explicitRange = sizeEnc?.scale?.range as [number, number] | undefined;
const [radiusMin, radiusMax] = explicitRange ?? [MIN_BUBBLE_RADIUS, MAX_BUBBLE_RADIUS];

sizeScale = scaleSqrt().domain([sizeMin, sizeMax]).range([radiusMin, radiusMax]).clamp(true);
}

const marks: PointMark[] = [];
Expand Down
Loading