Skip to content

Commit 84767fb

Browse files
committed
feat(engine): scatter trendline stacking/styling and mark-level color/size constants
Trendline grows from `boolean` to `boolean | { layer, stroke, strokeWidth }`. `layer: 'above'` lifts the reference line over a dense point cloud that would otherwise occlude it; stroke defaults to the theme's text color so the line stays visible on both light and dark grounds. Scatter points honor mark-level constants (fill, size, stroke, strokeWidth, opacity) when the matching encoding channel isn't a data field, so layered scatters can set per-layer color and size instead of collapsing to the default.
1 parent 95a1326 commit 84767fb

10 files changed

Lines changed: 702 additions & 125 deletions

File tree

bun.lock

Lines changed: 12 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@
3939
},
4040
"overrides": {
4141
"strip-ansi": "6.0.1",
42-
"string-width": "4.2.3"
42+
"string-width": "4.2.3",
43+
"react": "19.2.8",
44+
"react-dom": "19.2.8"
4345
},
4446
"devDependencies": {
4547
"@biomejs/biome": "^2.4.4",
@@ -52,8 +54,8 @@
5254
"husky": "^9.1.7",
5355
"lightningcss-cli": "^1.32.0",
5456
"lightningcss-cli-darwin-arm64": "^1.32.0",
55-
"react": "^19.2.4",
56-
"react-dom": "^19.2.4",
57+
"react": "19.2.8",
58+
"react-dom": "19.2.8",
5759
"ts-json-schema-generator": "^2.9.0",
5860
"tsup": "^8.5.1",
5961
"typescript": "^6.0.2",

packages/core/schema/chart.schema.json

Lines changed: 167 additions & 32 deletions
Large diffs are not rendered by default.

packages/core/schema/vizspec.schema.json

Lines changed: 311 additions & 64 deletions
Large diffs are not rendered by default.

packages/core/src/types/spec.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,30 @@ export function getRepresentativeColor(fill: string | GradientDef): string {
140140
// Mark definition (Vega-Lite aligned)
141141
// ---------------------------------------------------------------------------
142142

143+
/**
144+
* Configuration for the scatter regression trend line. Passed as the object
145+
* form of `MarkDef.trendline`.
146+
*/
147+
export interface TrendlineConfig {
148+
/**
149+
* Stacking order relative to the point marks. `'below'` (default) draws the
150+
* line behind the dots; `'above'` draws it on top so it stays visible over a
151+
* dense cloud.
152+
*/
153+
layer?: 'above' | 'below';
154+
/**
155+
* Stroke color. Defaults to the theme's high-contrast text color so the line
156+
* reads on both light and dark grounds. Set an explicit CSS color to override.
157+
*/
158+
stroke?: string;
159+
/**
160+
* Stroke width in pixels. Defaults to 1.5. Over a dense cloud with `layer:
161+
* 'above'`, a heavier line (e.g. 2.5) stays legible where the thin default
162+
* disappears into the dots.
163+
*/
164+
strokeWidth?: number;
165+
}
166+
143167
/**
144168
* Mark definition object with visual properties.
145169
*
@@ -217,8 +241,13 @@ export interface MarkDef {
217241
* suppress it when the chart already carries its own reference line (e.g. a
218242
* manual x=y diagonal in a separate layer), which would otherwise produce
219243
* two competing diagonals.
244+
*
245+
* Pass an object to control stacking order: the line renders `below` the
246+
* points by default, but a dense scatter (thousands of overlapping dots)
247+
* fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the
248+
* reference line over the cloud.
220249
*/
221-
trendline?: boolean;
250+
trendline?: boolean | TrendlineConfig;
222251
/**
223252
* Horizontal pixel offset from the data anchor. Only meaningful when `type`
224253
* is `'text'`. Use it to lift a label clear of the mark it annotates instead

packages/engine/src/charts/scatter/__tests__/compute.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,81 @@ describe('computeScatterMarks', () => {
404404
expect(uniqueXs.size).toBe(3);
405405
});
406406
});
407+
408+
describe('mark-level constants (no encoding field)', () => {
409+
const baseData = [
410+
{ x: 10, y: 20 },
411+
{ x: 30, y: 40 },
412+
];
413+
const baseEncoding = {
414+
x: { field: 'x', type: 'quantitative' as const },
415+
y: { field: 'y', type: 'quantitative' as const },
416+
};
417+
const baseSpec = {
418+
markType: 'point' as const,
419+
data: baseData,
420+
encoding: baseEncoding,
421+
chrome: {},
422+
annotations: [],
423+
responsive: true,
424+
theme: {},
425+
darkMode: 'off' as const,
426+
labels: { density: 'auto' as const, format: '' },
427+
};
428+
429+
it('applies a constant mark.fill to every point when no color field is set', () => {
430+
const spec: NormalizedChartSpec = {
431+
...baseSpec,
432+
markDef: { type: 'point', fill: '#c1462f' },
433+
};
434+
const scales = computeScales(spec, chartArea, spec.data);
435+
const marks = computeScatterMarks(spec, scales, chartArea, fullStrategy);
436+
expect(marks).toHaveLength(2);
437+
expect(marks.every((m) => m.fill === '#c1462f')).toBe(true);
438+
});
439+
440+
it('applies a constant mark.size as the point radius', () => {
441+
const spec: NormalizedChartSpec = {
442+
...baseSpec,
443+
markDef: { type: 'point', size: 12 },
444+
};
445+
const scales = computeScales(spec, chartArea, spec.data);
446+
const marks = computeScatterMarks(spec, scales, chartArea, fullStrategy);
447+
expect(marks.every((m) => m.r === 12)).toBe(true);
448+
});
449+
450+
it('applies a constant mark.opacity and mark.stroke', () => {
451+
const spec: NormalizedChartSpec = {
452+
...baseSpec,
453+
markDef: { type: 'point', opacity: 0.15, stroke: 'none' },
454+
};
455+
const scales = computeScales(spec, chartArea, spec.data);
456+
const marks = computeScatterMarks(spec, scales, chartArea, fullStrategy);
457+
expect(marks.every((m) => m.fillOpacity === 0.15)).toBe(true);
458+
expect(marks.every((m) => m.stroke === 'none')).toBe(true);
459+
});
460+
461+
it('lets an encoding color field win over a constant mark.fill', () => {
462+
const spec: NormalizedChartSpec = {
463+
...baseSpec,
464+
data: [
465+
{ x: 10, y: 20, grp: 'a' },
466+
{ x: 30, y: 40, grp: 'b' },
467+
],
468+
encoding: {
469+
...baseEncoding,
470+
color: { field: 'grp', type: 'nominal' },
471+
},
472+
markDef: { type: 'point', fill: '#c1462f' },
473+
};
474+
const scales = computeScales(spec, chartArea, spec.data);
475+
const marks = computeScatterMarks(spec, scales, chartArea, fullStrategy);
476+
// Two categories -> two distinct scale colors, neither forced to the constant.
477+
const fills = new Set(marks.map((m) => m.fill));
478+
expect(fills.size).toBe(2);
479+
expect(fills.has('#c1462f')).toBe(false);
480+
});
481+
});
407482
});
408483

409484
// ---------------------------------------------------------------------------
@@ -485,4 +560,45 @@ describe('trendline opt-out', () => {
485560
expect(marks.some((m) => m.type === 'line')).toBe(false);
486561
expect(marks.every((m) => m.type === 'point')).toBe(true);
487562
});
563+
564+
it('draws the trend line behind the points by default', () => {
565+
const spec = makeBasicScatterSpec();
566+
const scales = computeScales(spec, chartArea, spec.data);
567+
const marks = scatterRenderer(spec, scales, chartArea, fullStrategy, undefined as never);
568+
569+
// First mark renders first, i.e. underneath everything drawn after it.
570+
expect(marks[0].type).toBe('line');
571+
});
572+
573+
it('draws the trend line on top when trendline.layer is "above"', () => {
574+
const base = makeBasicScatterSpec();
575+
const spec: NormalizedChartSpec = {
576+
...base,
577+
markDef: { type: 'point', trendline: { layer: 'above' } },
578+
};
579+
const scales = computeScales(spec, chartArea, spec.data);
580+
const marks = scatterRenderer(spec, scales, chartArea, fullStrategy, undefined as never);
581+
582+
// Line still present, but now last so it paints over the point cloud.
583+
expect(marks.some((m) => m.type === 'line')).toBe(true);
584+
expect(marks[marks.length - 1].type).toBe('line');
585+
});
586+
587+
it('applies the trendline config stroke and strokeWidth to the line mark', () => {
588+
const base = makeBasicScatterSpec();
589+
const spec: NormalizedChartSpec = {
590+
...base,
591+
markDef: {
592+
type: 'point',
593+
trendline: { layer: 'above', stroke: '#ff00ff', strokeWidth: 3 },
594+
},
595+
};
596+
const scales = computeScales(spec, chartArea, spec.data);
597+
const marks = scatterRenderer(spec, scales, chartArea, fullStrategy, undefined as never);
598+
599+
const line = marks.find((m) => m.type === 'line');
600+
expect(line).toBeDefined();
601+
expect((line as { stroke: string }).stroke).toBe('#ff00ff');
602+
expect((line as { strokeWidth: number }).strokeWidth).toBe(3);
603+
});
488604
});

packages/engine/src/charts/scatter/__tests__/trendline.test.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,20 +108,36 @@ describe('computeTrendLine', () => {
108108
expect(result.strokeDasharray).toBe('6 4');
109109
});
110110

111-
it('has a subdued stroke color', () => {
111+
it('falls back to a subdued stroke color when none is passed', () => {
112112
const points = [makePoint(100, 200), makePoint(300, 100)];
113113
const result = computeTrendLine(points)!;
114114

115115
expect(result.stroke).toBe('#666666');
116116
});
117117

118-
it('has a thin stroke width', () => {
118+
it('uses the passed stroke color so it can read on the current theme ground', () => {
119+
// The renderer passes theme.colors.text so the line stays visible on
120+
// both light and dark backgrounds (the old hardcoded gray vanished on black).
121+
const points = [makePoint(100, 200), makePoint(300, 100)];
122+
const result = computeTrendLine(points, '#cccccc')!;
123+
124+
expect(result.stroke).toBe('#cccccc');
125+
});
126+
127+
it('has a thin stroke width by default', () => {
119128
const points = [makePoint(100, 200), makePoint(300, 100)];
120129
const result = computeTrendLine(points)!;
121130

122131
expect(result.strokeWidth).toBe(1.5);
123132
});
124133

134+
it('uses the passed stroke width so a dense-cloud line can be made heavier', () => {
135+
const points = [makePoint(100, 200), makePoint(300, 100)];
136+
const result = computeTrendLine(points, '#fff', 2.5)!;
137+
138+
expect(result.strokeWidth).toBe(2.5);
139+
});
140+
125141
it('has an aria label describing the trend', () => {
126142
const points = [makePoint(100, 200), makePoint(300, 100)];
127143
const result = computeTrendLine(points)!;

packages/engine/src/charts/scatter/compute.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,14 @@ export function computeScatterMarks(
120120
const resolvedSize = buildSizeScale(sizeEnc, spec.data, SIZE_SCALE_DEFAULTS.point);
121121
const sizeScale = resolvedSize?.scale;
122122

123+
// Mark-level constants (VL-aligned "Default fill/size/opacity"). These apply
124+
// only when the matching encoding channel isn't a data field: a constant
125+
// `mark.fill` colors every point, `mark.size` sets a constant radius. Without
126+
// this, layered scatters (a dim background layer + a highlighted layer) can't
127+
// set per-layer color/size and all points collapse to the default blue.
128+
const constFill = spec.markDef.fill;
129+
const constSize = spec.markDef.size;
130+
123131
const keyEnc = encoding.key && 'field' in encoding.key ? encoding.key : undefined;
124132
const keyField = keyEnc?.field;
125133
const marks: PointMark[] = [];
@@ -140,11 +148,14 @@ export function computeScatterMarks(
140148
color = Number.isFinite(val)
141149
? getSequentialColor(scales, val)
142150
: getColor(scales, '__default__');
143-
} else {
151+
} else if (colorField) {
144152
color = getColor(scales, category ?? '__default__');
153+
} else {
154+
// No color field: honor a constant mark.fill, else the default series color.
155+
color = constFill ?? getColor(scales, '__default__');
145156
}
146157

147-
let radius = DEFAULT_POINT_RADIUS;
158+
let radius = constSize != null ? constSize : DEFAULT_POINT_RADIUS;
148159
if (sizeScale && sizeField) {
149160
const sizeVal = Number(row[sizeField]);
150161
if (Number.isFinite(sizeVal)) {
@@ -168,9 +179,9 @@ export function computeScatterMarks(
168179
cy,
169180
r: radius,
170181
fill: color,
171-
stroke: '#ffffff',
172-
strokeWidth: 1,
173-
fillOpacity: 0.7,
182+
stroke: spec.markDef.stroke ?? '#ffffff',
183+
strokeWidth: spec.markDef.strokeWidth ?? 1,
184+
fillOpacity: spec.markDef.opacity ?? 0.7,
174185
data: row as Record<string, unknown>,
175186
aria,
176187
});

0 commit comments

Comments
 (0)