From 6be87f2a34ec8dc236a357d7daa6ac08f1495345 Mon Sep 17 00:00:00 2001 From: Riley Hilliard Date: Sat, 4 Jul 2026 01:52:00 -0500 Subject: [PATCH] feat: add mark.trendline opt-out for scatter regression line Scatter charts rendered a least-squares trend line unconditionally. When a chart draws its own reference line (e.g. a manual x=y diagonal in a separate layer), the auto trend line produced a second, competing diagonal. Add `mark.trendline` (default true, so no behavior change) and gate the renderer on `markDef.trendline !== false`. Authors suppress the auto line with `mark: { type: 'point', trendline: false }`. --- CHANGELOG.md | 1 + packages/core/src/types/spec.ts | 8 +++++++ .../charts/scatter/__tests__/compute.test.ts | 24 +++++++++++++++++++ packages/engine/src/charts/scatter/index.ts | 15 ++++++++---- 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca20010b..9a1ed602 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:** add `mark.trendline` to opt out of the auto regression line (`mark: { type: 'point', trendline: false }`). It defaulted to always-on and competed with author-drawn reference lines — a manual x=y diagonal in a separate layer produced two diagonals. Default behavior is unchanged. - **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. diff --git a/packages/core/src/types/spec.ts b/packages/core/src/types/spec.ts index 3c3f25f1..0256d536 100644 --- a/packages/core/src/types/spec.ts +++ b/packages/core/src/types/spec.ts @@ -191,6 +191,14 @@ export interface MarkDef { tooltip?: boolean | null; /** Clip marks to the chart area. */ clip?: boolean; + /** + * Overlay a least-squares regression line on scatter (point) marks. + * Defaults to true. Set `mark: { type: 'point', trendline: false }` to + * suppress it when the chart already carries its own reference line (e.g. a + * manual x=y diagonal in a separate layer), which would otherwise produce + * two competing diagonals. + */ + trendline?: boolean; } // --------------------------------------------------------------------------- diff --git a/packages/engine/src/charts/scatter/__tests__/compute.test.ts b/packages/engine/src/charts/scatter/__tests__/compute.test.ts index c7625d6e..8e0dc6f8 100644 --- a/packages/engine/src/charts/scatter/__tests__/compute.test.ts +++ b/packages/engine/src/charts/scatter/__tests__/compute.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import type { NormalizedChartSpec } from '../../../compiler/types'; import { computeScales } from '../../../layout/scales'; import { computeScatterMarks } from '../compute'; +import { scatterRenderer } from '../index'; import { computeTrendLine } from '../trendline'; // --------------------------------------------------------------------------- @@ -462,3 +463,26 @@ describe('computeTrendLine', () => { expect(computeTrendLine([])).toBeNull(); }); }); + +describe('trendline opt-out', () => { + it('renders a trend line by default', () => { + const spec = makeBasicScatterSpec(); + const scales = computeScales(spec, chartArea, spec.data); + const marks = scatterRenderer(spec, scales, chartArea, fullStrategy, undefined as never); + + expect(marks.some((m) => m.type === 'line')).toBe(true); + }); + + it('suppresses the trend line when mark.trendline is false', () => { + const base = makeBasicScatterSpec(); + const spec: NormalizedChartSpec = { + ...base, + markDef: { type: 'point', trendline: false }, + }; + const scales = computeScales(spec, chartArea, spec.data); + const marks = scatterRenderer(spec, scales, chartArea, fullStrategy, undefined as never); + + expect(marks.some((m) => m.type === 'line')).toBe(false); + expect(marks.every((m) => m.type === 'point')).toBe(true); + }); +}); diff --git a/packages/engine/src/charts/scatter/index.ts b/packages/engine/src/charts/scatter/index.ts index bbc2eacf..f6e5d518 100644 --- a/packages/engine/src/charts/scatter/index.ts +++ b/packages/engine/src/charts/scatter/index.ts @@ -23,11 +23,16 @@ export const scatterRenderer: ChartRenderer = (spec, scales, chartArea, strategy const pointMarks = computeScatterMarks(spec, scales, chartArea, strategy); const marks: Mark[] = [...pointMarks]; - // Add trend line if there are enough points - const trendLine = computeTrendLine(pointMarks); - if (trendLine) { - // Trend line goes behind points - marks.unshift(trendLine); + // Regression trend line renders by default but is opt-out via + // `mark: { type: 'point', trendline: false }`. Rendering it unconditionally + // competed with author-drawn reference lines (e.g. a manual x=y diagonal), + // so a chart could show two diagonals. + if (spec.markDef?.trendline !== false) { + const trendLine = computeTrendLine(pointMarks); + if (trendLine) { + // Trend line goes behind points + marks.unshift(trendLine); + } } return marks;