diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-18 08:26:47 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-18 08:26:47 +0800 |
| commit | 33cac3eef7d9962d8803cd812553549d6f555516 (patch) | |
| tree | 77c4686a6aebc790814abc110291fb873b1d96f7 /frontend/src/components/Chart.svelte | |
| parent | 60640dfa981e419cf86413c924fd26822bd42327 (diff) | |
| download | strategy-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/Chart.svelte')
| -rw-r--r-- | frontend/src/components/Chart.svelte | 112 |
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> |
