Incremental rendering of timeline scrubbing

This commit is contained in:
Danielv123 committed 2025-08-10 00:28:48 +02:00
1 parent 9dc5f8ff37
commit 0785798b7d
2 files changed
+387 -40

No files matched your search

+244
View File
@@ -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<number>();
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<void> {
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<void> {
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);
}
}
}
}
+143 -40
View File
@@ -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<Map<string, HTMLCanvasElement>>(new Map());
// Tile cache - maps "tileX,tileY" to ImageData for entire 512x512 tiles
const tileCache = useRef<Map<string, ImageData>>(new Map());
// Tile state cache - maps "tileX,tileY" to TileState for efficient timelapse navigation
const tileStateCache = useRef<Map<string, TileState>>(new Map());
// Chunk cache - maps "chunkX,chunkY" to ImageData
const chunkCache = useRef<Map<string, ImageData>>(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<void>[] = [];
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<ImageData | null> => {
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) => {