/* === SVG chart primitives === */ /* global React */ const { useState, useRef, useMemo, useEffect } = React; /** Generic line chart supporting multiple series, percentage formatting, threshold lines */ function LineChart({ series, // [{name, color, data:[...], dashed?:boolean}] dates, // array same length as data height = 240, width = 800, format = "raw", // 'raw' | 'percent' | 'nav' minY, maxY, threshold, // [{y, color, label}] onPointClick, showXLabels = true, }) { const wrapRef = useRef(null); const [w, setW] = useState(width); const [hover, setHover] = useState(null); useEffect(() => { if (!wrapRef.current) return; const ro = new ResizeObserver((entries) => { const cw = entries[0].contentRect.width; if (cw > 0) setW(cw); }); ro.observe(wrapRef.current); return () => ro.disconnect(); }, []); const padL = 44, padR = 0, padT = 12, padB = showXLabels ? 28 : 12; const innerW = Math.max(40, w - padL - padR); const innerH = height - padT - padB; const flatVals = useMemo(() => series.flatMap(s => s.data.filter(v => v != null)), [series]); const dataMin = flatVals.length ? Math.min(...flatVals) : 0; const dataMax = flatVals.length ? Math.max(...flatVals) : 1; const yMin = minY != null ? minY : dataMin - (dataMax - dataMin) * 0.08; const yMax = maxY != null ? maxY : dataMax + (dataMax - dataMin) * 0.08; const yRange = yMax - yMin || 1; const n = dates.length; // 修复:确保最后一个点能够到达右边框 const xAt = (i) => padL + (innerW * i) / (n > 1 ? n - 1 : 1); const yAt = (v) => padT + innerH * (1 - (v - yMin) / yRange); const fmtY = (v) => { if (format === "percent") return (v * 100).toFixed(1) + "%"; if (format === "nav") return v.toFixed(3); return v.toFixed(2); }; // Y ticks const ticks = 5; const yTicks = []; for (let i = 0; i <= ticks; i++) { const v = yMin + (yRange * i) / ticks; yTicks.push({ v, y: yAt(v) }); } // X ticks const xTickCount = 6; const xTicks = []; for (let i = 0; i < xTickCount; i++) { const idx = Math.round(((n - 1) * i) / (xTickCount - 1)); xTicks.push({ idx, label: dates[idx] ? dates[idx].slice(2) : "" }); } const buildPath = (data) => { let d = ""; let started = false; data.forEach((v, i) => { if (v == null) return; const x = xAt(i), y = yAt(v); d += (started ? " L " : "M ") + x.toFixed(1) + " " + y.toFixed(1); started = true; }); return d; }; const onMouseMove = (e) => { const rect = e.currentTarget.getBoundingClientRect(); const px = e.clientX - rect.left; let i = Math.round(((px - padL) / innerW) * (n - 1)); i = Math.max(0, Math.min(n - 1, i)); setHover({ idx: i, x: e.clientX - rect.left, y: e.clientY - rect.top }); }; return (
setHover(null)} onClick={() => { if (hover && onPointClick) onPointClick(hover.idx); }} style={{ display: "block", cursor: onPointClick ? "pointer" : "default" }} > {/* Grid */} {yTicks.map((t, i) => ( ))} {/* Y axis */} {yTicks.map((t, i) => ( {fmtY(t.v)} ))} {/* X axis */} {showXLabels && ( {xTicks.map((t, i) => ( {t.label} ))} )} {/* Threshold lines */} {threshold && threshold.map((t, i) => ( {t.label && ( {t.label} )} ))} {/* Series */} {series.map((s, i) => ( ))} {/* Hover guideline + points */} {hover && ( {series.map((s, i) => { const v = s.data[hover.idx]; if (v == null) return null; return ; })} )} {hover && (
{dates[hover.idx]}
{series.map((s, i) => { const v = s.data[hover.idx]; if (v == null) return null; return (
{s.name} {fmtY(v)}
); })}
)}
); } /** Stacked area chart for allocation */ function StackedAreaChart({ series, // [{name, color, data}] - each data is array of values (already summing to 1) labels, // labels under x axis height = 220, width = 800, }) { const wrapRef = useRef(null); const [w, setW] = useState(width); const [hover, setHover] = useState(null); useEffect(() => { if (!wrapRef.current) return; const ro = new ResizeObserver((entries) => { const cw = entries[0].contentRect.width; if (cw > 0) setW(cw); }); ro.observe(wrapRef.current); return () => ro.disconnect(); }, []); const padL = 38, padR = 0, padT = 12, padB = 28; const innerW = Math.max(40, w - padL - padR); const innerH = height - padT - padB; const n = labels.length; // Build cumulative - handle null values const cum = series.map(s => s.data.map(() => 0)); for (let t = 0; t < n; t++) { let acc = 0; series.forEach((s, i) => { const val = s.data[t]; acc += (val != null && !isNaN(val)) ? val : 0; cum[i][t] = acc; }); } // 修复:确保最后一个点能够到达右边框 const xAt = (i) => padL + (innerW * i) / (n > 1 ? n - 1 : 1); const yAt = (v) => padT + innerH * (1 - v); const buildArea = (sIdx) => { const top = cum[sIdx]; const bottom = sIdx === 0 ? top.map(() => 0) : cum[sIdx - 1]; let d = "M " + xAt(0) + " " + yAt(top[0]); for (let i = 1; i < n; i++) d += " L " + xAt(i) + " " + yAt(top[i]); for (let i = n - 1; i >= 0; i--) d += " L " + xAt(i) + " " + yAt(bottom[i]); d += " Z"; return d; }; const yTicks = [0, 0.25, 0.5, 0.75, 1]; const xTickCount = 6; const xTicks = []; for (let i = 0; i < xTickCount; i++) { const idx = Math.round(((n - 1) * i) / (xTickCount - 1)); xTicks.push({ idx, label: labels[idx] }); } const onMM = (e) => { const rect = e.currentTarget.getBoundingClientRect(); const px = e.clientX - rect.left; let i = Math.round(((px - padL) / innerW) * (n - 1)); i = Math.max(0, Math.min(n - 1, i)); setHover({ idx: i, x: e.clientX - rect.left, y: e.clientY - rect.top }); }; return (
setHover(null)} style={{ display: "block" }}> {yTicks.map((t,i)=> )} {yTicks.map((t,i)=>({(t*100).toFixed(0)+"%"}))} {xTicks.map((t,i)=>({t.label}))} {series.map((s,i)=> )} {hover && ( )} {hover && (
{labels[hover.idx]}
{series.map((s,i)=>(
{s.name} {(s.data[hover.idx]*100).toFixed(1)}%
))}
)}
); } /** Drawdown area chart (below zero) */ function DrawdownChart({ data, dates, color = "#ef3f3f", height = 200, threshold }) { const series = [{ name: "回撤", color, data }]; const minVal = Math.min(...data); return ( ); } /** Filled drawdown area chart */ function DrawdownArea({ data, dates, color = "#ef3f3f", height = 200, threshold }) { const wrapRef = useRef(null); const [w, setW] = useState(800); const [hover, setHover] = useState(null); // 过滤掉无效数据,同时确保日期和数据一一对应 const validPairs = []; if (Array.isArray(data) && Array.isArray(dates)) { for (let i = 0; i < Math.min(data.length, dates.length); i++) { if (typeof data[i] === 'number' && !isNaN(data[i])) { validPairs.push({ date: dates[i], value: data[i] }); } } } const safeData = validPairs.map(p => p.value); const safeDates = validPairs.map(p => p.date); useEffect(() => { if (!wrapRef.current) return; const ro = new ResizeObserver((entries) => { const cw = entries[0].contentRect.width; if (cw > 0) setW(cw); }); ro.observe(wrapRef.current); return () => ro.disconnect(); }, []); const padL = 44, padR = 0, padT = 12, padB = 28; const innerW = Math.max(40, w - padL - padR); const innerH = height - padT - padB; const minVal = safeData.length > 0 ? Math.min(...safeData) : -0.1; const yMin = minVal * 1.15; const yMax = 0.005; const n = safeDates.length; // 修复:确保最后一个点能够到达右边框 const xAt = (i) => padL + (innerW * i) / (n > 1 ? n - 1 : 1); const yAt = (v) => padT + innerH * (1 - (v - yMin) / (yMax - yMin)); if (n === 0 || safeData.length === 0) { return (
暂无回撤数据
); } let pathLine = "M " + xAt(0) + " " + yAt(safeData[0]); for (let i = 1; i < n; i++) pathLine += " L " + xAt(i) + " " + yAt(safeData[i]); let pathFill = pathLine + " L " + xAt(n-1) + " " + yAt(0) + " L " + xAt(0) + " " + yAt(0) + " Z"; const yTicks = [yMin, yMin * 0.66, yMin * 0.33, 0]; const xTickCount = 6; const xTicks = []; for (let i = 0; i < xTickCount; i++) { const idx = Math.round(((n - 1) * i) / (xTickCount - 1)); xTicks.push({ idx, label: safeDates[idx] ? safeDates[idx].slice(2) : "" }); } const onMM = (e) => { const rect = e.currentTarget.getBoundingClientRect(); const px = e.clientX - rect.left; let i = Math.round(((px - padL) / innerW) * (n - 1)); i = Math.max(0, Math.min(n - 1, i)); setHover({ idx: i, x: e.clientX - rect.left, y: e.clientY - rect.top }); }; return (
setHover(null)} style={{ display: "block" }}> {yTicks.map((t,i)=> )} {yTicks.map((t,i)=>({(t*100).toFixed(1)+"%"}))} {xTicks.map((t,i)=>({t.label}))} {threshold && threshold.map((t,i)=>( ))} {hover && ( )} {hover && (
{safeDates[hover.idx]}
回撤 {(safeData[hover.idx]*100).toFixed(2)}%
)}
); } /** Compact bar chart, horizontal */ function HBars({ items, format = "percent", colorMap }) { const max = Math.max(...items.map(it => Math.abs(it.value)), 0.001); return (
{items.map((it, i) => { const pct = Math.abs(it.value) / max; const color = (colorMap && colorMap[it.name]) || it.color || "#0f5cff"; const v = format === "percent" ? (it.value * 100).toFixed(1) + "%" : it.value.toFixed(2); return (
{it.name}
{v}
); })}
); } /** Mini sparkline */ function Sparkline({ data, color = "#0f5cff", width = 90, height = 24 }) { if (!data || data.length === 0) return null; const min = Math.min(...data); const max = Math.max(...data); const range = max - min || 1; const n = data.length; let d = "M 0 " + (height - ((data[0]-min)/range)*height); for (let i = 1; i < n; i++) { const x = (i/(n-1))*width; const y = height - ((data[i]-min)/range)*height; d += " L " + x.toFixed(1) + " " + y.toFixed(1); } return ( ); } Object.assign(window, { LineChart, StackedAreaChart, DrawdownArea, HBars, Sparkline });