diff options
Diffstat (limited to 'frontend/src/components/Chart.svelte')
| -rw-r--r-- | frontend/src/components/Chart.svelte | 131 |
1 files changed, 122 insertions, 9 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> |
