Implement multi-line scrollable chart with tooltip support: enhance data visualization and user interaction

This commit is contained in:
MayaTheShy
2026-03-21 19:40:33 -04:00
parent 73fbe98d86
commit 05291c9373

View File

@@ -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>