feat(v2): Phase 1 — map-first layout matching v1

Rebuilds the v1 map-centric experience in React:

Layout:
- 400px sidebar on left, interactive map on right (flex, 100vh)
- Exact same proportions and dark theme as v1

Sidebar (top→bottom):
- Header with active player count + Dashboard toggle button
- Server status dot (Coldeve online/offline with pulse)
- Aggregate counters: Rares (gold), Server KPH (blue glow), Kills (red)
- 6 sort buttons (Name, KPH, S.Kills, S.Rares, T.Kills, KPR)
- Player name filter
- Scrollable player list with per-row:
  - Name + coordinates
  - HP/Stamina/Mana vital bars (red/orange/blue gradients)
  - Session kills, total kills, KPH
  - Session rares, total rares, VTank meta state pill
  - Online time, deaths, prismatic tapers
  - Color-coded left border per player

Map:
- dereth.png with CSS transform pan (drag) + zoom (wheel, 1.1x factor, max 20x)
- Player dots (6px circles, color-matched to sidebar)
- Hover tooltip (name, coords, kph, kills)
- World coordinate display at cursor position
- Fit-to-window on first load

View toggle: Map View ↔ Dashboard with localStorage persistence.
All v1 CSS ported under ml-* prefix, scoped via map-layout.css.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 15:38:14 +02:00
parent 3791c01bf3
commit 2c4b8d3afb
16 changed files with 995 additions and 151 deletions

View file

@ -0,0 +1,88 @@
import React, { useState, useMemo, useCallback } from 'react';
import { PlayerList } from '../sidebar/PlayerList';
import { SortButtons, type SortKey } from '../sidebar/SortButtons';
import type { TelemetrySnapshot, VitalsMessage, ServerHealth } from '../../types';
interface Props {
players: TelemetrySnapshot[];
vitals: Map<string, VitalsMessage>;
serverHealth: ServerHealth | null;
totalRares: number;
totalKills: number;
getColor: (name: string) => string;
onSelectPlayer: (name: string) => void;
onViewToggle: () => void;
}
export const Sidebar: React.FC<Props> = ({
players, vitals, serverHealth, totalRares, totalKills, getColor, onSelectPlayer, onViewToggle,
}) => {
const [sortKey, setSortKey] = useState<SortKey>('name');
const [filter, setFilter] = useState('');
const serverKph = useMemo(() =>
players.reduce((sum, p) => sum + (parseInt(p.kills_per_hour) || 0), 0),
[players]);
const isOnline = serverHealth?.status?.toLowerCase() === 'online' || serverHealth?.status?.toLowerCase() === 'up';
const sorted = useMemo(() => {
let list = [...players];
if (filter) list = list.filter(p => p.character_name.toLowerCase().startsWith(filter.toLowerCase()));
switch (sortKey) {
case 'kph': list.sort((a, b) => (parseInt(b.kills_per_hour) || 0) - (parseInt(a.kills_per_hour) || 0)); break;
case 'skills': list.sort((a, b) => (b.kills || 0) - (a.kills || 0)); break;
case 'srares': list.sort((a, b) => (b.session_rares ?? 0) - (a.session_rares ?? 0)); break;
case 'tkills': list.sort((a, b) => (b.total_kills ?? 0) - (a.total_kills ?? 0)); break;
case 'kpr': list.sort((a, b) => {
const ar = (a.total_kills ?? 0) / Math.max(1, a.total_rares ?? 1);
const br = (b.total_kills ?? 0) / Math.max(1, b.total_rares ?? 1);
return ar - br;
}); break;
default: list.sort((a, b) => a.character_name.localeCompare(b.character_name));
}
return list;
}, [players, sortKey, filter]);
return (
<div className="ml-sidebar">
{/* Header */}
<div className="ml-sidebar-header">
<span className="ml-sidebar-title">Active Mosswart Enjoyers ({players.length})</span>
<button className="ml-view-toggle" onClick={onViewToggle}>Dashboard</button>
</div>
{/* Server status */}
<div className="ml-server-status">
<span className={`ml-status-dot ${isOnline ? 'online' : 'offline'}`} />
<span className="ml-status-text">Coldeve</span>
{serverHealth?.latency_ms != null && <span className="ml-status-latency">{serverHealth.latency_ms}ms</span>}
</div>
{/* Aggregate counters */}
<div className="ml-counters">
<div className="ml-counter rares"><span className="ml-counter-val">{totalRares}</span><span className="ml-counter-lbl">Rares</span></div>
<div className={`ml-counter kph ${serverKph > 5000 ? 'ultra' : ''}`}><span className="ml-counter-val">{serverKph.toLocaleString()}</span><span className="ml-counter-lbl">Server KPH</span></div>
<div className="ml-counter kills"><span className="ml-counter-val">{totalKills.toLocaleString()}</span><span className="ml-counter-lbl">Kills</span></div>
</div>
{/* Sort + filter */}
<SortButtons value={sortKey} onChange={setSortKey} />
<input
className="ml-filter"
type="text"
placeholder="Filter players..."
value={filter}
onChange={e => setFilter(e.target.value)}
/>
{/* Player list */}
<PlayerList
players={sorted}
vitals={vitals}
getColor={getColor}
onSelect={onSelectPlayer}
/>
</div>
);
};