summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-18 09:51:10 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-18 09:51:10 +0800
commitac55af50d93427b91db9f52415c3e7a749943420 (patch)
tree5590b7e1792df20b57eb0f48e409b3edec9c820b
parent4390ca3472b8a4a201835e358ad0ad2065e8b7c6 (diff)
downloadstrategy-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)不变。
-rw-r--r--frontend/src/components/Chart.svelte131
-rw-r--r--frontend/src/pages/ProjectResultsTab.svelte26
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>