mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(studio): make chart values discoverable along dense lines
This commit is contained in:
1 parent
9a6c6828e6
commit
1e0043d817
6 files changed
+130
-12
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user