1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
|
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { LineChart, Spline, Area, Tooltip } from 'layerchart';
import type { ChartState, Facet } 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;
}
/** hover 提示里追加的"非曲线"行(如权益曲线上的当日回撤) */
interface TooltipExtra {
key: string;
label: string;
color?: string;
}
interface Props {
// 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;
/** 回测区间页面阴影(浅色高亮带;默认开) */
bandShaded?: boolean;
/** 悬浮数值(在曲线序列之外)追加的行 */
extras?: TooltipExtra[];
valueFormatter?: (v: string | number | undefined) => string;
}
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;
let measured: number | undefined = $state(undefined);
// 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;
}
onMount(() => {
applyMeasure();
if (typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(applyMeasure);
observer.observe(host);
}
});
onDestroy(() => observer?.disconnect());
// series config for layerchart: dashed/opacity 走本 series 的 props,
// Spline 会合并 ctx.series?.props(含 highlight Points)。
const lcSeries = $derived(
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>
{#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"
series={lcSeries as never}
width={resolvedWidth}
{height}
{legend}
axis
clip
props={{ grid: { stroke: 'var(--border)' }, spline: { 'stroke-width': 2 } }}
highlight={{ lines: true, points: true }}
{belowMarks}
{marks}
{...lcExtras}
/>
</div>
|