Skip to content
Closed
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
28 changes: 28 additions & 0 deletions component/src/charts/__tests__/base-chart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ vi.mock("echarts/components", () => ({
DataZoomComponent: vi.fn(),
AriaComponent: vi.fn(),
RadarComponent: vi.fn(),
MarkLineComponent: vi.fn(),
GraphicComponent: vi.fn(),
}));

describe("BaseChart", () => {
Expand Down Expand Up @@ -207,4 +209,30 @@ describe("BaseChart", () => {
{ notMerge: true },
);
});

// --- DataZoom ---

it("does not include dataZoom by default", () => {
render(<BaseChart options={{ title: { text: "Test" } }} />);
const call = mockSetOption.mock.calls[0][0];
expect(call.dataZoom).toBeUndefined();
});

it("injects dataZoom config when enableDataZoom is true", () => {
render(<BaseChart options={{ title: { text: "Test" } }} enableDataZoom />);
const call = mockSetOption.mock.calls[0][0];
expect(call.dataZoom).toBeDefined();
expect(Array.isArray(call.dataZoom)).toBe(true);
expect(call.dataZoom).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "inside" }),
]),
);
});

it("does not inject dataZoom when enableDataZoom is false", () => {
render(<BaseChart options={{ title: { text: "Test" } }} enableDataZoom={false} />);
const call = mockSetOption.mock.calls[0][0];
expect(call.dataZoom).toBeUndefined();
});
});
97 changes: 97 additions & 0 deletions component/src/charts/__tests__/tooltip-formatter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import { buildTooltipFormatter } from "../chart-utils";
import type { TooltipParam } from "../chart-utils";

describe("buildTooltipFormatter", () => {
it("returns a function", () => {
const formatter = buildTooltipFormatter();
expect(typeof formatter).toBe("function");
});

it("includes seriesName when provided", () => {
const formatter = buildTooltipFormatter();
const param: TooltipParam = {
seriesName: "Revenue",
value: 1234,
name: "Jan",
marker: '<span style="color:#3b82f6">●</span>',
};
const result = formatter(param);
expect(result).toContain("Revenue: ");
expect(result).toContain("Jan");
expect(result).toContain("1234");
});

it("omits seriesName label when seriesName is undefined", () => {
const formatter = buildTooltipFormatter();
const param: TooltipParam = {
value: 42,
name: "Category A",
};
const result = formatter(param);
expect(result).not.toContain("undefined");
expect(result).toContain("42");
expect(result).toContain("Category A");
});

it("omits seriesName label when seriesName is empty string", () => {
const formatter = buildTooltipFormatter();
const param: TooltipParam = {
seriesName: "",
value: 100,
name: "X",
};
const result = formatter(param);
expect(result).not.toContain(": <b>");
});

it("handles array params (axis trigger with multiple series)", () => {
const formatter = buildTooltipFormatter();
const params: TooltipParam[] = [
{ seriesName: "A", value: 100, name: "Jan", marker: "●" },
{ seriesName: "B", value: 200, name: "Jan", marker: "●" },
];
const result = formatter(params);
expect(result).toContain("A: ");
expect(result).toContain("B: ");
expect(result).toContain("100");
expect(result).toContain("200");
expect(result).toContain("Jan");
});

it("handles array value (e.g. scatter/candlestick)", () => {
const formatter = buildTooltipFormatter();
const param: TooltipParam = {
seriesName: "Data",
value: ["x", 999],
name: "Point",
};
const result = formatter(param);
expect(result).toContain("999");
});

it("handles missing value gracefully", () => {
const formatter = buildTooltipFormatter();
const param: TooltipParam = {
seriesName: "Series",
name: "Label",
};
const result = formatter(param);
expect(result).toContain("Series: ");
expect(result).toContain("<b></b>");
});

it("handles non-string marker gracefully", () => {
const formatter = buildTooltipFormatter();
const param: TooltipParam = {
seriesName: "Test",
value: 50,
name: "X",
marker: { type: "rich" } as unknown,
};
const result = formatter(param);
expect(result).toContain("Test: ");
expect(result).toContain("50");
expect(result).not.toContain("[object");
});
});
3 changes: 2 additions & 1 deletion component/src/charts/bar-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
resolveShowLegend,
buildCompactGrid,
resolveItemColor,
buildTooltipFormatter,
} from "./chart-utils";
import { parseColorThresholds } from "./color-threshold";
import type { StylingRule } from "./styling-rule";
Expand Down Expand Up @@ -98,7 +99,7 @@ function BarChart({
};

return {
tooltip: { trigger: "axis" as const, axisPointer: { type: "shadow" as const } },
tooltip: { trigger: "axis" as const, axisPointer: { type: "shadow" as const }, formatter: buildTooltipFormatter() },
legend: effectiveShowLegend ? { bottom: 0 } : undefined,
grid: buildCompactGrid(compact, effectiveShowLegend),
xAxis: isHorizontal ? valueAxis : categoryAxis,
Expand Down
15 changes: 14 additions & 1 deletion component/src/charts/base-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
DataZoomComponent,
AriaComponent,
RadarComponent,
MarkLineComponent,
GraphicComponent,
} from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
import type { EChartsOption } from "echarts";
Expand All @@ -35,6 +37,8 @@ echarts.use([
DataZoomComponent,
AriaComponent,
RadarComponent,
MarkLineComponent,
GraphicComponent,
CanvasRenderer,
]);

Expand Down Expand Up @@ -99,6 +103,7 @@ function BaseChart({
onChartReady,
onClick,
onDataZoom,
enableDataZoom = false,
colorblindMode = false,
colorPalette,
}: BaseChartProps) {
Expand Down Expand Up @@ -150,14 +155,22 @@ function BaseChart({
const merged: EChartsOption = {
color: resolvedColors,
...options,
...(enableDataZoom
? {
dataZoom: [
{ type: "inside", xAxisIndex: 0 },
{ type: "inside", yAxisIndex: 0 },
],
}
: {}),
aria: {
enabled: true,
...userAria,
decal: { show: colorblindMode, ...userDecal },
},
};
instance.setOption(merged, { notMerge: true });
}, [options, colorblindMode, colorPalette, dark]);
}, [options, enableDataZoom, colorblindMode, colorPalette, dark]);

// Loading state
useEffect(() => {
Expand Down
34 changes: 34 additions & 0 deletions component/src/charts/chart-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,37 @@ export function resolveItemColor(
}
return undefined;
}

// ---------------------------------------------------------------------------
// ECharts tooltip formatter
// ---------------------------------------------------------------------------

export interface TooltipParam {
seriesName?: string;
name?: string;
value?: unknown;
marker?: unknown;
}

/**
* Build an ECharts tooltip formatter function for axis-trigger tooltips.
* Conditionally includes the series name label only when it is present,
* preventing "undefined:" from appearing in tooltips.
*
* The return type uses `unknown` for the params argument so it is assignable
* to ECharts' `TooltipFormatterCallback<TopLevelFormatterParams>`.
*/
export function buildTooltipFormatter(): (params: unknown) => string {
return (params: unknown) => {
const items = Array.isArray(params) ? (params as TooltipParam[]) : [params as TooltipParam];
const header = items[0]?.name ?? "";
const lines = items.map((p) => {
const raw = Array.isArray(p.value) ? p.value[1] : p.value;
const val = String(raw ?? "");
const label = p.seriesName ? `${p.seriesName}: ` : "";
const marker = typeof p.marker === "string" ? p.marker : "";
return `${marker} ${label}<b>${val}</b>`;
});
return header ? `${header}<br/>${lines.join("<br/>")}` : lines.join("<br/>");
};
}
3 changes: 2 additions & 1 deletion component/src/charts/line-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
resolveShowLegend,
buildCompactGrid,
resolveItemColor,
buildTooltipFormatter,
} from "./chart-utils";
import { parseColorThresholds } from "./color-threshold";
import type { StylingRule } from "./styling-rule";
Expand Down Expand Up @@ -78,7 +79,7 @@ function LineChart({
const thresholds = stylingRules ? [] : parseColorThresholds(colorThresholds ?? "");

return {
tooltip: { trigger: "axis" },
tooltip: { trigger: "axis", formatter: buildTooltipFormatter() },
legend: effectiveShowLegend ? { bottom: 0 } : undefined,
grid: {
...buildCompactGrid(compact, effectiveShowLegend),
Expand Down
2 changes: 2 additions & 0 deletions component/src/charts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export interface BaseChartProps {
onClick?: (params: EChartsClickEvent) => void;
/** Called when data zoom changes */
onDataZoom?: (params: unknown) => void;
/** Enable scroll-to-zoom on the data axis (DataZoom type: 'inside') */
enableDataZoom?: boolean;
/** Enable decal overlay patterns for colorblind accessibility */
colorblindMode?: boolean;
/**
Expand Down
9 changes: 7 additions & 2 deletions component/src/components/composed/chart-options-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export interface ChartOptionDef {
description?: string;
}

/** DataZoom option for axis-based charts (bar, line). */
const dataZoomOptions: ChartOptionDef[] = [
{ key: "enableDataZoom", label: "Enable Scroll Zoom", type: "boolean", default: false, category: "Interaction", description: "Allow scroll-to-zoom on the data axis to explore large datasets." },
];

const barOptions: ChartOptionDef[] = [
{
key: "orientation",
Expand Down Expand Up @@ -364,8 +369,8 @@ const treemapOptions: ChartOptionDef[] = [
];

const chartOptionsRegistry: Record<string, ChartOptionDef[]> = {
bar: [...barOptions, ...behaviorOptions, ...appearanceOptions, ...accessibilityOptions],
line: [...lineOptions, ...behaviorOptions, ...appearanceOptions, ...accessibilityOptions],
bar: [...barOptions, ...dataZoomOptions, ...behaviorOptions, ...appearanceOptions, ...accessibilityOptions],
line: [...lineOptions, ...dataZoomOptions, ...behaviorOptions, ...appearanceOptions, ...accessibilityOptions],
pie: [...pieOptions, ...behaviorOptions, ...appearanceOptions, ...accessibilityOptions],
"single-value": [...singleValueOptions, ...behaviorOptions],
graph: [...graphOptions, ...behaviorOptions],
Expand Down
2 changes: 2 additions & 0 deletions component/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ vi.mock("echarts/components", () => ({
DataZoomComponent: vi.fn(),
AriaComponent: vi.fn(),
RadarComponent: vi.fn(),
MarkLineComponent: vi.fn(),
GraphicComponent: vi.fn(),
}));

vi.mock("echarts/renderers", () => ({
Expand Down
Loading