feat(v2): Phase 2 — analytics tabs (Combat, Rares, Map, Inventory)

Below the character cards grid, adds four tabbed analytics sections:

Combat Tab (Recharts):
- Kills per hour horizontal bar chart (all characters, sorted)
- Total damage session bar chart
- Damage by element pie chart (aggregated across all characters)

Rares Tab:
- Summary cards: total rares, total kills, drop rate (1 in N)
- Recent rare drops timeline (from WebSocket events)
- Rares per character lifetime bar chart

Map Tab:
- Dereth map (dereth_highres.png) with SVG overlay
- Character position dots (green=hunting, yellow=other)
- Hover to see character name + coordinates
- Responsive, maintains aspect ratio

Inventory Tab:
- Cross-character item search with debounced input
- Results table: character, item, type, material, set, workmanship
- Powered by existing /search/items API

All tabs lazy-rendered (only active tab mounts). Horizontal scroll
tab bar on mobile. Dark theme consistent with cards.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 15:14:50 +02:00
parent 69ead07051
commit 3791c01bf3
11 changed files with 791 additions and 53 deletions

View file

@ -1,11 +1,39 @@
import { Layout } from './components/Layout'; import { Layout } from './components/Layout';
import { GlobalStats } from './components/GlobalStats'; import { GlobalStats } from './components/GlobalStats';
import { CharacterGrid } from './components/CharacterGrid'; import { CharacterGrid } from './components/CharacterGrid';
import { TabContainer } from './components/tabs/TabContainer';
import { CombatTab } from './components/tabs/CombatTab';
import { RaresTab } from './components/tabs/RaresTab';
import { MapTab } from './components/tabs/MapTab';
import { InventoryTab } from './components/tabs/InventoryTab';
import { useLiveData } from './hooks/useLiveData'; import { useLiveData } from './hooks/useLiveData';
import './styles/global.css'; import './styles/global.css';
export default function App() { export default function App() {
const { characters, serverHealth, totalRares, totalKills } = useLiveData(); const { characters, serverHealth, totalRares, totalKills, recentRares } = useLiveData();
const tabs = [
{
id: 'combat',
label: 'Combat',
content: <CombatTab characters={characters} />,
},
{
id: 'rares',
label: 'Rares',
content: <RaresTab characters={characters} totalRares={totalRares} totalKills={totalKills} recentRares={recentRares} />,
},
{
id: 'map',
label: 'Map',
content: <MapTab characters={characters} />,
},
{
id: 'inventory',
label: 'Inventory',
content: <InventoryTab />,
},
];
return ( return (
<Layout> <Layout>
@ -16,6 +44,7 @@ export default function App() {
serverHealth={serverHealth} serverHealth={serverHealth}
/> />
<CharacterGrid characters={characters} /> <CharacterGrid characters={characters} />
<TabContainer tabs={tabs} />
</Layout> </Layout>
); );
} }

View file

@ -0,0 +1,142 @@
import React, { useMemo } from 'react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
PieChart, Pie, Cell, Legend,
} from 'recharts';
import type { CharacterState } from '../../types';
interface Props {
characters: Map<string, CharacterState>;
}
const ELEMENT_COLORS: Record<string, string> = {
Slash: '#cc4444',
Pierce: '#44cc44',
Bludgeon: '#888888',
Fire: '#ff6622',
Cold: '#4488ff',
Acid: '#44cc44',
Electric: '#ffcc00',
Typeless: '#aa66cc',
};
export const CombatTab: React.FC<Props> = ({ characters }) => {
// Kill rate per character
const killData = useMemo(() => {
return Array.from(characters.values())
.filter(c => c.telemetry)
.map(c => ({
name: c.name.length > 18 ? c.name.slice(0, 16) + '..' : c.name,
fullName: c.name,
killsPerHour: parseInt(c.telemetry!.kills_per_hour) || 0,
totalKills: c.telemetry!.kills || 0,
}))
.sort((a, b) => b.killsPerHour - a.killsPerHour)
.slice(0, 30);
}, [characters]);
// Damage given per character (from combat stats)
const damageData = useMemo(() => {
return Array.from(characters.values())
.filter(c => c.combat?.session)
.map(c => ({
name: c.name.length > 18 ? c.name.slice(0, 16) + '..' : c.name,
fullName: c.name,
damage: c.combat!.session!.total_damage_given,
}))
.sort((a, b) => b.damage - a.damage)
.slice(0, 30);
}, [characters]);
// Aggregate element breakdown across all characters
const elementData = useMemo(() => {
const totals: Record<string, number> = {};
for (const ch of characters.values()) {
const session = ch.combat?.session;
if (!session?.monsters) continue;
for (const mon of Object.values(session.monsters)) {
if (!mon.offense) continue;
for (const byEl of Object.values(mon.offense)) {
for (const [el, stats] of Object.entries(byEl)) {
if (el === 'None' || el === 'Unknown') continue;
totals[el] = (totals[el] || 0) + (stats.damage || 0);
}
}
}
}
return Object.entries(totals)
.map(([name, value]) => ({ name, value }))
.filter(d => d.value > 0)
.sort((a, b) => b.value - a.value);
}, [characters]);
return (
<div className="combat-tab">
<div className="chart-section">
<h3 className="chart-title">Kills per Hour</h3>
<ResponsiveContainer width="100%" height={Math.max(200, killData.length * 28)}>
<BarChart data={killData} layout="vertical" margin={{ left: 10, right: 20, top: 5, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis type="number" stroke="#888" fontSize={11} />
<YAxis type="category" dataKey="name" width={130} stroke="#888" fontSize={11} />
<Tooltip
contentStyle={{ background: '#1a1a1a', border: '1px solid #444', fontSize: 12 }}
formatter={(v: number) => [v.toLocaleString(), 'Kills/hr']}
labelFormatter={(l, payload) => payload?.[0]?.payload?.fullName || l}
/>
<Bar dataKey="killsPerHour" fill="#44cc44" radius={[0, 3, 3, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
{damageData.length > 0 && (
<div className="chart-section">
<h3 className="chart-title">Total Damage (Session)</h3>
<ResponsiveContainer width="100%" height={Math.max(200, damageData.length * 28)}>
<BarChart data={damageData} layout="vertical" margin={{ left: 10, right: 20, top: 5, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis type="number" stroke="#888" fontSize={11} />
<YAxis type="category" dataKey="name" width={130} stroke="#888" fontSize={11} />
<Tooltip
contentStyle={{ background: '#1a1a1a', border: '1px solid #444', fontSize: 12 }}
formatter={(v: number) => [v.toLocaleString(), 'Damage']}
labelFormatter={(l, payload) => payload?.[0]?.payload?.fullName || l}
/>
<Bar dataKey="damage" fill="#ff6644" radius={[0, 3, 3, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
{elementData.length > 0 && (
<div className="chart-section">
<h3 className="chart-title">Damage by Element (All Characters)</h3>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={elementData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={100}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
labelLine={true}
fontSize={12}
>
{elementData.map((d) => (
<Cell key={d.name} fill={ELEMENT_COLORS[d.name] || '#888'} />
))}
</Pie>
<Tooltip
contentStyle={{ background: '#1a1a1a', border: '1px solid #444', fontSize: 12 }}
formatter={(v: number) => v.toLocaleString()}
/>
<Legend wrapperStyle={{ fontSize: 12, color: '#aaa' }} />
</PieChart>
</ResponsiveContainer>
</div>
)}
</div>
);
};

View file

@ -0,0 +1,96 @@
import React, { useState, useCallback, useRef } from 'react';
import { apiFetch } from '../../api/client';
interface SearchResult {
character_name: string;
item_name: string;
type?: string;
arcanelore?: string;
material?: string;
set_name?: string;
workmanship?: number;
value?: number;
}
interface SearchResponse {
results: SearchResult[];
total: number;
query: string;
}
export const InventoryTab: React.FC = () => {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const debounceRef = useRef<number>(0);
const doSearch = useCallback(async (q: string) => {
if (q.length < 2) { setResults([]); setTotal(0); return; }
setLoading(true);
try {
const data = await apiFetch<SearchResponse>(`/search/items?q=${encodeURIComponent(q)}&limit=100`);
setResults(data.results ?? []);
setTotal(data.total ?? 0);
} catch {
setResults([]);
}
setLoading(false);
}, []);
const handleInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setQuery(val);
clearTimeout(debounceRef.current);
debounceRef.current = window.setTimeout(() => doSearch(val), 400);
}, [doSearch]);
return (
<div className="inventory-tab">
<div className="search-bar">
<input
type="text"
value={query}
onChange={handleInput}
placeholder="Search items across all characters..."
className="search-input"
/>
{loading && <span className="search-spinner">Searching...</span>}
</div>
{total > 0 && (
<div className="search-count">{total.toLocaleString()} results</div>
)}
<div className="search-results">
{results.length === 0 && query.length >= 2 && !loading && (
<div className="search-empty">No items found</div>
)}
<table className="results-table">
<thead>
<tr>
<th>Character</th>
<th>Item</th>
<th>Type</th>
<th>Material</th>
<th>Set</th>
<th>Work</th>
</tr>
</thead>
<tbody>
{results.map((r, i) => (
<tr key={i}>
<td>{r.character_name}</td>
<td className="item-name">{r.item_name}</td>
<td>{r.type || ''}</td>
<td>{r.material || ''}</td>
<td>{r.set_name || ''}</td>
<td>{r.workmanship || ''}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};

View file

@ -0,0 +1,84 @@
import React, { useMemo, useRef, useState, useCallback } from 'react';
import type { CharacterState } from '../../types';
interface Props {
characters: Map<string, CharacterState>;
}
// UtilityBelt's coordinate bounds (matches v1 script.js)
const MAP_BOUNDS = { west: -102.1, east: 102.1, north: 102.1, south: -102.1 };
const MAP_SIZE = 800; // render size in CSS px
function coordToPixel(ew: number, ns: number): { x: number; y: number } {
const x = ((ew - MAP_BOUNDS.west) / (MAP_BOUNDS.east - MAP_BOUNDS.west)) * MAP_SIZE;
const y = ((MAP_BOUNDS.north - ns) / (MAP_BOUNDS.north - MAP_BOUNDS.south)) * MAP_SIZE;
return { x, y };
}
export const MapTab: React.FC<Props> = ({ characters }) => {
const containerRef = useRef<HTMLDivElement>(null);
const [hovered, setHovered] = useState<string | null>(null);
const dots = useMemo(() => {
return Array.from(characters.values())
.filter(c => c.telemetry && c.telemetry.ew !== undefined)
.map(c => {
const t = c.telemetry!;
const { x, y } = coordToPixel(t.ew, t.ns);
const isHunting = (t.vt_state || '').toLowerCase() === 'combat' ||
(t.vt_state || '').toLowerCase() === 'hunt';
return { name: c.name, x, y, isHunting, ns: t.ns, ew: t.ew };
});
}, [characters]);
const handleDotHover = useCallback((name: string | null) => setHovered(name), []);
return (
<div className="map-tab">
<div className="map-container" ref={containerRef}>
<img
src="/dereth_highres.png"
alt="Dereth Map"
className="map-image"
draggable={false}
/>
<svg className="map-overlay" viewBox={`0 0 ${MAP_SIZE} ${MAP_SIZE}`}>
{dots.map(d => (
<g key={d.name}>
<circle
cx={d.x}
cy={d.y}
r={hovered === d.name ? 6 : 4}
fill={d.isHunting ? '#44cc44' : '#ffaa00'}
stroke="#000"
strokeWidth={1}
opacity={0.9}
onMouseEnter={() => handleDotHover(d.name)}
onMouseLeave={() => handleDotHover(null)}
style={{ cursor: 'pointer' }}
/>
{hovered === d.name && (
<text
x={d.x + 8}
y={d.y + 4}
fill="#fff"
fontSize={11}
stroke="#000"
strokeWidth={0.3}
paintOrder="stroke"
>
{d.name} ({d.ns?.toFixed(1)}N, {d.ew?.toFixed(1)}E)
</text>
)}
</g>
))}
</svg>
</div>
<div className="map-legend">
<span><span className="legend-dot hunting" /> Hunting/Combat</span>
<span><span className="legend-dot other" /> Other state</span>
<span className="map-count">{dots.length} characters on map</span>
</div>
</div>
);
};

View file

@ -0,0 +1,84 @@
import React, { useMemo } from 'react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts';
import type { CharacterState, RareMessage } from '../../types';
interface Props {
characters: Map<string, CharacterState>;
totalRares: number;
totalKills: number;
recentRares: RareMessage[];
}
export const RaresTab: React.FC<Props> = ({ characters, totalRares, totalKills, recentRares }) => {
// Rares per character from telemetry
const raresData = useMemo(() => {
return Array.from(characters.values())
.filter(c => c.telemetry && (c.telemetry.total_rares ?? 0) > 0)
.map(c => ({
name: c.name.length > 18 ? c.name.slice(0, 16) + '..' : c.name,
fullName: c.name,
rares: c.telemetry!.total_rares ?? 0,
}))
.sort((a, b) => b.rares - a.rares);
}, [characters]);
const killsPerRare = totalRares > 0 ? Math.round(totalKills / totalRares) : 0;
return (
<div className="rares-tab">
{/* Summary stats */}
<div className="rares-summary">
<div className="rare-stat-card">
<span className="rare-stat-value">{totalRares}</span>
<span className="rare-stat-label">Total Rares Found</span>
</div>
<div className="rare-stat-card">
<span className="rare-stat-value">{totalKills.toLocaleString()}</span>
<span className="rare-stat-label">Total Kills</span>
</div>
<div className="rare-stat-card">
<span className="rare-stat-value">{killsPerRare > 0 ? `1 in ${killsPerRare.toLocaleString()}` : '--'}</span>
<span className="rare-stat-label">Drop Rate</span>
</div>
</div>
{/* Recent rare drops */}
{recentRares.length > 0 && (
<div className="chart-section">
<h3 className="chart-title">Recent Rare Drops (This Session)</h3>
<div className="rare-timeline">
{recentRares.map((r, i) => (
<div key={i} className="rare-event">
<span className="rare-time">{new Date(r.timestamp).toLocaleTimeString()}</span>
<span className="rare-char">{r.character_name}</span>
<span className="rare-name">{r.name}</span>
</div>
))}
</div>
</div>
)}
{/* Rares per character */}
{raresData.length > 0 && (
<div className="chart-section">
<h3 className="chart-title">Rares per Character (Lifetime)</h3>
<ResponsiveContainer width="100%" height={Math.max(200, raresData.length * 28)}>
<BarChart data={raresData} layout="vertical" margin={{ left: 10, right: 20, top: 5, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis type="number" stroke="#888" fontSize={11} />
<YAxis type="category" dataKey="name" width={130} stroke="#888" fontSize={11} />
<Tooltip
contentStyle={{ background: '#1a1a1a', border: '1px solid #444', fontSize: 12 }}
formatter={(v: number) => [v, 'Rares']}
labelFormatter={(l, payload) => payload?.[0]?.payload?.fullName || l}
/>
<Bar dataKey="rares" fill="#ffcc00" radius={[0, 3, 3, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
);
};

View file

@ -0,0 +1,34 @@
import React, { useState } from 'react';
interface Tab {
id: string;
label: string;
content: React.ReactNode;
}
interface Props {
tabs: Tab[];
}
export const TabContainer: React.FC<Props> = ({ tabs }) => {
const [activeTab, setActiveTab] = useState(tabs[0]?.id ?? '');
return (
<div className="tab-container">
<div className="tab-bar">
{tabs.map(tab => (
<button
key={tab.id}
className={`tab-btn ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div className="tab-content">
{tabs.find(t => t.id === activeTab)?.content}
</div>
</div>
);
};

View file

@ -272,6 +272,204 @@ body {
margin-bottom: 4px; margin-bottom: 4px;
} }
/* ── Tab Container ────────────────────────────────────── */
.tab-container {
margin-top: 24px;
border-top: 1px solid var(--border);
padding-top: 16px;
}
.tab-bar {
display: flex;
gap: 4px;
margin-bottom: 16px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.tab-btn {
padding: 8px 20px;
font-size: 0.85rem;
background: var(--bg-card);
color: var(--text-muted);
border: 1px solid var(--border);
border-radius: var(--radius) var(--radius) 0 0;
cursor: pointer;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.tab-btn:hover { background: var(--bg-card-hover); color: var(--text); }
.tab-btn.active {
background: var(--bg-card-hover);
color: var(--accent);
border-bottom-color: var(--bg-body);
}
.tab-content {
min-height: 300px;
}
/* ── Chart Sections ───────────────────────────────────── */
.chart-section {
margin-bottom: 24px;
}
.chart-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--text);
margin-bottom: 12px;
}
/* ── Rares Tab ────────────────────────────────────────── */
.rares-summary {
display: flex;
gap: 16px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.rare-stat-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 20px;
display: flex;
flex-direction: column;
align-items: center;
min-width: 140px;
}
.rare-stat-value {
font-size: 1.3rem;
font-weight: 700;
color: #ffcc00;
}
.rare-stat-label {
font-size: 0.7rem;
color: var(--text-muted);
margin-top: 4px;
}
.rare-timeline {
max-height: 200px;
overflow-y: auto;
}
.rare-event {
display: flex;
gap: 12px;
padding: 4px 0;
border-bottom: 1px solid #222;
font-size: 0.8rem;
}
.rare-time { color: var(--text-dim); width: 80px; }
.rare-char { color: var(--text-muted); width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rare-name { color: #ffcc00; font-weight: 600; }
/* ── Map Tab ──────────────────────────────────────────── */
.map-tab {
display: flex;
flex-direction: column;
align-items: center;
}
.map-container {
position: relative;
width: 100%;
max-width: 800px;
aspect-ratio: 1;
}
.map-image {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.map-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.map-legend {
display: flex;
gap: 16px;
align-items: center;
margin-top: 8px;
font-size: 0.75rem;
color: var(--text-muted);
}
.legend-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
}
.legend-dot.hunting { background: #44cc44; }
.legend-dot.other { background: #ffaa00; }
.map-count { margin-left: auto; }
/* ── Inventory Tab ────────────────────────────────────── */
.inventory-tab { width: 100%; }
.search-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.search-input {
flex: 1;
padding: 8px 12px;
font-size: 0.9rem;
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
outline: none;
}
.search-input:focus { border-color: var(--accent); }
.search-spinner { font-size: 0.75rem; color: var(--text-muted); }
.search-count { font-size: 0.75rem; color: var(--text-muted); margin-bottom: 8px; }
.search-empty { text-align: center; color: var(--text-dim); padding: 24px; }
.results-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.results-table th {
text-align: left;
padding: 6px 8px;
border-bottom: 1px solid var(--border);
color: var(--text-muted);
font-weight: 600;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.results-table td {
padding: 4px 8px;
border-bottom: 1px solid #1a1a1a;
color: var(--text);
}
.results-table tr:hover td { background: var(--bg-card-hover); }
.item-name { font-weight: 500; }
/* ── Mobile Responsive ────────────────────────────────── */ /* ── Mobile Responsive ────────────────────────────────── */
@media (max-width: 768px) { @media (max-width: 768px) {
.dashboard-header { .dashboard-header {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mosswart Overlord v2</title> <title>Mosswart Overlord v2</title>
<link rel="icon" type="image/png" href="/icons/7735.png" /> <link rel="icon" type="image/png" href="/icons/7735.png" />
<script type="module" crossorigin src="/v2/assets/index-DUtm0DVs.js"></script> <script type="module" crossorigin src="/v2/assets/index-DytF6DLt.js"></script>
<link rel="stylesheet" crossorigin href="/v2/assets/index-Ba_QIbRB.css"> <link rel="stylesheet" crossorigin href="/v2/assets/index-pBHPuybU.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>