fix(studio): make chart values discoverable along dense lines

This commit is contained in:
xiexian committed 2026-10-07 21:09:38 +08:00
1 parent 9a6c6828e6
commit 1e0043d817
6 files changed
+130 -12

No files matched your search

+1
View File
@@ -1342,6 +1342,7 @@ export const EN: Record<string, string> = {
"打开「工具与集成」": "Open Tools and integrations",
"图表": "Chart",
"图表:{title}(可横向滚动)": "Chart: {title} (scrolls sideways)",
"悬停或聚焦图表以查看数值": "Hover or focus the chart to see values",
"没有可画的数据": "Nothing to draw",
"柱状图": "Bar chart",
"折线图": "Line chart",
+3 -2
View File
@@ -20,17 +20,18 @@
.chart .cs2 { --c: var(--cat-3) }
.chart .cs3 { --c: var(--cat-4) }
.chart .cs4 { --c: var(--cat-5) }
.chart .bar rect:first-child { fill: var(--c) }
.chart .bar rect:first-of-type { fill: var(--c) }
.chart .hatch-line { stroke: var(--surface); stroke-width: 2; opacity: .7 }
.chart .line path { fill: none; stroke: var(--c); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round }
.chart .line.sv1 path { stroke-dasharray: 7 4 }
.chart .line.sv2 path { stroke-dasharray: 2 4 }
.chart .line .line-hit { stroke: transparent; stroke-width: 12; stroke-dasharray: none; pointer-events: stroke }
.chart .line .pt { fill: var(--surface); stroke: var(--c); stroke-width: 2 }
.chart .slice { fill: var(--c); stroke: var(--surface); stroke-width: 2 }
.chart .slice.sv1 { fill-opacity: .72 }
.chart .slice.sv2 { fill-opacity: .5 }
.chart [tabindex]:focus-visible { outline: 2px solid var(--text); outline-offset: 2px }
.chart .bar:hover rect:first-child, .chart .slice:hover { filter: brightness(1.08) }
.chart .bar:hover rect:first-of-type, .chart .slice:hover { filter: brightness(1.08) }
.chart-key { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 8px 0 0; padding: 0; list-style: none; font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint) }
.chart-key li { display: inline-flex; align-items: center; gap: 6px; min-width: 0 }
.chart-key .nm { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
@@ -0,0 +1,98 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render } from "@testing-library/react";
import "../testkit";
import { ChartCard } from "./ChartCard";
import { SALES, withSpec } from "./fixtures";
import type { ChartSpec } from "./spec";
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
const hint = "悬停或聚焦图表以查看数值";
const draw = (spec: ChartSpec) => render(<ChartCard spec={spec} />).container;
const readout = (box: HTMLElement) => box.querySelector(".chart-read")?.textContent;
function move(path: Element, x: number) {
const svg = path.closest("svg")!;
vi.spyOn(svg, "getBoundingClientRect").mockReturnValue({ left: -100, width: 1280 } as DOMRect);
fireEvent.mouseMove(path, { clientX: -100 + x * 2 });
}
describe("chart value discovery", () => {
it("offers a visible hint, reads hovered bars, and restores the hint on leave", () => {
const box = draw(SALES);
expect(readout(box)).toBe(hint);
const bar = box.querySelector(".bar")!;
fireEvent.mouseEnter(bar);
expect(readout(box)).toBe("revenue · Jan · 10");
fireEvent.mouseLeave(bar);
expect(readout(box)).toBe(hint);
});
it("gives bars, line points and pie slices native titles matching their accessible values", () => {
for (const type of ["bar", "line", "pie"] as const) {
const box = draw(withSpec({ marks: [{ type, x: "month", y: ["revenue"] }], y_axis: { unit: "USD" } }));
const marks = box.querySelectorAll(type === "bar" ? ".bar" : type === "line" ? ".pt" : ".slice");
expect(marks).toHaveLength(3);
for (const mark of marks) {
expect(mark.querySelector("title")?.textContent).toBe(mark.getAttribute("aria-label"));
expect(mark.querySelector("title")?.textContent).toContain("USD");
}
cleanup();
}
});
it("reads dense lines along the path even when point markers are omitted", () => {
const rows = Array.from({ length: 61 }, (_, i) => [`day ${i}`, i]);
const box = draw(withSpec({ data: { ...SALES.data, rows }, marks: [{ type: "line", x: "month", y: ["revenue"] }] }));
expect(box.querySelector(".pt")).toBeNull();
const path = box.querySelector(".line-hit")!;
expect(path).not.toBeNull();
move(path, 330);
expect(readout(box)).toBe("revenue · day 30 · 30");
move(path, 20);
expect(readout(box)).toBe("revenue · day 0 · 0");
move(path, 630);
expect(readout(box)).toBe("revenue · day 60 · 60");
fireEvent.mouseLeave(path);
expect(readout(box)).toBe(hint);
});
it("uses numeric positions, the hovered series and only points in that continuous run", () => {
const box = draw(withSpec({
data: { columns: [{ name: "x", type: "number" }, { name: "a", type: "number" }, { name: "b", type: "number" }], rows: [[100, 10, 100], [0, 0, 0], [80, 8, 80], [20, null, 20]] },
marks: [{ type: "line", x: "x", y: ["a", "b"] }], y_axis: { unit: "kg" },
}));
const paths = box.querySelectorAll(".line-hit");
expect(paths).toHaveLength(3);
move(paths[1], 160);
expect(readout(box)).toBe("a · 80 · 8 kg");
move(paths[2], 160);
expect(readout(box)).toBe("b · 20 · 20 kg");
expect(box.querySelectorAll(".line path:not(.line-hit)")).toHaveLength(3);
fireEvent.focus(box.querySelector('.pt[aria-label="a · 100 · 10 kg"]')!);
expect(readout(box)).toBe("a · 100 · 10 kg");
fireEvent.blur(box.querySelector('.pt[aria-label="a · 100 · 10 kg"]')!);
expect(readout(box)).toBe(hint);
});
it("keeps an isolated point readable when a dense series has gaps", () => {
const rows = Array.from({ length: 61 }, (_, i) => [`day ${i}`, i === 30 ? 42 : null]);
const box = draw(withSpec({ data: { ...SALES.data, rows }, marks: [{ type: "line", x: "month", y: ["revenue"] }] }));
const path = box.querySelector(".line-hit")!;
expect(path).not.toBeNull();
move(path, 330);
expect(readout(box)).toBe("revenue · day 30 · 42");
});
it("keeps hostile title text inert and tolerates an empty line", () => {
const evil = "<img src=x onerror=alert(1)>";
const box = draw(withSpec({ data: { ...SALES.data, rows: [[evil, 10]] } }));
expect(box.querySelector(".bar title")?.textContent).toBe(`revenue · ${evil} · 10`);
expect(box.querySelector("img, script, [onerror]")).toBeNull();
cleanup();
const empty = draw(withSpec({ data: { ...SALES.data, rows: [] }, marks: [{ type: "line", x: "month", y: ["revenue"] }] }));
expect(empty.querySelector(".line-hit")).toBeNull();
expect(readout(empty)).toBe(hint);
});
});
@@ -84,7 +84,7 @@ describe("a chart card", () => {
it("breaks a line at a null instead of bridging it", () => {
const box = draw(series());
expect(box.querySelectorAll(".line path")).toHaveLength(2);
expect(box.querySelectorAll(".line path:not(.line-hit)")).toHaveLength(2);
});
it("shows the data as a real table on request and hides it again", async () => {
@@ -104,7 +104,7 @@ export function ChartCard({ spec, callId }: { spec: ChartSpec; callId?: string }
</div>
))}
{axes(spec) && <figcaption className="chart-axes">{axes(spec)}</figcaption>}
<div className="chart-read" aria-live="polite">{read ?? " "}</div>
<div className="chart-read" aria-live="polite">{read ?? t("悬停或聚焦图表以查看数值")}</div>
</figure>
<ChartTable spec={spec} id={`${uid}-data`} />
</div>
+26 -8
View File
@@ -92,6 +92,7 @@ export function BarPlot({ spec, mark, hot, hatch }: Frame) {
const label = `${s.label} · ${p.categories[i]} · ${formatValue(v, spec.y_axis)}`;
return (
<g key={`${k}:${i}`} className={`bar ${seriesClass(k)}`} {...pointProps(label, hot, focusable)}>
<title>{label}</title>
<rect x={x} y={Math.min(a, b)} width={Math.max(1, slot - 1)} height={Math.max(1, Math.abs(b - a))} />
{k >= 5 && <rect className="hatch" x={x} y={Math.min(a, b)} width={Math.max(1, slot - 1)} height={Math.max(1, Math.abs(b - a))} fill={`url(#${hatch})`} />}
</g>
@@ -123,22 +124,39 @@ export function LinePlot({ spec, mark, hot }: Frame) {
<svg viewBox={`0 0 ${W} ${H}`} className="chart-svg" style={{ minWidth: W }}>
<Axes y={y} left={left} spec={spec} />
{p.series.map((s, k) => {
const runs: string[] = [];
let run = "";
const runs: number[][] = [];
let run: number[] = [];
for (const i of order) {
const v = s.values[i];
if (v === null) { if (run) runs.push(run); run = ""; continue; }
run += `${run ? "L" : "M"}${xs[i].toFixed(1)} ${at(v).toFixed(1)}`;
if (v === null) { if (run.length) runs.push(run); run = []; continue; }
run.push(i);
}
if (run) runs.push(run);
if (run.length) runs.push(run);
return (
<g key={k} className={`line ${seriesClass(k)}`}>
{runs.map((d, r) => <path key={r} d={d} />)}
{runs.map((indices, r) => {
const d = indices.map((i, j) => `${j ? "L" : "M"}${xs[i].toFixed(1)} ${at(s.values[i]!).toFixed(1)}`).join("");
const hit = indices.length === 1 ? `${d}L${xs[indices[0]].toFixed(1)} ${at(s.values[indices[0]]!).toFixed(1)}` : d;
return (
<g key={r}>
<path d={d} />
<path className="line-hit" d={hit} aria-hidden="true" onMouseMove={(e) => {
const rect = e.currentTarget.ownerSVGElement!.getBoundingClientRect();
if (!rect.width) return;
const x = ((e.clientX - rect.left) / rect.width) * W;
const i = indices.reduce((best, next) => Math.abs(xs[next] - x) < Math.abs(xs[best] - x) ? next : best);
hot(`${s.label} · ${p.categories[i]} · ${formatValue(s.values[i]!, spec.y_axis)}`);
}} onMouseLeave={() => hot(null)}>
<title>{s.label}</title>
</path>
</g>
);
})}
{s.values.map((v, i) => {
if (v === null) return null;
const label = `${s.label} · ${p.categories[i]} · ${formatValue(v, spec.y_axis)}`;
return dots
? <circle key={i} className="pt" cx={xs[i]} cy={at(v)} r={3.4} {...pointProps(label, hot, true)} />
? <circle key={i} className="pt" cx={xs[i]} cy={at(v)} r={3.4} {...pointProps(label, hot, true)}><title>{label}</title></circle>
: null;
})}
</g>
@@ -176,7 +194,7 @@ export function PiePlot({ spec, mark, hot }: Frame) {
angle += (s.value / total) * TAU;
const label = `${s.label} · ${formatValue(s.value, spec.y_axis)} · ${pct(s.value / total, 1)}`;
return (
<path key={i} className={`slice ${seriesClass(i)}`} d={arc(W / 4, H / 2, r, mark.donut ? r * 0.58 : 0, a0, angle)} fillRule="evenodd" {...pointProps(label, hot, slices.length <= FOCUSABLE_MAX)} />
<path key={i} className={`slice ${seriesClass(i)}`} d={arc(W / 4, H / 2, r, mark.donut ? r * 0.58 : 0, a0, angle)} fillRule="evenodd" {...pointProps(label, hot, slices.length <= FOCUSABLE_MAX)}><title>{label}</title></path>
);
})}
</svg>