From 05291c93733c4cc54bfb43dace807b7e6876015b Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Sat, 21 Mar 2026 19:40:33 -0400 Subject: [PATCH] Implement multi-line scrollable chart with tooltip support: enhance data visualization and user interaction --- web/client/src/components/AnalyticsPanel.jsx | 573 +++++++++++++------ 1 file changed, 399 insertions(+), 174 deletions(-) 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)} + +
+ ))} +
+ )} + + {/* 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
)}