Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 138 additions & 2 deletions packages/charts/src/draw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -346,6 +347,140 @@ 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().filter((value): value is number => Number.isFinite(value));
if (!all.length) return;
const width = Number(spec.config?.view?.continuousWidth) || layout(chart).width;
let 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);
Comment thread
pullfrog[bot] marked this conversation as resolved.
low -= room;
high += room;
values = ticks(low, high, width);
}
// 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 most = Math.max(...row);
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;
}
}
x.scale = { domain: [low, high], nice: false, zero: false };
Comment thread
pullfrog[bot] marked this conversation as resolved.
// 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 };
// 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: [
{
joinaggregate: [
{ op: "min" as const, field: valueField, as: "lo" },
{ op: "max" as const, field: valueField, as: "hi" },
],
groupby: [rowField],
},
{
filter:
side === "lo"
? `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,
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: valueField, type: "quantitative" as const, format: ",.12~g" } },
});
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. */
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
Expand Down Expand Up @@ -573,7 +708,7 @@ export async function draw(chart: Chart): Promise<{ svg: string; boxes: Map<numb
// Flint pads the value axis for the labels from levels that add the end total to the sum it closes, so it runs
// to twice the top and below zero. The levels here stop at the end total, and only a side with a label is padded.
const levels = [0];
for (const [index, row] of chart.rows.entries()) {
for (const [index] of chart.rows.entries()) {
const value = values[index];
const last = levels.at(-1)!;
levels.push(
Expand Down Expand Up @@ -610,6 +745,7 @@ export async function draw(chart: Chart): Promise<{ svg: string; boxes: Map<numb
spec.layer.push({ mark: { type: "point", opacity: 0 }, encoding });
}
}
if (chart.type === "dumbbell") dumbbell(chart, spec);
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));
Expand Down
43 changes: 43 additions & 0 deletions packages/charts/test/charts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ describe("comark-charts", () => {
expect(dumbbell).toContain('data-type="dumbbell"');
expect(dumbbell).toContain(">before</text>");
expect(dumbbell).toContain(">after</text>");
// 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(
`
Expand All @@ -119,6 +121,47 @@ describe("comark-charts", () => {
expect(waterfall.split(`>${value}</text>`)).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}"[^>]*>(.*?)</g>`, "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</text>");
// A grid line at every tick and a guide along every row.
const [columns, rows] = group("axis-grid");
expect(columns!.match(/<line/g)).toHaveLength(8);
expect(rows!.match(/<line[^>]*stroke-width="1"/g)).toHaveLength(4);
const printed = [...html.matchAll(/class="mark-text role-mark[^"]*"[^>]*>(.*?)<\/g>/gs)]
.flatMap(([, inside]) => [...inside!.matchAll(/<text[^>]*>([^<]+)<\/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(`
Expand Down
Loading