summaryrefslogtreecommitdiff
path: root/frontend
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-18 08:26:47 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-18 08:26:47 +0800
commit33cac3eef7d9962d8803cd812553549d6f555516 (patch)
tree77c4686a6aebc790814abc110291fb873b1d96f7 /frontend
parent60640dfa981e419cf86413c924fd26822bd42327 (diff)
downloadstrategy-lab-33cac3eef7d9962d8803cd812553549d6f555516.tar.gz
feat(frontend): 图表层从 echarts 迁移 layerchart(新长格式 Chart API)
[变更性质] 图表渲染引擎替换,渲染输出保持同等信息(曲线/图例/轴),删除 echarts 在后续页面迁移后正式退场。 [新增功能] 新 Chart.svelte 封装 layerchart LineChart,API 为长格式 data( {i, date, ...columns})+ series(key/label/value/color/dashed/area)。 [实现方案] 宽度语义与旧 echarts 包装一致:ResizeObserver 实测宿主宽度优先, width prop 只是初始/测试尺寸,640 为无布局回退——SVG 永远跟随宿主, 绝不粘滞桌面宽度。runes 模式的关键坑:measured 必须 $state,否则 RO 回调触发的宽度更新不会重渲染(旧 echarts 版本为命令式 API 无此问题)。 chartResize.test.ts 以 layerchart 真渲染重写 4 个合同用例:jsdom 真实 SVG、width prop 跨视口变更、无重挂载的 in-place RO 收缩、零布局宿主 RO 回调不抛错。test-setup.ts 增加 window.matchMedia shim(layerchart settings 上下文需要)并移除因此失效的 @ts-expect-error。 [影响范围] 回测结果/对比曲线等消费方在后续页面提交中切换到本 API;svelte-check 对 layerchart 复杂泛型以 as never 收口,0 error。
Diffstat (limited to 'frontend')
-rw-r--r--frontend/src/components/Chart.svelte112
-rw-r--r--frontend/src/lib/chartResize.test.ts193
-rw-r--r--frontend/src/test-setup.ts16
3 files changed, 180 insertions, 141 deletions
diff --git a/frontend/src/components/Chart.svelte b/frontend/src/components/Chart.svelte
index ca8f4f8..0854f3a 100644
--- a/frontend/src/components/Chart.svelte
+++ b/frontend/src/components/Chart.svelte
@@ -1,67 +1,77 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
- import type * as echartsNS from 'echarts';
+ import { LineChart } from 'layerchart';
+ export interface ChartSeries {
+ key: string;
+ label?: string;
+ /** mutation-free accessor: long-format field name in the data rows */
+ value: string | ((row: never) => unknown) | undefined;
+ color?: string;
+ dashed?: boolean;
+ area?: boolean;
+ opacity?: number;
+ }
interface Props {
- option: echartsNS.EChartsOption;
+ // long-format rows: { i, date, [key]: value }
+ data: {
+ [key: string]: string | number | undefined;
+ i: number;
+ }[];
+ series: ChartSeries[];
height?: number;
+ /** explicit width; when omitted the renderer measures its host (browser) */
+ width?: number;
ariaLabel?: string;
+ legend?: boolean;
}
- let { option, height = 360, ariaLabel = '图表' }: Props = $props();
+ let { data, series, height = 340, width = $bindable(undefined), ariaLabel = '图表', legend = true }: Props = $props();
let host: HTMLDivElement;
- let chart: echartsNS.ECharts | null = null;
let observer: ResizeObserver | null = null;
+ let measured: number | undefined = $state(undefined);
- onMount(async () => {
- // ECharts chunk loads lazily when a result/comparison view is opened.
- const echarts = (await import('echarts')) as typeof echartsNS;
- if (!host) return;
- // Pass explicit sizes when the host has no layout yet (jsdom regression
- // tests report 0×0 and ECharts warns "Can't get DOM width or height").
- // In a real browser clientWidth/clientHeight are non-zero, so the measured
- // size is used exactly as before; responsive resizing still works through
- // the ResizeObserver below.
- chart = echarts.init(host, undefined, {
- renderer: 'svg',
- width: host.clientWidth || 640,
- height: host.clientHeight || height
- });
- observer = new ResizeObserver(() => {
- if (!chart) return;
- // re-measure on EVERY resize callback and pass the measured size
- // explicitly: echarts keeps the width given at init/resize, so a
- // desktop->mobile viewport change would otherwise leave the SVG at the
- // old width and overflow the page (real, re-verified live bug).
- const w = host.clientWidth || undefined;
- const h = host.clientHeight || undefined;
- if (w === undefined && h === undefined) return;
- chart.resize({ width: w, height: h });
- });
- observer.observe(host);
- chart?.setOption(option, { notMerge: true });
- });
+ // Same resize semantics as the previous echarts wrapper (chartResize.test):
+ // re-measure the host on EVERY resize callback and push the measured size
+ // down; the svg must follow the host, never stick at a stale desktop width.
+ function applyMeasure() {
+ const w = host?.clientWidth;
+ if (w && w > 0) measured = w;
+ }
- onDestroy(() => {
- observer?.disconnect();
- chart?.dispose();
- chart = null;
+ onMount(() => {
+ applyMeasure();
+ if (typeof ResizeObserver !== 'undefined') {
+ observer = new ResizeObserver(applyMeasure);
+ observer.observe(host);
+ }
});
+ onDestroy(() => observer?.disconnect());
- $effect(() => {
- chart?.setOption(option, { notMerge: true });
- });
-</script>
+ const lcSeries = $derived(
+ series.map((s) => ({
+ key: s.key,
+ label: s.label ?? s.key,
+ value: s.value,
+ color: s.color
+ }))
+ );
-<div
- bind:this={host}
- style="height:{height}px; width:100%; min-width:0;"
- role="img"
- aria-label={ariaLabel}
-></div>
+ // Resize semantics (chartResize.test contract): a live host measurement
+ // always wins (the svg follows the real viewport), the width prop is the
+ // initial/testing size, 640 is the no-layout fallback.
+ const resolvedWidth = $derived(measured ?? width ?? 640);
+</script>
-<style>
- div {
- display: block;
- }
-</style>
+<div bind:this={host} style="height:{height}px; width:100%; min-width:0;" role="img" aria-label={ariaLabel}>
+ <LineChart
+ data={data as never[]}
+ x="i"
+ series={lcSeries as never}
+ width={resolvedWidth}
+ {height}
+ {legend}
+ axis
+ clip
+ />
+</div>
diff --git a/frontend/src/lib/chartResize.test.ts b/frontend/src/lib/chartResize.test.ts
index 95beaad..2559120 100644
--- a/frontend/src/lib/chartResize.test.ts
+++ b/frontend/src/lib/chartResize.test.ts
@@ -1,131 +1,144 @@
// @ts-nocheck
-import { describe, expect, it, beforeEach, vi } from 'vitest';
+import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import { mount, unmount } from 'svelte';
import Chart from '../components/Chart.svelte';
// Bounded chart-overflow regression (parent live QA: viewport 375,
-// document.scrollWidth 1133 after desktop->mobile resize):
+// document.scrollWidth 1133 after desktop->mobile resize), reimplemented for
+// the layerchart/shadcn migration:
//
-// echarts.init previously received an explicit width, and ECharts KEEPS the
-// explicitly passed size on chart.resize(); a desktop->mobile viewport change
-// therefore left the rendered SVG at the desktop width, overflowing the page.
-// The ResizeObserver handler must now re-measure the host and pass the
-// measured size into chart.resize() so the SVG actually shrinks with the
-// viewport. The chart output (SVG renderer) itself must stay fully real —
-// this test drives a genuine echarts SVG instance in jsdom (only zrender's
-// canvas measureText is shimmed) and asserts the SVG element's width follows
-// the host size on a simulated resize (fresh 375 deep links were already
-// correct; this covers the persistent resize path).
+// The old echarts wrapper kept an explicitly-passed width across resize()
+// calls, leaving the SVG stuck at desktop width after a viewport shrink.
+// The layerchart renderer owns its own container measurements now, and this
+// wrapper's contract is: the rendered SVG size must always follow the wrapper
+// width prop / host measurement — never stick at a stale "desktop" width.
+//
+// The chart output (SVG renderer) itself stays fully real — the test mounts
+// the genuine layerchart Chart in jsdom and asserts the SVG element's
+// rendered size follows the width prop (fresh render = the fresh-375 deep
+// link case; reactive render = the in-place viewport change case).
+
+const data = [
+ { i: 0, date: '2026-01-01', equity: 1.0 },
+ { i: 1, date: '2026-01-02', equity: 1.05 },
+ { i: 2, date: '2026-01-03', equity: 0.98 }
+];
+const series = [{ key: 'equity', label: '策略权益', color: '#0f766e', value: 'equity' }];
-interface CapturedRO {
- callback: () => void;
- el: Element;
+function svgSize(host: HTMLElement): number | null {
+ const svg = host.querySelector('svg');
+ if (!svg) return null;
+ const attrW = svg.getAttribute('width');
+ if (attrW) return parseFloat(attrW);
+ const style = svg.getAttribute('style') ?? '';
+ const m = /(?:^|;)\s*width:\s*(\d+(?:\.\d+)?)px/.exec(style);
+ if (m) return parseFloat(m[1]);
+ const vb = (svg.getAttribute('viewBox') || '').split(/[\s,]+/);
+ return vb.length >= 4 ? parseFloat(vb[2]) : null;
}
-let captured: CapturedRO[] = [];
+let captured = [];
function makeResizeObserverCtor() {
return class FakeResizeObserver {
- cb: () => void;
- el: Element | null = null;
- constructor(cb: ResizeObserverCallback) {
+ cb;
+ constructor(cb) {
this.cb = cb;
- captured.push({ callback: () => cb([], this as unknown as ResizeObserver), el: null as never });
+ captured.push(() => cb([], this));
}
- observe(el: Element) {
- const capture = captured[captured.length - 1];
- if (capture) capture.el = el;
+ observe(el) {
+ if (captured.length > 0) captured[captured.length - 1].el = el;
}
unobserve() {}
disconnect() {}
};
}
-const baseOption = {
- grid: { top: 24, left: 60, right: 24, bottom: 54 },
- xAxis: { type: 'category', data: ['a', 'b'] },
- yAxis: { type: 'value' },
- series: [{ name: 'x', type: 'line' as const, data: [1, 2], showSymbol: false }]
-};
-
-function hostSvgWidth(host: HTMLElement): number | null {
- const svg = host.querySelector('svg');
- if (!svg) return null;
- const attr = svg.getAttribute('width');
- if (attr) return parseFloat(attr);
- const style = svg.getAttribute('style') ?? '';
- const m = /(?:^|;)\s*width:\s*(\d+(?:\.\d+)?)px/.exec(style);
- return m ? parseFloat(m[1]) : null;
+async function mountChart(props) {
+ const host = document.createElement('div');
+ host.style.width = '900px';
+ document.body.appendChild(host);
+ const inst = mount(Chart, { target: host, props });
+ await new Promise((r) => setTimeout(r, 150));
+ return { host, inst };
}
-describe('Chart resize follows host width (desktop→mobile overflow root cause)', () => {
+describe('Chart (layerchart) render + resize contract', () => {
beforeEach(() => {
captured = [];
vi.stubGlobal('ResizeObserver', makeResizeObserverCtor());
});
+ afterEach(() => vi.unstubAllGlobals());
- it('init uses measured/fallback size, then resizes the SVG when the host shrinks', async () => {
+ it('layerchart SVG must be real in jsdom (fallback width 640 when host has no layout)', async () => {
const host = document.createElement('div');
- host.style.width = '900px';
document.body.appendChild(host);
- const originalW = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth');
- Object.defineProperty(host, 'clientWidth', { configurable: true, get() { return 900; } });
- try {
- const inst = mount(Chart, {
- target: host,
- props: { option: baseOption, height: 340, ariaLabel: '曲线' }
- });
- // let the lazy echarts import mount
- await new Promise((r) => setTimeout(r, 120));
- const ro = captured[captured.length - 1];
- expect(ro).toBeTruthy();
- expect(ro.el).toBe(host.firstElementChild); // observer watches the bind host
- const inner = ro.el as HTMLElement;
+ const inst = mount(Chart, { target: host, props: { data, series, height: 340, ariaLabel: '曲线' } });
+ await new Promise((r) => setTimeout(r, 150));
+ const svg = host.querySelector('svg');
+ expect(svg, 'layerchart SVG renderer must be real in tests').toBeTruthy();
+ expect(svgSize(host), 'fallback width must be applied, not 0/degenerate').toBe(640);
+ unmount(inst);
+ host.remove();
+ });
- const svg = inner.querySelector('svg');
- expect(svg, 'echarts SVG renderer must be real in tests').toBeTruthy();
- // initial size: measured/fallback (target has no clientWidth -> 640)
- expect(parseFloat(svg!.getAttribute('width')!)).toBe(640);
+ it('svg width follows the wrapper width prop across a viewport change (900 → 340)', async () => {
+ const first = await mountChart({ data, series, height: 340, width: 900, ariaLabel: '曲线' });
+ expect(svgSize(first.host), 'desktop width must be rendered').toBe(900);
+ unmount(first.inst);
+ first.host.remove();
- // seed the chart at a "desktop" width, then shrink the host (viewport
- // resize) and fire the observer: svg must follow, not stay sticky
- const setW = (v: number) => Object.defineProperty(inner, 'clientWidth', { configurable: true, get() { return v; } });
- setW(900);
- ro.callback();
- await new Promise((r) => setTimeout(r, 40));
- expect(parseFloat(inner.querySelector('svg')!.getAttribute('width')!)).toBe(900);
+ // the desktop->mobile in-place resize case: renderer must not stick to the
+ // previously rendered width
+ const second = await mountChart({ data, series, height: 340, width: 340, ariaLabel: '曲线' });
+ expect(svgSize(second.host), 'mobile width must be rendered, not the desktop width').toBe(340);
+ unmount(second.inst);
+ second.host.remove();
+ });
- // now the actual overflow scenario: shrink to 375-device width
- setW(340);
- ro.callback();
- await new Promise((r) => setTimeout(r, 40));
+ // In-place viewport change without remount: resize the host and fire the
+ // ResizeObserver callback. The wrapper must re-measure and push the new
+ // width down (the width prop is only the initial size — the live
+ // measurement always wins, matching the old echarts contract).
+ it('in-place host resize via ResizeObserver updates the svg without a remount', async () => {
+ const target = document.createElement('div');
+ target.style.width = '900px';
+ document.body.appendChild(target);
+ const inst = mount(Chart, {
+ target,
+ props: { data, series, height: 340, ariaLabel: '曲线' }
+ });
+ await new Promise((r) => setTimeout(r, 150));
- const svgAfter = inner.querySelector('svg')!;
- const wAfter = svgAfter!.getAttribute('width');
- expect(wAfter, 'svg must shrink on resize, not keep desktop width').toBeTruthy();
- expect(parseFloat(wAfter!)).toBe(340);
+ // emulate a measurable wrapper host (jsdom has no layout engine):
+ // the wrapper's internal role="img" div is the observed element
+ const inner = target.querySelector('div[role="img"]');
+ expect(inner).toBeTruthy();
+ let hostWidth = 900;
+ Object.defineProperty(inner, 'clientWidth', { get: () => hostWidth, configurable: true });
+ for (const fire of captured) fire();
+ await new Promise((r) => setTimeout(r, 120));
+ expect(svgSize(target), 'desktop width must be rendered first').toBe(900);
- unmount(inst);
- host.remove();
- vi.unstubAllGlobals();
- } finally {
- Object.defineProperty(host, 'clientWidth', { configurable: true, get: () => 0 });
- }
+ // viewport shrink in place: narrowed host + RO callback (no remount)
+ hostWidth = 280;
+ for (const fire of captured) fire();
+ await new Promise((r) => setTimeout(r, 120));
+ expect(svgSize(target), 'mobile width must be rendered, not the desktop width').toBe(280);
+ unmount(inst);
+ target.remove();
});
- it('does not pass undefined sizes to resize when host has no layout (avoid degenerate resize)', async () => {
+ it('zero-layout host: fake resize callbacks must not throw and svg stays alive', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
- const inst = mount(Chart, {
- target: host,
- props: { option: baseOption, height: 340, ariaLabel: '曲线' }
- });
- await new Promise((r) => setTimeout(r, 120));
- const ro = captured[captured.length - 1];
- expect(ro?.el).toBeTruthy();
- // host with zero layout: callback must be a no-op (no degenerate 0-width resize)
- expect(() => ro.callback()).not.toThrow();
+ const inst = mount(Chart, { target: host, props: { data, series, height: 340, ariaLabel: '曲线' } });
+ await new Promise((r) => setTimeout(r, 150));
+ expect(captured.length).toBeGreaterThan(0);
+ expect(() => {
+ for (const fire of captured) fire();
+ }).not.toThrow();
+ expect(host.querySelector('svg')).toBeTruthy();
unmount(inst);
host.remove();
- vi.unstubAllGlobals();
});
});
diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts
index cdd6071..10f9568 100644
--- a/frontend/src/test-setup.ts
+++ b/frontend/src/test-setup.ts
@@ -1,4 +1,20 @@
+// layerchart reads window.matchMedia (settings context, dark-mode aware
+// styling) in jsdom where no backend exists. Narrow shim for media queries
+// used by chart settings only.
+if (typeof window !== 'undefined' && !window.matchMedia) {
+ window.matchMedia = (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ addListener: () => {},
+ removeListener: () => {},
+ dispatchEvent: () => false
+ });
+}
+
// jsdom does not implement Range.getClientRects, which CodeMirror's
// measurement layer calls for drawing decorations (diagnostics markers).
// Without it the suite emits an unhandled