feat(v2): Phases 2-6 — trails, heatmap, portals, windows, effects
Phase 2 — Map overlays: - TrailsSVG: SVG polylines per character from /trails, polled 2s - HeatmapCanvas: canvas radial gradients from /spawns/heatmap - PortalMarkers: emoji markers from /portals - Sidebar toggles for heatmap and portals Phase 3 — Draggable windows: - WindowManagerContext: z-index stack for open windows - DraggableWindow: generic shell with drag-header, close btn, z-stack - ChatWindow: color-coded messages + input form (1000 msg buffer) - CharacterWindow: combat stats with monster damage table - InventoryWindow: item table with material/set/AL/dmg/workmanship - WindowRenderer: reads context, renders all open windows - Action buttons (Chat/Stats/Inv/Char/Radar) now open windows Phase 4 — Window types share same DraggableWindow shell with character-specific content. Combat stats and inventory via API. Phase 5 — Effects: - RareNotification: slide-in/slide-out banner with gold border - Fireworks: 30-particle explosion with CSS custom property animation - Notification queue with 6s display + 0.5s exit animation Phase 6 — Polish: - Window header uses modern blue gradient (not solid purple) - Chat uses monospace font - All overlay layers properly stacked (heatmap → trails → dots → portals) - Mobile: sidebar stacks above map at 768px breakpoint - Chat messages tracked per-character in useLiveData Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
183d662bb9
commit
de7b547349
20 changed files with 1040 additions and 193 deletions
95
frontend/src/components/effects/RareNotification.tsx
Normal file
95
frontend/src/components/effects/RareNotification.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import type { RareMessage } from '../../types';
|
||||
|
||||
interface Props {
|
||||
recentRares: RareMessage[];
|
||||
}
|
||||
|
||||
interface ActiveNotification {
|
||||
key: number;
|
||||
charName: string;
|
||||
rareName: string;
|
||||
exiting: boolean;
|
||||
}
|
||||
|
||||
let notifKey = 0;
|
||||
|
||||
export const RareNotification: React.FC<Props> = ({ recentRares }) => {
|
||||
const [active, setActive] = useState<ActiveNotification[]>([]);
|
||||
const [lastCount, setLastCount] = useState(0);
|
||||
const [fireworks, setFireworks] = useState<Array<{ id: number; particles: Array<{ dx: number; dy: number; color: string }> }>>([]);
|
||||
|
||||
// Detect new rares
|
||||
useEffect(() => {
|
||||
if (recentRares.length > lastCount && lastCount > 0) {
|
||||
const newRares = recentRares.slice(0, recentRares.length - lastCount);
|
||||
for (const r of newRares) {
|
||||
const key = ++notifKey;
|
||||
setActive(prev => [...prev, { key, charName: r.character_name, rareName: r.name, exiting: false }]);
|
||||
// Trigger fireworks
|
||||
triggerFireworks();
|
||||
// Auto-remove after 6s
|
||||
setTimeout(() => {
|
||||
setActive(prev => prev.map(n => n.key === key ? { ...n, exiting: true } : n));
|
||||
setTimeout(() => {
|
||||
setActive(prev => prev.filter(n => n.key !== key));
|
||||
}, 500);
|
||||
}, 6000);
|
||||
}
|
||||
}
|
||||
setLastCount(recentRares.length);
|
||||
}, [recentRares.length]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const triggerFireworks = useCallback(() => {
|
||||
const id = Date.now();
|
||||
const colors = ['#FFD700', '#FF4444', '#FF8800', '#AA44FF', '#4488FF'];
|
||||
const particles = Array.from({ length: 30 }, (_, i) => {
|
||||
const angle = (Math.PI * 2 * i) / 30 + (Math.random() - 0.5) * 0.5;
|
||||
const velocity = 100 + Math.random() * 200;
|
||||
return {
|
||||
dx: Math.cos(angle) * velocity,
|
||||
dy: Math.sin(angle) * velocity - 50,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
};
|
||||
});
|
||||
setFireworks(prev => [...prev, { id, particles }]);
|
||||
setTimeout(() => setFireworks(prev => prev.filter(f => f.id !== id)), 2200);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Notification banners */}
|
||||
<div className="ml-rare-notifications">
|
||||
{active.map(n => (
|
||||
<div key={n.key} className={`ml-rare-notif ${n.exiting ? 'exiting' : ''}`}>
|
||||
<div className="ml-rare-notif-title">LEGENDARY RARE!</div>
|
||||
<div className="ml-rare-notif-name">{n.rareName}</div>
|
||||
<div className="ml-rare-notif-by">found by</div>
|
||||
<div className="ml-rare-notif-char">{n.charName}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Fireworks particles */}
|
||||
<div className="ml-fireworks">
|
||||
{fireworks.map(fw => (
|
||||
<React.Fragment key={fw.id}>
|
||||
{fw.particles.map((p, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="ml-firework-particle"
|
||||
style={{
|
||||
left: '50%',
|
||||
top: '30%',
|
||||
backgroundColor: p.color,
|
||||
'--dx': `${p.dx}px`,
|
||||
'--dy': `${p.dy + 200}px`,
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
58
frontend/src/components/map/HeatmapCanvas.tsx
Normal file
58
frontend/src/components/map/HeatmapCanvas.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { worldToPx } from '../../utils/coordinates';
|
||||
import { apiFetch } from '../../api/client';
|
||||
|
||||
interface HeatmapPoint {
|
||||
ew: number;
|
||||
ns: number;
|
||||
intensity: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
imgW: number;
|
||||
imgH: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export const HeatmapCanvas: React.FC<Props> = ({ imgW, imgH, enabled }) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [data, setData] = useState<HeatmapPoint[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const fetch = async () => {
|
||||
try {
|
||||
const resp = await apiFetch<{ spawn_points: HeatmapPoint[] }>('/spawns/heatmap?hours=24&limit=50000');
|
||||
setData(resp.spawn_points ?? []);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
fetch();
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !enabled || data.length === 0 || imgW === 0) return;
|
||||
|
||||
canvas.width = imgW;
|
||||
canvas.height = imgH;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.clearRect(0, 0, imgW, imgH);
|
||||
|
||||
for (const point of data) {
|
||||
const { x, y } = worldToPx(point.ew, point.ns, imgW, imgH);
|
||||
const radius = Math.max(5, Math.min(12, 5 + Math.sqrt(point.intensity * 0.5)));
|
||||
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
|
||||
gradient.addColorStop(0, `rgba(255, 0, 0, ${Math.min(0.9, point.intensity / 40)})`);
|
||||
gradient.addColorStop(0.6, `rgba(255, 100, 0, ${Math.min(0.4, point.intensity / 120)})`);
|
||||
gradient.addColorStop(1, 'rgba(255, 150, 0, 0)');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(x - radius, y - radius, radius * 2, radius * 2);
|
||||
}
|
||||
}, [data, imgW, imgH, enabled]);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
return <canvas ref={canvasRef} className="ml-heatmap-canvas" />;
|
||||
};
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { MapTransformProvider } from '../../contexts/MapTransformContext';
|
||||
import { WindowManagerProvider } from '../../contexts/WindowManagerContext';
|
||||
import { MapView } from './MapView';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { WindowRenderer } from '../windows/WindowRenderer';
|
||||
import { RareNotification } from '../effects/RareNotification';
|
||||
import { usePlayerColors } from '../../hooks/usePlayerColors';
|
||||
import type { DashboardState } from '../../hooks/useLiveData';
|
||||
|
||||
|
|
@ -12,43 +15,52 @@ interface Props {
|
|||
|
||||
export const MapLayout: React.FC<Props> = ({ data, onViewToggle }) => {
|
||||
const getColor = usePlayerColors();
|
||||
const [showHeatmap, setShowHeatmap] = useState(false);
|
||||
const [showPortals, setShowPortals] = useState(false);
|
||||
|
||||
// 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) => {
|
||||
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>
|
||||
<WindowManagerProvider>
|
||||
<div className="ml-layout">
|
||||
<Sidebar
|
||||
players={players}
|
||||
vitals={vitalsMap}
|
||||
serverHealth={data.serverHealth}
|
||||
totalRares={data.totalRares}
|
||||
totalKills={data.totalKills}
|
||||
getColor={getColor}
|
||||
onSelectPlayer={handleSelectPlayer}
|
||||
onViewToggle={onViewToggle}
|
||||
showHeatmap={showHeatmap}
|
||||
showPortals={showPortals}
|
||||
onToggleHeatmap={setShowHeatmap}
|
||||
onTogglePortals={setShowPortals}
|
||||
/>
|
||||
<MapView
|
||||
players={players}
|
||||
getColor={getColor}
|
||||
onSelectPlayer={handleSelectPlayer}
|
||||
showHeatmap={showHeatmap}
|
||||
showPortals={showPortals}
|
||||
/>
|
||||
<WindowRenderer characters={data.characters} chatMessages={data.chatMessages} />
|
||||
<RareNotification recentRares={data.recentRares} />
|
||||
</div>
|
||||
</WindowManagerProvider>
|
||||
</MapTransformProvider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,15 +2,20 @@ import React, { useRef, useState, useCallback, useEffect } from 'react';
|
|||
import { useMapTransform } from '../../contexts/MapTransformContext';
|
||||
import { pxToWorld, formatCoord } from '../../utils/coordinates';
|
||||
import { PlayerDots } from './PlayerDots';
|
||||
import { TrailsSVG } from './TrailsSVG';
|
||||
import { HeatmapCanvas } from './HeatmapCanvas';
|
||||
import { PortalMarkers } from './PortalMarkers';
|
||||
import type { TelemetrySnapshot } from '../../types';
|
||||
|
||||
interface Props {
|
||||
players: TelemetrySnapshot[];
|
||||
getColor: (name: string) => string;
|
||||
onSelectPlayer: (name: string) => void;
|
||||
showHeatmap: boolean;
|
||||
showPortals: boolean;
|
||||
}
|
||||
|
||||
export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer }) => {
|
||||
export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer, showHeatmap, showPortals }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { transform, dispatch } = useMapTransform();
|
||||
const [imgSize, setImgSize] = useState({ w: 0, h: 0 });
|
||||
|
|
@ -23,7 +28,6 @@ export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer })
|
|||
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;
|
||||
|
|
@ -32,7 +36,6 @@ export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer })
|
|||
}
|
||||
}, [dispatch]);
|
||||
|
||||
// Wheel zoom
|
||||
const onWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
|
|
@ -41,7 +44,6 @@ export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer })
|
|||
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 };
|
||||
|
|
@ -53,7 +55,6 @@ export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer })
|
|||
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);
|
||||
|
|
@ -78,33 +79,32 @@ export const MapView: React.FC<Props> = ({ players, getColor, onSelectPlayer })
|
|||
>
|
||||
<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}
|
||||
/>
|
||||
<>
|
||||
<HeatmapCanvas imgW={imgSize.w} imgH={imgSize.h} enabled={showHeatmap} />
|
||||
<TrailsSVG imgW={imgSize.w} imgH={imgSize.h} getColor={getColor} />
|
||||
<PlayerDots
|
||||
players={players}
|
||||
imgW={imgSize.w}
|
||||
imgH={imgSize.h}
|
||||
getColor={getColor}
|
||||
onHover={handleDotHover}
|
||||
onSelect={onSelectPlayer}
|
||||
/>
|
||||
<PortalMarkers imgW={imgSize.w} imgH={imgSize.h} enabled={showPortals} />
|
||||
</>
|
||||
)}
|
||||
</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 />
|
||||
<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 className="ml-coords">{formatCoord(worldCoord.ns, worldCoord.ew)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
54
frontend/src/components/map/PortalMarkers.tsx
Normal file
54
frontend/src/components/map/PortalMarkers.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { worldToPx } from '../../utils/coordinates';
|
||||
import { apiFetch } from '../../api/client';
|
||||
|
||||
interface Portal {
|
||||
portal_name: string;
|
||||
coordinates: { ns: number; ew: number; z: number };
|
||||
discovered_by: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
imgW: number;
|
||||
imgH: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export const PortalMarkers: React.FC<Props> = ({ imgW, imgH, enabled }) => {
|
||||
const [portals, setPortals] = useState<Portal[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const fetch = async () => {
|
||||
try {
|
||||
const data = await apiFetch<{ portals: Portal[] }>('/portals');
|
||||
setPortals(data.portals ?? []);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
fetch();
|
||||
const id = setInterval(fetch, 60000);
|
||||
return () => clearInterval(id);
|
||||
}, [enabled]);
|
||||
|
||||
const markers = useMemo(() =>
|
||||
portals.map(p => ({
|
||||
...p,
|
||||
pos: worldToPx(p.coordinates.ew, p.coordinates.ns, imgW, imgH),
|
||||
})),
|
||||
[portals, imgW, imgH]);
|
||||
|
||||
if (!enabled || markers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="ml-portals-layer">
|
||||
{markers.map((p, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="ml-portal-icon"
|
||||
style={{ left: p.pos.x, top: p.pos.y }}
|
||||
title={`${p.portal_name} (by ${p.discovered_by})`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { PlayerList } from '../sidebar/PlayerList';
|
||||
import { SortButtons, type SortKey } from '../sidebar/SortButtons';
|
||||
import type { TelemetrySnapshot, VitalsMessage, ServerHealth } from '../../types';
|
||||
|
|
@ -12,10 +12,15 @@ interface Props {
|
|||
getColor: (name: string) => string;
|
||||
onSelectPlayer: (name: string) => void;
|
||||
onViewToggle: () => void;
|
||||
showHeatmap: boolean;
|
||||
showPortals: boolean;
|
||||
onToggleHeatmap: (v: boolean) => void;
|
||||
onTogglePortals: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<Props> = ({
|
||||
players, vitals, serverHealth, totalRares, totalKills, getColor, onSelectPlayer, onViewToggle,
|
||||
showHeatmap, showPortals, onToggleHeatmap, onTogglePortals,
|
||||
}) => {
|
||||
const [sortKey, setSortKey] = useState<SortKey>('name');
|
||||
const [filter, setFilter] = useState('');
|
||||
|
|
@ -46,27 +51,35 @@ export const Sidebar: React.FC<Props> = ({
|
|||
|
||||
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 */}
|
||||
{/* Map toggles */}
|
||||
<div className="ml-toggles">
|
||||
<label className="ml-toggle-label">
|
||||
<input type="checkbox" checked={showHeatmap} onChange={e => onToggleHeatmap(e.target.checked)} />
|
||||
<span>Spawn Heatmap</span>
|
||||
</label>
|
||||
<label className="ml-toggle-label">
|
||||
<input type="checkbox" checked={showPortals} onChange={e => onTogglePortals(e.target.checked)} />
|
||||
<span>Portals</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<SortButtons value={sortKey} onChange={setSortKey} />
|
||||
<input
|
||||
className="ml-filter"
|
||||
|
|
@ -76,7 +89,6 @@ export const Sidebar: React.FC<Props> = ({
|
|||
onChange={e => setFilter(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Player list */}
|
||||
<PlayerList
|
||||
players={sorted}
|
||||
vitals={vitals}
|
||||
|
|
|
|||
62
frontend/src/components/map/TrailsSVG.tsx
Normal file
62
frontend/src/components/map/TrailsSVG.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import React, { useMemo, useEffect, useState } from 'react';
|
||||
import { worldToPx } from '../../utils/coordinates';
|
||||
import { apiFetch } from '../../api/client';
|
||||
|
||||
interface TrailPoint {
|
||||
character_name: string;
|
||||
ew: number;
|
||||
ns: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
imgW: number;
|
||||
imgH: number;
|
||||
getColor: (name: string) => string;
|
||||
}
|
||||
|
||||
export const TrailsSVG: React.FC<Props> = React.memo(({ imgW, imgH, getColor }) => {
|
||||
const [trails, setTrails] = useState<TrailPoint[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTrails = async () => {
|
||||
try {
|
||||
const data = await apiFetch<{ trails: TrailPoint[] }>('/trails/?seconds=600');
|
||||
setTrails(data.trails ?? []);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
fetchTrails();
|
||||
const id = setInterval(fetchTrails, 2000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const polylines = useMemo(() => {
|
||||
const byChar: Record<string, string[]> = {};
|
||||
for (const pt of trails) {
|
||||
const { x, y } = worldToPx(pt.ew, pt.ns, imgW, imgH);
|
||||
if (!byChar[pt.character_name]) byChar[pt.character_name] = [];
|
||||
byChar[pt.character_name].push(`${x},${y}`);
|
||||
}
|
||||
return Object.entries(byChar)
|
||||
.filter(([, pts]) => pts.length >= 2)
|
||||
.map(([name, pts]) => ({ name, points: pts.join(' ') }));
|
||||
}, [trails, imgW, imgH]);
|
||||
|
||||
return (
|
||||
<svg className="ml-trails-svg" viewBox={`0 0 ${imgW} ${imgH}`} preserveAspectRatio="none">
|
||||
{polylines.map(p => (
|
||||
<polyline
|
||||
key={p.name}
|
||||
points={p.points}
|
||||
stroke={getColor(p.name)}
|
||||
fill="none"
|
||||
strokeWidth={2}
|
||||
strokeOpacity={0.7}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
|
||||
TrailsSVG.displayName = 'TrailsSVG';
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import React from 'react';
|
||||
import { formatCoord } from '../../utils/coordinates';
|
||||
import { useWindowManager } from '../../contexts/WindowManagerContext';
|
||||
import type { TelemetrySnapshot, VitalsMessage } from '../../types';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -10,34 +11,28 @@ interface Props {
|
|||
}
|
||||
|
||||
export const PlayerRow: React.FC<Props> = React.memo(({ player: p, vitals: v, color, onSelect }) => {
|
||||
const { openWindow } = useWindowManager();
|
||||
const vtState = (p.vt_state || 'idle').toLowerCase();
|
||||
const isActive = vtState === 'combat' || vtState === 'hunt';
|
||||
const kpr = (p.total_rares ?? 0) > 0
|
||||
? Math.round((p.total_kills ?? 0) / (p.total_rares ?? 1)).toLocaleString()
|
||||
: null;
|
||||
|
||||
const name = p.character_name;
|
||||
|
||||
return (
|
||||
<li className="ml-player-row" style={{ borderLeftColor: color }}>
|
||||
{/* Row 1: Name + coords */}
|
||||
<div className="ml-pr-header" onClick={onSelect}>
|
||||
<span className="ml-pr-name">{p.character_name}</span>
|
||||
<span className="ml-pr-name">{name}</span>
|
||||
<span className="ml-pr-coords">{formatCoord(p.ns, p.ew)}</span>
|
||||
</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 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-5: Stats grid — 3 columns × 3 rows for alignment */}
|
||||
<div className="ml-pr-grid">
|
||||
<span className="ml-gs" title="Session kills">⚔️ {p.kills?.toLocaleString() ?? 0}</span>
|
||||
<span className="ml-gs" title="Total kills">🏆 {(p.total_kills ?? 0).toLocaleString()}</span>
|
||||
|
|
@ -52,13 +47,12 @@ export const PlayerRow: React.FC<Props> = React.memo(({ player: p, vitals: v, co
|
|||
<span className="ml-gs" title="Prismatic tapers"><img src="/prismatic-taper-icon.png" className="ml-taper-icon" alt="" />{p.prismatic_taper_count ?? '0'}</span>
|
||||
</div>
|
||||
|
||||
{/* Row 6: Action buttons */}
|
||||
<div className="ml-pr-buttons">
|
||||
<button className="ml-btn accent" title="Chat">Chat</button>
|
||||
<button className="ml-btn accent" title="Stats">Stats</button>
|
||||
<button className="ml-btn accent" title="Inventory">Inv</button>
|
||||
<button className="ml-btn" title="Character">Char</button>
|
||||
<button className="ml-btn" title="Radar">Radar</button>
|
||||
<button className="ml-btn accent" onClick={() => openWindow(`chat-${name}`, `Chat: ${name}`, name)}>Chat</button>
|
||||
<button className="ml-btn accent" onClick={() => openWindow(`combat-${name}`, `Combat: ${name}`, name)}>Stats</button>
|
||||
<button className="ml-btn accent" onClick={() => openWindow(`inv-${name}`, `Inventory: ${name}`, name)}>Inv</button>
|
||||
<button className="ml-btn" onClick={() => openWindow(`char-${name}`, `Character: ${name}`, name)}>Char</button>
|
||||
<button className="ml-btn" onClick={() => openWindow(`radar-${name}`, `Radar: ${name}`, name)}>Radar</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
|
|
|||
61
frontend/src/components/windows/CharacterWindow.tsx
Normal file
61
frontend/src/components/windows/CharacterWindow.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { DraggableWindow } from './DraggableWindow';
|
||||
import { apiFetch } from '../../api/client';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
charName: string;
|
||||
zIndex: number;
|
||||
}
|
||||
|
||||
export const CharacterWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
|
||||
const [stats, setStats] = useState<Record<string, unknown> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<Record<string, unknown>>(`/combat-stats/${encodeURIComponent(charName)}`)
|
||||
.then(setStats).catch(() => {});
|
||||
}, [charName]);
|
||||
|
||||
const session = (stats as any)?.session;
|
||||
|
||||
return (
|
||||
<DraggableWindow id={id} title={`Character: ${charName}`} zIndex={zIndex} width={500} height={400}>
|
||||
<div style={{ padding: 8, fontSize: '0.8rem', color: '#ccc', overflowY: 'auto', flex: 1 }}>
|
||||
{session ? (
|
||||
<>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<strong>Session</strong>: {session.total_kills ?? 0} kills, {(session.total_damage_given ?? 0).toLocaleString()} dmg given
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<strong>Monsters fought</strong>: {Object.keys(session.monsters ?? {}).filter((k: string) => k !== '__cloak_surges__').length}
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #444', color: '#888' }}>
|
||||
<th style={{ textAlign: 'left', padding: '2px 4px' }}>Monster</th>
|
||||
<th style={{ textAlign: 'right', padding: '2px 4px' }}>Kills</th>
|
||||
<th style={{ textAlign: 'right', padding: '2px 4px' }}>Dmg Given</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.values(session.monsters ?? {})
|
||||
.filter((m: any) => m.name !== '__cloak_surges__')
|
||||
.sort((a: any, b: any) => (b.damage_given ?? 0) - (a.damage_given ?? 0))
|
||||
.slice(0, 30)
|
||||
.map((m: any) => (
|
||||
<tr key={m.name} style={{ borderBottom: '1px solid #222' }}>
|
||||
<td style={{ padding: '2px 4px' }}>{m.name}</td>
|
||||
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{m.kill_count}</td>
|
||||
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{(m.damage_given ?? 0).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: '#666' }}>Loading combat data...</div>
|
||||
)}
|
||||
</div>
|
||||
</DraggableWindow>
|
||||
);
|
||||
};
|
||||
45
frontend/src/components/windows/ChatWindow.tsx
Normal file
45
frontend/src/components/windows/ChatWindow.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { DraggableWindow } from './DraggableWindow';
|
||||
|
||||
interface ChatMsg {
|
||||
text: string;
|
||||
color?: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const CHAT_COLORS: Record<number, string> = {
|
||||
0:'#00FF00', 2:'#FFFFFF', 3:'#FF0000', 4:'#FFFFFF', 5:'#33CCFF', 6:'#CCFF99',
|
||||
7:'#00FFFF', 14:'#FFD700', 15:'#FF69B4', 17:'#AAAAFF', 18:'#88FF88',
|
||||
21:'#FF8888', 22:'#FFAA66',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
charName: string;
|
||||
zIndex: number;
|
||||
messages: ChatMsg[];
|
||||
}
|
||||
|
||||
export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages }) => {
|
||||
const msgsRef = useRef<HTMLDivElement>(null);
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (msgsRef.current) msgsRef.current.scrollTop = msgsRef.current.scrollHeight;
|
||||
}, [messages.length]);
|
||||
|
||||
return (
|
||||
<DraggableWindow id={id} title={`Chat: ${charName}`} zIndex={zIndex} width={600} height={300}>
|
||||
<div className="ml-chat-messages" ref={msgsRef}>
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} className="ml-chat-line" style={{ color: CHAT_COLORS[m.color ?? 2] ?? '#ddd' }}>
|
||||
{m.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<form className="ml-chat-form" onSubmit={e => { e.preventDefault(); setInput(''); }}>
|
||||
<input className="ml-chat-input" value={input} onChange={e => setInput(e.target.value)} placeholder="Enter chat..." />
|
||||
</form>
|
||||
</DraggableWindow>
|
||||
);
|
||||
};
|
||||
58
frontend/src/components/windows/DraggableWindow.tsx
Normal file
58
frontend/src/components/windows/DraggableWindow.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import React, { useRef, useCallback, useEffect } from 'react';
|
||||
import { useWindowManager } from '../../contexts/WindowManagerContext';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
title: string;
|
||||
zIndex: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DraggableWindow: React.FC<Props> = ({ id, title, zIndex, width = 700, height = 340, children }) => {
|
||||
const { closeWindow, bringToFront } = useWindowManager();
|
||||
const winRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef({ dragging: false, sx: 0, sy: 0, ox: 0, oy: 0 });
|
||||
const posRef = useRef({ x: 420, y: 10 + Math.random() * 40 });
|
||||
|
||||
const onHeaderDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
bringToFront(id);
|
||||
const rect = winRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
dragRef.current = { dragging: true, sx: e.clientX, sy: e.clientY, ox: rect.left, oy: rect.top };
|
||||
}, [id, bringToFront]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d.dragging || !winRef.current) return;
|
||||
posRef.current.x = d.ox + (e.clientX - d.sx);
|
||||
posRef.current.y = d.oy + (e.clientY - d.sy);
|
||||
winRef.current.style.left = `${posRef.current.x}px`;
|
||||
winRef.current.style.top = `${posRef.current.y}px`;
|
||||
};
|
||||
const onUp = () => { dragRef.current.dragging = false; };
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={winRef}
|
||||
className="ml-window"
|
||||
style={{ zIndex, width, height, left: posRef.current.x, top: posRef.current.y }}
|
||||
onMouseDown={() => bringToFront(id)}
|
||||
>
|
||||
<div className="ml-window-header" onMouseDown={onHeaderDown}>
|
||||
<span className="ml-window-title">{title}</span>
|
||||
<button className="ml-window-close" onClick={() => closeWindow(id)}>×</button>
|
||||
</div>
|
||||
<div className="ml-window-content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
70
frontend/src/components/windows/InventoryWindow.tsx
Normal file
70
frontend/src/components/windows/InventoryWindow.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { DraggableWindow } from './DraggableWindow';
|
||||
import { apiFetch } from '../../api/client';
|
||||
|
||||
interface Props { id: string; charName: string; zIndex: number; }
|
||||
|
||||
interface Item {
|
||||
Name: string;
|
||||
ObjectClass?: number;
|
||||
Icon?: number;
|
||||
Value?: number;
|
||||
Burden?: number;
|
||||
ArmorLevel?: number;
|
||||
MaxDamage?: number;
|
||||
Workmanship?: number;
|
||||
Material?: string;
|
||||
ItemSet?: string;
|
||||
Imbue?: string;
|
||||
EquipSkill?: string;
|
||||
}
|
||||
|
||||
export const InventoryWindow: React.FC<Props> = ({ id, charName, zIndex }) => {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
apiFetch<{ items: Item[] }>(`/inventory/${encodeURIComponent(charName)}?limit=500`)
|
||||
.then(d => setItems(d.items ?? []))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [charName]);
|
||||
|
||||
return (
|
||||
<DraggableWindow id={id} title={`Inventory: ${charName}`} zIndex={zIndex} width={650} height={450}>
|
||||
<div style={{ overflowY: 'auto', flex: 1, fontSize: '0.75rem' }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: 16, color: '#666' }}>Loading inventory...</div>
|
||||
) : items.length === 0 ? (
|
||||
<div style={{ padding: 16, color: '#666' }}>No inventory data</div>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid #444', color: '#888', fontSize: '0.7rem' }}>
|
||||
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Item</th>
|
||||
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Material</th>
|
||||
<th style={{ textAlign: 'left', padding: '3px 4px' }}>Set</th>
|
||||
<th style={{ textAlign: 'right', padding: '3px 4px' }}>AL</th>
|
||||
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Dmg</th>
|
||||
<th style={{ textAlign: 'right', padding: '3px 4px' }}>Work</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid #1a1a1a', color: '#ccc' }}>
|
||||
<td style={{ padding: '2px 4px', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.Name}</td>
|
||||
<td style={{ padding: '2px 4px', color: '#888' }}>{item.Material || ''}</td>
|
||||
<td style={{ padding: '2px 4px', color: '#888' }}>{item.ItemSet || ''}</td>
|
||||
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.ArmorLevel && item.ArmorLevel > 0 ? item.ArmorLevel : ''}</td>
|
||||
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.MaxDamage && item.MaxDamage > 0 ? item.MaxDamage : ''}</td>
|
||||
<td style={{ textAlign: 'right', padding: '2px 4px' }}>{item.Workmanship && item.Workmanship > 0 ? item.Workmanship : ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</DraggableWindow>
|
||||
);
|
||||
};
|
||||
37
frontend/src/components/windows/WindowRenderer.tsx
Normal file
37
frontend/src/components/windows/WindowRenderer.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import React from 'react';
|
||||
import { useWindowManager } from '../../contexts/WindowManagerContext';
|
||||
import { ChatWindow } from './ChatWindow';
|
||||
import { CharacterWindow } from './CharacterWindow';
|
||||
import { InventoryWindow } from './InventoryWindow';
|
||||
import type { CharacterState } from '../../types';
|
||||
|
||||
interface Props {
|
||||
characters: Map<string, CharacterState>;
|
||||
chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>;
|
||||
}
|
||||
|
||||
export const WindowRenderer: React.FC<Props> = ({ characters, chatMessages }) => {
|
||||
const { windows } = useWindowManager();
|
||||
|
||||
return (
|
||||
<>
|
||||
{windows.map(w => {
|
||||
const charName = w.charName ?? '';
|
||||
|
||||
if (w.id.startsWith('chat-')) {
|
||||
return <ChatWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} messages={chatMessages.get(charName) ?? []} />;
|
||||
}
|
||||
if (w.id.startsWith('char-') || w.id.startsWith('combat-')) {
|
||||
return <CharacterWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
|
||||
}
|
||||
if (w.id.startsWith('inv-')) {
|
||||
return <InventoryWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} />;
|
||||
}
|
||||
// Fallback: generic window with placeholder
|
||||
return (
|
||||
<ChatWindow key={w.id} id={w.id} charName={charName} zIndex={w.zIndex} messages={[]} />
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
49
frontend/src/contexts/WindowManagerContext.tsx
Normal file
49
frontend/src/contexts/WindowManagerContext.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
|
||||
|
||||
interface WindowState {
|
||||
id: string;
|
||||
title: string;
|
||||
charName?: string;
|
||||
zIndex: number;
|
||||
}
|
||||
|
||||
interface WindowManagerValue {
|
||||
windows: WindowState[];
|
||||
openWindow: (id: string, title: string, charName?: string) => void;
|
||||
closeWindow: (id: string) => void;
|
||||
bringToFront: (id: string) => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<WindowManagerValue>({
|
||||
windows: [],
|
||||
openWindow: () => {},
|
||||
closeWindow: () => {},
|
||||
bringToFront: () => {},
|
||||
});
|
||||
|
||||
export const WindowManagerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [windows, setWindows] = useState<WindowState[]>([]);
|
||||
const zRef = useRef(10000);
|
||||
|
||||
const openWindow = useCallback((id: string, title: string, charName?: string) => {
|
||||
setWindows(prev => {
|
||||
const existing = prev.find(w => w.id === id);
|
||||
if (existing) {
|
||||
return prev.map(w => w.id === id ? { ...w, zIndex: ++zRef.current } : w);
|
||||
}
|
||||
return [...prev, { id, title, charName, zIndex: ++zRef.current }];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeWindow = useCallback((id: string) => {
|
||||
setWindows(prev => prev.filter(w => w.id !== id));
|
||||
}, []);
|
||||
|
||||
const bringToFront = useCallback((id: string) => {
|
||||
setWindows(prev => prev.map(w => w.id === id ? { ...w, zIndex: ++zRef.current } : w));
|
||||
}, []);
|
||||
|
||||
return <Ctx.Provider value={{ windows, openWindow, closeWindow, bringToFront }}>{children}</Ctx.Provider>;
|
||||
};
|
||||
|
||||
export const useWindowManager = () => useContext(Ctx);
|
||||
|
|
@ -12,6 +12,7 @@ export interface DashboardState {
|
|||
totalRares: number;
|
||||
totalKills: number;
|
||||
recentRares: RareMessage[];
|
||||
chatMessages: Map<string, Array<{ text: string; color?: number; timestamp: string }>>;
|
||||
}
|
||||
|
||||
export function useLiveData(): DashboardState {
|
||||
|
|
@ -20,6 +21,7 @@ export function useLiveData(): DashboardState {
|
|||
const [totalRares, setTotalRares] = useState(0);
|
||||
const [totalKills, setTotalKills] = useState(0);
|
||||
const [recentRares, setRecentRares] = useState<RareMessage[]>([]);
|
||||
const [chatMessages, setChatMessages] = useState<Map<string, Array<{ text: string; color?: number; timestamp: string }>>>(new Map());
|
||||
const charsRef = useRef(characters);
|
||||
charsRef.current = characters;
|
||||
|
||||
|
|
@ -49,6 +51,15 @@ export function useLiveData(): DashboardState {
|
|||
} else if (msg.type === 'rare') {
|
||||
const r = msg as RareMessage;
|
||||
setRecentRares(prev => [r, ...prev].slice(0, 50));
|
||||
} else if (msg.type === 'chat') {
|
||||
const m = msg as unknown as { character_name: string; text: string; color?: number; timestamp: string };
|
||||
setChatMessages(prev => {
|
||||
const next = new Map(prev);
|
||||
const arr = [...(next.get(m.character_name) ?? []), { text: m.text, color: m.color, timestamp: m.timestamp }];
|
||||
if (arr.length > 1000) arr.splice(0, arr.length - 1000);
|
||||
next.set(m.character_name, arr);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [updateChar]);
|
||||
|
||||
|
|
@ -129,5 +140,5 @@ export function useLiveData(): DashboardState {
|
|||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return { characters, serverHealth, totalRares, totalKills, recentRares };
|
||||
return { characters, serverHealth, totalRares, totalKills, recentRares, chatMessages };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -373,6 +373,235 @@
|
|||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Map toggles ──────────────────────────────────────── */
|
||||
.ml-toggles {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.ml-toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #aaa;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ml-toggle-label input { accent-color: #4488ff; }
|
||||
|
||||
/* ── Trail SVG overlay ────────────────────────────────── */
|
||||
.ml-trails-svg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Heatmap canvas overlay ───────────────────────────── */
|
||||
.ml-heatmap-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ── Portal markers ───────────────────────────────────── */
|
||||
.ml-portals-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ml-portal-icon {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: all;
|
||||
cursor: help;
|
||||
}
|
||||
.ml-portal-icon::before {
|
||||
content: '🌀';
|
||||
font-size: 10px;
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* ── Draggable windows ────────────────────────────────── */
|
||||
.ml-window {
|
||||
position: fixed;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.ml-window-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
background: linear-gradient(135deg, #2a3a5a, #1a2a40);
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid #334;
|
||||
}
|
||||
|
||||
.ml-window-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #aaccff;
|
||||
}
|
||||
|
||||
.ml-window-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.ml-window-close:hover { color: #f66; }
|
||||
|
||||
.ml-window-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Chat window ──────────────────────────────────────── */
|
||||
.ml-chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.75rem;
|
||||
font-family: 'Consolas', 'Courier New', monospace;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.ml-chat-line {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ml-chat-form {
|
||||
display: flex;
|
||||
border-top: 1px solid #333;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.ml-chat-input {
|
||||
flex: 1;
|
||||
background: #222;
|
||||
color: #eee;
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.78rem;
|
||||
outline: none;
|
||||
}
|
||||
.ml-chat-input:focus { border-color: #4488ff; }
|
||||
.ml-chat-input::placeholder { color: #666; }
|
||||
|
||||
/* ── Rare notifications ───────────────────────────────── */
|
||||
.ml-rare-notifications {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 99999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ml-rare-notif {
|
||||
background: linear-gradient(135deg, #1a0a2e, #2a1040);
|
||||
border: 2px solid #ffcc00;
|
||||
border-radius: 8px;
|
||||
padding: 16px 32px;
|
||||
text-align: center;
|
||||
animation: ml-notif-in 0.5s ease-out;
|
||||
box-shadow: 0 0 40px rgba(255, 204, 0, 0.3);
|
||||
}
|
||||
|
||||
.ml-rare-notif.exiting {
|
||||
animation: ml-notif-out 0.5s ease-in forwards;
|
||||
}
|
||||
|
||||
.ml-rare-notif-title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 800;
|
||||
color: #ffcc00;
|
||||
text-shadow: 0 0 20px rgba(255, 204, 0, 0.5);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ml-rare-notif-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.ml-rare-notif-by {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.ml-rare-notif-char {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #ffcc00;
|
||||
}
|
||||
|
||||
@keyframes ml-notif-in {
|
||||
from { transform: translateY(-40px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes ml-notif-out {
|
||||
to { transform: translateY(-60px); opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Fireworks ────────────────────────────────────────── */
|
||||
.ml-fireworks {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 99998;
|
||||
}
|
||||
|
||||
.ml-firework-particle {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
animation: ml-particle 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
}
|
||||
|
||||
@keyframes ml-particle {
|
||||
0% { transform: translate(0, 0) scale(1); opacity: 1; }
|
||||
100% { transform: translate(var(--dx), var(--dy)) scale(0); opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Mobile ───────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.ml-layout { flex-direction: column; }
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
120
static/v2/assets/index-BkJV_2F3.js
Normal file
120
static/v2/assets/index-BkJV_2F3.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
|
|
@ -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-SmjESM0y.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/v2/assets/index-CB6KCKZz.css">
|
||||
<script type="module" crossorigin src="/v2/assets/index-BkJV_2F3.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/v2/assets/index-B55o-nLL.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue