diff --git a/app/e2e/fixtures.ts b/app/e2e/fixtures.ts index 68f330f5..1c43c746 100644 --- a/app/e2e/fixtures.ts +++ b/app/e2e/fixtures.ts @@ -148,8 +148,9 @@ export async function typeInEditor( return; } - // Strategy 2: Keyboard fallback (for environments where cmView is not accessible) - if (dispatched === "no-view") { + // Strategy 2: Keyboard fallback (for environments where cmView is not accessible + // or when the view is temporarily readonly during initialization) + if (dispatched === "no-view" || dispatched === "readonly") { await expect(cm).toHaveAttribute("contenteditable", "true", { timeout: 2_000 }); await cm.click(); await page.keyboard.press("ControlOrMeta+a"); @@ -162,7 +163,7 @@ export async function typeInEditor( return; } - // Retry-worthy states: no-editor, readonly, dispatch-failed + // Retry-worthy states: no-editor, dispatch-failed throw new Error(`CM6 dispatch returned "${dispatched}" — retrying`); }).toPass({ timeout: 20_000 }); } diff --git a/component/src/charts/__tests__/base-chart.test.tsx b/component/src/charts/__tests__/base-chart.test.tsx index b2b37bbe..8514118a 100644 --- a/component/src/charts/__tests__/base-chart.test.tsx +++ b/component/src/charts/__tests__/base-chart.test.tsx @@ -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", () => { diff --git a/component/src/charts/__tests__/line-chart.test.tsx b/component/src/charts/__tests__/line-chart.test.tsx index dc3839c1..f6960d63 100644 --- a/component/src/charts/__tests__/line-chart.test.tsx +++ b/component/src/charts/__tests__/line-chart.test.tsx @@ -159,4 +159,31 @@ describe("LineChart", () => { const optionsCall = mockSetOption.mock.calls[0][0]; expect(optionsCall.series[0].step).toBeUndefined(); }); + + // --- Reference lines --- + + it("attaches markLine to the first series when referenceLines is provided", () => { + const refs = JSON.stringify([{ value: 50, label: "Target", color: "#ff0000" }]); + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.series[0].markLine).toBeDefined(); + expect(optionsCall.series[0].markLine.data).toHaveLength(1); + expect(optionsCall.series[0].markLine.data[0].yAxis).toBe(50); + expect(optionsCall.series[0].markLine.data[0].label.formatter).toBe("Target"); + expect(optionsCall.series[0].markLine.data[0].lineStyle.color).toBe("#ff0000"); + }); + + it("does not attach markLine when referenceLines is not provided", () => { + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.series[0].markLine).toBeUndefined(); + }); + + it("only attaches markLine to the first series in multi-series", () => { + const refs = JSON.stringify([{ value: 100 }]); + render(); + const optionsCall = mockSetOption.mock.calls[0][0]; + expect(optionsCall.series[0].markLine).toBeDefined(); + expect(optionsCall.series[1].markLine).toBeUndefined(); + }); }); diff --git a/component/src/charts/__tests__/reference-line.test.ts b/component/src/charts/__tests__/reference-line.test.ts new file mode 100644 index 00000000..996800e2 --- /dev/null +++ b/component/src/charts/__tests__/reference-line.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "vitest"; +import { parseReferenceLines, buildMarkLineFromRefs } from "../chart-utils"; +import type { ReferenceLine } from "../chart-utils"; + +describe("parseReferenceLines", () => { + it("returns empty array for undefined input", () => { + expect(parseReferenceLines(undefined)).toEqual([]); + }); + + it("returns empty array for empty string", () => { + expect(parseReferenceLines("")).toEqual([]); + }); + + it("parses a single horizontal reference line", () => { + const input = JSON.stringify([{ value: 50, label: "Target", color: "#ff0000" }]); + const result = parseReferenceLines(input); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ value: 50, label: "Target", color: "#ff0000" }); + }); + + it("parses multiple reference lines", () => { + const input = JSON.stringify([ + { value: 25, label: "Low" }, + { value: 75, label: "High", color: "#00ff00" }, + ]); + const result = parseReferenceLines(input); + expect(result).toHaveLength(2); + }); + + it("returns empty array for invalid JSON", () => { + expect(parseReferenceLines("not-json")).toEqual([]); + }); + + it("filters out entries without a value", () => { + const input = JSON.stringify([{ label: "No value" }, { value: 50, label: "OK" }]); + const result = parseReferenceLines(input); + expect(result).toHaveLength(1); + expect(result[0].value).toBe(50); + }); +}); + +describe("buildMarkLineFromRefs", () => { + it("returns undefined for empty array", () => { + expect(buildMarkLineFromRefs([])).toBeUndefined(); + }); + + it("builds markLine for a single reference line with defaults", () => { + const result = buildMarkLineFromRefs([{ value: 50 }]); + expect(result).toBeDefined(); + expect(result!.silent).toBe(true); + expect(result!.symbol).toBe("none"); + expect(result!.data).toHaveLength(1); + + const entry = result!.data[0]; + expect(entry.yAxis).toBe(50); + expect(entry.label.formatter).toBe("50"); + expect(entry.lineStyle.color).toBe("#888"); + expect(entry.lineStyle.type).toBe("dashed"); + }); + + it("uses the label text when provided", () => { + const result = buildMarkLineFromRefs([{ value: 75, label: "Target" }]); + expect(result!.data[0].label.formatter).toBe("Target"); + }); + + it("uses custom color when provided", () => { + const result = buildMarkLineFromRefs([{ value: 25, color: "#ff0000" }]); + expect(result!.data[0].lineStyle.color).toBe("#ff0000"); + }); + + it("falls back to default color #888 when color is omitted", () => { + const result = buildMarkLineFromRefs([{ value: 10 }]); + expect(result!.data[0].lineStyle.color).toBe("#888"); + }); + + it("builds markLine for multiple reference lines", () => { + const lines: ReferenceLine[] = [ + { value: 20, label: "Low", color: "#00ff00" }, + { value: 80, label: "High", color: "#ff0000" }, + ]; + const result = buildMarkLineFromRefs(lines); + expect(result!.data).toHaveLength(2); + expect(result!.data[0].yAxis).toBe(20); + expect(result!.data[0].label.formatter).toBe("Low"); + expect(result!.data[0].lineStyle.color).toBe("#00ff00"); + expect(result!.data[1].yAxis).toBe(80); + expect(result!.data[1].label.formatter).toBe("High"); + expect(result!.data[1].lineStyle.color).toBe("#ff0000"); + }); + + it("sets label position to insideEndTop", () => { + const result = buildMarkLineFromRefs([{ value: 42 }]); + expect(result!.data[0].label.position).toBe("insideEndTop"); + }); +}); + +describe("ReferenceLine type", () => { + it("accepts minimal reference line", () => { + const line: ReferenceLine = { value: 100 }; + expect(line.value).toBe(100); + }); +}); diff --git a/component/src/charts/bar-chart.tsx b/component/src/charts/bar-chart.tsx index 91f8e88b..9c92215d 100644 --- a/component/src/charts/bar-chart.tsx +++ b/component/src/charts/bar-chart.tsx @@ -9,6 +9,8 @@ import { resolveShowLegend, buildCompactGrid, resolveItemColor, + parseReferenceLines, + buildMarkLineFromRefs, } from "./chart-utils"; import { parseColorThresholds } from "./color-threshold"; import type { StylingRule } from "./styling-rule"; @@ -34,6 +36,8 @@ export interface BarChartProps extends Omit { xAxisLabel?: string; /** Y-axis name label */ yAxisLabel?: string; + /** JSON string of reference lines: [{ value, label?, color? }] */ + referenceLines?: string; /** @deprecated Use stylingRules instead. JSON string of thresholds for per-bar coloring */ colorThresholds?: string; /** Rule-based styling rules */ @@ -62,6 +66,7 @@ function BarChart({ showGridLines = true, xAxisLabel, yAxisLabel, + referenceLines: referenceLinesJson, colorThresholds, stylingRules, paramValues, @@ -79,6 +84,8 @@ function BarChart({ const effectiveShowValues = compact ? false : showValues; const effectiveBarWidth = barWidth > 0 ? barWidth : undefined; const thresholds = stylingRules ? [] : parseColorThresholds(colorThresholds ?? ""); + const refLines = parseReferenceLines(referenceLinesJson); + const markLine = buildMarkLineFromRefs(refLines); const categoryAxis = { type: "category" as const, @@ -103,7 +110,7 @@ function BarChart({ grid: buildCompactGrid(compact, effectiveShowLegend), xAxis: isHorizontal ? valueAxis : categoryAxis, yAxis: isHorizontal ? categoryAxis : valueAxis, - series: seriesKeys.map((key) => ({ + series: seriesKeys.map((key, idx) => ({ name: key, type: "bar" as const, data: data.map((d) => { @@ -121,9 +128,11 @@ function BarChart({ ? { show: true, position: isHorizontal ? ("right" as const) : ("top" as const) } : undefined, emphasis: seriesKeys.length > 1 ? { focus: "series" as const } : {}, + // Attach reference lines to the first series only + ...(idx === 0 && markLine ? { markLine } : {}), })), }; - }, [data, orientation, stacked, showValues, showLegend, barWidth, barGap, showGridLines, xAxisLabel, yAxisLabel, colorThresholds, stylingRules, paramValues, compact, hideLegend]); + }, [data, orientation, stacked, showValues, showLegend, barWidth, barGap, showGridLines, xAxisLabel, yAxisLabel, referenceLinesJson, colorThresholds, stylingRules, paramValues, compact, hideLegend]); return (
diff --git a/component/src/charts/base-chart.tsx b/component/src/charts/base-chart.tsx index 17589360..eb91db0b 100644 --- a/component/src/charts/base-chart.tsx +++ b/component/src/charts/base-chart.tsx @@ -9,6 +9,8 @@ import { DataZoomComponent, AriaComponent, RadarComponent, + MarkLineComponent, + GraphicComponent, } from "echarts/components"; import { CanvasRenderer } from "echarts/renderers"; import type { EChartsOption } from "echarts"; @@ -35,6 +37,8 @@ echarts.use([ DataZoomComponent, AriaComponent, RadarComponent, + MarkLineComponent, + GraphicComponent, CanvasRenderer, ]); diff --git a/component/src/charts/chart-utils.ts b/component/src/charts/chart-utils.ts index 7fc9ba74..49ae5724 100644 --- a/component/src/charts/chart-utils.ts +++ b/component/src/charts/chart-utils.ts @@ -4,6 +4,59 @@ import { resolveThresholdColor } from "./color-threshold"; import type { StylingRule } from "./styling-rule"; import { resolveStylingRuleColor } from "./styling-rule"; +// --------------------------------------------------------------------------- +// Reference lines (markLine) +// --------------------------------------------------------------------------- + +export interface ReferenceLine { + value: number; + label?: string; + color?: string; +} + +/** + * Parse a JSON string of reference lines. Returns empty array on + * invalid input or missing values. + */ +export function parseReferenceLines(input: string | undefined): ReferenceLine[] { + if (!input) return []; + try { + const parsed = JSON.parse(input); + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (item: unknown): item is ReferenceLine => + typeof item === "object" && + item !== null && + "value" in item && + typeof (item as ReferenceLine).value === "number", + ); + } catch { + return []; + } +} + +/** + * Build ECharts markLine data from reference lines. + */ +export function buildMarkLineFromRefs(lines: ReferenceLine[]) { + if (!lines.length) return undefined; + return { + silent: true, + symbol: "none", + data: lines.map((line) => ({ + yAxis: line.value, + label: { + formatter: line.label ?? String(line.value), + position: "insideEndTop" as const, + }, + lineStyle: { + color: line.color ?? "#888", + type: "dashed" as const, + }, + })), + }; +} + /** Detect whether the document is currently in dark mode. */ export function isDark(): boolean { if (typeof document === "undefined") return false; diff --git a/component/src/charts/line-chart.tsx b/component/src/charts/line-chart.tsx index 6db021d3..b4d83b10 100644 --- a/component/src/charts/line-chart.tsx +++ b/component/src/charts/line-chart.tsx @@ -9,6 +9,8 @@ import { resolveShowLegend, buildCompactGrid, resolveItemColor, + parseReferenceLines, + buildMarkLineFromRefs, } from "./chart-utils"; import { parseColorThresholds } from "./color-threshold"; import type { StylingRule } from "./styling-rule"; @@ -34,6 +36,8 @@ export interface LineChartProps extends Omit { showGridLines?: boolean; /** Use stepped line style */ stepped?: boolean; + /** JSON string of reference lines: [{ value, label?, color? }] */ + referenceLines?: string; /** @deprecated Use stylingRules instead. JSON string of thresholds */ colorThresholds?: string; /** Rule-based styling rules */ @@ -62,6 +66,7 @@ function LineChart({ lineWidth = 2, showGridLines = true, stepped = false, + referenceLines: referenceLinesJson, colorThresholds, stylingRules, paramValues, @@ -76,6 +81,8 @@ function LineChart({ const seriesKeys = Object.keys(data[0]).filter((k) => k !== "x"); const effectiveShowLegend = resolveShowLegend(showLegend, seriesKeys.length, hideLegend); const thresholds = stylingRules ? [] : parseColorThresholds(colorThresholds ?? ""); + const refLines = parseReferenceLines(referenceLinesJson); + const markLine = buildMarkLineFromRefs(refLines); return { tooltip: { trigger: "axis" }, @@ -100,7 +107,7 @@ function LineChart({ axisLabel: { show: !compact }, splitLine: { show: showGridLines }, }, - series: seriesKeys.map((key) => { + series: seriesKeys.map((key, idx) => { let lastValue: number | undefined; for (let i = data.length - 1; i >= 0; i -= 1) { const candidate = data[i][key]; @@ -123,10 +130,12 @@ function LineChart({ showSymbol: showPoints, areaStyle: area ? {} : undefined, emphasis: seriesKeys.length > 1 ? { focus: "series" as const } : {}, + // Attach reference lines to the first series only + ...(idx === 0 && markLine ? { markLine } : {}), }; }), }; - }, [data, xAxisLabel, yAxisLabel, smooth, area, showLegend, showPoints, lineWidth, showGridLines, stepped, colorThresholds, stylingRules, paramValues, compact, hideLegend]); + }, [data, xAxisLabel, yAxisLabel, smooth, area, showLegend, showPoints, lineWidth, showGridLines, stepped, referenceLinesJson, colorThresholds, stylingRules, paramValues, compact, hideLegend]); return (
diff --git a/component/src/components/composed/chart-options-schema.ts b/component/src/components/composed/chart-options-schema.ts index 384d21eb..bb228f0a 100644 --- a/component/src/components/composed/chart-options-schema.ts +++ b/component/src/components/composed/chart-options-schema.ts @@ -33,6 +33,7 @@ const barOptions: ChartOptionDef[] = [ { key: "xAxisLabel", label: "X-Axis Label", type: "text", default: "", category: "Labels", description: "Custom label displayed below the horizontal axis." }, { key: "yAxisLabel", label: "Y-Axis Label", type: "text", default: "", category: "Labels", description: "Custom label displayed beside the vertical axis." }, { key: "showGridLines", label: "Show Grid Lines", type: "boolean", default: true, category: "Style", description: "Show faint horizontal reference lines behind the bars." }, + { key: "referenceLines", label: "Reference Lines (JSON)", type: "text", default: "", category: "Annotations", description: 'Horizontal reference lines as JSON: [{"value":50,"label":"Target","color":"#ff0000"}]' }, ]; const lineOptions: ChartOptionDef[] = [ @@ -45,6 +46,7 @@ const lineOptions: ChartOptionDef[] = [ { key: "xAxisLabel", label: "X-Axis Label", type: "text", default: "", category: "Labels", description: "Custom label displayed below the horizontal axis." }, { key: "yAxisLabel", label: "Y-Axis Label", type: "text", default: "", category: "Labels", description: "Custom label displayed beside the vertical axis." }, { key: "showLegend", label: "Show Legend", type: "boolean", default: true, category: "Labels", description: "Show the chart legend identifying each data series." }, + { key: "referenceLines", label: "Reference Lines (JSON)", type: "text", default: "", category: "Annotations", description: 'Horizontal reference lines as JSON: [{"value":50,"label":"Target","color":"#ff0000"}]' }, ]; const pieOptions: ChartOptionDef[] = [ diff --git a/component/vitest.setup.ts b/component/vitest.setup.ts index f8443727..48e07097 100644 --- a/component/vitest.setup.ts +++ b/component/vitest.setup.ts @@ -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", () => ({