furhter bug fixes and updates
This commit is contained in:
@@ -9,7 +9,8 @@ interface MapPoint {
|
||||
map_id: number;
|
||||
map_name: string;
|
||||
map_start_dt: string;
|
||||
players_online_at_start: number;
|
||||
map_end_dt: string | null;
|
||||
total_players: number;
|
||||
}
|
||||
|
||||
interface PlayerChip {
|
||||
@@ -22,31 +23,39 @@ const HEIGHT = 288;
|
||||
const PAD_LEFT = 32;
|
||||
const PAD_RIGHT = 8;
|
||||
const PAD_TOP = 8;
|
||||
const PAD_BOTTOM = 36;
|
||||
const FETCH_LIMIT = 150; // pool of maps available to pan/zoom through
|
||||
const PAD_BOTTOM = 12; // no map-name labels anymore, so far less bottom padding needed
|
||||
const INITIAL_FETCH = 150;
|
||||
const PAGE_FETCH = 100; // fetched when panning back beyond what's loaded
|
||||
const DEFAULT_VISIBLE = 40;
|
||||
const MIN_VISIBLE = 5;
|
||||
const HARD_FLOOR_EPOCH = Date.UTC(2015, 6, 16); // won't page back further than July 16, 2015
|
||||
|
||||
function parseDt(s: string): number {
|
||||
return new Date(s.replace(' ', 'T')).getTime();
|
||||
}
|
||||
|
||||
export default function MapPopulationChart() {
|
||||
const { ref: containerRef, width } = useContainerWidth<HTMLDivElement>();
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [points, setPoints] = useState<MapPoint[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fetchingMore, setFetchingMore] = useState(false);
|
||||
const [reachedStart, setReachedStart] = useState(false);
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||
|
||||
// Visible window (index range into `points`) — what scroll-to-zoom /
|
||||
// drag-to-pan adjusts. Defaults to the most recent DEFAULT_VISIBLE maps.
|
||||
const [viewStartIdx, setViewStartIdx] = useState(0);
|
||||
const [viewEndIdx, setViewEndIdx] = useState(0);
|
||||
|
||||
const dragStartRef = useRef<{ x: number; startIdx: number; endIdx: number } | null>(null);
|
||||
const dragMovedRef = useRef(false);
|
||||
const fetchInFlightRef = useRef(false);
|
||||
|
||||
const [selectedMap, setSelectedMap] = useState<MapPoint | null>(null);
|
||||
const [selectedPlayers, setSelectedPlayers] = useState<PlayerChip[]>([]);
|
||||
const [loadingPlayers, setLoadingPlayers] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/map-population?limit=${FETCH_LIMIT}`)
|
||||
fetch(`/api/map-population?limit=${INITIAL_FETCH}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const pts: MapPoint[] = data.points ?? [];
|
||||
@@ -75,10 +84,42 @@ export default function MapPopulationChart() {
|
||||
}
|
||||
}
|
||||
|
||||
// If panning/zooming brings the view near index 0, fetch more (older)
|
||||
// maps and prepend them, shifting indices to keep the visual window
|
||||
// stable. Stops once the earliest loaded map is at/before the hard floor.
|
||||
function ensureMoreIfNeeded(nextStartIdx: number) {
|
||||
if (fetchInFlightRef.current || reachedStart) return;
|
||||
if (nextStartIdx > 5) return; // only bother once we're close to the loaded edge
|
||||
const earliest = points[0];
|
||||
if (!earliest) return;
|
||||
if (parseDt(earliest.map_start_dt) <= HARD_FLOOR_EPOCH) {
|
||||
setReachedStart(true);
|
||||
return;
|
||||
}
|
||||
fetchInFlightRef.current = true;
|
||||
setFetchingMore(true);
|
||||
fetch(`/api/map-population?limit=${PAGE_FETCH}&before=${encodeURIComponent(new Date(parseDt(earliest.map_start_dt)).toISOString())}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const older: MapPoint[] = data.points ?? [];
|
||||
if (older.length === 0) {
|
||||
setReachedStart(true);
|
||||
return;
|
||||
}
|
||||
setPoints((prev) => [...older, ...prev]);
|
||||
setViewStartIdx((v) => v + older.length);
|
||||
setViewEndIdx((v) => v + older.length);
|
||||
})
|
||||
.finally(() => {
|
||||
fetchInFlightRef.current = false;
|
||||
setFetchingMore(false);
|
||||
});
|
||||
}
|
||||
|
||||
const visiblePoints = points.slice(viewStartIdx, viewEndIdx);
|
||||
const plotWidth = Math.max(0, width - PAD_LEFT - PAD_RIGHT);
|
||||
const plotHeight = HEIGHT - PAD_TOP - PAD_BOTTOM;
|
||||
const maxValue = Math.max(1, ...visiblePoints.map((p) => p.players_online_at_start));
|
||||
const maxValue = Math.max(1, ...visiblePoints.map((p) => p.total_players));
|
||||
const axisMax = Math.ceil((maxValue * 1.15) / 5) * 5 || 5;
|
||||
|
||||
const slotWidth = visiblePoints.length > 0 ? plotWidth / visiblePoints.length : 0;
|
||||
@@ -127,6 +168,7 @@ export default function MapPopulationChart() {
|
||||
}
|
||||
setViewStartIdx(newStart);
|
||||
setViewEndIdx(newEnd);
|
||||
ensureMoreIfNeeded(newStart);
|
||||
}
|
||||
} else {
|
||||
setHoverIndex(indexForX(x));
|
||||
@@ -137,34 +179,44 @@ export default function MapPopulationChart() {
|
||||
dragStartRef.current = null;
|
||||
}
|
||||
|
||||
function handleWheel(e: React.WheelEvent<SVGSVGElement>) {
|
||||
e.preventDefault();
|
||||
if (points.length === 0) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const anchorIdxInView = indexForX(x);
|
||||
const anchorAbsIdx = viewStartIdx + anchorIdxInView;
|
||||
useEffect(() => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return;
|
||||
|
||||
const currentSize = viewEndIdx - viewStartIdx;
|
||||
const zoomFactor = e.deltaY > 0 ? 1.2 : 1 / 1.2; // scroll down = zoom out (more bars), up = zoom in (fewer bars)
|
||||
let newSize = Math.round(currentSize * zoomFactor);
|
||||
newSize = Math.max(MIN_VISIBLE, Math.min(points.length, newSize));
|
||||
function onWheel(e: WheelEvent) {
|
||||
e.preventDefault();
|
||||
if (points.length === 0) return;
|
||||
const rect = svg!.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const anchorIdxInView = indexForX(x);
|
||||
const anchorAbsIdx = viewStartIdx + anchorIdxInView;
|
||||
|
||||
const ratio = currentSize > 0 ? (anchorAbsIdx - viewStartIdx) / currentSize : 0.5;
|
||||
let newStart = Math.round(anchorAbsIdx - ratio * newSize);
|
||||
let newEnd = newStart + newSize;
|
||||
const currentSize = viewEndIdx - viewStartIdx;
|
||||
const zoomFactor = e.deltaY > 0 ? 1.2 : 1 / 1.2;
|
||||
let newSize = Math.round(currentSize * zoomFactor);
|
||||
newSize = Math.max(MIN_VISIBLE, Math.min(points.length, newSize));
|
||||
|
||||
if (newStart < 0) {
|
||||
newStart = 0;
|
||||
newEnd = newSize;
|
||||
const ratio = currentSize > 0 ? (anchorAbsIdx - viewStartIdx) / currentSize : 0.5;
|
||||
let newStart = Math.round(anchorAbsIdx - ratio * newSize);
|
||||
let newEnd = newStart + newSize;
|
||||
|
||||
if (newStart < 0) {
|
||||
newStart = 0;
|
||||
newEnd = newSize;
|
||||
}
|
||||
if (newEnd > points.length) {
|
||||
newEnd = points.length;
|
||||
newStart = newEnd - newSize;
|
||||
}
|
||||
setViewStartIdx(newStart);
|
||||
setViewEndIdx(newEnd);
|
||||
ensureMoreIfNeeded(newStart);
|
||||
}
|
||||
if (newEnd > points.length) {
|
||||
newEnd = points.length;
|
||||
newStart = newEnd - newSize;
|
||||
}
|
||||
setViewStartIdx(newStart);
|
||||
setViewEndIdx(newEnd);
|
||||
}
|
||||
|
||||
svg.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => svg.removeEventListener('wheel', onWheel);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [points.length, viewStartIdx, viewEndIdx]);
|
||||
|
||||
function resetZoom() {
|
||||
const startIdx = Math.max(0, points.length - DEFAULT_VISIBLE);
|
||||
@@ -172,7 +224,7 @@ export default function MapPopulationChart() {
|
||||
setViewEndIdx(points.length);
|
||||
}
|
||||
|
||||
const isZoomed = viewStartIdx > 0 || viewEndIdx < points.length;
|
||||
const isZoomed = viewStartIdx > Math.max(0, points.length - DEFAULT_VISIBLE) || viewEndIdx < points.length;
|
||||
const yTicks = [0, Math.round(axisMax / 2), axisMax];
|
||||
|
||||
return (
|
||||
@@ -180,7 +232,8 @@ export default function MapPopulationChart() {
|
||||
<div className="mb-6">
|
||||
<div className="stat-label mb-1">Population by map</div>
|
||||
<h2 className="text-lg text-ink">
|
||||
{visiblePoints.length} of {points.length} maps
|
||||
{visiblePoints.length} of {points.length}
|
||||
{reachedStart ? '' : '+'} maps
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -190,7 +243,8 @@ export default function MapPopulationChart() {
|
||||
<path d="M9 9l6 6M9 15l6-6" strokeLinecap="round" />
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
</svg>
|
||||
Scroll to zoom, drag to pan, click a bar for who was online
|
||||
Scroll to zoom, drag to pan (all the way back to 2015), click a bar for who was online
|
||||
{fetchingMore && <span className="text-ink-faint">— loading more…</span>}
|
||||
</div>
|
||||
{isZoomed && (
|
||||
<button onClick={resetZoom} className="text-xs text-ink-muted hover:text-accent transition-colors underline">
|
||||
@@ -211,6 +265,7 @@ export default function MapPopulationChart() {
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={width}
|
||||
height={HEIGHT}
|
||||
style={{ cursor: 'crosshair', touchAction: 'none' }}
|
||||
@@ -221,7 +276,6 @@ export default function MapPopulationChart() {
|
||||
setHoverIndex(null);
|
||||
dragStartRef.current = null;
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
onClick={() => {
|
||||
if (dragMovedRef.current) {
|
||||
dragMovedRef.current = false;
|
||||
@@ -232,6 +286,13 @@ export default function MapPopulationChart() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="mapBarGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#5EE596" />
|
||||
<stop offset="100%" stopColor="#2A9C5C" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{yTicks.map((v) => {
|
||||
const y = barY(v);
|
||||
return (
|
||||
@@ -248,33 +309,16 @@ export default function MapPopulationChart() {
|
||||
<rect
|
||||
key={p.map_id}
|
||||
x={barX(i)}
|
||||
y={barY(p.players_online_at_start)}
|
||||
y={barY(p.total_players)}
|
||||
width={barWidth}
|
||||
height={barHeight(p.players_online_at_start)}
|
||||
rx={2}
|
||||
fill="#3FD37A"
|
||||
fillOpacity={hoverIndex === i ? 1 : 0.85}
|
||||
height={barHeight(p.total_players)}
|
||||
rx={3}
|
||||
fill="url(#mapBarGradient)"
|
||||
fillOpacity={hoverIndex === i ? 1 : 0.8}
|
||||
style={hoverIndex === i ? { filter: 'drop-shadow(0 0 6px rgba(63, 211, 122, 0.55))' } : undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
{visiblePoints.map((p, i) => {
|
||||
const showEvery = Math.max(1, Math.ceil((visiblePoints.length * 70) / plotWidth));
|
||||
if (i !== 0 && i !== visiblePoints.length - 1 && i % showEvery !== 0) return null;
|
||||
const label = p.map_name.length > 12 ? `${p.map_name.slice(0, 11)}…` : p.map_name;
|
||||
return (
|
||||
<text
|
||||
key={p.map_id}
|
||||
x={barX(i) + barWidth / 2}
|
||||
y={HEIGHT - PAD_BOTTOM + 14}
|
||||
textAnchor="middle"
|
||||
fontSize={9}
|
||||
fill="#8A9691"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
|
||||
<line
|
||||
x1={PAD_LEFT}
|
||||
y1={PAD_TOP + plotHeight}
|
||||
@@ -288,8 +332,8 @@ export default function MapPopulationChart() {
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
left: Math.min(width - 180, Math.max(0, barX(hoverIndex) - 60)),
|
||||
top: barY(visiblePoints[hoverIndex].players_online_at_start) - 60,
|
||||
left: Math.min(width - 200, Math.max(0, barX(hoverIndex) - 60)),
|
||||
top: barY(visiblePoints[hoverIndex].total_players) - 70,
|
||||
background: '#121715',
|
||||
border: '1px solid #1F2723',
|
||||
borderRadius: 6,
|
||||
@@ -303,7 +347,7 @@ export default function MapPopulationChart() {
|
||||
{new Date(visiblePoints[hoverIndex].map_start_dt.replace(' ', 'T')).toLocaleString()}
|
||||
</div>
|
||||
<div style={{ color: '#3FD37A' }}>
|
||||
{visiblePoints[hoverIndex].players_online_at_start} players at map start
|
||||
{visiblePoints[hoverIndex].total_players} total players during this session
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -323,13 +367,20 @@ export default function MapPopulationChart() {
|
||||
|
||||
{selectedMap && (
|
||||
<div className="mt-4 pt-4 border-t border-base-border">
|
||||
<div className="stat-label mb-3">
|
||||
<div className="stat-label mb-1">
|
||||
Players during{' '}
|
||||
<Link href={`/maps/period/${selectedMap.map_id}`} className="text-accent hover:underline">
|
||||
{selectedMap.map_name}
|
||||
</Link>{' '}
|
||||
({selectedPlayers.length} player{selectedPlayers.length === 1 ? '' : 's'})
|
||||
</div>
|
||||
<div className="text-xs text-ink-faint mb-3">
|
||||
{new Date(selectedMap.map_start_dt.replace(' ', 'T')).toLocaleString()}
|
||||
{' – '}
|
||||
{selectedMap.map_end_dt
|
||||
? new Date(selectedMap.map_end_dt.replace(' ', 'T')).toLocaleString()
|
||||
: 'still running'}
|
||||
</div>
|
||||
<PlayerChipList players={selectedPlayers} loading={loadingPlayers} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user