Implement multi-line scrollable chart with tooltip support: enhance data visualization and user interaction
This commit is contained in:
@@ -1,15 +1,230 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import React, { useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { useInventoryStore } from '../store/inventoryStore';
|
||||
import { formatItemName, formatCount, getItemCategory, ITEM_CATEGORIES } from '../utils/itemUtils';
|
||||
import ItemIcon from './ItemIcon';
|
||||
import './AnalyticsPanel.css';
|
||||
|
||||
// Unique gradient ID counter
|
||||
let _gradientId = 0;
|
||||
// ===== Palette for multi-line series =====
|
||||
const SERIES_COLORS = [
|
||||
'#55ff55', '#55ffff', '#ffaa00', '#ff55ff', '#ff5555',
|
||||
'#5555ff', '#ffff55', '#ff8844', '#44ddaa', '#dd88ff',
|
||||
'#88ccff', '#ffcc88',
|
||||
];
|
||||
|
||||
// ===== Smooth SVG Line Chart with gradient fill & data points =====
|
||||
function LineChart({ data, width = 400, height = 180, color = '#55ff55', label = '', id = 'chart' }) {
|
||||
if (!data || data.length < 2) {
|
||||
// ===== Smooth curve through points (cardinal spline) =====
|
||||
function smoothPath(pts) {
|
||||
if (!pts || pts.length === 0) return '';
|
||||
if (pts.length < 3) {
|
||||
return pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' ');
|
||||
}
|
||||
let d = `M ${pts[0].x.toFixed(1)} ${pts[0].y.toFixed(1)}`;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const p0 = pts[Math.max(0, i - 1)];
|
||||
const p1 = pts[i];
|
||||
const p2 = pts[i + 1];
|
||||
const p3 = pts[Math.min(pts.length - 1, i + 2)];
|
||||
const cp1x = p1.x + (p2.x - p0.x) / 6;
|
||||
const cp1y = p1.y + (p2.y - p0.y) / 6;
|
||||
const cp2x = p2.x - (p3.x - p1.x) / 6;
|
||||
const cp2y = p2.y - (p3.y - p1.y) / 6;
|
||||
d += ` C ${cp1x.toFixed(1)} ${cp1y.toFixed(1)}, ${cp2x.toFixed(1)} ${cp2y.toFixed(1)}, ${p2.x.toFixed(1)} ${p2.y.toFixed(1)}`;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
// ===== Multi-line scrollable SVG chart =====
|
||||
function MultiLineChart({ series, height = 220, id = 'ml-chart' }) {
|
||||
// series = [{ name, color, data: [{ time, value }] }, ...]
|
||||
const containerRef = useRef(null);
|
||||
const [containerWidth, setContainerWidth] = useState(700);
|
||||
const [viewStart, setViewStart] = useState(0); // 0..1 fractional scroll position
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStartX, setDragStartX] = useState(0);
|
||||
const [dragStartView, setDragStartView] = useState(0);
|
||||
const [tooltip, setTooltip] = useState(null);
|
||||
|
||||
// Use a visible window size — show ~30 data points at a time
|
||||
const VISIBLE_WINDOW = 30;
|
||||
|
||||
// Measure container
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const obs = new ResizeObserver(([e]) => setContainerWidth(e.contentRect.width));
|
||||
obs.observe(containerRef.current);
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
// Collect all unique timestamps across all series
|
||||
const allTimestamps = useMemo(() => {
|
||||
const tsSet = new Set();
|
||||
(series || []).forEach((s) => (s.data || []).forEach((d) => tsSet.add(d.time)));
|
||||
return [...tsSet].sort((a, b) => new Date(a) - new Date(b));
|
||||
}, [series]);
|
||||
|
||||
const totalPoints = allTimestamps.length;
|
||||
const canScroll = totalPoints > VISIBLE_WINDOW;
|
||||
|
||||
// Compute the visible slice of timestamps
|
||||
const maxOffset = Math.max(0, totalPoints - VISIBLE_WINDOW);
|
||||
const scrollOffset = Math.round(viewStart * maxOffset);
|
||||
const visibleTimestamps = canScroll
|
||||
? allTimestamps.slice(scrollOffset, scrollOffset + VISIBLE_WINDOW)
|
||||
: allTimestamps;
|
||||
|
||||
// Reset scroll when series change significantly
|
||||
useEffect(() => {
|
||||
setViewStart(1); // default: show latest
|
||||
}, [series.length]);
|
||||
|
||||
// Build data per series for visible window
|
||||
const pad = { top: 24, right: 14, bottom: 28, left: 52 };
|
||||
const w = containerWidth - pad.left - pad.right;
|
||||
const h = height - pad.top - pad.bottom;
|
||||
|
||||
const computedSeries = useMemo(() => {
|
||||
if (visibleTimestamps.length < 1) return [];
|
||||
|
||||
// Build a timestamp → index map for x positioning
|
||||
const tsIdx = {};
|
||||
visibleTimestamps.forEach((ts, i) => { tsIdx[ts] = i; });
|
||||
const count = visibleTimestamps.length;
|
||||
|
||||
// Global min/max over ALL visible series in this window
|
||||
let globalMin = Infinity;
|
||||
let globalMax = -Infinity;
|
||||
const seriesLookups = (series || []).map((s) => {
|
||||
const lookup = {};
|
||||
(s.data || []).forEach((d) => { lookup[d.time] = d.value; });
|
||||
return lookup;
|
||||
});
|
||||
|
||||
seriesLookups.forEach((lookup) => {
|
||||
visibleTimestamps.forEach((ts) => {
|
||||
const v = lookup[ts];
|
||||
if (v != null) {
|
||||
if (v < globalMin) globalMin = v;
|
||||
if (v > globalMax) globalMax = v;
|
||||
}
|
||||
});
|
||||
});
|
||||
if (!isFinite(globalMin)) globalMin = 0;
|
||||
if (!isFinite(globalMax)) globalMax = 1;
|
||||
const range = globalMax - globalMin || 1;
|
||||
|
||||
return (series || []).map((s, si) => {
|
||||
const lookup = seriesLookups[si];
|
||||
const points = [];
|
||||
visibleTimestamps.forEach((ts, i) => {
|
||||
const val = lookup[ts];
|
||||
if (val != null) {
|
||||
points.push({
|
||||
x: pad.left + (i / Math.max(count - 1, 1)) * w,
|
||||
y: pad.top + h - ((val - globalMin) / range) * h,
|
||||
value: val,
|
||||
time: ts,
|
||||
});
|
||||
}
|
||||
});
|
||||
const path = smoothPath(points);
|
||||
const lastPt = points.length > 0 ? points[points.length - 1] : null;
|
||||
return { ...s, points, path, lastPt, globalMin, globalMax, range };
|
||||
});
|
||||
}, [series, visibleTimestamps, w, h, pad.left, pad.top]);
|
||||
|
||||
// Y axis labels
|
||||
const yLabels = useMemo(() => {
|
||||
if (computedSeries.length === 0) return [];
|
||||
const { globalMin, range } = computedSeries[0];
|
||||
return [0, 0.25, 0.5, 0.75, 1].map((frac) => ({
|
||||
y: pad.top + h * (1 - frac),
|
||||
text: formatCount(Math.round(globalMin + range * frac)),
|
||||
}));
|
||||
}, [computedSeries, h, pad.top]);
|
||||
|
||||
// X axis labels
|
||||
const xLabels = useMemo(() => {
|
||||
const count = Math.min(6, visibleTimestamps.length);
|
||||
if (count < 2) return [];
|
||||
const indices = Array.from({ length: count }, (_, i) => Math.round(i * (visibleTimestamps.length - 1) / (count - 1)));
|
||||
return indices.filter((idx) => visibleTimestamps[idx]).map((idx) => ({
|
||||
x: pad.left + (idx / Math.max(visibleTimestamps.length - 1, 1)) * w,
|
||||
text: formatTime(visibleTimestamps[idx]),
|
||||
}));
|
||||
}, [visibleTimestamps, w, pad.left]);
|
||||
|
||||
// Scroll handler (wheel)
|
||||
const handleWheel = useCallback((e) => {
|
||||
if (!canScroll) return;
|
||||
e.preventDefault();
|
||||
setViewStart((prev) => {
|
||||
const delta = e.deltaY > 0 ? 0.05 : -0.05;
|
||||
return Math.max(0, Math.min(1, prev + delta));
|
||||
});
|
||||
}, [canScroll]);
|
||||
|
||||
// Drag-to-scroll handlers
|
||||
const handleMouseDown = useCallback((e) => {
|
||||
if (!canScroll) return;
|
||||
setIsDragging(true);
|
||||
setDragStartX(e.clientX);
|
||||
setDragStartView(viewStart);
|
||||
}, [canScroll, viewStart]);
|
||||
|
||||
const handleMouseMove = useCallback((e) => {
|
||||
if (!isDragging || !canScroll) return;
|
||||
const dx = e.clientX - dragStartX;
|
||||
const pctDelta = -dx / containerWidth;
|
||||
setViewStart(Math.max(0, Math.min(1, dragStartView + pctDelta)));
|
||||
}, [isDragging, canScroll, dragStartX, dragStartView, containerWidth]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}
|
||||
}, [isDragging, handleMouseMove, handleMouseUp]);
|
||||
|
||||
// Tooltip handler
|
||||
const handleChartMouseMove = useCallback((e) => {
|
||||
if (isDragging || !containerRef.current || computedSeries.length === 0) {
|
||||
setTooltip(null);
|
||||
return;
|
||||
}
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const mx = e.clientX - rect.left;
|
||||
const my = e.clientY - rect.top;
|
||||
|
||||
// Find closest timestamp index
|
||||
const relX = mx - pad.left;
|
||||
if (relX < 0 || relX > w) { setTooltip(null); return; }
|
||||
const frac = relX / w;
|
||||
const idx = Math.round(frac * (visibleTimestamps.length - 1));
|
||||
if (idx < 0 || idx >= visibleTimestamps.length) { setTooltip(null); return; }
|
||||
|
||||
const ts = visibleTimestamps[idx];
|
||||
const items = computedSeries.map((s) => {
|
||||
const pt = s.points.find((p) => p.time === ts);
|
||||
return pt ? { name: s.name, color: s.color, value: pt.value } : null;
|
||||
}).filter(Boolean);
|
||||
|
||||
if (items.length === 0) { setTooltip(null); return; }
|
||||
const snapX = pad.left + (idx / Math.max(visibleTimestamps.length - 1, 1)) * w;
|
||||
setTooltip({ x: snapX, y: my, time: ts, items });
|
||||
}, [isDragging, computedSeries, w, pad.left, visibleTimestamps]);
|
||||
|
||||
const handleChartMouseLeave = useCallback(() => setTooltip(null), []);
|
||||
|
||||
const hasData = series && series.length > 0 && series.some((s) => s.data && s.data.length >= 2);
|
||||
|
||||
if (!hasData) {
|
||||
return (
|
||||
<div className="chart-empty" style={{ height }}>
|
||||
<span className="chart-empty-icon">📉</span>
|
||||
@@ -19,138 +234,117 @@ function LineChart({ data, width = 400, height = 180, color = '#55ff55', label =
|
||||
);
|
||||
}
|
||||
|
||||
const gradId = `grad-${id}-${color.replace('#', '')}`;
|
||||
const pad = { top: 20, right: 16, bottom: 28, left: 50 };
|
||||
const w = width - pad.left - pad.right;
|
||||
const h = height - pad.top - pad.bottom;
|
||||
|
||||
const values = data.map((d) => d.value);
|
||||
const maxY = Math.max(...values);
|
||||
const minY = Math.min(...values);
|
||||
const range = maxY - minY || 1;
|
||||
|
||||
const points = data.map((d, i) => ({
|
||||
x: pad.left + (i / (data.length - 1 || 1)) * w,
|
||||
y: pad.top + h - ((d.value - minY) / range) * h,
|
||||
value: d.value,
|
||||
}));
|
||||
|
||||
// Smooth curve using cardinal spline
|
||||
const smoothPath = (pts) => {
|
||||
if (pts.length < 3) {
|
||||
return pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' ');
|
||||
}
|
||||
let d = `M ${pts[0].x.toFixed(1)} ${pts[0].y.toFixed(1)}`;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const p0 = pts[Math.max(0, i - 1)];
|
||||
const p1 = pts[i];
|
||||
const p2 = pts[i + 1];
|
||||
const p3 = pts[Math.min(pts.length - 1, i + 2)];
|
||||
const cp1x = p1.x + (p2.x - p0.x) / 6;
|
||||
const cp1y = p1.y + (p2.y - p0.y) / 6;
|
||||
const cp2x = p2.x - (p3.x - p1.x) / 6;
|
||||
const cp2y = p2.y - (p3.y - p1.y) / 6;
|
||||
d += ` C ${cp1x.toFixed(1)} ${cp1y.toFixed(1)}, ${cp2x.toFixed(1)} ${cp2y.toFixed(1)}, ${p2.x.toFixed(1)} ${p2.y.toFixed(1)}`;
|
||||
}
|
||||
return d;
|
||||
};
|
||||
|
||||
const pathD = smoothPath(points);
|
||||
const areaD =
|
||||
pathD +
|
||||
` L ${points[points.length - 1].x.toFixed(1)} ${(pad.top + h).toFixed(1)} L ${points[0].x.toFixed(1)} ${(pad.top + h).toFixed(1)} Z`;
|
||||
|
||||
// Y axis labels (4 ticks)
|
||||
const yLabels = [0, 0.33, 0.66, 1].map((frac) => ({
|
||||
y: pad.top + h * (1 - frac),
|
||||
text: formatCount(Math.round(minY + range * frac)),
|
||||
}));
|
||||
|
||||
// X axis labels
|
||||
const xCount = Math.min(5, data.length);
|
||||
const xIndices = Array.from({ length: xCount }, (_, i) => Math.round(i * (data.length - 1) / (xCount - 1)));
|
||||
const xLabels = xIndices
|
||||
.filter((idx) => data[idx]?.time)
|
||||
.map((idx) => ({
|
||||
x: pad.left + (idx / (data.length - 1 || 1)) * w,
|
||||
text: formatTime(data[idx].time),
|
||||
}));
|
||||
|
||||
// Latest value indicator
|
||||
const lastPt = points[points.length - 1];
|
||||
const prevVal = data.length > 1 ? data[data.length - 2].value : data[0].value;
|
||||
const currVal = data[data.length - 1].value;
|
||||
const delta = currVal - prevVal;
|
||||
// Scrollbar thumb position
|
||||
const thumbWidth = canScroll ? Math.max(15, (VISIBLE_WINDOW / totalPoints) * 100) : 100;
|
||||
const thumbLeft = canScroll ? viewStart * (100 - thumbWidth) : 0;
|
||||
|
||||
return (
|
||||
<svg width={width} height={height} className="svg-chart" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="xMidYMid meet">
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity="0.35" />
|
||||
<stop offset="100%" stopColor={color} stopOpacity="0.02" />
|
||||
</linearGradient>
|
||||
<filter id={`glow-${gradId}`}>
|
||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<div
|
||||
className={`multiline-chart-container ${isDragging ? 'dragging' : ''}`}
|
||||
ref={containerRef}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleChartMouseMove}
|
||||
onMouseLeave={handleChartMouseLeave}
|
||||
>
|
||||
<svg width={containerWidth} height={height} className="svg-chart" viewBox={`0 0 ${containerWidth} ${height}`} preserveAspectRatio="xMidYMid meet">
|
||||
<defs>
|
||||
{computedSeries.map((s, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<linearGradient id={`${id}-grad-${i}`} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor={s.color} stopOpacity="0.20" />
|
||||
<stop offset="100%" stopColor={s.color} stopOpacity="0.01" />
|
||||
</linearGradient>
|
||||
<filter id={`${id}-glow-${i}`}>
|
||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</defs>
|
||||
|
||||
{/* Grid lines */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => (
|
||||
<line
|
||||
key={frac}
|
||||
x1={pad.left}
|
||||
y1={pad.top + h * (1 - frac)}
|
||||
x2={pad.left + w}
|
||||
y2={pad.top + h * (1 - frac)}
|
||||
stroke="#2a2a2a"
|
||||
strokeWidth="1"
|
||||
strokeDasharray={frac === 0 || frac === 1 ? 'none' : '3,3'}
|
||||
/>
|
||||
))}
|
||||
{/* Grid lines */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => (
|
||||
<line key={frac} x1={pad.left} y1={pad.top + h * (1 - frac)} x2={pad.left + w} y2={pad.top + h * (1 - frac)}
|
||||
stroke="#2a2a2a" strokeWidth="1" strokeDasharray={frac === 0 || frac === 1 ? 'none' : '3,3'} />
|
||||
))}
|
||||
|
||||
{/* Y axis labels */}
|
||||
{yLabels.map((l, i) => (
|
||||
<text key={i} x={pad.left - 6} y={l.y + 3} fill="#666" fontSize="7" textAnchor="end" fontFamily="'Silkscreen', monospace">
|
||||
{l.text}
|
||||
</text>
|
||||
))}
|
||||
{/* Y axis labels */}
|
||||
{yLabels.map((l, i) => (
|
||||
<text key={i} x={pad.left - 6} y={l.y + 3} fill="#666" fontSize="7" textAnchor="end" fontFamily="'Silkscreen', monospace">{l.text}</text>
|
||||
))}
|
||||
|
||||
{/* X axis labels */}
|
||||
{xLabels.map((l, i) => (
|
||||
<text key={i} x={l.x} y={pad.top + h + 16} fill="#555" fontSize="7" textAnchor="middle" fontFamily="'Silkscreen', monospace">
|
||||
{l.text}
|
||||
</text>
|
||||
))}
|
||||
{/* X axis labels */}
|
||||
{xLabels.map((l, i) => (
|
||||
<text key={i} x={l.x} y={pad.top + h + 16} fill="#555" fontSize="7" textAnchor="middle" fontFamily="'Silkscreen', monospace">{l.text}</text>
|
||||
))}
|
||||
|
||||
{/* Area fill with gradient */}
|
||||
<path d={areaD} fill={`url(#${gradId})`} />
|
||||
{/* Series — area fills, lines, dots */}
|
||||
{computedSeries.map((s, si) => {
|
||||
if (s.points.length < 2) return null;
|
||||
const lineD = s.path;
|
||||
const areaD = lineD
|
||||
+ ` L ${s.points[s.points.length - 1].x.toFixed(1)} ${(pad.top + h).toFixed(1)}`
|
||||
+ ` L ${s.points[0].x.toFixed(1)} ${(pad.top + h).toFixed(1)} Z`;
|
||||
|
||||
{/* Line with glow */}
|
||||
<path d={pathD} fill="none" stroke={color} strokeWidth="2.5" filter={`url(#glow-${gradId})`} strokeLinecap="round" />
|
||||
<path d={pathD} fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
|
||||
return (
|
||||
<g key={si}>
|
||||
{/* Area fill (only for first series to keep it clean) */}
|
||||
{si === 0 && <path d={areaD} fill={`url(#${id}-grad-${si})`} />}
|
||||
{/* Line */}
|
||||
<path d={lineD} fill="none" stroke={s.color} strokeWidth="2" filter={`url(#${id}-glow-${si})`} strokeLinecap="round" opacity="0.9" />
|
||||
<path d={lineD} fill="none" stroke={s.color} strokeWidth="1.5" strokeLinecap="round" />
|
||||
{/* Dots — fewer when dense */}
|
||||
{s.points.filter((_, i) => s.points.length <= 25 || i % Math.ceil(s.points.length / 18) === 0 || i === s.points.length - 1).map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r="2" fill={s.color} stroke="#1a1a1a" strokeWidth="0.8" opacity="0.7" />
|
||||
))}
|
||||
{/* Endpoint */}
|
||||
{s.lastPt && (
|
||||
<circle cx={s.lastPt.x} cy={s.lastPt.y} r="3.5" fill={s.color} stroke="#fff" strokeWidth="1" />
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Data points (show fewer if many data points) */}
|
||||
{points.filter((_, i) => data.length <= 20 || i % Math.ceil(data.length / 15) === 0 || i === points.length - 1).map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r="2.5" fill={color} stroke="#1a1a1a" strokeWidth="1" opacity="0.8" />
|
||||
))}
|
||||
{/* Tooltip crosshair */}
|
||||
{tooltip && (
|
||||
<line x1={tooltip.x} y1={pad.top} x2={tooltip.x} y2={pad.top + h} stroke="#666" strokeWidth="1" strokeDasharray="3,2" />
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Latest value highlight */}
|
||||
<circle cx={lastPt.x} cy={lastPt.y} r="4" fill={color} stroke="#fff" strokeWidth="1.5" filter={`url(#glow-${gradId})`} />
|
||||
<text x={lastPt.x} y={lastPt.y - 8} fill="#fff" fontSize="8" fontWeight="700" textAnchor="middle" fontFamily="'Silkscreen', monospace">
|
||||
{formatCount(currVal)}
|
||||
</text>
|
||||
|
||||
{/* Label */}
|
||||
{label && (
|
||||
<text x={pad.left + 4} y={pad.top - 6} fill="#777" fontSize="7" fontFamily="'Silkscreen', monospace" textTransform="uppercase">
|
||||
{label}
|
||||
</text>
|
||||
{/* HTML tooltip overlay */}
|
||||
{tooltip && (
|
||||
<div
|
||||
className="chart-tooltip"
|
||||
style={{ left: Math.min(tooltip.x, containerWidth - 120), top: 8 }}
|
||||
>
|
||||
<div className="chart-tooltip-time">{formatTime(tooltip.time)}</div>
|
||||
{tooltip.items.map((item, i) => (
|
||||
<div key={i} className="chart-tooltip-row">
|
||||
<span className="chart-tooltip-dot" style={{ background: item.color }} />
|
||||
<span className="chart-tooltip-name">{formatItemName(item.name)}</span>
|
||||
<span className="chart-tooltip-val">{formatCount(item.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Scrollbar */}
|
||||
{canScroll && (
|
||||
<div className="chart-scrollbar">
|
||||
<div className="chart-scrollbar-track">
|
||||
<div
|
||||
className="chart-scrollbar-thumb"
|
||||
style={{ width: `${thumbWidth}%`, left: `${thumbLeft}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="chart-scrollbar-hint">Scroll or drag to navigate</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -357,7 +551,8 @@ function AnalyticsPanel() {
|
||||
const smeltable = useInventoryStore((state) => state.smeltable);
|
||||
const disabledRecipes = useInventoryStore((state) => state.disabledRecipes);
|
||||
|
||||
const [selectedHistoryItem, setSelectedHistoryItem] = useState('');
|
||||
// Multi-item tracking
|
||||
const [trackedItems, setTrackedItems] = useState([]); // [{ name, color }]
|
||||
const [historySearch, setHistorySearch] = useState('');
|
||||
|
||||
// Fetch summary on mount
|
||||
@@ -367,12 +562,14 @@ function AnalyticsPanel() {
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchHistorySummary]);
|
||||
|
||||
// Fetch selected item history
|
||||
// Fetch history for each tracked item
|
||||
useEffect(() => {
|
||||
if (selectedHistoryItem) {
|
||||
fetchItemHistory(selectedHistoryItem);
|
||||
}
|
||||
}, [selectedHistoryItem, fetchItemHistory]);
|
||||
trackedItems.forEach((t) => {
|
||||
if (!itemHistory[t.name]) {
|
||||
fetchItemHistory(t.name);
|
||||
}
|
||||
});
|
||||
}, [trackedItems, fetchItemHistory, itemHistory]);
|
||||
|
||||
// Top 10 items by count
|
||||
const topItems = useMemo(() => {
|
||||
@@ -397,39 +594,41 @@ function AnalyticsPanel() {
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}, [inventory.itemList]);
|
||||
|
||||
// Storage history chart data
|
||||
const storageChartData = useMemo(() => {
|
||||
// Storage history — as a single series (total)
|
||||
const storageSeries = useMemo(() => {
|
||||
if (!historySummary || historySummary.length === 0) return [];
|
||||
return historySummary.map((h) => ({
|
||||
time: h.recordedAt,
|
||||
value: h.total,
|
||||
}));
|
||||
return [{
|
||||
name: 'Total',
|
||||
color: '#55ff55',
|
||||
data: historySummary.map((h) => ({ time: h.recordedAt, value: h.total })),
|
||||
}];
|
||||
}, [historySummary]);
|
||||
|
||||
// Selected item history chart data
|
||||
const itemChartData = useMemo(() => {
|
||||
const history = itemHistory[selectedHistoryItem];
|
||||
if (!history || history.length === 0) return [];
|
||||
return history
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((h) => ({
|
||||
time: h.recordedAt,
|
||||
value: h.count,
|
||||
}));
|
||||
}, [itemHistory, selectedHistoryItem]);
|
||||
// Multi-item trend series
|
||||
const itemTrendSeries = useMemo(() => {
|
||||
return trackedItems.map((t) => {
|
||||
const history = itemHistory[t.name];
|
||||
if (!history || history.length === 0) return { name: t.name, color: t.color, data: [] };
|
||||
return {
|
||||
name: t.name,
|
||||
color: t.color,
|
||||
data: history.slice().reverse().map((h) => ({ time: h.recordedAt, value: h.count })),
|
||||
};
|
||||
});
|
||||
}, [trackedItems, itemHistory]);
|
||||
|
||||
// Filtered items for search dropdown
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!historySearch) return [];
|
||||
const q = historySearch.toLowerCase();
|
||||
const trackedNames = new Set(trackedItems.map((t) => t.name));
|
||||
return (inventory.itemList || [])
|
||||
.filter((item) => {
|
||||
const name = (item.displayName || item.name || '').toLowerCase();
|
||||
return name.includes(q);
|
||||
return name.includes(q) && !trackedNames.has(item.name);
|
||||
})
|
||||
.slice(0, 8);
|
||||
}, [historySearch, inventory.itemList]);
|
||||
}, [historySearch, inventory.itemList, trackedItems]);
|
||||
|
||||
// Smelting stats
|
||||
const smeltingStats = useMemo(() => {
|
||||
@@ -453,6 +652,20 @@ function AnalyticsPanel() {
|
||||
return latest.total - prev.total;
|
||||
}, [historySummary]);
|
||||
|
||||
// Add / remove tracked items
|
||||
const addTrackedItem = (itemName) => {
|
||||
if (trackedItems.length >= 12) return;
|
||||
if (trackedItems.some((t) => t.name === itemName)) return;
|
||||
const colorIdx = trackedItems.length % SERIES_COLORS.length;
|
||||
setTrackedItems((prev) => [...prev, { name: itemName, color: SERIES_COLORS[colorIdx] }]);
|
||||
setHistorySearch('');
|
||||
fetchItemHistory(itemName);
|
||||
};
|
||||
|
||||
const removeTrackedItem = (itemName) => {
|
||||
setTrackedItems((prev) => prev.filter((t) => t.name !== itemName));
|
||||
};
|
||||
|
||||
const capacityPct = inventory.totalSlots > 0 ? Math.round((inventory.usedSlots / inventory.totalSlots) * 100) : 0;
|
||||
|
||||
return (
|
||||
@@ -511,12 +724,12 @@ function AnalyticsPanel() {
|
||||
<div className="analytics-card analytics-card-wide analytics-card--chart">
|
||||
<div className="card-header">
|
||||
<h3>📈 Storage Over Time</h3>
|
||||
{storageChartData.length >= 2 && (
|
||||
<span className="card-badge">{storageChartData.length} snapshots</span>
|
||||
{storageSeries.length > 0 && storageSeries[0].data.length >= 2 && (
|
||||
<span className="card-badge">{storageSeries[0].data.length} snapshots</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="chart-wrapper">
|
||||
<LineChart data={storageChartData} width={700} height={200} color="#55ff55" label="Total Items" id="storage" />
|
||||
<MultiLineChart series={storageSeries} height={200} id="storage" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -561,17 +774,20 @@ function AnalyticsPanel() {
|
||||
<BarChart data={topItems} width={440} height={300} color="#ffaa00" />
|
||||
</div>
|
||||
|
||||
{/* Item Trend Lookup */}
|
||||
<div className="analytics-card analytics-card--trend">
|
||||
{/* Item Trend — multi-line, full width */}
|
||||
<div className="analytics-card analytics-card-wide analytics-card--trend">
|
||||
<div className="card-header">
|
||||
<h3>🔍 Item Trend</h3>
|
||||
<h3>🔍 Item Trends</h3>
|
||||
<span className="card-badge">{trackedItems.length} / 12 tracked</span>
|
||||
</div>
|
||||
|
||||
{/* Search bar to add items */}
|
||||
<div className="item-history-search">
|
||||
<div className="search-input-wrapper">
|
||||
<span className="search-icon">🔎</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search for an item..."
|
||||
placeholder="Search and add items to track..."
|
||||
value={historySearch}
|
||||
onChange={(e) => setHistorySearch(e.target.value)}
|
||||
className="search-input"
|
||||
@@ -583,10 +799,7 @@ function AnalyticsPanel() {
|
||||
<div
|
||||
key={item.name}
|
||||
className="item-history-option"
|
||||
onClick={() => {
|
||||
setSelectedHistoryItem(item.name);
|
||||
setHistorySearch(formatItemName(item.name));
|
||||
}}
|
||||
onClick={() => addTrackedItem(item.name)}
|
||||
>
|
||||
<ItemIcon itemName={item.name} size={16} />
|
||||
<span>{formatItemName(item.name)}</span>
|
||||
@@ -596,19 +809,31 @@ function AnalyticsPanel() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedHistoryItem ? (
|
||||
<div className="item-history-chart">
|
||||
<div className="item-history-label">
|
||||
<ItemIcon itemName={selectedHistoryItem} size={22} />
|
||||
<span>{formatItemName(selectedHistoryItem)}</span>
|
||||
</div>
|
||||
<LineChart data={itemChartData} width={400} height={160} color="#55ffff" label="Count" id="item-trend" />
|
||||
|
||||
{/* Tracked item chips */}
|
||||
{trackedItems.length > 0 && (
|
||||
<div className="tracked-items-bar">
|
||||
{trackedItems.map((t) => (
|
||||
<div key={t.name} className="tracked-chip" style={{ borderColor: t.color }}>
|
||||
<span className="tracked-chip-dot" style={{ background: t.color }} />
|
||||
<ItemIcon itemName={t.name} size={14} />
|
||||
<span className="tracked-chip-name">{formatItemName(t.name)}</span>
|
||||
<button className="tracked-chip-remove" onClick={() => removeTrackedItem(t.name)} title="Remove">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chart */}
|
||||
{itemTrendSeries.length > 0 && itemTrendSeries.some((s) => s.data.length >= 2) ? (
|
||||
<div className="chart-wrapper">
|
||||
<MultiLineChart series={itemTrendSeries} height={220} id="item-trends" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="chart-empty chart-empty--compact">
|
||||
<span className="chart-empty-icon">📋</span>
|
||||
<span>Select an item above</span>
|
||||
<span className="chart-empty-sub">Track item quantity over time</span>
|
||||
<span>{trackedItems.length > 0 ? 'Loading history data...' : 'Search and add items above to track their trends'}</span>
|
||||
<span className="chart-empty-sub">Each item gets its own colored line</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user