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}`;
|
||||
}
|
||||
120
static/v2/assets/index-D9dGqbQ9.js
Normal file
120
static/v2/assets/index-D9dGqbQ9.js
Normal file
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
|
|
@ -5,8 +5,8 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mosswart Overlord v2</title>
|
||||
<link rel="icon" type="image/png" href="/icons/7735.png" />
|
||||
<script type="module" crossorigin src="/v2/assets/index-DytF6DLt.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/v2/assets/index-pBHPuybU.css">
|
||||
<script type="module" crossorigin src="/v2/assets/index-D9dGqbQ9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/v2/assets/index-Dfeaqu0o.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue