diff --git a/plugins/minimap/tile-utils.ts b/plugins/minimap/tile-utils.ts index 5f1733eb..8fc366c2 100644 --- a/plugins/minimap/tile-utils.ts +++ b/plugins/minimap/tile-utils.ts @@ -309,4 +309,248 @@ export function extractAvailableTicks(tileData: BinaryData): number[] { } return Array.from(ticks).sort((a, b) => a - b); +} + +/** + * Interface for tracking pixel changes in timelapse data + */ +export interface PixelChange { + x: number; + y: number; + newColor: number; + oldColor: number; +} + +/** + * Interface for chunk records in timelapse data + */ +export interface ChunkRecord { + tick: number; + chunkX: number; + chunkY: number; + data: BinaryData; +} + +/** + * Interface for pixel change records in timelapse data + */ +export interface PixelChangeRecord { + tick: number; + changes: PixelChange[]; +} + +/** + * Interface for parsed tile data with all change records + */ +export interface ParsedTileData { + chunks: ChunkRecord[]; + pixelChanges: PixelChangeRecord[]; + allTicks: number[]; +} + +/** + * Parse tile data into structured change records for efficient timelapse navigation + */ +export function parseTileData(tileData: BinaryData): ParsedTileData { + const chunks: ChunkRecord[] = []; + const pixelChanges: PixelChangeRecord[] = []; + const ticks = new Set(); + + let offset = 0; + while (offset < tileData.length) { + if (offset + 1 > tileData.length) { + break; + } + + const type = readUInt8(tileData, offset); + offset += 1; + + if (type === 1) { // Chunk + if (offset + 7 > tileData.length) { + break; + } + + const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick + ticks.add(tick); + offset += 4; + + const chunkCoordsByte = readUInt8(tileData, offset); + offset += 1; + const length = readUInt16BE(tileData, offset); + offset += 2; + + if (offset + length > tileData.length) { + break; + } + + const chunkX = chunkCoordsByte >> 4; + const chunkY = chunkCoordsByte & 0x0F; + const data = sliceData(tileData, offset, offset + length); + + chunks.push({ + tick, + chunkX, + chunkY, + data + }); + + offset += length; + } else if (type === 2) { // Pixels + if (offset + 6 > tileData.length) { + break; + } + + const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick + ticks.add(tick); + offset += 4; + const pixelCount = readUInt16BE(tileData, offset); + offset += 2; + + const expectedDataLength = pixelCount * 6; + if (offset + expectedDataLength > tileData.length) { + break; + } + + const changes: PixelChange[] = []; + for (let i = 0; i < pixelCount; i++) { + const pixelOffset = offset + i * 6; + const x = readUInt8(tileData, pixelOffset); + const y = readUInt8(tileData, pixelOffset + 1); + const newColor = readUInt16BE(tileData, pixelOffset + 2); + const oldColor = readUInt16BE(tileData, pixelOffset + 4); + + if (x < 256 && y < 256) { + changes.push({ x, y, newColor, oldColor }); + } + } + + if (changes.length > 0) { + pixelChanges.push({ + tick, + changes + }); + } + + offset += expectedDataLength; + } else { + break; + } + } + + return { + chunks, + pixelChanges, + allTicks: Array.from(ticks).sort((a, b) => a - b) + }; +} + +/** + * Apply chunk data to RGB565 pixel array + */ +export async function applyChunkToPixels(pixels: Uint16Array, chunkRecord: ChunkRecord): Promise { + try { + const chunkPixels = await renderChunkToPixels(chunkRecord.data); + + // Update tile pixels with chunk data + for (let y = 0; y < 32; y++) { + for (let x = 0; x < 32; x++) { + const chunkIndex = y * 32 + x; + const tileX = chunkRecord.chunkX * 32 + x; + const tileY = chunkRecord.chunkY * 32 + y; + const tileIndex = tileY * 256 + tileX; + pixels[tileIndex] = chunkPixels[chunkIndex]; + } + } + } catch (err) { + console.error(`Failed to apply chunk ${chunkRecord.chunkX},${chunkRecord.chunkY}:`, err); + } +} + +/** + * Clear chunk area to black (for rewinding past chunk creation) + */ +export function clearChunkInPixels(pixels: Uint16Array, chunkRecord: ChunkRecord): void { + for (let y = 0; y < 32; y++) { + for (let x = 0; x < 32; x++) { + const tileX = chunkRecord.chunkX * 32 + x; + const tileY = chunkRecord.chunkY * 32 + y; + const tileIndex = tileY * 256 + tileX; + pixels[tileIndex] = 0; // Black + } + } +} + +/** + * Apply pixel changes to RGB565 pixel array (forward direction) + */ +export function applyPixelChanges(pixels: Uint16Array, changes: PixelChange[]): void { + for (const change of changes) { + const tileIndex = change.y * 256 + change.x; + pixels[tileIndex] = change.newColor; + } +} + +/** + * Revert pixel changes from RGB565 pixel array (backward direction) + */ +export function revertPixelChanges(pixels: Uint16Array, changes: PixelChange[]): void { + for (const change of changes) { + const tileIndex = change.y * 256 + change.x; + pixels[tileIndex] = change.oldColor; + } +} + +/** + * Render tile data incrementally from one tick to another + * Much more efficient than re-rendering from scratch + */ +export async function renderTileIncremental( + parsedData: ParsedTileData, + currentPixels: Uint16Array, + fromTick: number, + toTick: number +): Promise { + const { chunks, pixelChanges } = parsedData; + + if (fromTick === toTick) { + return; // No changes needed + } + + const forward = toTick > fromTick; + + if (forward) { + // Apply changes forward from fromTick to toTick + + // Apply chunks that occur in this range + for (const chunk of chunks) { + if (chunk.tick > fromTick && chunk.tick <= toTick) { + await applyChunkToPixels(currentPixels, chunk); + } + } + + // Apply pixel changes that occur in this range + for (const record of pixelChanges) { + if (record.tick > fromTick && record.tick <= toTick) { + applyPixelChanges(currentPixels, record.changes); + } + } + } else { + // Revert changes backward from fromTick to toTick + + // Revert pixel changes in reverse order + for (let i = pixelChanges.length - 1; i >= 0; i--) { + const record = pixelChanges[i]; + if (record.tick <= fromTick && record.tick > toTick) { + revertPixelChanges(currentPixels, record.changes); + } + } + + // Revert chunks (clear to black if we're going before their creation time) + for (let i = chunks.length - 1; i >= 0; i--) { + const chunk = chunks[i]; + if (chunk.tick <= fromTick && chunk.tick > toTick) { + clearChunkInPixels(currentPixels, chunk); + } + } + } } diff --git a/plugins/minimap/web/CanvasMinimapPage.tsx b/plugins/minimap/web/CanvasMinimapPage.tsx index ea0e0f51..c56dfbc7 100644 --- a/plugins/minimap/web/CanvasMinimapPage.tsx +++ b/plugins/minimap/web/CanvasMinimapPage.tsx @@ -3,7 +3,15 @@ import { Row, Col, Button, Card, Space, Select, Switch, Slider, Typography, Tool import { ControlContext } from "@clusterio/web_ui"; import { GetInstanceBoundsRequest, GetRawTileRequest, TileDataEvent } from "../messages"; import * as zlib from "zlib"; -import { renderTileToPixels, pixelsToImageData, rgb565ToRgb888, extractAvailableTicks } from "../tile-utils"; +import { + renderTileToPixels, + pixelsToImageData, + rgb565ToRgb888, + extractAvailableTicks, + parseTileData, + renderTileIncremental, + ParsedTileData +} from "../tile-utils"; const { Text } = Typography; @@ -29,6 +37,13 @@ interface ViewState { zoomLevel: number; } +interface TileState { + currentTick: number; + pixels: Uint16Array; + parsedData: ParsedTileData; + imageData: ImageData; +} + const CHUNK_SIZE = 32; // 32x32 pixels per chunk const TILE_SIZE = 256; // 256x256 pixels per tile (8x8 chunks) const CHUNKS_PER_TILE = 8; @@ -102,8 +117,8 @@ export default function CanvasMinimapPage() { // Virtual tile storage - maps "tileX,tileY" to canvas element const virtualTiles = useRef>(new Map()); - // Tile cache - maps "tileX,tileY" to ImageData for entire 512x512 tiles - const tileCache = useRef>(new Map()); + // Tile state cache - maps "tileX,tileY" to TileState for efficient timelapse navigation + const tileStateCache = useRef>(new Map()); // Chunk cache - maps "chunkX,chunkY" to ImageData const chunkCache = useRef>(new Map()); @@ -238,36 +253,101 @@ export default function CanvasMinimapPage() { // Clear caches when switching modes virtualTiles.current.clear(); - tileCache.current.clear(); + tileStateCache.current.clear(); chunkCache.current.clear(); loadingTiles.current.clear(); }; - const stepToTick = (tickIndex: number) => { + const stepToTick = async (tickIndex: number) => { if (tickIndex >= 0 && tickIndex < availableTicks.length) { const newTick = availableTicks[tickIndex]; + const oldTick = currentTick; - // Clear caches to force reload with new tick - virtualTiles.current.clear(); - tileCache.current.clear(); - chunkCache.current.clear(); - loadingTiles.current.clear(); + if (oldTick === newTick) { + return; // No change needed + } + + // Use incremental updates for loaded tiles instead of clearing cache + const updatePromises: Promise[] = []; + + for (const [tileKey, tileState] of tileStateCache.current) { + updatePromises.push((async () => { + try { + // Apply incremental changes from current tick to new tick + await renderTileIncremental( + tileState.parsedData, + tileState.pixels, + tileState.currentTick, + newTick + ); + + // Update tile state + tileState.currentTick = newTick; + + // Convert to ImageData and update virtual tile + const newImageData = pixelsToImageData(tileState.pixels); + tileState.imageData = newImageData; + + // Update virtual tile canvas if it exists + const virtualTile = virtualTiles.current.get(tileKey); + if (virtualTile) { + const ctx = virtualTile.getContext('2d')!; + ctx.putImageData(newImageData, 0, 0); + } + + // Update chunk cache for this tile + const [tileX, tileY] = tileKey.split(',').map(Number); + for (let cy = 0; cy < CHUNKS_PER_TILE; cy++) { + for (let cx = 0; cx < CHUNKS_PER_TILE; cx++) { + const actualChunkX = tileX * CHUNKS_PER_TILE + cx; + const actualChunkY = tileY * CHUNKS_PER_TILE + cy; + const actualChunkKey = `${actualChunkX},${actualChunkY}`; + + // Extract chunk area from the full tile ImageData + const chunkData = new Uint8ClampedArray(CHUNK_SIZE * CHUNK_SIZE * 4); + for (let y = 0; y < CHUNK_SIZE; y++) { + for (let x = 0; x < CHUNK_SIZE; x++) { + const tilePixelIndex = ((cy * CHUNK_SIZE + y) * TILE_SIZE + (cx * CHUNK_SIZE + x)) * 4; + const chunkPixelIndex = (y * CHUNK_SIZE + x) * 4; + + chunkData[chunkPixelIndex] = newImageData.data[tilePixelIndex]; // R + chunkData[chunkPixelIndex + 1] = newImageData.data[tilePixelIndex + 1]; // G + chunkData[chunkPixelIndex + 2] = newImageData.data[tilePixelIndex + 2]; // B + chunkData[chunkPixelIndex + 3] = newImageData.data[tilePixelIndex + 3]; // A + } + } + + const chunkImageData = new ImageData(chunkData, CHUNK_SIZE, CHUNK_SIZE); + chunkCache.current.set(actualChunkKey, chunkImageData); + } + } + } catch (err) { + console.error(`Failed to update tile ${tileKey} incrementally:`, err); + // If incremental update fails, remove from cache to force full reload + tileStateCache.current.delete(tileKey); + virtualTiles.current.delete(tileKey); + } + })()); + } + + // Wait for all tile updates to complete + await Promise.all(updatePromises); setCurrentTick(newTick); } }; - const stepForward = () => { + const stepForward = async () => { const currentIndex = availableTicks.findIndex(tick => tick === currentTick); if (currentIndex >= 0 && currentIndex < availableTicks.length - 1) { - stepToTick(currentIndex + 1); + await stepToTick(currentIndex + 1); } }; - const stepBackward = () => { + const stepBackward = async () => { const currentIndex = availableTicks.findIndex(tick => tick === currentTick); if (currentIndex > 0) { - stepToTick(currentIndex - 1); + await stepToTick(currentIndex - 1); } }; @@ -278,10 +358,10 @@ export default function CanvasMinimapPage() { // Handle playback timer useEffect(() => { if (isPlaying && isTimelapseMode && availableTicks.length > 0) { - playbackTimerRef.current = setInterval(() => { + playbackTimerRef.current = setInterval(async () => { const currentIndex = availableTicks.findIndex(tick => tick === currentTick); if (currentIndex >= 0 && currentIndex < availableTicks.length - 1) { - stepToTick(currentIndex + 1); + await stepToTick(currentIndex + 1); } else { // Reached the end, stop playing setIsPlaying(false); @@ -537,9 +617,28 @@ export default function CanvasMinimapPage() { const loadTile = async (tileX: number, tileY: number): Promise => { const tileKey = `${tileX},${tileY}`; - // Check tile cache first - if (tileCache.current.has(tileKey)) { - return tileCache.current.get(tileKey)!; + // Check tile state cache first + if (tileStateCache.current.has(tileKey)) { + const tileState = tileStateCache.current.get(tileKey)!; + + // Check if we need to update to current tick + const targetTick = isTimelapseMode ? currentTick || 0 : tileState.parsedData.allTicks[tileState.parsedData.allTicks.length - 1] || 0; + if (tileState.currentTick !== targetTick) { + try { + await renderTileIncremental( + tileState.parsedData, + tileState.pixels, + tileState.currentTick, + targetTick + ); + tileState.currentTick = targetTick; + tileState.imageData = pixelsToImageData(tileState.pixels); + } catch (err) { + console.error(`Failed to update tile ${tileKey} to tick ${targetTick}:`, err); + } + } + + return tileState.imageData; } // Check if we're already loading this tile @@ -571,15 +670,29 @@ export default function CanvasMinimapPage() { tileData[i] = binaryString.charCodeAt(i); } - // Parse the tile data to get RGB565 pixels - const targetTick = isTimelapseMode ? currentTick || undefined : undefined; - const pixels = await renderTileToPixels(tileData, targetTick); + // Parse the tile data into structured change records + const parsedData = parseTileData(tileData); + + // Determine target tick + const targetTick = isTimelapseMode ? + (currentTick || 0) : + (parsedData.allTicks[parsedData.allTicks.length - 1] || 0); + + // Render to target tick (undefined means latest for non-timelapse mode) + const renderTick = isTimelapseMode ? targetTick : undefined; + const pixels = await renderTileToPixels(tileData, renderTick); // Convert to ImageData const tileImageData = pixelsToImageData(pixels); - // Cache the tile - tileCache.current.set(tileKey, tileImageData); + // Create and cache the tile state + const tileState: TileState = { + currentTick: targetTick, + pixels: pixels, + parsedData: parsedData, + imageData: tileImageData + }; + tileStateCache.current.set(tileKey, tileState); // Extract and cache all chunks from this tile for (let cy = 0; cy < CHUNKS_PER_TILE; cy++) { @@ -646,11 +759,11 @@ export default function CanvasMinimapPage() { ctx.putImageData(imageData, pixelX, pixelY); } - // Update the tile cache if it exists - const cachedTile = tileCache.current.get(tileKey); - if (cachedTile) { + // Update the tile state if it exists + const tileState = tileStateCache.current.get(tileKey); + if (tileState) { // Update the tile ImageData directly - const tileData = cachedTile.data; + const tileData = tileState.imageData.data; const chunkData = imageData.data; for (let y = 0; y < CHUNK_SIZE; y++) { @@ -910,22 +1023,12 @@ export default function CanvasMinimapPage() { // Clear caches when switching instances virtualTiles.current.clear(); - tileCache.current.clear(); + tileStateCache.current.clear(); chunkCache.current.clear(); loadingTiles.current.clear(); } }, [selectedInstance, instances]); - // Clear caches when currentTick changes (React state timing fix) - useEffect(() => { - if (isTimelapseMode) { - virtualTiles.current.clear(); - tileCache.current.clear(); - chunkCache.current.clear(); - loadingTiles.current.clear(); - } - }, [currentTick, isTimelapseMode]); - // Reload ticks when instance, surface, force, or view changes useEffect(() => { if (isTimelapseMode && selectedInstance && selectedSurface && selectedForce) { @@ -1108,7 +1211,7 @@ export default function CanvasMinimapPage() { min={0} max={availableTicks.length - 1} value={availableTicks.findIndex(tick => tick === currentTick)} - onChange={(value) => stepToTick(value)} + onChange={async (value) => await stepToTick(value)} style={{ marginTop: "8px" }} tooltip={{ formatter: (value) => {