Skip to content
Draft
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
322 changes: 157 additions & 165 deletions frontend/src/apexcharts.ts

Large diffs are not rendered by default.

109 changes: 109 additions & 0 deletions frontend/src/chart_data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { PlotValue } from "./chart_series.ts";

export type DataPoint = {
name: string | number;
x: PlotValue;
y: PlotValue | PlotValue[];
color: PlotValue;
z: PlotValue | undefined;
link: string | undefined;
};

/** A row that draws a line across the chart instead of plotting a point. */
export type ReferenceLine = Record<
"xline" | "xline_end" | "yline" | "yline_end" | "label" | "color",
PlotValue
>;

/** One chart's properties, as `chart.handlebars` encodes them. */
export type ChartData = {
type: string;
time: boolean;
labels: boolean;
marker: number | undefined;
xtitle: string | undefined;
ytitle: string | undefined;
ztitle: string | undefined;
xticks: number | undefined;
yticks: number | undefined;
ystep: number | undefined;
xmin: number | undefined;
ymin: number | undefined;
xmax: number | undefined;
ymax: number | undefined;
toolbar: boolean;
show_legend: boolean;
logarithmic: boolean;
horizontal: boolean;
stacked: boolean;
colors: PlotValue[];
points: DataPoint[];
reference_lines: ReferenceLine[];
};

const plot_value = (value: unknown): PlotValue => {
if (value == null) return null;
if (typeof value === "number" || typeof value === "string") return value;
if (typeof value === "boolean") return Number(value);
return String(value);
};

const text_value = (value: unknown) =>
String(plot_value(value) ?? "") || undefined;

const number_value = (value: unknown) => {
const text = plot_value(value);
const number = text == null || text === "" ? Number.NaN : Number(text);
return Number.isFinite(number) ? number : undefined;
};

const data_point = ([name, x, y, color, z, link]: unknown[]): DataPoint => ({
name: plot_value(name) ?? "",
x: plot_value(x),
y: Array.isArray(y) ? y.map(plot_value) : plot_value(y),
color: plot_value(color),
// ApexCharts reads a point as three-dimensional as soon as it carries a z,
// so a row that never mentioned one must not carry a null.
z: z === undefined ? undefined : plot_value(z),
link: text_value(link),
});

const reference_line = (row: Record<string, unknown>): ReferenceLine => ({
xline: plot_value(row.xline),
xline_end: plot_value(row.xline_end),
yline: plot_value(row.yline),
yline_end: plot_value(row.yline_end),
label: plot_value(row.label),
color: plot_value(row.color),
});

export function read_chart_data(json: string | null): ChartData {
const data = JSON.parse(json ?? "");
const rows = Array.isArray(data.points) ? data.points : [];
return {
type: text_value(data.type) ?? "",
time: !!data.time,
labels: !!data.labels,
marker: number_value(data.marker),
xtitle: text_value(data.xtitle),
ytitle: text_value(data.ytitle),
ztitle: text_value(data.ztitle),
xticks: number_value(data.xticks),
yticks: number_value(data.yticks),
ystep: number_value(data.ystep),
xmin: number_value(data.xmin),
ymin: number_value(data.ymin),
xmax: number_value(data.xmax),
ymax: number_value(data.ymax),
toolbar: !!data.toolbar,
show_legend: data.show_legend == null || !!data.show_legend,
logarithmic: !!data.logarithmic,
horizontal: !!data.horizontal,
stacked: !!data.stacked,
colors: Array.isArray(data.colors) ? data.colors.map(plot_value) : [],
points: rows.filter(Array.isArray).map(data_point),
reference_lines: rows
.filter((row: unknown) => !Array.isArray(row))
.map(reference_line),
};
}
21 changes: 12 additions & 9 deletions frontend/src/chart_series.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
export type XValue = number | string | Date;
export type PlotValue = string | number | null;
export type XValue = PlotValue | Date;
export type ChartPoint = {
x: XValue;
y: number | string | number[] | null;
z?: number;
y: PlotValue | PlotValue[];
z?: PlotValue;
fillColor?: string;
link?: string;
};
export type ChartSeries = { name: string; data: ChartPoint[] };
export type Series = Map<string, ChartSeries>;
export type ChartSeries = { name: string | number; data: ChartPoint[] };
export type Series = Map<ChartSeries["name"], ChartSeries>;

const NUMERIC_X_CHART_TYPES = ["line", "area", "bar", "scatter", "bubble"];

Expand All @@ -21,8 +22,10 @@ const Y_WHEN_A_SERIES_SKIPS_A_LABEL = new Map<string, number | null>([
]);

/** equal x values share a key */
const x_key = (x: XValue): number | string =>
x instanceof Date ? x.getTime() : x;
const x_key = (x: XValue): PlotValue => (x instanceof Date ? x.getTime() : x);

/** A missing x sorts as zero, which is how JavaScript compares it. */
const is_lower = (x: XValue, than: XValue) => (x ?? 0) < (than ?? 0);

const x_is_text = (series: ChartSeries[]) =>
typeof series[0]?.data?.[0]?.x === "string";
Expand Down Expand Up @@ -50,11 +53,11 @@ export function xaxis_type_for(
*/
export function merged_x_values(series: ChartSeries[]): XValue[] {
const unread = series.map(({ data }) => data.map(({ x }) => x));
const merged = new Map();
const merged = new Map<PlotValue, XValue>();
while (unread.some((xs) => xs.length > 0)) {
const with_lowest_x = unread
.filter((xs) => xs.length > 0)
.reduce((a, b) => (b[0] < a[0] ? b : a));
.reduce((a, b) => (is_lower(b[0], a[0]) ? b : a));
const x = with_lowest_x.shift() as XValue;
merged.set(x_key(x), x);
}
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/globals.d.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
// Names the browser bundle relies on at runtime rather than through an import.
// What the bundle installs on the page at runtime rather than through an
// import: the page's own scripts and the browser tests read these back.

interface Window {
/** Every chart rendered on the page, in the order they were built. */
charts?: unknown[];
charts?: import("./apexcharts.ts").RenderedChart[];
/** A Bootstrap a page loaded for itself, preferred over the bundled copy. */
bootstrap?: typeof import("@tabler/core").bootstrap;
}

interface HTMLElement {
/** Attached by sqlpage_select_dropdown to every select it takes over. */
tomselect?: import("tom-select/popular").default;
}
2 changes: 1 addition & 1 deletion tests/end-to-end/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,5 @@ export const test = base.extend({
},
});

export type { Page } from "@playwright/test";
export type { ConsoleMessage, Page } from "@playwright/test";
export { expect };
2 changes: 2 additions & 0 deletions tests/end-to-end/fixtures/chart/labeled-pie.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'pie' AS type, TRUE AS labels;
WITH points(label, value) AS (VALUES ('Yes', 65), ('No', 35)) SELECT * FROM points;
3 changes: 3 additions & 0 deletions tests/end-to-end/fixtures/chart/labeled-range-bar.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
SELECT 'chart' AS component, 'test-chart' AS id, 'Chart test fixture' AS title, 'rangeBar' AS type, TRUE AS time, TRUE AS labels;
SELECT 'Design' AS series, 'Alice' AS label, '2024-03-01' AS value, '2024-03-05' AS value;
SELECT 'Build' AS series, 'Bob' AS label, '2024-03-04' AS value, '2024-03-09' AS value;
81 changes: 42 additions & 39 deletions tests/end-to-end/fixtures/chart/test.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,4 @@
import { expect, type Page, test } from "../../fixture.ts";

type ChartPoint = { x: string | number | Date; y: number | null };

declare global {
interface Window {
charts?: {
w: {
config: {
chart: { type: string; stacked: boolean };
xaxis: { type?: string; tickAmount?: number };
series: { name: string | number; data?: ChartPoint[] }[];
tooltip: {
custom?: (args: {
seriesIndex: number;
dataPointIndex: number;
w: unknown;
}) => string;
};
};
globals: { labels: (string | number)[] };
};
}[];
}
}
import { type ConsoleMessage, expect, type Page, test } from "../../fixture.ts";

const MARKS =
".apexcharts-bar-area, .apexcharts-rangebar-area, .apexcharts-treemap-rect, .apexcharts-pie-area, .apexcharts-heatmap-rect, .apexcharts-series .apexcharts-marker";
Expand All @@ -32,7 +8,7 @@ const ORANGE = "#f76707";
const GREEN = "#37b24d";
async function renderChart(page: Page, fixture: string) {
const failures: string[] = [];
const recordError = (message: { type(): string; text(): string }) => {
const recordError = (message: ConsoleMessage) => {
if (message.type() === "error") failures.push(message.text());
};
page.on("console", recordError);
Expand All @@ -46,13 +22,15 @@ async function renderChart(page: Page, fixture: string) {
const container = document.getElementById("test-chart");
if (!container) throw new Error("Chart fixture did not render");
const rendered = window.charts?.[0];
const series = (rendered?.w.config.series ?? []).map((s) => ({
name: s.name,
points: (s.data ?? []).map((p) => [
p.x instanceof Date ? p.x.toISOString() : p.x,
p.y,
]),
}));
const series = (rendered?.w.config.series ?? [])
.filter((s) => typeof s !== "number")
.map((s) => ({
name: s.name,
points: s.data.map((p) => [
p.x instanceof Date ? p.x.toISOString() : p.x,
p.y,
]),
}));
const drawnPerSeries = series.map(({ name }) => {
const markers = [
...container.querySelectorAll<SVGGraphicsElement>(
Expand Down Expand Up @@ -122,9 +100,12 @@ async function renderChart(page: Page, fixture: string) {
tickAmount: rendered?.w.config.xaxis.tickAmount ?? null,
},
generatedLabels: rendered?.w.globals.labels ?? [],
threeDimensional: rendered?.w.globals.isDataXYZ ?? null,
axisLabels,
dataLabels: [
...container.querySelectorAll(".apexcharts-datalabel"),
...container.querySelectorAll(
".apexcharts-datalabel, .apexcharts-pie-label",
),
].map((label) => label.textContent),
barGroups,
series,
Expand Down Expand Up @@ -407,6 +388,16 @@ for (const type of ["area", "scatter", "heatmap"]) {
});
}

test("counts a third dimension only where the rows carried one", async ({
page,
}) => {
const flat = await renderChart(page, "index");
const bubbles = await renderChart(page, "bubble-categories");

expect(flat.threeDimensional).toBe(false);
expect(bubbles.threeDimensional).toBe(true);
});

test("keeps the bubble size of the points it lined up", async ({ page }) => {
const chart = await renderChart(page, "bubble-categories");

Expand Down Expand Up @@ -455,6 +446,22 @@ test("gives the tooltip title the color of the tooltip around it", async ({
expect(colors.title).toBe(colors.tooltip);
});

test("names the series of every bar of a range bar chart", async ({ page }) => {
const chart = await renderChart(page, "labeled-range-bar");

expect(chart.failures).toEqual([]);
expect(chart.dataLabels).toEqual(["Design", "Build"]);
});

test("gives every slice of a pie chart its label and its share", async ({
page,
}) => {
const chart = await renderChart(page, "labeled-pie");

expect(chart.failures).toEqual([]);
expect(chart.dataLabels).toEqual(["Yes: 65%", "No: 35%"]);
});

test("draws a reference line that carries no label", async ({ page }) => {
const chart = await renderChart(page, "unlabeled-reference-lines");

Expand Down Expand Up @@ -601,11 +608,7 @@ test("labels each axis of a bubble tooltip with its own title", async ({
const custom = chart.w.config.tooltip.custom;
if (!custom) throw new Error("A bubble chart needs the custom tooltip");
const holder = document.createElement("div");
holder.innerHTML = custom({
seriesIndex: 0,
dataPointIndex: 1,
w: chart.w,
});
holder.innerHTML = custom({ seriesIndex: 0, dataPointIndex: 1 });
const values = holder.querySelectorAll(".apexcharts-tooltip-text-y-value");
return [...holder.querySelectorAll(".apexcharts-tooltip-text-y-label")].map(
(label, i): [string, string] => [
Expand Down
4 changes: 2 additions & 2 deletions tests/end-to-end/fixtures/map/test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, type Page, test } from "../../fixture.ts";
import { type ConsoleMessage, expect, type Page, test } from "../../fixture.ts";

const PARIS_WITHOUT_ITS_LONGITUDE = "48.85,";
const NOT_COORDINATES = "somewhere nice";
Expand All @@ -7,7 +7,7 @@ async function renderMap(page: Page, fixture: string, markerCount = 0) {
const errors: string[] = [];
const logged: string[] = [];
const recordPageError = (error: Error) => errors.push(error.message);
const recordConsoleError = (message: { type(): string; text(): string }) => {
const recordConsoleError = (message: ConsoleMessage) => {
if (message.type() === "error") logged.push(message.text());
};
page.on("pageerror", recordPageError);
Expand Down
7 changes: 0 additions & 7 deletions tests/end-to-end/globals.d.ts

This file was deleted.

2 changes: 1 addition & 1 deletion tests/end-to-end/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
"moduleResolution": "nodenext",
"types": ["node"]
},
"include": ["**/*.ts"]
"include": ["**/*.ts", "../../frontend/src/globals.d.ts"]
}
18 changes: 7 additions & 11 deletions tests/js/chart_series.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import test from "node:test";
import {
align_series,
align_series_for,
type ChartPoint,
type ChartSeries,
merged_x_values,
xaxis_type_for,
} from "../../frontend/src/chart_series.ts";
Expand All @@ -12,17 +14,11 @@ const LEAVES_A_GAP = null;
const STACKED = true;
const UNSTACKED = false;

type XValue = number | string | Date;
type Point = {
x: XValue;
y: number | string | null | number[];
z?: number;
fillColor?: string;
};
type Series = { name: string; data: Point[] };

const series = (name: string, ...data: Point[]): Series => ({ name, data });
const xs = (s: Series) => s.data.map((p) => p.x);
const series = (name: string, ...data: ChartPoint[]): ChartSeries => ({
name,
data,
});
const xs = (s: ChartSeries) => s.data.map((p) => p.x);

test("uses a continuous axis for numeric Cartesian x values", () => {
const numeric = [series("a", { x: 1, y: 1 }, { x: 12, y: 12 })];
Expand Down
1 change: 0 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
"noUnusedParameters": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"noImplicitAny": false,
"types": [],
"noImplicitReturns": true
},
Expand Down
Loading