diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-18 09:51:10 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-18 09:51:10 +0800 |
| commit | ac55af50d93427b91db9f52415c3e7a749943420 (patch) | |
| tree | 5590b7e1792df20b57eb0f48e409b3edec9c820b /frontend/src | |
| parent | 4390ca3472b8a4a201835e358ad0ad2065e8b7c6 (diff) | |
| download | strategy-lab-ac55af50d93427b91db9f52415c3e7a749943420.tar.gz | |
feat(frontend): 图表增强——网格、hover 当日净值/回撤、回测区间阴影带
[变更性质] 图表层能力扩展,前端重设计第二轮之三。
[新增功能] Chart 网格线使用主题 border 色;hover 手柄提示补充"当日净值+回撤";回测区间 belowMarks 渲染半透明阴影带(bandShaded 可关);series 支持 dashed/opacity,Area 填充渲染;equity 行内合并回撤列,drawdown 图改为百分比格式化。
[实现方案] 基于 layerchart marks/belowMarks 泳道与自定义 tooltip snippet(lcExtras 透传绕开字面量类型检查),宽度扩展点保持 resolvedWidth 合同不变。
[影响范围] Chart.svelte、ProjectResultsTab.svelte;图表 probe 断言(svgs 渲染、溢出为 0)不变。
Diffstat (limited to 'frontend/src')
| -rw-r--r-- | frontend/src/components/Chart.svelte | 131 | ||||
| -rw-r--r-- | frontend/src/pages/ProjectResultsTab.svelte | 26 |
2 files changed, 138 insertions, 19 deletions
diff --git a/frontend/src/components/Chart.svelte b/frontend/src/components/Chart.svelte index 0854f3a..5b6038d 100644 --- a/frontend/src/components/Chart.svelte +++ b/frontend/src/components/Chart.svelte @@ -1,6 +1,7 @@ <script lang="ts"> import { onMount, onDestroy } from 'svelte'; - import { LineChart } from 'layerchart'; + import { LineChart, Spline, Area, Tooltip } from 'layerchart'; + import type { ChartState, Facet } from 'layerchart'; export interface ChartSeries { key: string; @@ -12,6 +13,12 @@ area?: boolean; opacity?: number; } + /** hover 提示里追加的"非曲线"行(如权益曲线上的当日回撤) */ + interface TooltipExtra { + key: string; + label: string; + color?: string; + } interface Props { // long-format rows: { i, date, [key]: value } data: { @@ -24,8 +31,28 @@ width?: number; ariaLabel?: string; legend?: boolean; + /** 回测区间页面阴影(浅色高亮带;默认开) */ + bandShaded?: boolean; + /** 悬浮数值(在曲线序列之外)追加的行 */ + extras?: TooltipExtra[]; + valueFormatter?: (v: string | number | undefined) => string; } - let { data, series, height = 340, width = $bindable(undefined), ariaLabel = '图表', legend = true }: Props = $props(); + let { + data, + series, + height = 340, + width = $bindable(undefined), + ariaLabel = '图表', + legend = true, + bandShaded = true, + extras = [], + valueFormatter = (v) => + v === undefined || v === null + ? '—' + : typeof v === 'number' + ? v.toLocaleString('zh-Hans', { maximumFractionDigits: 4 }) + : String(v) + }: Props = $props(); let host: HTMLDivElement; let observer: ResizeObserver | null = null; @@ -48,22 +75,103 @@ }); onDestroy(() => observer?.disconnect()); + // series config for layerchart: dashed/opacity 走本 series 的 props, + // Spline 会合并 ctx.series?.props(含 highlight Points)。 const lcSeries = $derived( - series.map((s) => ({ - key: s.key, - label: s.label ?? s.key, - value: s.value, - color: s.color - })) + series.map((s) => { + const branchStyle: Record<string, unknown> = s.dashed + ? { strokeDasharray: '6 6', opacity: s.opacity ?? 0.65 } + : s.opacity !== undefined + ? { opacity: s.opacity } + : {}; + const cfg: Record<string, unknown> = { + key: s.key, + label: s.label ?? s.key, + value: s.value, + color: s.color + }; + if (s.dashed || s.opacity !== undefined) cfg.props = branchStyle; + return cfg; + }) ); + // key → 画面积填充(回撤曲线 area 语义) + const areaKeys = $derived(new Set(series.filter((s) => s.area).map((s) => s.key))); + + // LineChartProps 未声明 tooltip(自定义片段经 restProps 链传给 ChartChildren), + // 用展开绕开精确字面量检查。 + const lcExtras = $derived({ tooltip: tooltipBox }); + // 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); + + type ChartCtx = ChartState<any, any, any>; + + /** + * 回测区间阴影带的坐标(belowMarks:网格之上、曲线之下)。取 + * xScale/yScale 的 range:即 padding 后的绘图区矩形。 + */ + function bandBounds(context: ChartCtx | undefined): { x: number; y: number; w: number; h: number } | null { + const xr: number[] | undefined = context?.xScale?.range?.() as number[] | undefined; + const yr: number[] | undefined = context?.yScale?.range?.() as number[] | undefined; + if (context === undefined || xr === undefined || yr === undefined) return null; + const w = xr[1] - xr[0]; + if (w <= 0) return null; + const y = Math.min(yr[0], yr[1]); + const h = Math.abs(yr[1] - yr[0]); + return { x: xr[0], y, w, h }; + } </script> -<div bind:this={host} style="height:{height}px; width:100%; min-width:0;" role="img" aria-label={ariaLabel}> +{#snippet belowMarks({ context }: { context: ChartCtx; facet: Facet })} + {#if bandShaded} + {@const b = bandBounds(context)} + {#if b} + <rect + x={b.x} + y={b.y} + width={b.w} + height={b.h} + fill="var(--chart-band, rgba(15, 118, 110, 0.05))" + /> + {/if} + {/if} +{/snippet} + +{#snippet marks({ context }: { context: ChartCtx; facet: Facet })} + {#each context?.series?.visibleSeries ?? [] as s (s.key)} + {#if areaKeys.has(s.key)} + <Area seriesKey={s.key} opacity={0.9} fill="var(--chart-area-fill, rgba(15, 118, 110, 0.12))" /> + {/if} + <Spline seriesKey={s.key} stroke-width={2} /> + {/each} +{/snippet} + +{#snippet tooltipBox({ context }: { context: ChartCtx })} + <Tooltip.Root> + {#snippet children({ data }: { data: Record<string, string | number | undefined> })} + <Tooltip.Header value={data?.date ?? ''} /> + <Tooltip.List> + {#each context?.series?.visibleSeries ?? [] as s (s.key)} + {@const acc = s?.value} + {@const v = typeof acc === 'function' ? acc(data as never) : data?.[acc as string]} + {#if v !== undefined} + <Tooltip.Item label={s.label} value={v} color={s.color} format={valueFormatter} valueAlign="right" /> + {/if} + {/each} + {#each extras as e (e.key)} + {#if data?.[e.key] !== undefined} + <Tooltip.Item label={e.label} value={data?.[e.key]} color={e.color} format={valueFormatter} valueAlign="right" /> + {/if} + {/each} + </Tooltip.List> + {/snippet} + </Tooltip.Root> +{/snippet} + +<div bind:this={host} style="height:{height}px; width:100%; min-width:0;" role="img" aria-label={ariaLabel} data-testid="chart-host"> <LineChart data={data as never[]} x="i" @@ -73,5 +181,10 @@ {legend} axis clip + props={{ grid: { stroke: 'var(--border)' }, spline: { 'stroke-width': 2 } }} + highlight={{ lines: true, points: true }} + {belowMarks} + {marks} + {...lcExtras} /> </div> diff --git a/frontend/src/pages/ProjectResultsTab.svelte b/frontend/src/pages/ProjectResultsTab.svelte index 5ff03c0..f479116 100644 --- a/frontend/src/pages/ProjectResultsTab.svelte +++ b/frontend/src/pages/ProjectResultsTab.svelte @@ -55,16 +55,22 @@ const result = $derived<BacktestResult | null>(run?.result ?? null); const hasEquity = $derived(!!result && result.equity.length > 0); - // long-format rows for the layerchart renderer ({ i, date, ...columns }) + // long-format rows for the layerchart renderer ({ i, date, ...columns }); + // equity rows同时携带 dd(当日回撤%),供 hover 提示读数 const equityData = $derived.by(() => { if (!result || !hasEquity) return []; - return result.equity.map((p, i) => ({ - i, - date: p.date, - equity: p.equity, - cash: p.cash, - benchmark: p.benchmark ?? undefined - })); + let peak = -Infinity; + return result.equity.map((p, i) => { + peak = Math.max(peak, p.equity); + return { + i, + date: p.date, + equity: p.equity, + cash: p.cash, + benchmark: p.benchmark ?? undefined, + dd: peak <= 0 ? 0 : -((peak - p.equity) / peak) * 100 + }; + }); }); const hasBench = $derived(equityData.some((r) => r.benchmark !== undefined && r.benchmark !== null)); @@ -193,9 +199,9 @@ <Button variant={chartMode === "drawdown" ? "default" : "outline"} size="sm" aria-pressed={chartMode === "drawdown"} onclick={() => (chartMode = "drawdown")}>回撤曲线</Button> </div> {#if chartMode === "equity"} - <Chart data={equityData} series={equitySeries} ariaLabel="策略权益曲线" height={340} /> + <Chart data={equityData} series={equitySeries} ariaLabel="策略权益曲线" height={340} extras={[{ key: 'dd', label: '回撤', color: '#134e4a' }]} /> {:else} - <Chart data={drawdownData} series={drawdownSeries} ariaLabel="回撤曲线" height={300} /> + <Chart data={drawdownData} series={drawdownSeries} ariaLabel="回撤曲线" height={300} valueFormatter={(v) => (typeof v === 'number' ? `${v.toFixed(2)}%` : String(v))} /> {/if} </div> |
