summaryrefslogtreecommitdiff
path: root/frontend/src/components
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/src/components
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/src/components')
-rw-r--r--frontend/src/components/Chart.svelte112
1 files changed, 61 insertions, 51 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>