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 { useInventoryStore } from '../store/inventoryStore';
|
||||||
import { formatItemName, formatCount, getItemCategory, ITEM_CATEGORIES } from '../utils/itemUtils';
|
import { formatItemName, formatCount, getItemCategory, ITEM_CATEGORIES } from '../utils/itemUtils';
|
||||||
import ItemIcon from './ItemIcon';
|
import ItemIcon from './ItemIcon';
|
||||||
import './AnalyticsPanel.css';
|
import './AnalyticsPanel.css';
|
||||||
|
|
||||||
// Unique gradient ID counter
|
// ===== Palette for multi-line series =====
|
||||||
let _gradientId = 0;
|
const SERIES_COLORS = [
|
||||||
|
'#55ff55', '#55ffff', '#ffaa00', '#ff55ff', '#ff5555',
|
||||||
|
'#5555ff', '#ffff55', '#ff8844', '#44ddaa', '#dd88ff',
|
||||||
|
'#88ccff', '#ffcc88',
|
||||||
|
];
|
||||||
|
|
||||||
// ===== Smooth SVG Line Chart with gradient fill & data points =====
|
// ===== Smooth curve through points (cardinal spline) =====
|
||||||
function LineChart({ data, width = 400, height = 180, color = '#55ff55', label = '', id = 'chart' }) {
|
function smoothPath(pts) {
|
||||||
if (!data || data.length < 2) {
|
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 (
|
return (
|
||||||
<div className="chart-empty" style={{ height }}>
|
<div className="chart-empty" style={{ height }}>
|
||||||
<span className="chart-empty-icon">📉</span>
|
<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('#', '')}`;
|
// Scrollbar thumb position
|
||||||
const pad = { top: 20, right: 16, bottom: 28, left: 50 };
|
const thumbWidth = canScroll ? Math.max(15, (VISIBLE_WINDOW / totalPoints) * 100) : 100;
|
||||||
const w = width - pad.left - pad.right;
|
const thumbLeft = canScroll ? viewStart * (100 - thumbWidth) : 0;
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg width={width} height={height} className="svg-chart" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="xMidYMid meet">
|
<div
|
||||||
<defs>
|
className={`multiline-chart-container ${isDragging ? 'dragging' : ''}`}
|
||||||
<linearGradient id={gradId} x1="0" x2="0" y1="0" y2="1">
|
ref={containerRef}
|
||||||
<stop offset="0%" stopColor={color} stopOpacity="0.35" />
|
onWheel={handleWheel}
|
||||||
<stop offset="100%" stopColor={color} stopOpacity="0.02" />
|
onMouseDown={handleMouseDown}
|
||||||
</linearGradient>
|
onMouseMove={handleChartMouseMove}
|
||||||
<filter id={`glow-${gradId}`}>
|
onMouseLeave={handleChartMouseLeave}
|
||||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
>
|
||||||
<feMerge>
|
<svg width={containerWidth} height={height} className="svg-chart" viewBox={`0 0 ${containerWidth} ${height}`} preserveAspectRatio="xMidYMid meet">
|
||||||
<feMergeNode in="blur" />
|
<defs>
|
||||||
<feMergeNode in="SourceGraphic" />
|
{computedSeries.map((s, i) => (
|
||||||
</feMerge>
|
<React.Fragment key={i}>
|
||||||
</filter>
|
<linearGradient id={`${id}-grad-${i}`} x1="0" x2="0" y1="0" y2="1">
|
||||||
</defs>
|
<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 */}
|
{/* Grid lines */}
|
||||||
{[0, 0.25, 0.5, 0.75, 1].map((frac) => (
|
{[0, 0.25, 0.5, 0.75, 1].map((frac) => (
|
||||||
<line
|
<line key={frac} x1={pad.left} y1={pad.top + h * (1 - frac)} x2={pad.left + w} y2={pad.top + h * (1 - frac)}
|
||||||
key={frac}
|
stroke="#2a2a2a" strokeWidth="1" strokeDasharray={frac === 0 || frac === 1 ? 'none' : '3,3'} />
|
||||||
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 */}
|
{/* Y axis labels */}
|
||||||
{yLabels.map((l, i) => (
|
{yLabels.map((l, i) => (
|
||||||
<text key={i} x={pad.left - 6} y={l.y + 3} fill="#666" fontSize="7" textAnchor="end" fontFamily="'Silkscreen', monospace">
|
<text key={i} x={pad.left - 6} y={l.y + 3} fill="#666" fontSize="7" textAnchor="end" fontFamily="'Silkscreen', monospace">{l.text}</text>
|
||||||
{l.text}
|
))}
|
||||||
</text>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* X axis labels */}
|
{/* X axis labels */}
|
||||||
{xLabels.map((l, i) => (
|
{xLabels.map((l, i) => (
|
||||||
<text key={i} x={l.x} y={pad.top + h + 16} fill="#555" fontSize="7" textAnchor="middle" fontFamily="'Silkscreen', monospace">
|
<text key={i} x={l.x} y={pad.top + h + 16} fill="#555" fontSize="7" textAnchor="middle" fontFamily="'Silkscreen', monospace">{l.text}</text>
|
||||||
{l.text}
|
))}
|
||||||
</text>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Area fill with gradient */}
|
{/* Series — area fills, lines, dots */}
|
||||||
<path d={areaD} fill={`url(#${gradId})`} />
|
{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 */}
|
return (
|
||||||
<path d={pathD} fill="none" stroke={color} strokeWidth="2.5" filter={`url(#glow-${gradId})`} strokeLinecap="round" />
|
<g key={si}>
|
||||||
<path d={pathD} fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
|
{/* 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) */}
|
{/* Tooltip crosshair */}
|
||||||
{points.filter((_, i) => data.length <= 20 || i % Math.ceil(data.length / 15) === 0 || i === points.length - 1).map((p, i) => (
|
{tooltip && (
|
||||||
<circle key={i} cx={p.x} cy={p.y} r="2.5" fill={color} stroke="#1a1a1a" strokeWidth="1" opacity="0.8" />
|
<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 */}
|
{/* HTML tooltip overlay */}
|
||||||
<circle cx={lastPt.x} cy={lastPt.y} r="4" fill={color} stroke="#fff" strokeWidth="1.5" filter={`url(#glow-${gradId})`} />
|
{tooltip && (
|
||||||
<text x={lastPt.x} y={lastPt.y - 8} fill="#fff" fontSize="8" fontWeight="700" textAnchor="middle" fontFamily="'Silkscreen', monospace">
|
<div
|
||||||
{formatCount(currVal)}
|
className="chart-tooltip"
|
||||||
</text>
|
style={{ left: Math.min(tooltip.x, containerWidth - 120), top: 8 }}
|
||||||
|
>
|
||||||
{/* Label */}
|
<div className="chart-tooltip-time">{formatTime(tooltip.time)}</div>
|
||||||
{label && (
|
{tooltip.items.map((item, i) => (
|
||||||
<text x={pad.left + 4} y={pad.top - 6} fill="#777" fontSize="7" fontFamily="'Silkscreen', monospace" textTransform="uppercase">
|
<div key={i} className="chart-tooltip-row">
|
||||||
{label}
|
<span className="chart-tooltip-dot" style={{ background: item.color }} />
|
||||||
</text>
|
<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 smeltable = useInventoryStore((state) => state.smeltable);
|
||||||
const disabledRecipes = useInventoryStore((state) => state.disabledRecipes);
|
const disabledRecipes = useInventoryStore((state) => state.disabledRecipes);
|
||||||
|
|
||||||
const [selectedHistoryItem, setSelectedHistoryItem] = useState('');
|
// Multi-item tracking
|
||||||
|
const [trackedItems, setTrackedItems] = useState([]); // [{ name, color }]
|
||||||
const [historySearch, setHistorySearch] = useState('');
|
const [historySearch, setHistorySearch] = useState('');
|
||||||
|
|
||||||
// Fetch summary on mount
|
// Fetch summary on mount
|
||||||
@@ -367,12 +562,14 @@ function AnalyticsPanel() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchHistorySummary]);
|
}, [fetchHistorySummary]);
|
||||||
|
|
||||||
// Fetch selected item history
|
// Fetch history for each tracked item
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedHistoryItem) {
|
trackedItems.forEach((t) => {
|
||||||
fetchItemHistory(selectedHistoryItem);
|
if (!itemHistory[t.name]) {
|
||||||
}
|
fetchItemHistory(t.name);
|
||||||
}, [selectedHistoryItem, fetchItemHistory]);
|
}
|
||||||
|
});
|
||||||
|
}, [trackedItems, fetchItemHistory, itemHistory]);
|
||||||
|
|
||||||
// Top 10 items by count
|
// Top 10 items by count
|
||||||
const topItems = useMemo(() => {
|
const topItems = useMemo(() => {
|
||||||
@@ -397,39 +594,41 @@ function AnalyticsPanel() {
|
|||||||
.sort((a, b) => b.count - a.count);
|
.sort((a, b) => b.count - a.count);
|
||||||
}, [inventory.itemList]);
|
}, [inventory.itemList]);
|
||||||
|
|
||||||
// Storage history chart data
|
// Storage history — as a single series (total)
|
||||||
const storageChartData = useMemo(() => {
|
const storageSeries = useMemo(() => {
|
||||||
if (!historySummary || historySummary.length === 0) return [];
|
if (!historySummary || historySummary.length === 0) return [];
|
||||||
return historySummary.map((h) => ({
|
return [{
|
||||||
time: h.recordedAt,
|
name: 'Total',
|
||||||
value: h.total,
|
color: '#55ff55',
|
||||||
}));
|
data: historySummary.map((h) => ({ time: h.recordedAt, value: h.total })),
|
||||||
|
}];
|
||||||
}, [historySummary]);
|
}, [historySummary]);
|
||||||
|
|
||||||
// Selected item history chart data
|
// Multi-item trend series
|
||||||
const itemChartData = useMemo(() => {
|
const itemTrendSeries = useMemo(() => {
|
||||||
const history = itemHistory[selectedHistoryItem];
|
return trackedItems.map((t) => {
|
||||||
if (!history || history.length === 0) return [];
|
const history = itemHistory[t.name];
|
||||||
return history
|
if (!history || history.length === 0) return { name: t.name, color: t.color, data: [] };
|
||||||
.slice()
|
return {
|
||||||
.reverse()
|
name: t.name,
|
||||||
.map((h) => ({
|
color: t.color,
|
||||||
time: h.recordedAt,
|
data: history.slice().reverse().map((h) => ({ time: h.recordedAt, value: h.count })),
|
||||||
value: h.count,
|
};
|
||||||
}));
|
});
|
||||||
}, [itemHistory, selectedHistoryItem]);
|
}, [trackedItems, itemHistory]);
|
||||||
|
|
||||||
// Filtered items for search dropdown
|
// Filtered items for search dropdown
|
||||||
const filteredItems = useMemo(() => {
|
const filteredItems = useMemo(() => {
|
||||||
if (!historySearch) return [];
|
if (!historySearch) return [];
|
||||||
const q = historySearch.toLowerCase();
|
const q = historySearch.toLowerCase();
|
||||||
|
const trackedNames = new Set(trackedItems.map((t) => t.name));
|
||||||
return (inventory.itemList || [])
|
return (inventory.itemList || [])
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
const name = (item.displayName || item.name || '').toLowerCase();
|
const name = (item.displayName || item.name || '').toLowerCase();
|
||||||
return name.includes(q);
|
return name.includes(q) && !trackedNames.has(item.name);
|
||||||
})
|
})
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
}, [historySearch, inventory.itemList]);
|
}, [historySearch, inventory.itemList, trackedItems]);
|
||||||
|
|
||||||
// Smelting stats
|
// Smelting stats
|
||||||
const smeltingStats = useMemo(() => {
|
const smeltingStats = useMemo(() => {
|
||||||
@@ -453,6 +652,20 @@ function AnalyticsPanel() {
|
|||||||
return latest.total - prev.total;
|
return latest.total - prev.total;
|
||||||
}, [historySummary]);
|
}, [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;
|
const capacityPct = inventory.totalSlots > 0 ? Math.round((inventory.usedSlots / inventory.totalSlots) * 100) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -511,12 +724,12 @@ function AnalyticsPanel() {
|
|||||||
<div className="analytics-card analytics-card-wide analytics-card--chart">
|
<div className="analytics-card analytics-card-wide analytics-card--chart">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h3>📈 Storage Over Time</h3>
|
<h3>📈 Storage Over Time</h3>
|
||||||
{storageChartData.length >= 2 && (
|
{storageSeries.length > 0 && storageSeries[0].data.length >= 2 && (
|
||||||
<span className="card-badge">{storageChartData.length} snapshots</span>
|
<span className="card-badge">{storageSeries[0].data.length} snapshots</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="chart-wrapper">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -561,17 +774,20 @@ function AnalyticsPanel() {
|
|||||||
<BarChart data={topItems} width={440} height={300} color="#ffaa00" />
|
<BarChart data={topItems} width={440} height={300} color="#ffaa00" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Item Trend Lookup */}
|
{/* Item Trend — multi-line, full width */}
|
||||||
<div className="analytics-card analytics-card--trend">
|
<div className="analytics-card analytics-card-wide analytics-card--trend">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<h3>🔍 Item Trend</h3>
|
<h3>🔍 Item Trends</h3>
|
||||||
|
<span className="card-badge">{trackedItems.length} / 12 tracked</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search bar to add items */}
|
||||||
<div className="item-history-search">
|
<div className="item-history-search">
|
||||||
<div className="search-input-wrapper">
|
<div className="search-input-wrapper">
|
||||||
<span className="search-icon">🔎</span>
|
<span className="search-icon">🔎</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search for an item..."
|
placeholder="Search and add items to track..."
|
||||||
value={historySearch}
|
value={historySearch}
|
||||||
onChange={(e) => setHistorySearch(e.target.value)}
|
onChange={(e) => setHistorySearch(e.target.value)}
|
||||||
className="search-input"
|
className="search-input"
|
||||||
@@ -583,10 +799,7 @@ function AnalyticsPanel() {
|
|||||||
<div
|
<div
|
||||||
key={item.name}
|
key={item.name}
|
||||||
className="item-history-option"
|
className="item-history-option"
|
||||||
onClick={() => {
|
onClick={() => addTrackedItem(item.name)}
|
||||||
setSelectedHistoryItem(item.name);
|
|
||||||
setHistorySearch(formatItemName(item.name));
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ItemIcon itemName={item.name} size={16} />
|
<ItemIcon itemName={item.name} size={16} />
|
||||||
<span>{formatItemName(item.name)}</span>
|
<span>{formatItemName(item.name)}</span>
|
||||||
@@ -596,19 +809,31 @@ function AnalyticsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{selectedHistoryItem ? (
|
|
||||||
<div className="item-history-chart">
|
{/* Tracked item chips */}
|
||||||
<div className="item-history-label">
|
{trackedItems.length > 0 && (
|
||||||
<ItemIcon itemName={selectedHistoryItem} size={22} />
|
<div className="tracked-items-bar">
|
||||||
<span>{formatItemName(selectedHistoryItem)}</span>
|
{trackedItems.map((t) => (
|
||||||
</div>
|
<div key={t.name} className="tracked-chip" style={{ borderColor: t.color }}>
|
||||||
<LineChart data={itemChartData} width={400} height={160} color="#55ffff" label="Count" id="item-trend" />
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="chart-empty chart-empty--compact">
|
<div className="chart-empty chart-empty--compact">
|
||||||
<span className="chart-empty-icon">📋</span>
|
<span className="chart-empty-icon">📋</span>
|
||||||
<span>Select an item above</span>
|
<span>{trackedItems.length > 0 ? 'Loading history data...' : 'Search and add items above to track their trends'}</span>
|
||||||
<span className="chart-empty-sub">Track item quantity over time</span>
|
<span className="chart-empty-sub">Each item gets its own colored line</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user