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[] = [];