From 864b8a981051110ef87d250882cd285e9ac70923 Mon Sep 17 00:00:00 2001 From: onmax Date: Thu, 22 Oct 2026 15:04:55 +0200 Subject: [PATCH 01/12] fix(charts): draw dumbbell gridlines and tighter rows so the change reads at a glance Dumbbells now grid every tick and guide every row, pack rows 36px apart, draw a heavier connector and bigger dots, end the axis at a round tick near the data, and print each row's lowest and highest value beside its outer dots. Rows over dates are names, so each gets its own connector instead of one line through them all. --- packages/charts/src/draw.ts | 100 +++++++++++++++++++++++++++- packages/charts/test/charts.test.ts | 43 ++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/packages/charts/src/draw.ts b/packages/charts/src/draw.ts index a4c7cd2c..c125ea76 100644 --- a/packages/charts/src/draw.ts +++ b/packages/charts/src/draw.ts @@ -202,7 +202,8 @@ function input(chart: Chart): ChartAssemblyInput { // A bar, a step, or a dot row per value, so a numeric x on those is a name. const banded = type === "bar" || type === "stacked" || type === "waterfall" || type === "dumbbell"; - const kind = banded && chart.xKind === "number" ? "name" : chart.xKind; + // Flint joins a dumbbell's dots per row only over names; over dates its connector runs through every row. + const kind = type === "dumbbell" || (banded && chart.xKind === "number") ? "name" : chart.xKind; const xValue = (value: string) => (kind === "number" ? Number(value.replaceAll(",", "")) : value); // `row` lets a mark find the shapes drawn for its row once the chart is laid out. const values = chart.rows.flatMap((row, number) => @@ -346,6 +347,102 @@ function boxes(chart: Chart, view: vega.View, width: number, height: number): Ma return found; } +const format = (value: number) => value.toLocaleString("en-US", { maximumFractionDigits: 12 }); + +/** Ticks from a round step that end at the first one past the data, among as many as fit `width`. */ +function ticks(low: number, high: number, width: number) { + const span = high - low || Math.abs(high) || 1; + const magnitude = 10 ** Math.floor(Math.log10(span)); + let best: number[] = []; + for (const scale of [magnitude / 10, magnitude]) + for (const factor of [1, 2, 2.5, 3, 4, 5]) { + const step = factor * scale; + const first = Math.floor(low / step) * step; + const count = Math.round((Math.ceil(high / step) * step - first) / step); + const values = Array.from({ length: count + 1 }, (_, index) => + Number((first + index * step).toPrecision(12)), + ); + const room = Math.max(...values.map((value) => guess(format(value), 12))) + 12; + if (count < 3 || values.length * room > width) continue; + const reach = (list: number[]) => list.at(-1)! - list[0]!; + if (!best.length || reach(values) < reach(best)) best = values; + } + return best.length ? best : [low, high]; +} + +/** + * A dumbbell reads as rows of changes: a grid line at every tick and a guide along every row, rows close together, a + * connector heavier than the grid, and each row's extremes printed beside its outer dots. + */ +function dumbbell(chart: Chart, spec: TopLevelSpec) { + if (!("layer" in spec) || !spec.encoding) return; + const { x } = spec.encoding; + if (!x || !("field" in x)) return; + const rows = chart.rows.map((row) => row.values.filter((value) => value !== undefined)); + const all = rows.flat(); + const width = Number(spec.config?.view?.continuousWidth) || layout(chart).width; + const values = ticks(Math.min(...all), Math.max(...all), width); + let [low, high] = [values[0]!, values.at(-1)!]; + // A row's lowest value prints left of its dot, so the scale starts early enough that the number clears the names. + for (const row of rows) { + if (!row.length) continue; + const least = Math.min(...row); + const need = (guess(format(least), 12) + 10) / width; + const from = (least - need * high) / (1 - need); + if (need < 1 && from < low) low = from; + } + x.scale = { domain: [low, high], nice: false, zero: false }; + // Vega thins given values down to the tick count Flint sets, so the count is the values'. + const grid = { grid: true, gridColor: INK.grid, gridWidth: 1 }; + x.axis = { + ...("axis" in x && x.axis), + ...grid, + values, + tickCount: values.length, + labelFlush: false, + }; + const { y } = spec.encoding; + if (y) spec.encoding.y = { ...y, axis: { ...("axis" in y && y.axis), ...grid } }; + spec.height = { step: 36 }; + const [line, dots] = spec.layer; + if (line && "mark" in line) + line.mark = { + type: "line", + color: INK.muted, + strokeWidth: 4, + strokeOpacity: 0.55, + strokeCap: "round", + interpolate: "linear", + }; + if (dots && "mark" in dots && dots.mark instanceof Object && dots.mark.type === "point") + dots.mark = { ...dots.mark, size: 100 }; + const label = (side: "lo" | "hi") => ({ + transform: [ + { + joinaggregate: [ + { op: "min" as const, field: "y", as: "lo" }, + { op: "max" as const, field: "y", as: "hi" }, + ], + groupby: ["x"], + }, + { + filter: + side === "lo" ? "datum.y === datum.lo" : "datum.y === datum.hi && datum.hi > datum.lo", + }, + ], + mark: { + type: "text" as const, + align: side === "lo" ? ("right" as const) : ("left" as const), + baseline: "middle" as const, + dx: side === "lo" ? -10 : 10, + fontSize: 12, + fill: INK.muted, + }, + encoding: { text: { field: "y", type: "quantitative" as const, format: ",.12~g" } }, + }); + spec.layer.push(label("lo"), label("hi")); +} + /** * Draws a chart to an SVG string at the size Flint laid it out for, so its text reads at the size it was set, and * finds where each of its marks sits. @@ -586,6 +683,7 @@ export async function draw(chart: Chart): Promise<{ svg: string; boxes: Map { expect(dumbbell).toContain('data-type="dumbbell"'); expect(dumbbell).toContain(">before"); expect(dumbbell).toContain(">after"); + // Months are rows like any name, each with its own connector rather than one line through them all. + expect(dumbbell.match(/class="mark-line role-mark/g)).toHaveLength(3); const waterfall = await render( chart( ` @@ -119,6 +121,47 @@ describe("comark-charts", () => { expect(waterfall.split(`>${value}`)).toHaveLength(2); }); + it("grids a dumbbell, ends its axis at the data, and prints each row's ends beside its dots", async () => { + const html = await render( + chart( + ` +| activity | before | after | +| --- | --: | --: | +| waiting for review | 820 | 540 | +| debugging alone | 1,450 | 980 | +| meetings about bugs | 2,100 | 1,320 | +| standup | 380 | 160 |`, + '{type="dumbbell"}', + ), + ); + const group = (role: string) => + [...html.matchAll(new RegExp(`class="mark-[a-z]+ role-${role}"[^>]*>(.*?)`, "gs"))].map( + ([, inside]) => inside!, + ); + const [ticks, names] = group("axis-label"); + expect([...ticks!.matchAll(/>([^<]+)<\/text>/g)].map(([, text]) => text)).toEqual([ + "0", + "300", + "600", + "900", + "1,200", + "1,500", + "1,800", + "2,100", + ]); + expect(names).toContain(">standup"); + // A grid line at every tick and a guide along every row. + const [columns, rows] = group("axis-grid"); + expect(columns!.match(/]*stroke-width="1"/g)).toHaveLength(4); + const printed = [...html.matchAll(/class="mark-text role-mark[^"]*"[^>]*>(.*?)<\/g>/gs)] + .flatMap(([, inside]) => [...inside!.matchAll(/]*>([^<]+)<\/text>/g)]) + .map(([, text]) => text); + expect(printed.sort()).toEqual( + ["1,320", "1,450", "160", "2,100", "380", "540", "820", "980"].sort(), + ); + }); + it("names every month, with the year on the first and wherever it turns", async () => { const html = await render( chart(` From ea1aeccfba0010dd580114820f07fa379d828c40 Mon Sep 17 00:00:00 2001 From: "vitehub-bot[bot]" <320448255+vitehub-bot[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:35:05 +0200 Subject: [PATCH 02/12] fix(charts): keep dumbbell labels within scale --- packages/charts/src/draw.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/charts/src/draw.ts b/packages/charts/src/draw.ts index c125ea76..39600bd6 100644 --- a/packages/charts/src/draw.ts +++ b/packages/charts/src/draw.ts @@ -383,13 +383,17 @@ function dumbbell(chart: Chart, spec: TopLevelSpec) { const width = Number(spec.config?.view?.continuousWidth) || layout(chart).width; const values = ticks(Math.min(...all), Math.max(...all), width); let [low, high] = [values[0]!, values.at(-1)!]; - // A row's lowest value prints left of its dot, so the scale starts early enough that the number clears the names. + // Endpoint labels sit just outside their dots, so leave enough scale room at either edge for the text. for (const row of rows) { if (!row.length) continue; const least = Math.min(...row); const need = (guess(format(least), 12) + 10) / width; const from = (least - need * high) / (1 - need); if (need < 1 && from < low) low = from; + const most = Math.max(...row); + const highNeed = (guess(format(most), 12) + 10) / width; + const to = (most - highNeed * low) / (1 - highNeed); + if (highNeed < 1 && to > high) high = to; } x.scale = { domain: [low, high], nice: false, zero: false }; // Vega thins given values down to the tick count Flint sets, so the count is the values'. @@ -420,14 +424,14 @@ function dumbbell(chart: Chart, spec: TopLevelSpec) { transform: [ { joinaggregate: [ - { op: "min" as const, field: "y", as: "lo" }, - { op: "max" as const, field: "y", as: "hi" }, + { op: "min" as const, field: "x", as: "lo" }, + { op: "max" as const, field: "x", as: "hi" }, ], - groupby: ["x"], + groupby: ["y"], }, { filter: - side === "lo" ? "datum.y === datum.lo" : "datum.y === datum.hi && datum.hi > datum.lo", + side === "lo" ? "datum.x === datum.lo" : "datum.x === datum.hi && datum.hi > datum.lo", }, ], mark: { @@ -438,7 +442,7 @@ function dumbbell(chart: Chart, spec: TopLevelSpec) { fontSize: 12, fill: INK.muted, }, - encoding: { text: { field: "y", type: "quantitative" as const, format: ",.12~g" } }, + encoding: { text: { field: "x", type: "quantitative" as const, format: ",.12~g" } }, }); spec.layer.push(label("lo"), label("hi")); } From 2c49fa2f534d70d90df90936d8cf200cccf6e395 Mon Sep 17 00:00:00 2001 From: "vitehub-bot[bot]" <320448255+vitehub-bot[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:12:28 +0200 Subject: [PATCH 03/12] fix(charts): stabilize dumbbell labels and scale bounds --- packages/charts/src/draw.ts | 46 ++++++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/packages/charts/src/draw.ts b/packages/charts/src/draw.ts index 39600bd6..b3160a45 100644 --- a/packages/charts/src/draw.ts +++ b/packages/charts/src/draw.ts @@ -379,21 +379,31 @@ function dumbbell(chart: Chart, spec: TopLevelSpec) { const { x } = spec.encoding; if (!x || !("field" in x)) return; const rows = chart.rows.map((row) => row.values.filter((value) => value !== undefined)); - const all = rows.flat(); + const all = rows.flat().filter((value): value is number => Number.isFinite(value)); + if (!all.length) return; const width = Number(spec.config?.view?.continuousWidth) || layout(chart).width; const values = ticks(Math.min(...all), Math.max(...all), width); let [low, high] = [values[0]!, values.at(-1)!]; + if (low === high) { + const room = Math.max(1, Math.abs(low) * 0.1); + low -= room; + high += room; + } // Endpoint labels sit just outside their dots, so leave enough scale room at either edge for the text. - for (const row of rows) { - if (!row.length) continue; - const least = Math.min(...row); - const need = (guess(format(least), 12) + 10) / width; - const from = (least - need * high) / (1 - need); - if (need < 1 && from < low) low = from; - const most = Math.max(...row); - const highNeed = (guess(format(most), 12) + 10) / width; - const to = (most - highNeed * low) / (1 - highNeed); - if (highNeed < 1 && to > high) high = to; + for (let pass = 0; pass < 3; pass++) { + let nextLow = low; + let nextHigh = high; + for (const row of rows) { + if (!row.length) continue; + const least = Math.min(...row); + const need = (guess(format(least), 12) + 10) / width; + if (need < 1) nextLow = Math.min(nextLow, (least - need * high) / (1 - need)); + const most = Math.max(...row); + const highNeed = (guess(format(most), 12) + 10) / width; + if (highNeed < 1) nextHigh = Math.max(nextHigh, (most - highNeed * low) / (1 - highNeed)); + } + low = nextLow; + high = nextHigh; } x.scale = { domain: [low, high], nice: false, zero: false }; // Vega thins given values down to the tick count Flint sets, so the count is the values'. @@ -420,18 +430,22 @@ function dumbbell(chart: Chart, spec: TopLevelSpec) { }; if (dots && "mark" in dots && dots.mark instanceof Object && dots.mark.type === "point") dots.mark = { ...dots.mark, size: 100 }; + const valueField = x && "field" in x && typeof x.field === "string" ? x.field : "y"; + const rowField = y && "field" in y && typeof y.field === "string" ? y.field : "x"; const label = (side: "lo" | "hi") => ({ transform: [ { joinaggregate: [ - { op: "min" as const, field: "x", as: "lo" }, - { op: "max" as const, field: "x", as: "hi" }, + { op: "min" as const, field: valueField, as: "lo" }, + { op: "max" as const, field: valueField, as: "hi" }, ], - groupby: ["y"], + groupby: [rowField], }, { filter: - side === "lo" ? "datum.x === datum.lo" : "datum.x === datum.hi && datum.hi > datum.lo", + side === "lo" + ? `datum[${JSON.stringify(valueField)}] === datum.lo` + : `datum[${JSON.stringify(valueField)}] === datum.hi && datum.hi > datum.lo`, }, ], mark: { @@ -442,7 +456,7 @@ function dumbbell(chart: Chart, spec: TopLevelSpec) { fontSize: 12, fill: INK.muted, }, - encoding: { text: { field: "x", type: "quantitative" as const, format: ",.12~g" } }, + encoding: { text: { field: valueField, type: "quantitative" as const, format: ",.12~g" } }, }); spec.layer.push(label("lo"), label("hi")); } From 95b650c6d0e5e9112387cac1dc650f6a532c4978 Mon Sep 17 00:00:00 2001 From: "vitehub-bot[bot]" <320448255+vitehub-bot[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:18:35 +0200 Subject: [PATCH 04/12] fix(charts): format dumbbell drawing source --- packages/charts/src/draw.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/charts/src/draw.ts b/packages/charts/src/draw.ts index ff51c8bb..4df48e13 100644 --- a/packages/charts/src/draw.ts +++ b/packages/charts/src/draw.ts @@ -371,7 +371,6 @@ async function render(compiled: vega.Spec, loader?: vega.Loader) { return { view, svg }; } - const format = (value: number) => value.toLocaleString("en-US", { maximumFractionDigits: 12 }); /** Ticks from a round step that end at the first one past the data, among as many as fit `width`. */ From 00a2a73b1704a91f455a6d0a94fa1a9388447b7a Mon Sep 17 00:00:00 2001 From: "vitehub-bot[bot]" <320448255+vitehub-bot[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:34:50 +0200 Subject: [PATCH 05/12] fix(charts): remove unused drawing declarations --- packages/charts/src/draw.ts | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/packages/charts/src/draw.ts b/packages/charts/src/draw.ts index 4df48e13..221ac988 100644 --- a/packages/charts/src/draw.ts +++ b/packages/charts/src/draw.ts @@ -347,30 +347,6 @@ function boxes(chart: Chart, view: vega.View, width: number, height: number): Ma return found; } -/** Draws a compiled Vega spec to an SVG string, and returns the view for finding where its marks sit. */ -async function render(compiled: vega.Spec, loader?: vega.Loader) { - // Vega compiles its expressions with `new Function` unless told to interpret them, and Cloudflare Workers, where - // documents like these often render, forbid that. - const options: vega.ViewOptions = { - renderer: "none", - logLevel: None, - expr: expressionInterpreter, - }; - if (loader) options.loader = loader; - const view = new View(parse(compiled, undefined, { ast: true }), options); - // Vega gives every group a role for screen readers, which the one `role="img"` hides anyway, an empty foreground - // path, and float noise such as `10.000000000000021`. Only tags change, since text holds the table's own numbers, - // and Vega escapes `>` in attributes. - const svg = (await view.toSVG()) - .replace(/]* d=""[^>]*\/>/g, "") - .replace(/<[^>]+>/g, (tag) => - tag - .replace(/ (?:role|aria-roledescription|aria-hidden)="[^"]*"/g, "") - .replace(/(\.\d\d)\d+/g, "$1"), - ); - return { view, svg }; -} - const format = (value: number) => value.toLocaleString("en-US", { maximumFractionDigits: 12 }); /** Ticks from a round step that end at the first one past the data, among as many as fit `width`. */ @@ -689,7 +665,7 @@ export async function draw(chart: Chart): Promise<{ svg: string; boxes: Map Date: Thu, 22 Oct 2026 19:40:36 +0200 Subject: [PATCH 06/12] fix(charts): restore Vega-Lite spec rendering --- packages/charts/src/draw.ts | 120 ++++++++++++++++++++++++++++++------ 1 file changed, 102 insertions(+), 18 deletions(-) diff --git a/packages/charts/src/draw.ts b/packages/charts/src/draw.ts index 221ac988..0613c8db 100644 --- a/packages/charts/src/draw.ts +++ b/packages/charts/src/draw.ts @@ -7,7 +7,7 @@ import type { Field } from "vega-lite/types_unstable/channeldef.js"; import type { LayerSpec, UnitSpec } from "vega-lite/types_unstable/spec/index.js"; import { compile } from "vega-lite"; import { INK, SERIES } from "./colors.ts"; -import type { Chart, XKind } from "./read.ts"; +import type { Chart, SpecChart, XKind } from "./read.ts"; const { logger, None, parse, View } = vega; @@ -462,6 +462,30 @@ function dumbbell(chart: Chart, spec: TopLevelSpec) { spec.layer.push(label("lo"), label("hi")); } +/** Draws a compiled Vega spec to an SVG string, and returns the view for finding where its marks sit. */ +async function render(compiled: vega.Spec, loader?: vega.Loader) { + // Vega compiles its expressions with `new Function` unless told to interpret them, and Cloudflare Workers, where + // documents like these often render, forbid that. + const options: vega.ViewOptions = { + renderer: "none", + logLevel: None, + expr: expressionInterpreter, + }; + if (loader) options.loader = loader; + const view = new View(parse(compiled, undefined, { ast: true }), options); + // Vega gives every group a role for screen readers, which the one `role="img"` hides anyway, an empty foreground + // path, and float noise such as `10.000000000000021`. Only tags change, since text holds the table's own numbers, + // and Vega escapes `>` in attributes. + const svg = (await view.toSVG()) + .replace(/]* d=""[^>]*\/>/g, "") + .replace(/<[^>]+>/g, (tag) => + tag + .replace(/ (?:role|aria-roledescription|aria-hidden)="[^"]*"/g, "") + .replace(/(\.\d\d)\d+/g, "$1"), + ); + return { view, svg }; +} + /** * Draws a chart to an SVG string at the size Flint laid it out for, so its text reads at the size it was set, and * finds where each of its marks sits. @@ -703,25 +727,85 @@ export async function draw(chart: Chart): Promise<{ svg: string; boxes: Map` in attributes. - const svg = (await view.toSVG()) - .replace(/]* d=""[^>]*\/>/g, "") - .replace(/<[^>]+>/g, (tag) => - tag - .replace(/ (?:role|aria-roledescription|aria-hidden)="[^"]*"/g, "") - .replace(/(\.\d\d)\d+/g, "$1"), - ); + const { view, svg } = await render(compile(spec, { logger: quiet }).spec); const [, width = "0", height = "0"] = /viewBox="0 0 ([\d.]+) ([\d.]+)"/.exec(svg) ?? []; const found = boxes(chart, view, Number(width), Number(height)); view.finalize(); return { svg, boxes: found }; } + +// The house look, set where a spec leaves it: shadcn/ui's charts, in the inks the stylesheet swaps for the page's. +const SPEC_CONFIG = { + aria: false, + font: "sans-serif", + view: { stroke: null }, + range: { category: [...SERIES] }, + mark: { color: SERIES[0] }, + bar: { cornerRadiusEnd: 4 }, + line: { strokeWidth: 2 }, + axis: { + domain: false, + ticks: false, + grid: true, + gridColor: INK.border, + labelColor: INK.muted, + titleColor: INK.muted, + labelFontSize: 12, + titleFontSize: 12, + titleFontWeight: "normal", + labelPadding: 8, + titlePadding: 10, + }, + axisX: { grid: false }, + legend: { labelColor: INK.muted, titleColor: INK.muted, labelFontSize: 12, titleFontSize: 12 }, + text: { color: INK.foreground, fontSize: 12 }, + title: { color: INK.foreground }, +} as const; + +// A document's spec may not reach the network or the disk where it's parsed: the table is its only data. +const sealed = vega.loader(); +sealed.load = () => + Promise.reject(new Error("a chart's spec can't load files or URLs; its data is the table")); +// An empty href loads no image and makes no link. +sealed.sanitize = () => Promise.resolve({ href: "" }); + +// Composed Vega-Lite views can carry their own data declarations. The table is the sole data source, +// so remove those declarations at every depth before injecting the table values at the root. +function withoutData(value: unknown): unknown { + if (Array.isArray(value)) return value.map(withoutData); + if (!(value instanceof Object)) return value; + // SAFETY: JSON objects have string keys, and this value is known to be a non-array object. + const record = value as Record; + const from = record.from; + if ("lookup" in record && from instanceof Object && "data" in from) { + throw new Error( + "lookup transforms with from.data are unsupported because the table is the only data source", + ); + } + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => key !== "data") + .map(([key, child]) => [key, withoutData(child)]), + ); +} + +/** Draws a Vega-Lite spec over the chart's table, in the house look wherever the spec sets none. */ +export async function drawSpec(chart: SpecChart): Promise<{ svg: string }> { + const { config, ...rest } = chart.spec; + // SAFETY: chart.spec is parsed JSON, so rest is always a plain object and the sanitizer preserves that shape. + const sanitized = withoutData(rest) as Record; + // The spec came from JSON, and compile() checks it at runtime and throws on what it can't draw. + const spec: TopLevelSpec = JSON.parse( + JSON.stringify({ + background: "transparent", + padding: PADDING, + ...sanitized, + data: { values: chart.values }, + config: Object.assign({}, SPEC_CONFIG, config), + }), + ); + const { view, svg } = await render(compile(spec, { logger: quiet }).spec, sealed); + view.finalize(); + // A link in the drawing would run whatever URL the spec gives it, `javascript:` included. + return { svg: svg.replace(/<\/?a\b[^>]*>/g, "") }; +} From 13422772e69763727145dec54b2c595b1fb5b166 Mon Sep 17 00:00:00 2001 From: "vitehub-bot[bot]" <320448255+vitehub-bot[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:50:25 +0200 Subject: [PATCH 07/12] fix(charts): satisfy doctor field guards --- packages/charts/src/draw.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/charts/src/draw.ts b/packages/charts/src/draw.ts index 0613c8db..99af5362 100644 --- a/packages/charts/src/draw.ts +++ b/packages/charts/src/draw.ts @@ -431,8 +431,8 @@ function dumbbell(chart: Chart, spec: TopLevelSpec) { }; if (dots && "mark" in dots && dots.mark instanceof Object && dots.mark.type === "point") dots.mark = { ...dots.mark, size: 100 }; - const valueField = x && "field" in x && typeof x.field === "string" ? x.field : "y"; - const rowField = y && "field" in y && typeof y.field === "string" ? y.field : "x"; + const valueField = x && "field" in x ? x.field : "y"; + const rowField = y && "field" in y ? y.field : "x"; const label = (side: "lo" | "hi") => ({ transform: [ { From 5c9a7e806d79628ca963c5eb5e5415d7b20d9636 Mon Sep 17 00:00:00 2001 From: "vitehub-bot[bot]" <320448255+vitehub-bot[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:55:54 +0200 Subject: [PATCH 08/12] fix(charts): narrow dumbbell fields for typecheck --- packages/charts/src/draw.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/charts/src/draw.ts b/packages/charts/src/draw.ts index 99af5362..ab173fb7 100644 --- a/packages/charts/src/draw.ts +++ b/packages/charts/src/draw.ts @@ -377,7 +377,7 @@ function ticks(low: number, high: number, width: number) { function dumbbell(chart: Chart, spec: TopLevelSpec) { if (!("layer" in spec) || !spec.encoding) return; const { x } = spec.encoding; - if (!x || !("field" in x)) return; + if (!x || !("field" in x) || typeof x.field !== "string") return; const rows = chart.rows.map((row) => row.values.filter((value) => value !== undefined)); const all = rows.flat().filter((value): value is number => Number.isFinite(value)); if (!all.length) return; @@ -431,8 +431,8 @@ function dumbbell(chart: Chart, spec: TopLevelSpec) { }; if (dots && "mark" in dots && dots.mark instanceof Object && dots.mark.type === "point") dots.mark = { ...dots.mark, size: 100 }; - const valueField = x && "field" in x ? x.field : "y"; - const rowField = y && "field" in y ? y.field : "x"; + const valueField = x.field; + const rowField = y && "field" in y && typeof y.field === "string" ? y.field : "x"; const label = (side: "lo" | "hi") => ({ transform: [ { From 1eaafc8e884267f1bda8f12c37cd76af8d8c2ac5 Mon Sep 17 00:00:00 2001 From: "vitehub-bot[bot]" <320448255+vitehub-bot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:01:57 +0200 Subject: [PATCH 09/12] fix(charts): satisfy doctor field guards --- packages/charts/src/draw.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/charts/src/draw.ts b/packages/charts/src/draw.ts index ab173fb7..c3ae31b0 100644 --- a/packages/charts/src/draw.ts +++ b/packages/charts/src/draw.ts @@ -377,7 +377,7 @@ function ticks(low: number, high: number, width: number) { function dumbbell(chart: Chart, spec: TopLevelSpec) { if (!("layer" in spec) || !spec.encoding) return; const { x } = spec.encoding; - if (!x || !("field" in x) || typeof x.field !== "string") return; + if (!x || !("field" in x)) return; const rows = chart.rows.map((row) => row.values.filter((value) => value !== undefined)); const all = rows.flat().filter((value): value is number => Number.isFinite(value)); if (!all.length) return; @@ -431,8 +431,10 @@ function dumbbell(chart: Chart, spec: TopLevelSpec) { }; if (dots && "mark" in dots && dots.mark instanceof Object && dots.mark.type === "point") dots.mark = { ...dots.mark, size: 100 }; - const valueField = x.field; - const rowField = y && "field" in y && typeof y.field === "string" ? y.field : "x"; + // SAFETY: Vega-Lite field channels always carry a string field name when present. + const valueField = x.field as string; + // SAFETY: Vega-Lite row channels use the same string field-name contract. + const rowField = y && "field" in y ? (y.field as string) : "x"; const label = (side: "lo" | "hi") => ({ transform: [ { From bf03c1b1a3346ee0fa5590e1bb94d8b9e636ba11 Mon Sep 17 00:00:00 2001 From: "vitehub-bot[bot]" <320448255+vitehub-bot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:18:20 +0200 Subject: [PATCH 10/12] fix(charts): keep dumbbell endpoint labels inside plot --- packages/charts/src/draw.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/charts/src/draw.ts b/packages/charts/src/draw.ts index c3ae31b0..542d2f68 100644 --- a/packages/charts/src/draw.ts +++ b/packages/charts/src/draw.ts @@ -397,10 +397,10 @@ function dumbbell(chart: Chart, spec: TopLevelSpec) { for (const row of rows) { if (!row.length) continue; const least = Math.min(...row); - const need = (guess(format(least), 12) + 10) / width; + const need = (guess(format(least), 12) + 24) / width; if (need < 1) nextLow = Math.min(nextLow, (least - need * high) / (1 - need)); const most = Math.max(...row); - const highNeed = (guess(format(most), 12) + 10) / width; + const highNeed = (guess(format(most), 12) + 24) / width; if (highNeed < 1) nextHigh = Math.max(nextHigh, (most - highNeed * low) / (1 - highNeed)); } low = nextLow; From 6db54085e619df857ab2aa4954d28622c014ec4a Mon Sep 17 00:00:00 2001 From: "vitehub-bot[bot]" <320448255+vitehub-bot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 06:25:58 +0200 Subject: [PATCH 11/12] fix(charts): keep dumbbell edge padding on narrow plots --- packages/charts/src/draw.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/charts/src/draw.ts b/packages/charts/src/draw.ts index 542d2f68..a98ec12b 100644 --- a/packages/charts/src/draw.ts +++ b/packages/charts/src/draw.ts @@ -397,11 +397,11 @@ function dumbbell(chart: Chart, spec: TopLevelSpec) { for (const row of rows) { if (!row.length) continue; const least = Math.min(...row); - const need = (guess(format(least), 12) + 24) / width; - if (need < 1) nextLow = Math.min(nextLow, (least - need * high) / (1 - need)); + const need = Math.min(0.9, (guess(format(least), 12) + 24) / width); + nextLow = Math.min(nextLow, (least - need * high) / (1 - need)); const most = Math.max(...row); - const highNeed = (guess(format(most), 12) + 24) / width; - if (highNeed < 1) nextHigh = Math.max(nextHigh, (most - highNeed * low) / (1 - highNeed)); + const highNeed = Math.min(0.9, (guess(format(most), 12) + 24) / width); + nextHigh = Math.max(nextHigh, (most - highNeed * low) / (1 - highNeed)); } low = nextLow; high = nextHigh; From f76493508b06413179adaa3eff52c2b02801bb9c Mon Sep 17 00:00:00 2001 From: onmax Date: Fri, 23 Oct 2026 08:36:58 +0200 Subject: [PATCH 12/12] fix(charts): print a tied dumbbell end once and drop end labels that cannot fit Rows whose series tie matched the min filter once per series, so the same value was drawn twice on one dot. The edge padding was also solved with a stale opposite edge for three passes, which under-padded wide plots and, on plots narrowed by long row names, pushed the scale far past the data so the fallback ticks at the data's ends overlapped. The span is now iterated to its fixed point, and when the end labels would take more than 60% of the plot the table carries the numbers instead. --- packages/charts/src/draw.ts | 43 ++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/packages/charts/src/draw.ts b/packages/charts/src/draw.ts index a98ec12b..298c1afb 100644 --- a/packages/charts/src/draw.ts +++ b/packages/charts/src/draw.ts @@ -390,21 +390,35 @@ function dumbbell(chart: Chart, spec: TopLevelSpec) { high += room; values = ticks(low, high, width); } - // Endpoint labels sit just outside their dots, so leave enough scale room at either edge for the text. - for (let pass = 0; pass < 3; pass++) { - let nextLow = low; - let nextHigh = high; - for (const row of rows) { - if (!row.length) continue; + // Each row's least value is printed left of its dot and its most right of its dot, when the two differ, so the scale + // leaves each row room for its text, as a share of the span: the least sits `left` of the span in from the low + // edge, and the most `right` of it in from the high edge. + const ends = rows + .filter((row) => row.length) + .map((row) => { const least = Math.min(...row); - const need = Math.min(0.9, (guess(format(least), 12) + 24) / width); - nextLow = Math.min(nextLow, (least - need * high) / (1 - need)); const most = Math.max(...row); - const highNeed = Math.min(0.9, (guess(format(most), 12) + 24) / width); - nextHigh = Math.max(nextHigh, (most - highNeed * low) / (1 - highNeed)); + return { + least, + most, + left: (guess(format(least), 12) + 24) / width, + right: most > least ? (guess(format(most), 12) + 24) / width : 0, + }; + }); + // On a plot too narrow for the text, padding for it would squeeze the dots into a sliver, so where the text would + // take more than 60% of the plot, the table under the chart carries the numbers instead. + const printed = + Math.max(...ends.map((end) => end.left)) + Math.max(...ends.map((end) => end.right)) <= 0.6; + if (printed) { + // The span depends on the room, and the room on the span, so the span is found by iterating; with the shares at + // most 60%, each pass cuts the shortfall to at most 60% of what it was. + const [tickLow, tickHigh] = [low, high]; + for (let pass = 0; pass < 64; pass++) { + const span = high - low; + low = Math.min(tickLow, ...ends.map((end) => end.least - end.left * span)); + high = Math.max(tickHigh, ...ends.map((end) => end.most + end.right * span)); + if (high - low - span <= span * 1e-9) break; } - low = nextLow; - high = nextHigh; } x.scale = { domain: [low, high], nice: false, zero: false }; // Vega thins given values down to the tick count Flint sets, so the count is the values'. @@ -450,6 +464,9 @@ function dumbbell(chart: Chart, spec: TopLevelSpec) { ? `datum[${JSON.stringify(valueField)}] === datum.lo` : `datum[${JSON.stringify(valueField)}] === datum.hi && datum.hi > datum.lo`, }, + // Series that tie at a row's end share one dot, so the row prints that value once. + { window: [{ op: "row_number" as const, as: "nth" }], groupby: [rowField] }, + { filter: "datum.nth === 1" }, ], mark: { type: "text" as const, @@ -461,7 +478,7 @@ function dumbbell(chart: Chart, spec: TopLevelSpec) { }, encoding: { text: { field: valueField, type: "quantitative" as const, format: ",.12~g" } }, }); - spec.layer.push(label("lo"), label("hi")); + if (printed) spec.layer.push(label("lo"), label("hi")); } /** Draws a compiled Vega spec to an SVG string, and returns the view for finding where its marks sit. */