diff --git a/plugins/minimap/instance.ts b/plugins/minimap/instance.ts index 89ca7968..182dc262 100644 --- a/plugins/minimap/instance.ts +++ b/plugins/minimap/instance.ts @@ -6,6 +6,7 @@ interface TileDataIpc { type: "chart"; data: ChartData[]; position: [number, number]; + tick: number; } export class InstancePlugin extends BaseInstancePlugin { @@ -26,7 +27,7 @@ export class InstancePlugin extends BaseInstancePlugin { async handleTileDataFromLua(data: TileDataIpc) { try { - const { type, data: rawData, position } = data; + const { type, data: rawData, position, tick } = data; if (type === "chart") { // Handle new chart data format @@ -38,17 +39,17 @@ export class InstancePlugin extends BaseInstancePlugin { } // Send chart data to controller - const event = new TileDataEvent( + const TileDataEvents = chartData.map(chart => new TileDataEvent( this.instance.config.get("instance.id"), - chartData[0].surface, - chartData[0].force, + chart.surface, + chart.force, position[0], position[1], - 0, // TODO: Get tick from lua - chartData[0] - ); + tick, + chart + )); - return this.instance.sendTo("controller", event); + return Promise.all(TileDataEvents.map(event => this.instance.sendTo("controller", event))); } } catch (err) { diff --git a/plugins/minimap/module/minimap.lua b/plugins/minimap/module/minimap.lua index 42ec7810..650cc18c 100644 --- a/plugins/minimap/module/minimap.lua +++ b/plugins/minimap/module/minimap.lua @@ -49,6 +49,7 @@ local function dump_chunk_chart(chunk_position) clusterio_api.send_json("minimap:tile_data", { type = "chart", position = {world_x, world_y}, + tick = game.tick, data = data, }) end diff --git a/plugins/minimap/web/CanvasMinimapPage.tsx b/plugins/minimap/web/CanvasMinimapPage.tsx index f34b6e8a..03926672 100644 --- a/plugins/minimap/web/CanvasMinimapPage.tsx +++ b/plugins/minimap/web/CanvasMinimapPage.tsx @@ -1,9 +1,11 @@ import React, { useState, useEffect, useContext, useRef, useCallback } from "react"; -import { Row, Col, Button, Card, Space, Select } from "antd"; +import { Row, Col, Button, Card, Space, Select, Switch, Slider, Typography, Tooltip } from "antd"; import { ControlContext } from "@clusterio/web_ui"; import { GetInstanceBoundsRequest, GetRawTileRequest, TileDataEvent } from "../messages"; import * as zlib from "zlib"; -import { renderTileToPixels, pixelsToImageData, rgb565ToRgb888 } from "../tile-utils"; +import { renderTileToPixels, pixelsToImageData, rgb565ToRgb888, extractAvailableTicks } from "../tile-utils"; + +const { Text } = Typography; interface Instance { instanceId: number; @@ -86,6 +88,13 @@ export default function CanvasMinimapPage() { // Mouse drag state for panning (for cursor display only) const [isDragging, setIsDragging] = useState(false); + // Timelapse state + const [isTimelapseMode, setIsTimelapseMode] = useState(false); + const [availableTicks, setAvailableTicks] = useState([]); + const [currentTick, setCurrentTick] = useState(null); + const [isPlaying, setIsPlaying] = useState(false); + const [playbackSpeed, setPlaybackSpeed] = useState(1); + // Canvas refs const canvasRef = useRef(null); const containerRef = useRef(null); @@ -112,8 +121,209 @@ export default function CanvasMinimapPage() { // Refs for tracking pressed movement keys for smooth movement at 60fps const keysPressed = useRef>(new Set()); + // Timelapse playback timer ref + const playbackTimerRef = useRef(); + const control = useContext(ControlContext); + // Calculate which tiles are visible based on current view state + const calculateVisibleTiles = () => { + const canvas = canvasRef.current; + if (!canvas) return null; + + const scale = viewState.zoomLevel; + const width = canvas.width; + const height = canvas.height; + + // Calculate visible world area + const worldWidth = width / scale; + const worldHeight = height / scale; + const worldLeft = viewState.centerX - worldWidth / 2; + const worldTop = viewState.centerY - worldHeight / 2; + const worldRight = worldLeft + worldWidth; + const worldBottom = worldTop + worldHeight; + + // Calculate which tiles are visible (with extra margin to ensure full coverage) + const leftTile = Math.floor(worldLeft / TILE_SIZE) - 1; + const topTile = Math.floor(worldTop / TILE_SIZE) - 1; + const rightTile = Math.ceil(worldRight / TILE_SIZE) + 1; + const bottomTile = Math.ceil(worldBottom / TILE_SIZE) + 1; + + return { + leftTile, + topTile, + rightTile, + bottomTile, + scale, + worldLeft, + worldTop, + worldRight, + worldBottom + }; + }; + + // Timelapse functions + const loadAvailableTicks = async () => { + if (!selectedInstance) return; + + const visibleTiles = calculateVisibleTiles(); + if (!visibleTiles) { + console.warn("Canvas not available for tile culling calculation"); + return; + } + + try { + const { leftTile, topTile, rightTile, bottomTile } = visibleTiles; + + const allTicks = new Set(); + let tilesChecked = 0; + let tilesWithData = 0; + + // Check all visible tiles + for (let tileY = topTile; tileY <= bottomTile; tileY++) { + for (let tileX = leftTile; tileX <= rightTile; tileX++) { + try { + const response = await control.send(new GetRawTileRequest( + selectedInstance, + selectedSurface, + selectedForce, + tileX, + tileY + )); + + tilesChecked++; + + if (response.tile_data) { + const binaryString = atob(response.tile_data); + const tileData = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + tileData[i] = binaryString.charCodeAt(i); + } + + const ticks = extractAvailableTicks(tileData); + console.log(`Tile ${tileX},${tileY}: Found ${ticks.length} ticks:`, ticks); + + if (ticks.length > 0) { + tilesWithData++; + ticks.forEach(tick => allTicks.add(tick)); + } + } + } catch (err) { + console.warn(`Failed to load tile ${tileX},${tileY}:`, err); + } + } + } + + const sortedTicks = Array.from(allTicks).sort((a, b) => a - b); + setAvailableTicks(sortedTicks); + + console.log(`Timelapse Summary:`); + console.log(`- Checked tiles: ${tilesChecked} (${leftTile},${topTile} to ${rightTile},${bottomTile})`); + console.log(`- Tiles with data: ${tilesWithData}`); + console.log(`- Total unique timestamps: ${sortedTicks.length}`); + console.log(`- Timestamps:`, sortedTicks); + + // Set current tick to latest if not set + if (sortedTicks.length > 0 && currentTick === null) { + const latestTick = sortedTicks[sortedTicks.length - 1]; + console.log(`Setting current tick to latest:`, latestTick); + setCurrentTick(latestTick); + } else if (sortedTicks.length === 0) { + console.log(`No timestamps found - currentTick will remain null`); + } + } catch (err) { + console.error("Failed to load available ticks:", err); + } + }; + + const toggleTimelapseMode = async (enabled: boolean) => { + setIsTimelapseMode(enabled); + setIsPlaying(false); + + if (enabled) { + await loadAvailableTicks(); + } else { + setCurrentTick(null); + } + + // Clear caches when switching modes + virtualTiles.current.clear(); + tileCache.current.clear(); + chunkCache.current.clear(); + loadingTiles.current.clear(); + }; + + const stepToTick = (tickIndex: number) => { + if (tickIndex >= 0 && tickIndex < availableTicks.length) { + setCurrentTick(availableTicks[tickIndex]); + + // Clear caches to force reload with new tick + virtualTiles.current.clear(); + tileCache.current.clear(); + chunkCache.current.clear(); + loadingTiles.current.clear(); + } + }; + + const stepForward = () => { + const currentIndex = availableTicks.findIndex(tick => tick === currentTick); + if (currentIndex >= 0 && currentIndex < availableTicks.length - 1) { + stepToTick(currentIndex + 1); + } + }; + + const stepBackward = () => { + const currentIndex = availableTicks.findIndex(tick => tick === currentTick); + if (currentIndex > 0) { + stepToTick(currentIndex - 1); + } + }; + + const togglePlayback = () => { + setIsPlaying(!isPlaying); + }; + + // Handle playback timer + useEffect(() => { + if (isPlaying && isTimelapseMode && availableTicks.length > 0) { + playbackTimerRef.current = setInterval(() => { + const currentIndex = availableTicks.findIndex(tick => tick === currentTick); + if (currentIndex >= 0 && currentIndex < availableTicks.length - 1) { + stepToTick(currentIndex + 1); + } else { + // Reached the end, stop playing + setIsPlaying(false); + } + }, 1000 / playbackSpeed); + } else { + if (playbackTimerRef.current) { + clearInterval(playbackTimerRef.current); + playbackTimerRef.current = undefined; + } + } + + return () => { + if (playbackTimerRef.current) { + clearInterval(playbackTimerRef.current); + } + }; + }, [isPlaying, isTimelapseMode, currentTick, playbackSpeed, availableTicks]); + + // Format tick as time + const formatTickTime = (tick: number): string => { + const seconds = Math.floor(tick / 60); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours}h ${minutes % 60}m ${seconds % 60}s`; + } else if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } else { + return `${seconds}s`; + } + }; + // Load instance bounds on component mount useEffect(() => { loadInstances(); @@ -354,7 +564,8 @@ export default function CanvasMinimapPage() { selectedSurface, selectedForce, tileX, - tileY + tileY, + isTimelapseMode ? currentTick || undefined : undefined )); if (!response.tile_data) { @@ -369,7 +580,10 @@ export default function CanvasMinimapPage() { } // Parse the tile data to get RGB565 pixels - const pixels = await renderTileToPixels(tileData); + const pixels = await renderTileToPixels( + tileData, + isTimelapseMode ? currentTick || undefined : undefined + ); // Convert to ImageData const tileImageData = pixelsToImageData(pixels); @@ -538,22 +752,11 @@ export default function CanvasMinimapPage() { ctx.fillStyle = '#1a1a1a'; ctx.fillRect(0, 0, width, height); - // Calculate scale factor (pixels per world unit) - this must be the same for X and Y to preserve 1:1 aspect ratio - const scale = viewState.zoomLevel; + // Calculate visible tiles using shared function + const visibleTiles = calculateVisibleTiles(); + if (!visibleTiles) return; - // Calculate visible world area - ensuring 1:1 aspect ratio - const worldWidth = width / scale; - const worldHeight = height / scale; - const worldLeft = viewState.centerX - worldWidth / 2; - const worldTop = viewState.centerY - worldHeight / 2; - const worldRight = worldLeft + worldWidth; - const worldBottom = worldTop + worldHeight; - - // Calculate which tiles are visible (with extra margin to ensure full coverage) - const leftTile = Math.floor(worldLeft / TILE_SIZE) - 1; - const topTile = Math.floor(worldTop / TILE_SIZE) - 1; - const rightTile = Math.ceil(worldRight / TILE_SIZE) + 1; - const bottomTile = Math.ceil(worldBottom / TILE_SIZE) + 1; + const { leftTile, topTile, rightTile, bottomTile, scale, worldLeft, worldTop } = visibleTiles; const scaledTileSize = TILE_SIZE * scale; const firstTileScreenX = Math.round((leftTile * TILE_SIZE - worldLeft) * scale); @@ -723,6 +926,18 @@ export default function CanvasMinimapPage() { } }, [selectedInstance, instances]); + // Reload ticks when instance, surface, force, or view changes + useEffect(() => { + if (isTimelapseMode && selectedInstance && selectedSurface && selectedForce) { + // Debounce the reload to avoid too many requests during navigation + const timeoutId = setTimeout(() => { + loadAvailableTicks(); + }, 500); + + return () => clearTimeout(timeoutId); + } + }, [selectedInstance, selectedSurface, selectedForce, isTimelapseMode, viewState.centerX, viewState.centerY, viewState.zoomLevel]); + // Snap current zoom level to nearest pixel-perfect level on mount useEffect(() => { setViewState(prev => ({ @@ -731,9 +946,9 @@ export default function CanvasMinimapPage() { })); }, []); - // Set up live chunk update listener + // Set up live chunk update listener (only in live mode) useEffect(() => { - if (!selectedInstance || !selectedSurface || !selectedForce) { + if (!selectedInstance || !selectedSurface || !selectedForce || isTimelapseMode) { return; } @@ -773,7 +988,7 @@ export default function CanvasMinimapPage() { return () => { plugin.offTileUpdate(handleChunkUpdate); }; - }, [selectedInstance, selectedSurface, selectedForce, updateChunk, control]); + }, [selectedInstance, selectedSurface, selectedForce, updateChunk, control, isTimelapseMode]); return (
@@ -823,13 +1038,98 @@ export default function CanvasMinimapPage() { + + + Timelapse: + + {isTimelapseMode && ( + + {availableTicks.length} snapshots + + )} + + } > {selectedInstance ? (
+ {isTimelapseMode && availableTicks.length > 0 && ( +
+ + + + + + + Speed: + + + +
+ Timeline: + + {currentTick ? formatTickTime(currentTick) : "No data"} + + tick === currentTick)} + onChange={(value) => stepToTick(value)} + style={{ marginTop: "8px" }} + tooltip={{ + formatter: (value) => { + if (value !== undefined && availableTicks[value]) { + return formatTickTime(availableTicks[value]); + } + return ""; + } + }} + /> +
+
+
+ )}
Use WASD to move, click and drag to pan, +/- or scroll wheel to zoom. Current zoom: {viewState.zoomLevel}x + {isTimelapseMode && ( + + 📹 Timelapse Mode {currentTick ? `- ${formatTickTime(currentTick)}` : ""} + + )}