diff --git a/web/client/src/components/AnalyticsPanel.jsx b/web/client/src/components/AnalyticsPanel.jsx
index 3fca00c..95b3e60 100644
--- a/web/client/src/components/AnalyticsPanel.jsx
+++ b/web/client/src/components/AnalyticsPanel.jsx
@@ -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 (
📉
@@ -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 (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ {computedSeries.map((s, i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
- {/* Grid lines */}
- {[0, 0.25, 0.5, 0.75, 1].map((frac) => (
-
- ))}
+ {/* Grid lines */}
+ {[0, 0.25, 0.5, 0.75, 1].map((frac) => (
+
+ ))}
- {/* Y axis labels */}
- {yLabels.map((l, i) => (
-
- {l.text}
-
- ))}
+ {/* Y axis labels */}
+ {yLabels.map((l, i) => (
+ {l.text}
+ ))}
- {/* X axis labels */}
- {xLabels.map((l, i) => (
-
- {l.text}
-
- ))}
+ {/* X axis labels */}
+ {xLabels.map((l, i) => (
+ {l.text}
+ ))}
- {/* Area fill with gradient */}
-
+ {/* 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 */}
-
-
+ return (
+
+ {/* Area fill (only for first series to keep it clean) */}
+ {si === 0 && }
+ {/* Line */}
+
+
+ {/* 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) => (
+
+ ))}
+ {/* Endpoint */}
+ {s.lastPt && (
+
+ )}
+
+ );
+ })}
- {/* 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) => (
-
- ))}
+ {/* Tooltip crosshair */}
+ {tooltip && (
+
+ )}
+
- {/* Latest value highlight */}
-
-
- {formatCount(currVal)}
-
-
- {/* Label */}
- {label && (
-
- {label}
-
+ {/* HTML tooltip overlay */}
+ {tooltip && (
+
+
{formatTime(tooltip.time)}
+ {tooltip.items.map((item, i) => (
+
+
+ {formatItemName(item.name)}
+ {formatCount(item.value)}
+
+ ))}
+
)}
-
+
+ {/* Scrollbar */}
+ {canScroll && (
+
+
+
Scroll or drag to navigate
+
+ )}
+
);
}
@@ -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() {
📈 Storage Over Time
- {storageChartData.length >= 2 && (
- {storageChartData.length} snapshots
+ {storageSeries.length > 0 && storageSeries[0].data.length >= 2 && (
+ {storageSeries[0].data.length} snapshots
)}
-
+
@@ -561,17 +774,20 @@ function AnalyticsPanel() {
- {/* Item Trend Lookup */}
-
+ {/* Item Trend — multi-line, full width */}
+
-
🔍 Item Trend
+ 🔍 Item Trends
+ {trackedItems.length} / 12 tracked
+
+ {/* Search bar to add items */}
🔎
setHistorySearch(e.target.value)}
className="search-input"
@@ -583,10 +799,7 @@ function AnalyticsPanel() {
{
- setSelectedHistoryItem(item.name);
- setHistorySearch(formatItemName(item.name));
- }}
+ onClick={() => addTrackedItem(item.name)}
>
{formatItemName(item.name)}
@@ -596,19 +809,31 @@ function AnalyticsPanel() {
)}
- {selectedHistoryItem ? (
-
-
-
- {formatItemName(selectedHistoryItem)}
-
-
+
+ {/* Tracked item chips */}
+ {trackedItems.length > 0 && (
+
+ {trackedItems.map((t) => (
+
+
+
+ {formatItemName(t.name)}
+ removeTrackedItem(t.name)} title="Remove">✕
+
+ ))}
+
+ )}
+
+ {/* Chart */}
+ {itemTrendSeries.length > 0 && itemTrendSeries.some((s) => s.data.length >= 2) ? (
+
+
) : (
📋
- Select an item above
- Track item quantity over time
+ {trackedItems.length > 0 ? 'Loading history data...' : 'Search and add items above to track their trends'}
+ Each item gets its own colored line
)}