/* === 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 (
{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 (
{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 (
{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 (
);
})}
);
}
/** 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 });