From c373174110953f1210fdd364bf920571c9709461 Mon Sep 17 00:00:00 2001 From: Riley Hilliard Date: Sat, 4 Jul 2026 01:39:18 -0500 Subject: [PATCH] feat: honor explicit domain and range on scatter bubble size scale The bubble size scale always derived its domain from data min/max and hard-coded the radius range to [3, 30], ignoring encoding.size.scale.domain/range. On a scatter with points clustered in a small region, the largest datum always mapped to a 30px radius (60px diameter), blobbing dense clusters into one mass with no author lever to shrink them. Read encoding.size.scale.domain and .range when present: a domain wider than the data keeps the largest datum below the max radius, and an explicit range caps the radii. Clamp the scale so an author-narrowed domain never yields out-of-range radii. Falls back to the previous data-derived domain and [3, 30] range when unset. --- CHANGELOG.md | 1 + .../charts/scatter/__tests__/compute.test.ts | 34 +++++++++++++++++++ packages/engine/src/charts/scatter/compute.ts | 19 +++++++---- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0ae4645..ca20010b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/packages/engine/src/charts/scatter/__tests__/compute.test.ts b/packages/engine/src/charts/scatter/__tests__/compute.test.ts index 33d20397..c7625d6e 100644 --- a/packages/engine/src/charts/scatter/__tests__/compute.test.ts +++ b/packages/engine/src/charts/scatter/__tests__/compute.test.ts @@ -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', () => { diff --git a/packages/engine/src/charts/scatter/compute.ts b/packages/engine/src/charts/scatter/compute.ts index edc16ec1..844f6a7c 100644 --- a/packages/engine/src/charts/scatter/compute.ts +++ b/packages/engine/src/charts/scatter/compute.ts @@ -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[] = [];