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:
parent
3791c01bf3
commit
2c4b8d3afb
16 changed files with 995 additions and 151 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { Layout } from './components/Layout';
|
||||
import { GlobalStats } from './components/GlobalStats';
|
||||
import { CharacterGrid } from './components/CharacterGrid';
|
||||
|
|
@ -6,44 +7,43 @@ 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 { MapLayout } from './components/map/MapLayout';
|
||||
import { useLiveData } from './hooks/useLiveData';
|
||||
import './styles/global.css';
|
||||
import './styles/map-layout.css';
|
||||
|
||||
type ViewMode = 'map' | 'dashboard';
|
||||
|
||||
export default function App() {
|
||||
const { characters, serverHealth, totalRares, totalKills, recentRares } = useLiveData();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(
|
||||
() => (localStorage.getItem('v2-view') as ViewMode) || 'map'
|
||||
);
|
||||
const data = useLiveData();
|
||||
|
||||
const toggleView = () => {
|
||||
const next = viewMode === 'map' ? 'dashboard' : 'map';
|
||||
setViewMode(next);
|
||||
localStorage.setItem('v2-view', next);
|
||||
};
|
||||
|
||||
if (viewMode === 'map') {
|
||||
return <MapLayout data={data} onViewToggle={toggleView} />;
|
||||
}
|
||||
|
||||
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 />,
|
||||
},
|
||||
{ id: 'combat', label: 'Combat', content: <CombatTab characters={data.characters} /> },
|
||||
{ id: 'rares', label: 'Rares', content: <RaresTab characters={data.characters} totalRares={data.totalRares} totalKills={data.totalKills} recentRares={data.recentRares} /> },
|
||||
{ id: 'map', label: 'Map', content: <MapTab characters={data.characters} /> },
|
||||
{ id: 'inventory', label: 'Inventory', content: <InventoryTab /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<GlobalStats
|
||||
activeChars={characters.size}
|
||||
totalKills={totalKills}
|
||||
totalRares={totalRares}
|
||||
serverHealth={serverHealth}
|
||||
/>
|
||||
<CharacterGrid characters={characters} />
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
|
||||
<button onClick={toggleView} className="tab-btn">Map View</button>
|
||||
</div>
|
||||
<GlobalStats activeChars={data.characters.size} totalKills={data.totalKills} totalRares={data.totalRares} serverHealth={data.serverHealth} />
|
||||
<CharacterGrid characters={data.characters} />
|
||||
<TabContainer tabs={tabs} />
|
||||
</Layout>
|
||||
);
|
||||
|
|
|
|||
54
frontend/src/components/map/MapLayout.tsx
Normal file
54
frontend/src/components/map/MapLayout.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { MapTransformProvider } from '../../contexts/MapTransformContext';
|
||||
import { MapView } from './MapView';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { usePlayerColors } from '../../hooks/usePlayerColors';
|
||||
import type { DashboardState } from '../../hooks/useLiveData';
|
||||
|
||||
interface Props {
|
||||
data: DashboardState;
|
||||
onViewToggle: () => void;
|
||||
}
|
||||
|
||||
export const MapLayout: React.FC<Props> = ({ data, onViewToggle }) => {
|
||||
const getColor = usePlayerColors();
|
||||
|
||||
// Build flat players array from characters map
|
||||
const players = Array.from(data.characters.values())
|
||||
.filter(c => c.telemetry)
|
||||
.map(c => c.telemetry!);
|
||||
|
||||
// Build vitals map
|
||||
const vitalsMap = new Map(
|
||||
Array.from(data.characters.values())
|
||||
.filter(c => c.vitals)
|
||||
.map(c => [c.name, c.vitals!])
|
||||
);
|
||||
|
||||
const handleSelectPlayer = useCallback((name: string) => {
|
||||
// TODO: zoom map to player position
|
||||
console.log('Select player:', name);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MapTransformProvider>
|
||||
<div className="ml-layout">
|
||||
<Sidebar
|
||||
players={players}
|
||||
vitals={vitalsMap}
|
||||
serverHealth={data.serverHealth}
|
||||
totalRares={data.totalRares}
|
||||
totalKills={data.totalKills}
|
||||
getColor={getColor}
|
||||
onSelectPlayer={handleSelectPlayer}
|
||||
onViewToggle={onViewToggle}
|
||||
/>
|
||||
<MapView
|
||||
players={players}
|
||||
getColor={getColor}
|
||||
onSelectPlayer={handleSelectPlayer}
|
||||
/>
|
||||
</div>
|
||||
</MapTransformProvider>
|
||||
);
|
||||
};
|
||||
111
frontend/src/components/map/MapView.tsx
Normal file
111
frontend/src/components/map/MapView.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
||||
import { useMapTransform } from '../../contexts/MapTransformContext';
|
||||
import { pxToWorld, formatCoord } from '../../utils/coordinates';
|
||||
import { PlayerDots } from './PlayerDots';
|
||||
import type { TelemetrySnapshot } from '../../types';
|
||||
|
||||
interface Props {
|
||||
players: TelemetrySnapshot[];
|
||||
getColor: (name: string) => string;
|
||||
onSelectPlayer: (name: string) => void;
|
||||
}
|
||||
|
||||
export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { transform, dispatch } = useMapTransform();
|
||||
const [imgSize, setImgSize] = useState({ w: 0, h: 0 });
|
||||
const [tooltip, setTooltip] = useState<{ x: number; y: number; player: TelemetrySnapshot } | null>(null);
|
||||
const [worldCoord, setWorldCoord] = useState<{ ns: number; ew: number } | null>(null);
|
||||
const dragRef = useRef<{ dragging: boolean; sx: number; sy: number; startOffX: number; startOffY: number }>({
|
||||
dragging: false, sx: 0, sy: 0, startOffX: 0, startOffY: 0,
|
||||
});
|
||||
|
||||
const onImgLoad = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
const img = e.currentTarget;
|
||||
setImgSize({ w: img.naturalWidth, h: img.naturalHeight });
|
||||
// Fit map to container on first load
|
||||
if (containerRef.current) {
|
||||
const cw = containerRef.current.clientWidth;
|
||||
const ch = containerRef.current.clientHeight;
|
||||
const scale = Math.min(cw / img.naturalWidth, ch / img.naturalHeight);
|
||||
dispatch({ type: 'SET', scale, offX: (cw - img.naturalWidth * scale) / 2, offY: (ch - img.naturalHeight * scale) / 2 });
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
// Wheel zoom
|
||||
const onWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const factor = e.deltaY < 0 ? 1.1 : 0.9;
|
||||
dispatch({ type: 'ZOOM', factor, cx: e.clientX - rect.left, cy: e.clientY - rect.top });
|
||||
}, [dispatch]);
|
||||
|
||||
// Pan drag
|
||||
const onMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
dragRef.current = { dragging: true, sx: e.clientX, sy: e.clientY, startOffX: transform.offX, startOffY: transform.offY };
|
||||
}, [transform.offX, transform.offY]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (d.dragging) {
|
||||
dispatch({ type: 'SET', scale: transform.scale, offX: d.startOffX + (e.clientX - d.sx), offY: d.startOffY + (e.clientY - d.sy) });
|
||||
}
|
||||
// World coordinate display
|
||||
if (containerRef.current && imgSize.w > 0) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const coord = pxToWorld(e.clientX - rect.left, e.clientY - rect.top, transform.scale, transform.offX, transform.offY, imgSize.w, imgSize.h);
|
||||
setWorldCoord(coord);
|
||||
}
|
||||
};
|
||||
const onMouseUp = () => { dragRef.current.dragging = false; };
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
return () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); };
|
||||
}, [dispatch, transform.scale, transform.offX, transform.offY, imgSize.w, imgSize.h]);
|
||||
|
||||
const handleDotHover = useCallback((player: TelemetrySnapshot | null, x: number, y: number) => {
|
||||
setTooltip(player ? { x, y, player } : null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="ml-map-container" ref={containerRef} onWheel={onWheel} onMouseDown={onMouseDown}>
|
||||
<div
|
||||
className="ml-map-group"
|
||||
style={{ transform: `translate(${transform.offX}px, ${transform.offY}px) scale(${transform.scale})` }}
|
||||
>
|
||||
<img src="/dereth.png" alt="Dereth" className="ml-map-img" onLoad={onImgLoad} draggable={false} />
|
||||
{imgSize.w > 0 && (
|
||||
<PlayerDots
|
||||
players={players}
|
||||
imgW={imgSize.w}
|
||||
imgH={imgSize.h}
|
||||
getColor={getColor}
|
||||
onHover={handleDotHover}
|
||||
onSelect={onSelectPlayer}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tooltip */}
|
||||
{tooltip && (
|
||||
<div className="ml-tooltip" style={{ left: tooltip.x + 12, top: tooltip.y - 10 }}>
|
||||
<strong>{tooltip.player.character_name}</strong>
|
||||
<br />
|
||||
{formatCoord(tooltip.player.ns, tooltip.player.ew)}
|
||||
<br />
|
||||
{tooltip.player.kills_per_hour} kph · {tooltip.player.kills?.toLocaleString()} kills
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coordinate display */}
|
||||
{worldCoord && (
|
||||
<div className="ml-coords">
|
||||
{formatCoord(worldCoord.ns, worldCoord.ew)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
46
frontend/src/components/map/PlayerDots.tsx
Normal file
46
frontend/src/components/map/PlayerDots.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { worldToPx } from '../../utils/coordinates';
|
||||
import type { TelemetrySnapshot } from '../../types';
|
||||
|
||||
interface Props {
|
||||
players: TelemetrySnapshot[];
|
||||
imgW: number;
|
||||
imgH: number;
|
||||
getColor: (name: string) => string;
|
||||
onHover: (player: TelemetrySnapshot | null, x: number, y: number) => void;
|
||||
onSelect: (name: string) => void;
|
||||
}
|
||||
|
||||
export const PlayerDots: React.FC<Props> = React.memo(({ players, imgW, imgH, getColor, onHover, onSelect }) => {
|
||||
const dots = useMemo(() =>
|
||||
players.filter(p => p.ew !== undefined && p.ns !== undefined).map(p => ({
|
||||
...p,
|
||||
pos: worldToPx(p.ew, p.ns, imgW, imgH),
|
||||
color: getColor(p.character_name),
|
||||
})),
|
||||
[players, imgW, imgH, getColor]);
|
||||
|
||||
return (
|
||||
<div className="ml-dots-layer">
|
||||
{dots.map(d => (
|
||||
<div
|
||||
key={d.character_name}
|
||||
className="ml-dot"
|
||||
style={{
|
||||
left: d.pos.x,
|
||||
top: d.pos.y,
|
||||
backgroundColor: d.color,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
const rect = e.currentTarget.closest('.ml-map-container')?.getBoundingClientRect();
|
||||
if (rect) onHover(d, e.clientX - rect.left, e.clientY - rect.top);
|
||||
}}
|
||||
onMouseLeave={() => onHover(null, 0, 0)}
|
||||
onClick={() => onSelect(d.character_name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
PlayerDots.displayName = 'PlayerDots';
|
||||
88
frontend/src/components/map/Sidebar.tsx
Normal file
88
frontend/src/components/map/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
24
frontend/src/components/sidebar/PlayerList.tsx
Normal file
24
frontend/src/components/sidebar/PlayerList.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import React from 'react';
|
||||
import { PlayerRow } from './PlayerRow';
|
||||
import type { TelemetrySnapshot, VitalsMessage } from '../../types';
|
||||
|
||||
interface Props {
|
||||
players: TelemetrySnapshot[];
|
||||
vitals: Map<string, VitalsMessage>;
|
||||
getColor: (name: string) => string;
|
||||
onSelect: (name: string) => void;
|
||||
}
|
||||
|
||||
export const PlayerList: React.FC<Props> = ({ players, vitals, getColor, onSelect }) => (
|
||||
<ul className="ml-player-list">
|
||||
{players.map(p => (
|
||||
<PlayerRow
|
||||
key={p.character_name}
|
||||
player={p}
|
||||
vitals={vitals.get(p.character_name) ?? null}
|
||||
color={getColor(p.character_name)}
|
||||
onSelect={() => onSelect(p.character_name)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
59
frontend/src/components/sidebar/PlayerRow.tsx
Normal file
59
frontend/src/components/sidebar/PlayerRow.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import React from 'react';
|
||||
import { formatCoord } from '../../utils/coordinates';
|
||||
import type { TelemetrySnapshot, VitalsMessage } from '../../types';
|
||||
|
||||
interface Props {
|
||||
player: TelemetrySnapshot;
|
||||
vitals: VitalsMessage | null;
|
||||
color: string;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
export const PlayerRow: React.FC<Props> = React.memo(({ player: p, vitals: v, color, onSelect }) => {
|
||||
const vtState = (p.vt_state || 'idle').toLowerCase();
|
||||
const isActive = vtState === 'combat' || vtState === 'hunt';
|
||||
|
||||
return (
|
||||
<li className="ml-player-row" style={{ borderLeftColor: color }} onClick={onSelect}>
|
||||
{/* Row 1: Name + coords */}
|
||||
<div className="ml-pr-name">{p.character_name}</div>
|
||||
<div className="ml-pr-coords">{formatCoord(p.ns, p.ew)}</div>
|
||||
|
||||
{/* Row 2: Vital bars */}
|
||||
<div className="ml-pr-vitals">
|
||||
<div className="ml-vital-bar hp">
|
||||
<div className="ml-vital-fill" style={{ width: `${v?.health_percentage ?? 0}%` }} />
|
||||
</div>
|
||||
<div className="ml-vital-bar sta">
|
||||
<div className="ml-vital-fill" style={{ width: `${v?.stamina_percentage ?? 0}%` }} />
|
||||
</div>
|
||||
<div className="ml-vital-bar mana">
|
||||
<div className="ml-vital-fill" style={{ width: `${v?.mana_percentage ?? 0}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Stats */}
|
||||
<div className="ml-pr-stats">
|
||||
<span className="ml-stat" title="Session kills">{p.kills?.toLocaleString() ?? 0}</span>
|
||||
<span className="ml-stat" title="Total kills">{(p.total_kills ?? 0).toLocaleString()}</span>
|
||||
<span className="ml-stat" title="Kills per hour">{p.kills_per_hour ?? '0'} kph</span>
|
||||
</div>
|
||||
|
||||
{/* Row 4: Rares + meta */}
|
||||
<div className="ml-pr-stats">
|
||||
<span className="ml-stat" title="Session rares">{p.session_rares ?? 0}r</span>
|
||||
<span className="ml-stat" title="Total rares">{p.total_rares ?? 0}r</span>
|
||||
<span className={`ml-meta-pill ${isActive ? 'active' : ''}`}>{p.vt_state || 'idle'}</span>
|
||||
</div>
|
||||
|
||||
{/* Row 5: Time + deaths + tapers */}
|
||||
<div className="ml-pr-stats">
|
||||
<span className="ml-stat" title="Online time">{p.onlinetime?.replace(/^00\./, '') ?? '--'}</span>
|
||||
<span className="ml-stat" title="Deaths">{p.deaths ?? '0'}d</span>
|
||||
<span className="ml-stat" title="Prismatic tapers">{p.prismatic_taper_count ?? '0'}t</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
PlayerRow.displayName = 'PlayerRow';
|
||||
31
frontend/src/components/sidebar/SortButtons.tsx
Normal file
31
frontend/src/components/sidebar/SortButtons.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import React from 'react';
|
||||
|
||||
export type SortKey = 'name' | 'kph' | 'skills' | 'srares' | 'tkills' | 'kpr';
|
||||
|
||||
const SORTS: { key: SortKey; label: string }[] = [
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'kph', label: 'KPH' },
|
||||
{ key: 'skills', label: 'S.Kills' },
|
||||
{ key: 'srares', label: 'S.Rares' },
|
||||
{ key: 'tkills', label: 'T.Kills' },
|
||||
{ key: 'kpr', label: 'KPR' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
value: SortKey;
|
||||
onChange: (key: SortKey) => void;
|
||||
}
|
||||
|
||||
export const SortButtons: React.FC<Props> = ({ value, onChange }) => (
|
||||
<div className="ml-sort-buttons">
|
||||
{SORTS.map(s => (
|
||||
<button
|
||||
key={s.key}
|
||||
className={`ml-sort-btn ${value === s.key ? 'active' : ''}`}
|
||||
onClick={() => onChange(s.key)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
47
frontend/src/contexts/MapTransformContext.tsx
Normal file
47
frontend/src/contexts/MapTransformContext.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import React, { createContext, useContext, useReducer, type Dispatch } from 'react';
|
||||
|
||||
interface MapTransform {
|
||||
scale: number;
|
||||
offX: number;
|
||||
offY: number;
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: 'SET'; scale: number; offX: number; offY: number }
|
||||
| { type: 'ZOOM'; factor: number; cx: number; cy: number }
|
||||
| { type: 'PAN'; dx: number; dy: number };
|
||||
|
||||
const MAX_ZOOM = 20;
|
||||
const MIN_ZOOM = 0.3;
|
||||
|
||||
function reducer(state: MapTransform, action: Action): MapTransform {
|
||||
switch (action.type) {
|
||||
case 'SET':
|
||||
return { scale: action.scale, offX: action.offX, offY: action.offY };
|
||||
case 'ZOOM': {
|
||||
const newScale = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, state.scale * action.factor));
|
||||
const ratio = newScale / state.scale;
|
||||
return {
|
||||
scale: newScale,
|
||||
offX: action.cx - (action.cx - state.offX) * ratio,
|
||||
offY: action.cy - (action.cy - state.offY) * ratio,
|
||||
};
|
||||
}
|
||||
case 'PAN':
|
||||
return { ...state, offX: state.offX + action.dx, offY: state.offY + action.dy };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const Ctx = createContext<{ transform: MapTransform; dispatch: Dispatch<Action> }>({
|
||||
transform: { scale: 1, offX: 0, offY: 0 },
|
||||
dispatch: () => {},
|
||||
});
|
||||
|
||||
export const MapTransformProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [transform, dispatch] = useReducer(reducer, { scale: 1, offX: 0, offY: 0 });
|
||||
return <Ctx.Provider value={{ transform, dispatch }}>{children}</Ctx.Provider>;
|
||||
};
|
||||
|
||||
export const useMapTransform = () => useContext(Ctx);
|
||||
33
frontend/src/hooks/usePlayerColors.ts
Normal file
33
frontend/src/hooks/usePlayerColors.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { useRef, useCallback } from 'react';
|
||||
|
||||
// Matches v1 script.js PALETTE — 30 accessible colors
|
||||
const PALETTE = [
|
||||
'#1f77b4','#ff7f0e','#2ca02c','#d62728','#9467bd','#8c564b','#e377c2','#7f7f7f',
|
||||
'#bcbd22','#17becf','#aec7e8','#ffbb78','#98df8a','#ff9896','#c5b0d5','#c49c94',
|
||||
'#f7b6d2','#c7c7c7','#dbdb8d','#9edae5','#393b79','#637939','#8c6d31','#843c39',
|
||||
'#7b4173','#5254a3','#6b6ecf','#9c9ede','#d6616b','#ce6dbd',
|
||||
];
|
||||
|
||||
function hashColor(name: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) h = ((h << 5) - h + name.charCodeAt(i)) | 0;
|
||||
return `hsl(${Math.abs(h) % 360}, 72%, 50%)`;
|
||||
}
|
||||
|
||||
export function usePlayerColors() {
|
||||
const mapRef = useRef(new Map<string, string>());
|
||||
const idxRef = useRef(0);
|
||||
|
||||
const getColor = useCallback((name: string): string => {
|
||||
let c = mapRef.current.get(name);
|
||||
if (!c) {
|
||||
c = idxRef.current < PALETTE.length
|
||||
? PALETTE[idxRef.current++]
|
||||
: hashColor(name);
|
||||
mapRef.current.set(name, c);
|
||||
}
|
||||
return c;
|
||||
}, []);
|
||||
|
||||
return getColor;
|
||||
}
|
||||
320
frontend/src/styles/map-layout.css
Normal file
320
frontend/src/styles/map-layout.css
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
/* ═══════════════════════════════════════════════════════════
|
||||
Map Layout — faithful reproduction of v1 style.css
|
||||
Scoped under .ml-* prefix to avoid conflicts with dashboard
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Layout ───────────────────────────────────────────── */
|
||||
.ml-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #111;
|
||||
color: #eee;
|
||||
font-family: "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
/* ── Sidebar ──────────────────────────────────────────── */
|
||||
.ml-sidebar {
|
||||
width: 400px;
|
||||
min-width: 400px;
|
||||
background: #1a1a1a;
|
||||
border-right: 2px solid #333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.ml-sidebar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ml-sidebar-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #88f;
|
||||
}
|
||||
|
||||
.ml-view-toggle {
|
||||
font-size: 0.7rem;
|
||||
padding: 3px 10px;
|
||||
background: #333;
|
||||
color: #aaa;
|
||||
border: 1px solid #555;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ml-view-toggle:hover { background: #444; color: #fff; }
|
||||
|
||||
/* ── Server status ────────────────────────────────────── */
|
||||
.ml-server-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 0 8px;
|
||||
font-size: 0.75rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.ml-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.ml-status-dot.online { background: #4c4; animation: ml-pulse 2s ease-in-out infinite; }
|
||||
.ml-status-dot.offline { background: #c44; }
|
||||
.ml-status-latency { margin-left: auto; color: #888; }
|
||||
|
||||
@keyframes ml-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
|
||||
/* ── Aggregate counters ───────────────────────────────── */
|
||||
.ml-counters {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.ml-counter {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 6px 4px;
|
||||
border-radius: 4px;
|
||||
background: #222;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.ml-counter-val {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ml-counter-lbl {
|
||||
display: block;
|
||||
font-size: 0.6rem;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.ml-counter.rares .ml-counter-val { color: #ffcc00; }
|
||||
.ml-counter.kph .ml-counter-val { color: #4af; }
|
||||
.ml-counter.kph { border-color: #234; animation: ml-kph-glow 3s ease-in-out infinite; }
|
||||
.ml-counter.kph.ultra { background: linear-gradient(135deg, #112, #221); animation: ml-kph-glow 1.5s ease-in-out infinite; }
|
||||
.ml-counter.kills .ml-counter-val { color: #f66; }
|
||||
|
||||
@keyframes ml-kph-glow {
|
||||
0%, 100% { box-shadow: 0 0 4px rgba(68, 170, 255, 0.2); }
|
||||
50% { box-shadow: 0 0 12px rgba(68, 170, 255, 0.5); }
|
||||
}
|
||||
|
||||
/* ── Sort buttons ─────────────────────────────────────── */
|
||||
.ml-sort-buttons {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.ml-sort-btn {
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
background: #2a2a2a;
|
||||
color: #888;
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.ml-sort-btn:hover { background: #333; color: #ccc; }
|
||||
.ml-sort-btn.active { background: #334; color: #88f; border-color: #88f; }
|
||||
|
||||
/* ── Filter input ─────────────────────────────────────── */
|
||||
.ml-filter {
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
font-size: 0.78rem;
|
||||
background: #222;
|
||||
color: #eee;
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
margin-bottom: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ml-filter:focus { border-color: #88f; }
|
||||
.ml-filter::placeholder { color: #666; }
|
||||
|
||||
/* ── Player list ──────────────────────────────────────── */
|
||||
.ml-player-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ml-player-row {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
border-left: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.ml-player-row:hover { background: #252525; }
|
||||
|
||||
.ml-pr-name {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ml-pr-coords {
|
||||
font-size: 0.65rem;
|
||||
color: #888;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
/* ── Vital bars ───────────────────────────────────────── */
|
||||
.ml-pr-vitals {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ml-vital-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ml-vital-bar.hp { background: #330000; }
|
||||
.ml-vital-bar.sta { background: #331a00; }
|
||||
.ml-vital-bar.mana { background: #001433; }
|
||||
|
||||
.ml-vital-bar.hp .ml-vital-fill { background: linear-gradient(90deg, #ff4444, #ff6666); }
|
||||
.ml-vital-bar.sta .ml-vital-fill { background: linear-gradient(90deg, #ffaa00, #ffcc44); }
|
||||
.ml-vital-bar.mana .ml-vital-fill { background: linear-gradient(90deg, #4488ff, #66aaff); }
|
||||
|
||||
.ml-vital-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* ── Stats row ────────────────────────────────────────── */
|
||||
.ml-pr-stats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 0.68rem;
|
||||
color: #aaa;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.ml-stat {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ml-meta-pill {
|
||||
font-size: 0.6rem;
|
||||
padding: 0 6px;
|
||||
border-radius: 3px;
|
||||
background: #333;
|
||||
color: #888;
|
||||
margin-left: auto;
|
||||
}
|
||||
.ml-meta-pill.active { background: rgba(68, 204, 68, 0.15); color: #4c4; }
|
||||
|
||||
/* ── Map container ────────────────────────────────────── */
|
||||
.ml-map-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
cursor: grab;
|
||||
}
|
||||
.ml-map-container:active { cursor: grabbing; }
|
||||
|
||||
.ml-map-group {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.ml-map-img {
|
||||
display: block;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
/* ── Player dots ──────────────────────────────────────── */
|
||||
.ml-dots-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ml-dot {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 1px solid rgba(0, 0, 0, 0.5);
|
||||
pointer-events: all;
|
||||
cursor: pointer;
|
||||
z-index: 5;
|
||||
}
|
||||
.ml-dot:hover {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* ── Tooltip ──────────────────────────────────────────── */
|
||||
.ml-tooltip {
|
||||
position: absolute;
|
||||
background: rgba(0, 30, 60, 0.92);
|
||||
color: #eee;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
white-space: nowrap;
|
||||
border: 1px solid #335;
|
||||
}
|
||||
|
||||
/* ── Coordinate display ───────────────────────────────── */
|
||||
.ml-coords {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
background: rgba(0, 50, 100, 0.85);
|
||||
color: #eee;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Mobile ───────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.ml-layout { flex-direction: column; }
|
||||
.ml-sidebar { width: 100%; min-width: 100%; max-height: 40vh; border-right: none; border-bottom: 2px solid #333; }
|
||||
.ml-map-container { min-height: 60vh; }
|
||||
}
|
||||
31
frontend/src/utils/coordinates.ts
Normal file
31
frontend/src/utils/coordinates.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Matches v1 script.js MAP_BOUNDS (UtilityBelt's coordinate system)
|
||||
export const MAP_BOUNDS = {
|
||||
west: -102.1,
|
||||
east: 102.1,
|
||||
north: 102.1,
|
||||
south: -102.1,
|
||||
};
|
||||
|
||||
export function worldToPx(ew: number, ns: number, imgW: number, imgH: number) {
|
||||
const x = ((ew - MAP_BOUNDS.west) / (MAP_BOUNDS.east - MAP_BOUNDS.west)) * imgW;
|
||||
const y = ((MAP_BOUNDS.north - ns) / (MAP_BOUNDS.north - MAP_BOUNDS.south)) * imgH;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
export function pxToWorld(
|
||||
screenX: number, screenY: number,
|
||||
scale: number, offX: number, offY: number,
|
||||
imgW: number, imgH: number,
|
||||
) {
|
||||
const mapX = (screenX - offX) / scale;
|
||||
const mapY = (screenY - offY) / scale;
|
||||
const ew = MAP_BOUNDS.west + (mapX / imgW) * (MAP_BOUNDS.east - MAP_BOUNDS.west);
|
||||
const ns = MAP_BOUNDS.north - (mapY / imgH) * (MAP_BOUNDS.north - MAP_BOUNDS.south);
|
||||
return { ew, ns };
|
||||
}
|
||||
|
||||
export function formatCoord(ns: number, ew: number): string {
|
||||
const nsDir = ns >= 0 ? 'N' : 'S';
|
||||
const ewDir = ew >= 0 ? 'E' : 'W';
|
||||
return `${Math.abs(ns).toFixed(1)}${nsDir}, ${Math.abs(ew).toFixed(1)}${ewDir}`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue