From 2bce506d969ccbcfcf443c67323aafd59529d751 Mon Sep 17 00:00:00 2001 From: Danielv123 Date: Mon, 21 Jul 2025 19:16:55 +0200 Subject: [PATCH] Lint and refactor --- plugins/minimap/controller.ts | 251 +++--- plugins/minimap/instance.ts | 23 +- plugins/minimap/messages.ts | 130 +-- plugins/minimap/tile-utils.ts | 691 ++++++++++------ plugins/minimap/web/CanvasMinimapPage.tsx | 960 ++++++++++++---------- plugins/minimap/web/MinimapPage.tsx | 52 +- plugins/minimap/web/index.tsx | 6 +- 7 files changed, 1233 insertions(+), 880 deletions(-) diff --git a/plugins/minimap/controller.ts b/plugins/minimap/controller.ts index 5c4c4757..164dd162 100644 --- a/plugins/minimap/controller.ts +++ b/plugins/minimap/controller.ts @@ -22,7 +22,7 @@ import { renderTileToPixels, pixelsToRGBA, parseRecipeTileBinary, - ParsedRecipeTile + ParsedRecipeTile, } from "./tile-utils"; const CHUNK_SIZE = 32; @@ -51,7 +51,9 @@ export class ControllerPlugin extends BaseControllerPlugin { icon?: any; last_user?: string; }>>(); - private lastSeenRecipeContent = new Map>(); // tileKey -> posKey -> recipe + + // tileKey -> posKey -> recipe + private lastSeenRecipeContent = new Map>(); private savingTiles: boolean = false; private savingChartTags = new Set(); private saveInterval: NodeJS.Timeout | null = null; @@ -109,7 +111,12 @@ export class ControllerPlugin extends BaseControllerPlugin { } // Compare two pixel arrays and return changes - private comparePixels(oldPixels: Uint16Array, newPixels: Uint16Array, chunkX: number, chunkY: number): PixelChange[] { + private comparePixels( + oldPixels: Uint16Array, + newPixels: Uint16Array, + chunkX: number, + chunkY: number + ): PixelChange[] { const changes: PixelChange[] = []; for (let y = 0; y < 32; y++) { @@ -146,7 +153,7 @@ export class ControllerPlugin extends BaseControllerPlugin { const groups = new Map(); for (const ch of changes) { const key = `${ch.newColor}_${ch.oldColor}`; - if (!groups.has(key)) groups.set(key, []); + if (!groups.has(key)) { groups.set(key, []); } groups.get(key)!.push(ch); } @@ -184,8 +191,8 @@ export class ControllerPlugin extends BaseControllerPlugin { app.get("/api/minimap/tile/:instanceId/:surface/:force/:z/:x/:y.png", async (req, res) => { try { const { instanceId, surface, force, z, x, y } = req.params; - const tileX = parseInt(x); - const tileY = parseInt(y); + const tileX = parseInt(x, 10); + const tileY = parseInt(y, 10); const tileName = `${instanceId}_${surface}_${force}_${tileX}_${tileY}.bin`; const tilePath = path.join(this.tilesPath, tileName); @@ -237,13 +244,13 @@ export class ControllerPlugin extends BaseControllerPlugin { // Use renderTileToPixels to get the current state, then convert to image const currentPixels = await renderTileToPixels(tileData); - // Convert RGB565 pixel data to RGBA buffer for Sharp + // Convert RGB565 pixel data to RGBA buffer for Sharp const rgbaData = pixelsToRGBA(currentPixels); const raw = Buffer.from(rgbaData); // Create and return the image directly return sharp(raw, { - raw: { width: 256, height: 256, channels: 4 } + raw: { width: 256, height: 256, channels: 4 }, }).png().toBuffer(); } @@ -285,7 +292,10 @@ export class ControllerPlugin extends BaseControllerPlugin { } } - async updateTile(existingTile: Buffer, newChunks: Map): Promise { + async updateTile( + existingTile: Buffer, + newChunks: Map + ): Promise { const existingChunks = new Map(); // Parse existing tile data to find existing chunks @@ -300,49 +310,17 @@ export class ControllerPlugin extends BaseControllerPlugin { offset += 1; if (type === 1) { // Chunk - if (offset + 7 > existingTile.length) { - this.logger.error(`Invalid tile data: insufficient chunk header data at offset ${offset}`); + const parseResult = this.parseChunkData(existingTile, offset, existingChunks); + if (!parseResult.success) { break; } - - const tick = existingTile.readUInt32BE(offset); - offset += 4; - const chunkCoordsByte = existingTile.readUInt8(offset); - offset += 1; - const length = existingTile.readUInt16BE(offset); - offset += 2; - - if (offset + length > existingTile.length) { - this.logger.error(`Invalid tile data: insufficient chunk data at offset ${offset}`); - break; - } - - const chunkX = chunkCoordsByte >> 4; - const chunkY = chunkCoordsByte & 0x0F; - const chunkName = `${chunkX}_${chunkY}`; - const data = existingTile.slice(offset, offset + length); - offset += length; - existingChunks.set(chunkName, { tick, data }); - + offset = parseResult.newOffset; } else if (type === 2) { // Pixels - if (offset + 6 > existingTile.length) { - this.logger.error(`Invalid tile data: insufficient pixel header data at offset ${offset}`); + const parseResult = this.parsePixelData(existingTile, offset); + if (!parseResult.success) { break; } - - const tick = existingTile.readUInt32BE(offset); - offset += 4; - const pixelCount = existingTile.readUInt16BE(offset); - offset += 2; - const pixelDataLength = pixelCount * 6; - - if (offset + pixelDataLength > existingTile.length) { - this.logger.error(`Invalid tile data: insufficient pixel data at offset ${offset}`); - break; - } - - // Skip pixel data since we're not rebuilding the file - offset += pixelDataLength; + offset = parseResult.newOffset; } else { this.logger.error(`Unknown tile data type: ${type} at offset ${offset}`); break; @@ -399,13 +377,71 @@ export class ControllerPlugin extends BaseControllerPlugin { } if (!hasChanges) { - return; + return undefined; } // Append new data to existing tile (never modify existing data) return Buffer.concat([existingTile, ...appendBuffers]); } + private parseChunkData( + existingTile: Buffer, + offset: number, + existingChunks: Map + ): { success: boolean, newOffset: number } { + if (offset + 7 > existingTile.length) { + this.logger.error(`Invalid tile data: insufficient chunk header data at offset ${offset}`); + return { success: false, newOffset: offset }; + } + + const tick = existingTile.readUInt32BE(offset); + offset += 4; + const chunkCoordsByte = existingTile.readUInt8(offset); + offset += 1; + const length = existingTile.readUInt16BE(offset); + offset += 2; + + if (offset + length > existingTile.length) { + this.logger.error(`Invalid tile data: insufficient chunk data at offset ${offset}`); + return { success: false, newOffset: offset }; + } + + const chunkX = chunkCoordsByte >> 4; + const chunkY = chunkCoordsByte & 0x0F; + const chunkName = `${chunkX}_${chunkY}`; + const data = existingTile.slice(offset, offset + length); + offset += length; + existingChunks.set(chunkName, { tick, data }); + + return { success: true, newOffset: offset }; + } + + private parsePixelData( + existingTile: Buffer, + offset: number + ): { success: boolean, newOffset: number } { + if (offset + 6 > existingTile.length) { + this.logger.error(`Invalid tile data: insufficient pixel header data at offset ${offset}`); + return { success: false, newOffset: offset }; + } + + const tick = existingTile.readUInt32BE(offset); + offset += 4; + const pixelCount = existingTile.readUInt16BE(offset); + offset += 2; + const pixelDataLength = pixelCount * 6; + + if (offset + pixelDataLength > existingTile.length) { + this.logger.error(`Invalid tile data: insufficient pixel data at offset ${offset}`); + return { success: false, newOffset: offset }; + } + + // Skip pixel data since we're not rebuilding the file + offset += pixelDataLength; + + return { success: true, newOffset: offset }; + } + async loadExistingTagContent() { try { if (!await fs.pathExists(this.chartTagsPath)) { @@ -413,48 +449,59 @@ export class ControllerPlugin extends BaseControllerPlugin { } const files = await fs.readdir(this.chartTagsPath); - const tagFiles = files.filter(file => file.endsWith('_chart_tags.json')); + const tagFiles = files.filter(file => file.endsWith("_chart_tags.json")); for (const file of tagFiles) { - const fileKey = file.replace('_chart_tags.json', ''); - const filePath = path.join(this.chartTagsPath, file); - - if (!this.lastSeenTagContent.has(fileKey)) { - this.lastSeenTagContent.set(fileKey, new Map()); - } - const seenTags = this.lastSeenTagContent.get(fileKey)!; - - try { - const content = await fs.readFile(filePath, 'utf-8'); - const lines = content.trim().split('\n').filter(line => line.trim()); - - for (const line of lines) { - try { - const tagData = JSON.parse(line); - if (typeof tagData.tag_number === 'number' && !tagData.end_tick) { - // Only store the latest content for tags that haven't been deleted - seenTags.set(tagData.tag_number, { - position: tagData.position, - text: tagData.text, - icon: tagData.icon, - last_user: tagData.last_user - }); - } - } catch (parseErr) { - this.logger.warn(`Failed to parse chart tag line in ${file}: ${parseErr}`); - } - } - - this.logger.info(`Loaded ${seenTags.size} existing tag contents from ${file}`); - } catch (readErr) { - this.logger.warn(`Failed to read chart tag file ${file}: ${readErr}`); - } + await this.loadTagContentFromFile(file); } } catch (err) { this.logger.error(`Error loading existing tag content: ${err}`); } } + private async loadTagContentFromFile(file: string) { + const fileKey = file.replace("_chart_tags.json", ""); + const filePath = path.join(this.chartTagsPath, file); + + if (!this.lastSeenTagContent.has(fileKey)) { + this.lastSeenTagContent.set(fileKey, new Map()); + } + const seenTags = this.lastSeenTagContent.get(fileKey)!; + + try { + const content = await fs.readFile(filePath, "utf-8"); + const lines = content.trim().split("\n").filter(line => line.trim()); + + for (const line of lines) { + this.processTagLine(line, seenTags); + } + + this.logger.info(`Loaded ${seenTags.size} existing tag contents from ${file}`); + } catch (readErr) { + this.logger.warn(`Failed to read chart tag file ${file}: ${readErr}`); + } + } + + private processTagLine(line: string, seenTags: Map) { + try { + const tagData = JSON.parse(line); + const isNumberTag = typeof tagData.tag_number === "number"; + const notDeleted = !tagData.end_tick; + + if (isNumberTag && notDeleted) { + // Only store the latest content for tags that haven't been deleted + seenTags.set(tagData.tag_number, { + position: tagData.position, + text: tagData.text, + icon: tagData.icon, + last_user: tagData.last_user, + }); + } + } catch (parseErr) { + this.logger.warn(`Failed to parse chart tag line: ${parseErr}`); + } + } + /** * Parses existing recipe jsonl files and builds an in-memory map that tracks * the latest active (non-ended) recipe for every position. This allows incoming @@ -496,8 +543,8 @@ export class ControllerPlugin extends BaseControllerPlugin { private async parseRecipeTileForState(tileKey: string, buffer: Buffer) { // Derive tile coordinates from key (last two underscore-separated parts) const parts = tileKey.split("_"); - const tileY = parseInt(parts.pop()!); // last - const tileX = parseInt(parts.pop()!); // second last + const tileY = parseInt(parts.pop()!, 10); // last + const tileX = parseInt(parts.pop()!, 10); // second last const parsed: ParsedRecipeTile = parseRecipeTileBinary(tileX, tileY, buffer, null); // Populate dictionary mapping (name -> index) @@ -505,7 +552,7 @@ export class ControllerPlugin extends BaseControllerPlugin { for (const [id, name] of parsed.dictionary) { dict.set(name, id); const currentNext = this.nextRecipeIndex.get(tileKey) ?? 0; - if (id + 1 > currentNext) this.nextRecipeIndex.set(tileKey, id + 1); + if (id + 1 > currentNext) { this.nextRecipeIndex.set(tileKey, id + 1); } } // Populate active recipe cache @@ -583,9 +630,9 @@ export class ControllerPlugin extends BaseControllerPlugin { const filePath = path.join(this.chartTagsPath, `${fileKey}_chart_tags.json`); // Convert chart tags to newline-delimited JSON format - const jsonLines = tagsToSave.map(tag => JSON.stringify(tag)).join('\n'); + const jsonLines = tagsToSave.map(tag => JSON.stringify(tag)).join("\n"); - await fs.appendFile(filePath, jsonLines + '\n'); + await fs.appendFile(filePath, `${jsonLines}\n`); // Mark tags as successfully saved if (!this.lastSeenTagContent.has(fileKey)) { @@ -597,7 +644,7 @@ export class ControllerPlugin extends BaseControllerPlugin { position: tag.position, text: tag.text, icon: tag.icon, - last_user: tag.last_user + last_user: tag.last_user, }); } @@ -638,11 +685,11 @@ export class ControllerPlugin extends BaseControllerPlugin { } else if (lastSeenContent) { // For creation/modification events, check if content has actually changed const contentUnchanged = - lastSeenContent.position[0] === tag_data.position[0] && - lastSeenContent.position[1] === tag_data.position[1] && - lastSeenContent.text === tag_data.text && - JSON.stringify(lastSeenContent.icon) === JSON.stringify(tag_data.icon) && - lastSeenContent.last_user === tag_data.last_user; + lastSeenContent.position[0] === tag_data.position[0] + && lastSeenContent.position[1] === tag_data.position[1] + && lastSeenContent.text === tag_data.text + && JSON.stringify(lastSeenContent.icon) === JSON.stringify(tag_data.icon) + && lastSeenContent.last_user === tag_data.last_user; if (contentUnchanged) { return; // Skip if content hasn't changed @@ -652,7 +699,7 @@ export class ControllerPlugin extends BaseControllerPlugin { // Add instance_id to the tag data for storage const enrichedTagData = { ...tag_data, - instance_id + instance_id, }; // Immediately update last seen content to improve deduplication before save completes @@ -663,7 +710,7 @@ export class ControllerPlugin extends BaseControllerPlugin { position: tag_data.position, text: tag_data.text, icon: tag_data.icon, - last_user: tag_data.last_user + last_user: tag_data.last_user, }); } @@ -678,7 +725,7 @@ export class ControllerPlugin extends BaseControllerPlugin { // Send live update to connected web clients const updateEvent = new ChartTagDataEvent(instance_id, tag_data); - // Broadcast to web clients (overriding the formal src/dst routing) + // Broadcast to web clients this.controller.sendTo(new lib.Address(lib.Address.broadcast, lib.Address.control), updateEvent); } catch (err) { @@ -716,7 +763,7 @@ export class ControllerPlugin extends BaseControllerPlugin { chunk ); - // Broadcast to web clients (overriding the formal src/dst routing) + // Broadcast to web clients this.controller.sendTo(new lib.Address(lib.Address.broadcast, lib.Address.control), updateEvent); } catch (err) { @@ -737,10 +784,10 @@ export class ControllerPlugin extends BaseControllerPlugin { try { const tileData = await fs.readFile(tilePath); - const base64Data = tileData.toString('base64'); + const base64Data = tileData.toString("base64"); return { - tile_data: base64Data + tile_data: base64Data, }; } catch (err) { this.logger.error(`Error reading tile file: ${err}`); @@ -759,8 +806,8 @@ export class ControllerPlugin extends BaseControllerPlugin { } try { - const content = await fs.readFile(filePath, 'utf-8'); - const lines = content.trim().split('\n').filter(line => line.trim()); + const content = await fs.readFile(filePath, "utf-8"); + const lines = content.trim().split("\n").filter(line => line.trim()); const chartTags: ChartTagData[] = []; for (const line of lines) { @@ -852,7 +899,7 @@ export class ControllerPlugin extends BaseControllerPlugin { buffersToWrite = this.encodeSet(tileKey, recipe_data.start_tick ?? 0, px, py, recipe_data.recipe); } - if (buffersToWrite.length === 0) return; + if (buffersToWrite.length === 0) { return; } if (!this.recipeSavingQueue.has(tileKey)) { this.recipeSavingQueue.set(tileKey, []); diff --git a/plugins/minimap/instance.ts b/plugins/minimap/instance.ts index ff59b1ac..990afc63 100644 --- a/plugins/minimap/instance.ts +++ b/plugins/minimap/instance.ts @@ -65,11 +65,11 @@ export class InstancePlugin extends BaseInstancePlugin { async handleTileDataFromLua(data: TileDataIpc) { try { const { type, data: rawData, position, tick } = data; - + if (type === "chart") { // Handle new chart data format const chartData = rawData as ChartData[]; - + if (!chartData || !Array.isArray(chartData)) { this.logger.error("Invalid chart data received"); return; @@ -86,9 +86,9 @@ export class InstancePlugin extends BaseInstancePlugin { chart )); - return Promise.all(TileDataEvents.map(event => this.instance.sendTo("controller", event))); + TileDataEvents.map(event => this.instance.sendTo("controller", event)); } - + } catch (err) { this.logger.error(`Failed to process tile data from Lua: ${err}`); } @@ -96,7 +96,7 @@ export class InstancePlugin extends BaseInstancePlugin { async handleChartTagDataFromLua(data: ChartTagDataIpc) { // Validate the chart tag data - if (!data || typeof data.tag_number !== 'number') { + if (!data || typeof data.tag_number !== "number") { this.logger.error("Invalid chart tag data received"); return; } @@ -108,14 +108,14 @@ export class InstancePlugin extends BaseInstancePlugin { ); // Send to controller - return this.instance.sendTo("controller", chartTagEvent); + this.instance.sendTo("controller", chartTagEvent); } async handleRecipeDataFromLua(data: RecipeDataIpc) { try { if (!data) { - this.logger.error("Invalid recipe data received") - return + this.logger.error("Invalid recipe data received"); + return; } const recipeData: RecipeData = { @@ -133,8 +133,7 @@ export class InstancePlugin extends BaseInstancePlugin { recipeData ); - return this.instance.sendTo("controller", event); - + this.instance.sendTo("controller", event); } catch (err: unknown) { this.logger.error(`Failed to process recipe data from Lua: ${err}`); } @@ -144,7 +143,7 @@ export class InstancePlugin extends BaseInstancePlugin { // Initialize the Lua module when the instance starts try { await this.sendRcon("/sc if not storage.minimap then storage.minimap = {} end"); - + // Set up periodic tile updates every 30 seconds await this.sendRcon(`/sc if not storage.minimap.update_timer then @@ -165,4 +164,4 @@ export class InstancePlugin extends BaseInstancePlugin { // Could handle custom factorio output here if the Lua module sends special messages } } -} +} diff --git a/plugins/minimap/messages.ts b/plugins/minimap/messages.ts index 53717be7..03ce3d0c 100644 --- a/plugins/minimap/messages.ts +++ b/plugins/minimap/messages.ts @@ -35,25 +35,25 @@ export const ChartTagDataSchema = Type.Object({ export type ChartTagData = Static; export const RecipeDataSchema = Type.Object({ - start_tick: Type.Optional(Type.Number()), - end_tick: Type.Optional(Type.Number()), - surface: Type.String(), - force: Type.String(), - position: Type.Tuple([Type.Number(), Type.Number()]), - recipe: Type.Optional(Type.String()), - icon: Type.Optional(SignalIDSchema), + start_tick: Type.Optional(Type.Number()), + end_tick: Type.Optional(Type.Number()), + surface: Type.String(), + force: Type.String(), + position: Type.Tuple([Type.Number(), Type.Number()]), + recipe: Type.Optional(Type.String()), + icon: Type.Optional(SignalIDSchema), }); export type RecipeData = Static; /** * TileDataEvent: Used for both Factorio Instance -> Controller and Controller -> Web Clients - * + * * Contains compressed RGB565 chart data from Factorio's internal mapping system. * When sent from instance to controller: triggers storage in persistent tile files. * When sent from controller to web clients: enables real-time minimap updates. - * - * Data flow: + * + * Data flow: * - Lua module -> Instance plugin -> Controller plugin (for persistence) * - Controller plugin -> Web UI plugin -> Canvas renderer (for live updates) */ @@ -100,12 +100,12 @@ export class TileDataEvent { /** * ChartTagDataEvent: Used for both Factorio Instance -> Controller and Controller -> Web Clients - * + * * Contains chart tag information from Factorio's map tags. * When sent from instance to controller: triggers storage for tag history and timelapse. * When sent from controller to web clients: enables real-time tag updates. - * - * Data flow: + * + * Data flow: * - Lua module -> Instance plugin -> Controller plugin (for persistence) * - Controller plugin -> Web UI plugin -> Tag renderer (for live updates) */ @@ -136,34 +136,34 @@ export class ChartTagDataEvent { } export class RecipeDataEvent { - declare ["constructor"]: typeof RecipeDataEvent; - static type = "event" as const; - static src = ["instance", "controller"] as const; - static dst = ["controller", "control"] as const; - static plugin = "minimap" as const; - static permission = null; + declare ["constructor"]: typeof RecipeDataEvent; + static type = "event" as const; + static src = ["instance", "controller"] as const; + static dst = ["controller", "control"] as const; + static plugin = "minimap" as const; + static permission = null; - constructor( + constructor( public instance_id: number, public recipe_data: RecipeData, - ) {} + ) {} - static jsonSchema = Type.Object({ - "instance_id": Type.Number(), - "recipe_data": RecipeDataSchema, - }); + static jsonSchema = Type.Object({ + "instance_id": Type.Number(), + "recipe_data": RecipeDataSchema, + }); - static fromJSON(json: Static) { - return new this( - json.instance_id, - json.recipe_data - ); - } + static fromJSON(json: Static) { + return new this( + json.instance_id, + json.recipe_data + ); + } } /** * GetRawTileRequest: Request raw tile data from controller storage - * + * * Returns base64-encoded tile data that can be processed in timelapse mode * or for initial tile loading when canvas minimap starts up. */ @@ -187,7 +187,7 @@ export class GetRawTileRequest { static jsonSchema = Type.Object({ "instance_id": Type.Number(), "surface": Type.String(), - "force": Type.String(), + "force": Type.String(), "tile_x": Type.Number(), "tile_y": Type.Number(), "tick": Type.Optional(Type.Number()), @@ -211,7 +211,7 @@ export class GetRawTileRequest { /** * GetChartTagsRequest: Request existing chart tags from controller storage - * + * * Returns all saved chart tags for a specific instance/surface/force combination. * Used to load existing chart tags when the web UI starts up or selection changes. */ @@ -246,46 +246,46 @@ export class GetChartTagsRequest { static Response = lib.plainJson(Type.Object({ "chart_tags": Type.Array(ChartTagDataSchema), })); -} +} export class GetRawRecipeTileRequest { - declare ["constructor"]: typeof GetRawRecipeTileRequest; - static type = "request" as const; - static src = "control" as const; - static dst = "controller" as const; - static plugin = "minimap" as const; - static permission = "minimap.view"; + declare ["constructor"]: typeof GetRawRecipeTileRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static plugin = "minimap" as const; + static permission = "minimap.view"; - constructor( + constructor( public instance_id: number, public surface: string, public force: string, public tile_x: number, public tile_y: number, public tick?: number, - ) {} + ) {} - static jsonSchema = Type.Object({ - "instance_id": Type.Number(), - "surface": Type.String(), - "force": Type.String(), - "tile_x": Type.Number(), - "tile_y": Type.Number(), - "tick": Type.Optional(Type.Number()), - }); + static jsonSchema = Type.Object({ + "instance_id": Type.Number(), + "surface": Type.String(), + "force": Type.String(), + "tile_x": Type.Number(), + "tile_y": Type.Number(), + "tick": Type.Optional(Type.Number()), + }); - static fromJSON(json: Static) { - return new this( - json.instance_id, - json.surface, - json.force, - json.tile_x, - json.tile_y, - json.tick - ); - } + static fromJSON(json: Static) { + return new this( + json.instance_id, + json.surface, + json.force, + json.tile_x, + json.tile_y, + json.tick + ); + } - static Response = lib.plainJson(Type.Object({ - "recipe_tile": Type.Union([Type.String(), Type.Null()]), - })); -} + static Response = lib.plainJson(Type.Object({ + "recipe_tile": Type.Union([Type.String(), Type.Null()]), + })); +} diff --git a/plugins/minimap/tile-utils.ts b/plugins/minimap/tile-utils.ts index 1438bc22..68e2d809 100644 --- a/plugins/minimap/tile-utils.ts +++ b/plugins/minimap/tile-utils.ts @@ -1,7 +1,7 @@ import * as zlib from "zlib"; // Environment detection -const isNode = typeof Buffer !== 'undefined' && typeof process !== 'undefined' && process.versions?.node; +const isNode = typeof Buffer !== "undefined" && typeof process !== "undefined" && process.versions?.node; // Cross-platform type for binary data type BinaryData = Buffer | Uint8Array; @@ -49,26 +49,41 @@ async function inflateData(data: BinaryData): Promise { const { promisify } = await import("util"); const inflateAsync = promisify(zlib.inflate); return inflateAsync(data as Buffer); - } else { - // Browser environment - use sync version (it's actually async-friendly due to polyfill) - const buffer = data instanceof Buffer ? data : Buffer.from(data); - return zlib.inflateSync(buffer); } + // Browser environment - use promisified inflate + return new Promise((resolve, reject) => { + try { + const buffer = data instanceof Buffer ? data : Buffer.from(data); + zlib.inflate(buffer, (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }); + } catch (err) { + reject(err); + } + }); } /** * Convert RGB565 to RGB888 values using Factorio's exact method + * @param rgb565Value The RGB565 color value to convert + * @returns Tuple of RGB values [r, g, b] */ export function rgb565ToRgb888(rgb565Value: number): [number, number, number] { // This matches ByteColor(uint16_t rgb5) constructor in Factorio source - const r = ((rgb565Value >> 11) & 0x1F) << 3; // 5 bits -> 8 bits - const g = ((rgb565Value >> 5) & 0x3F) << 2; // 6 bits -> 8 bits - const b = (rgb565Value & 0x1F) << 3; // 5 bits -> 8 bits + const r = ((rgb565Value >> 11) & 0x1F) << 3; // 5 bits -> 8 bits + const g = ((rgb565Value >> 5) & 0x3F) << 2; // 6 bits -> 8 bits + const b = (rgb565Value & 0x1F) << 3; // 5 bits -> 8 bits return [r, g, b]; } /** * Render chunk data to get RGB565 pixel array + * @param chunkData The compressed chunk data to render + * @returns RGB565 pixel array for 32x32 chunk */ export async function renderChunkToPixels(chunkData: BinaryData): Promise { try { @@ -89,7 +104,6 @@ export async function renderChunkToPixels(chunkData: BinaryData): Promise { const currentPixels = new Uint16Array(256 * 256); // 256x256 tile @@ -131,89 +152,19 @@ export async function renderTileToPixels(tileData: BinaryData, maxTick?: number) offset += 1; if (type === 1) { // Chunk - if (offset + 7 > tileData.length) { + const chunkResult = await processChunkData( + tileData, offset, currentPixels, maxTick + ); + if (!chunkResult.success) { break; } - - const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick - offset += 4; - - // Skip this chunk if it's beyond our target tick - if (maxTick !== undefined && tick > maxTick) { - const chunkCoordsByte = readUInt8(tileData, offset); - offset += 1; - const length = readUInt16BE(tileData, offset); - offset += 2; - offset += length; // Skip chunk data - continue; - } - - 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 chunkData = sliceData(tileData, offset, offset + length); - - try { - const chunkPixels = await renderChunkToPixels(chunkData); - - // Update current pixel state - for (let y = 0; y < 32; y++) { - for (let x = 0; x < 32; x++) { - const chunkIndex = y * 32 + x; - const tileX = chunkX * 32 + x; - const tileY = chunkY * 32 + y; - const tileIndex = tileY * 256 + tileX; - currentPixels[tileIndex] = chunkPixels[chunkIndex]; - } - } - } catch (decompressError) { - console.error(`Failed to decompress chunk data: ${decompressError}`); - } - - offset += length; + offset = chunkResult.newOffset; } else if (type === 2) { // Pixels - if (offset + 6 > tileData.length) { + const pixelResult = processPixelData(tileData, offset, currentPixels, maxTick); + if (!pixelResult.success) { break; } - - const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick - offset += 4; - const pixelCount = readUInt16BE(tileData, offset); - offset += 2; - const newColor = readUInt16BE(tileData, offset); - offset += 2; - const oldColor = readUInt16BE(tileData, offset); - offset += 2; - - const expectedDataLength = pixelCount * 2; // x,y per pixel - if (offset + expectedDataLength > tileData.length) { - break; - } - - // Skip if beyond target tick - if (maxTick !== undefined && tick > maxTick) { - offset += expectedDataLength; - continue; - } - - for (let i = 0; i < pixelCount; i++) { - const pixelOffset = offset + i * 2; - const x = readUInt8(tileData, pixelOffset); - const y = readUInt8(tileData, pixelOffset + 1); - if (x < 256 && y < 256) { - currentPixels[y * 256 + x] = newColor; - } - } - - offset += expectedDataLength; + offset = pixelResult.newOffset; } else { break; } @@ -222,9 +173,140 @@ export async function renderTileToPixels(tileData: BinaryData, maxTick?: number) return currentPixels; } +/** + * Process chunk data during tile rendering + * @param tileData The binary tile data being processed + * @param offset Current offset in the tile data + * @param currentPixels The pixel array to update + * @param maxTick Optional maximum tick to render up to + * @returns Processing result with success status and new offset + */ +async function processChunkData( + tileData: BinaryData, + offset: number, + currentPixels: Uint16Array, + maxTick?: number +): Promise<{ success: boolean; newOffset: number }> { + if (offset + 7 > tileData.length) { + return { success: false, newOffset: offset }; + } + + const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick + offset += 4; + + // Skip this chunk if it's beyond our target tick + if (maxTick !== undefined && tick > maxTick) { + const chunkCoordsByte = readUInt8(tileData, offset); + offset += 1; + const length = readUInt16BE(tileData, offset); + offset += 2; + offset += length; // Skip chunk data + return { success: true, newOffset: offset }; + } + + const chunkCoordsByte = readUInt8(tileData, offset); + offset += 1; + const length = readUInt16BE(tileData, offset); + offset += 2; + + if (offset + length > tileData.length) { + return { success: false, newOffset: offset }; + } + + const chunkX = chunkCoordsByte >> 4; + const chunkY = chunkCoordsByte & 0x0F; + const chunkData = sliceData(tileData, offset, offset + length); + + try { + const chunkPixels = await renderChunkToPixels(chunkData); + + // Update current pixel state + updateTilePixelsWithChunk(currentPixels, chunkPixels, chunkX, chunkY); + } catch (decompressError) { + // Continue processing other chunks on error + } + + return { success: true, newOffset: offset + length }; +} + +/** + * Process pixel data during tile rendering + * @param tileData The binary tile data being processed + * @param offset Current offset in the tile data + * @param currentPixels The pixel array to update + * @param maxTick Optional maximum tick to render up to + * @returns Processing result with success status and new offset + */ +function processPixelData( + tileData: BinaryData, + offset: number, + currentPixels: Uint16Array, + maxTick?: number +): { success: boolean; newOffset: number } { + if (offset + 6 > tileData.length) { + return { success: false, newOffset: offset }; + } + + const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick + offset += 4; + const pixelCount = readUInt16BE(tileData, offset); + offset += 2; + const newColor = readUInt16BE(tileData, offset); + offset += 2; + const oldColor = readUInt16BE(tileData, offset); + offset += 2; + + const expectedDataLength = pixelCount * 2; // x,y per pixel + if (offset + expectedDataLength > tileData.length) { + return { success: false, newOffset: offset }; + } + + // Skip if beyond target tick + if (maxTick !== undefined && tick > maxTick) { + return { success: true, newOffset: offset + expectedDataLength }; + } + + for (let i = 0; i < pixelCount; i++) { + const pixelOffset = offset + i * 2; + const x = readUInt8(tileData, pixelOffset); + const y = readUInt8(tileData, pixelOffset + 1); + if (x < 256 && y < 256) { + currentPixels[y * 256 + x] = newColor; + } + } + + return { success: true, newOffset: offset + expectedDataLength }; +} + +/** + * Update tile pixels with chunk data + * @param currentPixels The tile pixel array to update + * @param chunkPixels The chunk pixel data to apply + * @param chunkX Chunk X coordinate within tile + * @param chunkY Chunk Y coordinate within tile + */ +function updateTilePixelsWithChunk( + currentPixels: Uint16Array, + chunkPixels: Uint16Array, + chunkX: number, + chunkY: number +): void { + for (let y = 0; y < 32; y++) { + for (let x = 0; x < 32; x++) { + const chunkIndex = y * 32 + x; + const tileX = chunkX * 32 + x; + const tileY = chunkY * 32 + y; + const tileIndex = tileY * 256 + tileX; + currentPixels[tileIndex] = chunkPixels[chunkIndex]; + } + } +} + /** * Convert RGB565 pixel array to RGBA pixel data for canvas rendering * Returns Uint8ClampedArray that can be used to create ImageData in browser + * @param pixels The RGB565 pixel array to convert + * @returns RGBA pixel data as Uint8ClampedArray */ export function pixelsToRGBA(pixels: Uint16Array): Uint8ClampedArray { const imageData = new Uint8ClampedArray(256 * 256 * 4); @@ -245,10 +327,12 @@ export function pixelsToRGBA(pixels: Uint16Array): Uint8ClampedArray { /** * Convert RGB565 pixel array to ImageData for canvas rendering (browser only) + * @param pixels The RGB565 pixel array to convert + * @returns ImageData object for canvas rendering */ export function pixelsToImageData(pixels: Uint16Array): any { - if (typeof (globalThis as any).ImageData === 'undefined') { - throw new Error('ImageData is not available in this environment. Use pixelsToRGBA() instead.'); + if (typeof (globalThis as any).ImageData === "undefined") { + throw new Error("ImageData is not available in this environment. Use pixelsToRGBA() instead."); } const rgbaData = pixelsToRGBA(pixels); return new (globalThis as any).ImageData(rgbaData, 256, 256); @@ -256,6 +340,8 @@ export function pixelsToImageData(pixels: Uint16Array): any { /** * Parse tile data and extract all available ticks for timelapse functionality + * @param tileData The binary tile data to parse + * @returns Array of available ticks sorted in ascending order */ export function extractAvailableTicks(tileData: BinaryData): number[] { const ticks = new Set(); @@ -347,6 +433,8 @@ export interface ParsedTileData { /** * Parse tile data into structured change records for efficient timelapse navigation + * @param tileData The binary tile data to parse + * @returns Parsed tile data with chunks, pixel changes, and all ticks */ export function parseTileData(tileData: BinaryData): ParsedTileData { const chunks: ChunkRecord[] = []; @@ -363,65 +451,17 @@ export function parseTileData(tileData: BinaryData): ParsedTileData { offset += 1; if (type === 1) { // Chunk - if (offset + 7 > tileData.length) { + const result = parseChunkRecord(tileData, offset, chunks, ticks); + if (!result.success) { 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; + offset = result.newOffset; } else if (type === 2) { // Pixels - if (offset + 6 > tileData.length) { + const result = parsePixelChangeRecord(tileData, offset, pixelChanges, ticks); + if (!result.success) { 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 newColor = readUInt16BE(tileData, offset); - offset += 2; - const oldColor = readUInt16BE(tileData, offset); - offset += 2; - - const expectedDataLength = pixelCount * 2; - if (offset + expectedDataLength > tileData.length) { - break; - } - - const changes: PixelChange[] = []; - for (let i = 0; i < pixelCount; i++) { - const pixelOffset = offset + i * 2; - const x = readUInt8(tileData, pixelOffset); - const y = readUInt8(tileData, pixelOffset + 1); - changes.push({ x, y, newColor, oldColor }); - } - - pixelChanges.push({ tick, changes }); - offset += expectedDataLength; + offset = result.newOffset; } else { break; } @@ -430,34 +470,118 @@ export function parseTileData(tileData: BinaryData): ParsedTileData { return { chunks, pixelChanges, - allTicks: Array.from(ticks).sort((a, b) => a - b) + allTicks: Array.from(ticks).sort((a, b) => a - b), }; } +/** + * Parse chunk record from tile data + * @param tileData The binary tile data being processed + * @param offset Current offset in the tile data + * @param chunks Array to add the parsed chunk to + * @param ticks Set to add the chunk tick to + * @returns Processing result with success status and new offset + */ +function parseChunkRecord( + tileData: BinaryData, + offset: number, + chunks: ChunkRecord[], + ticks: Set +): { success: boolean; newOffset: number } { + if (offset + 7 > tileData.length) { + return { success: false, newOffset: offset }; + } + + 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) { + return { success: false, newOffset: offset }; + } + + const chunkX = chunkCoordsByte >> 4; + const chunkY = chunkCoordsByte & 0x0F; + const data = sliceData(tileData, offset, offset + length); + + chunks.push({ + tick, + chunkX, + chunkY, + data, + }); + + return { success: true, newOffset: offset + length }; +} + +/** + * Parse pixel change record from tile data + * @param tileData The binary tile data being processed + * @param offset Current offset in the tile data + * @param pixelChanges Array to add the parsed pixel changes to + * @param ticks Set to add the pixel change tick to + * @returns Processing result with success status and new offset + */ +function parsePixelChangeRecord( + tileData: BinaryData, + offset: number, + pixelChanges: PixelChangeRecord[], + ticks: Set +): { success: boolean; newOffset: number } { + if (offset + 6 > tileData.length) { + return { success: false, newOffset: offset }; + } + + const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick + ticks.add(tick); + offset += 4; + const pixelCount = readUInt16BE(tileData, offset); + offset += 2; + const newColor = readUInt16BE(tileData, offset); + offset += 2; + const oldColor = readUInt16BE(tileData, offset); + offset += 2; + + const expectedDataLength = pixelCount * 2; + if (offset + expectedDataLength > tileData.length) { + return { success: false, newOffset: offset }; + } + + const changes: PixelChange[] = []; + for (let i = 0; i < pixelCount; i++) { + const pixelOffset = offset + i * 2; + const x = readUInt8(tileData, pixelOffset); + const y = readUInt8(tileData, pixelOffset + 1); + changes.push({ x, y, newColor, oldColor }); + } + + pixelChanges.push({ tick, changes }); + return { success: true, newOffset: offset + expectedDataLength }; +} + /** * Apply chunk data to RGB565 pixel array + * @param pixels The tile pixel array to update + * @param chunkRecord The chunk record to apply */ 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]; - } - } + updateTilePixelsWithChunk(pixels, chunkPixels, chunkRecord.chunkX, chunkRecord.chunkY); } catch (err) { - console.error(`Failed to apply chunk ${chunkRecord.chunkX},${chunkRecord.chunkY}:`, err); + // Error applying chunk - continue without this chunk } } /** * Clear chunk area to black (for rewinding past chunk creation) + * @param pixels The tile pixel array to update + * @param chunkRecord The chunk record to clear */ export function clearChunkInPixels(pixels: Uint16Array, chunkRecord: ChunkRecord): void { for (let y = 0; y < 32; y++) { @@ -472,6 +596,8 @@ export function clearChunkInPixels(pixels: Uint16Array, chunkRecord: ChunkRecord /** * Apply pixel changes to RGB565 pixel array (forward direction) + * @param pixels The tile pixel array to update + * @param changes Array of pixel changes to apply */ export function applyPixelChanges(pixels: Uint16Array, changes: PixelChange[]): void { for (const change of changes) { @@ -482,6 +608,8 @@ export function applyPixelChanges(pixels: Uint16Array, changes: PixelChange[]): /** * Revert pixel changes from RGB565 pixel array (backward direction) + * @param pixels The tile pixel array to update + * @param changes Array of pixel changes to revert */ export function revertPixelChanges(pixels: Uint16Array, changes: PixelChange[]): void { for (const change of changes) { @@ -493,6 +621,10 @@ export function revertPixelChanges(pixels: Uint16Array, changes: PixelChange[]): /** * Render tile data incrementally from one tick to another * Much more efficient than re-rendering from scratch + * @param parsedData The parsed tile data + * @param currentPixels The current pixel state + * @param fromTick The starting tick + * @param toTick The target tick */ export async function renderTileIncremental( parsedData: ParsedTileData, @@ -509,38 +641,70 @@ export async function renderTileIncremental( 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); - } - } + await applyChangesForward(chunks, pixelChanges, currentPixels, fromTick, toTick); } else { - // Revert changes backward from fromTick to toTick + await applyChangesBackward(chunks, pixelChanges, currentPixels, fromTick, 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); - } +/** + * Apply changes in forward direction + * @param chunks Array of chunk records to process + * @param pixelChanges Array of pixel change records to process + * @param currentPixels The pixel array to update + * @param fromTick The starting tick + * @param toTick The target tick + */ +async function applyChangesForward( + chunks: ChunkRecord[], + pixelChanges: PixelChangeRecord[], + currentPixels: Uint16Array, + fromTick: number, + toTick: number +): Promise { + // Apply chunks that occur in this range + for (const chunk of chunks) { + if (chunk.tick > fromTick && chunk.tick <= toTick) { + await applyChunkToPixels(currentPixels, chunk); } + } - // 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); - } + // Apply pixel changes that occur in this range + for (const record of pixelChanges) { + if (record.tick > fromTick && record.tick <= toTick) { + applyPixelChanges(currentPixels, record.changes); + } + } +} + +/** + * Apply changes in backward direction + * @param chunks Array of chunk records to process + * @param pixelChanges Array of pixel change records to process + * @param currentPixels The pixel array to update + * @param fromTick The starting tick + * @param toTick The target tick + */ +async function applyChangesBackward( + chunks: ChunkRecord[], + pixelChanges: PixelChangeRecord[], + currentPixels: Uint16Array, + fromTick: number, + toTick: number +): Promise { + // 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); } } } @@ -557,12 +721,17 @@ export interface ParsedRecipeTile { /** * Parse binary recipe tile data and return timeline ticks and active recipes at target tick * @param tileX Tile X coordinate for world position calculation - * @param tileY Tile Y coordinate for world position calculation + * @param tileY Tile Y coordinate for world position calculation * @param buf Binary recipe tile data * @param targetTick Target tick for filtering (null = latest state) * @returns Parsed recipe data with ticks and active recipes */ -export function parseRecipeTileBinary(tileX: number, tileY: number, buf: BinaryData, targetTick: number | null): ParsedRecipeTile { +export function parseRecipeTileBinary( + tileX: number, + tileY: number, + buf: BinaryData, + targetTick: number | null +): ParsedRecipeTile { const ticks: number[] = []; const dict = new Map(); const active = new Map(); @@ -574,60 +743,110 @@ export function parseRecipeTileBinary(tileX: number, tileY: number, buf: BinaryD offset += 1; if (type === 0) { // Dictionary entry - const idx = readUInt16BE(buf, offset); - offset += 2; - const len = readUInt8(buf, offset); - offset += 1; - const nameBytes = sliceData(buf, offset, offset + len); - offset += len; - - // Convert to string using cross-platform method - let recipeName: string; - if (isNode && nameBytes instanceof Buffer) { - recipeName = nameBytes.toString('utf-8'); - } else { - recipeName = new TextDecoder().decode(nameBytes as Uint8Array); - } - - dict.set(idx, recipeName); + const result = parseDictionaryEntry(buf, offset, dict); + offset = result.newOffset; } else if (type === 1 || type === 2) { // Set recipe (1) or Clear recipe (2) - const sec = readUInt32BE(buf, offset); - offset += 4; - const px = readUInt8(buf, offset); - offset += 1; - const py = readUInt8(buf, offset); - offset += 1; - - let idx: number | undefined; - if (type === 1) { - idx = readUInt16BE(buf, offset); - offset += 2; - } - - const tickVal = sec * 60; - ticks.push(tickVal); - - if (sec > targetSec) { - // Future to target time – we can skip rest if file is chronological - // but to be safe just continue without applying - continue; - } - - const worldX = tileX * 256 + px; - const worldY = tileY * 256 + py; - const posKey = `${worldX},${worldY}`; - - if (type === 1 && idx !== undefined) { - const name = dict.get(idx); - if (name) active.set(posKey, name); - } else if (type === 2) { - active.delete(posKey); - } + const result = parseRecipeEntry( + buf, offset, type, tileX, tileY, ticks, active, targetSec, dict + ); + offset = result.newOffset; } else { - console.warn("Unknown recipe record type", type); + // Unknown recipe record type - stop parsing break; } } return { ticks, activeRecipes: active, dictionary: dict }; -} +} + +/** + * Parse dictionary entry from recipe tile + * @param buf The binary recipe tile data + * @param offset Current offset in the data + * @param dict Dictionary map to add the entry to + * @returns Processing result with new offset + */ +function parseDictionaryEntry( + buf: BinaryData, + offset: number, + dict: Map +): { newOffset: number } { + const idx = readUInt16BE(buf, offset); + offset += 2; + const len = readUInt8(buf, offset); + offset += 1; + const nameBytes = sliceData(buf, offset, offset + len); + offset += len; + + // Convert to string using cross-platform method + let recipeName: string; + if (isNode && nameBytes instanceof Buffer) { + recipeName = nameBytes.toString("utf-8"); + } else { + recipeName = new TextDecoder().decode(nameBytes as Uint8Array); + } + + dict.set(idx, recipeName); + return { newOffset: offset }; +} + +/** + * Parse recipe entry from recipe tile + * @param buf The binary recipe tile data + * @param offset Current offset in the data + * @param type Entry type (1 = set recipe, 2 = clear recipe) + * @param tileX Tile X coordinate for world position calculation + * @param tileY Tile Y coordinate for world position calculation + * @param ticks Array to add the entry tick to + * @param active Map of active recipes to update + * @param targetSec Target second for filtering + * @param dict Dictionary map for recipe name lookup + * @returns Processing result with new offset + */ +function parseRecipeEntry( + buf: BinaryData, + offset: number, + type: number, + tileX: number, + tileY: number, + ticks: number[], + active: Map, + targetSec: number, + dict: Map +): { newOffset: number } { + const sec = readUInt32BE(buf, offset); + offset += 4; + const px = readUInt8(buf, offset); + offset += 1; + const py = readUInt8(buf, offset); + offset += 1; + + let idx: number | undefined; + if (type === 1) { + idx = readUInt16BE(buf, offset); + offset += 2; + } + + const tickVal = sec * 60; + ticks.push(tickVal); + + if (sec > targetSec) { + // Future to target time – skip without applying + return { newOffset: offset }; + } + + const worldX = tileX * 256 + px; + const worldY = tileY * 256 + py; + const posKey = `${worldX},${worldY}`; + + if (type === 1 && idx !== undefined) { + const name = dict.get(idx); + if (name) { + active.set(posKey, name); + } + } else if (type === 2) { + active.delete(posKey); + } + + return { newOffset: offset }; +} diff --git a/plugins/minimap/web/CanvasMinimapPage.tsx b/plugins/minimap/web/CanvasMinimapPage.tsx index d4e3f20a..95b3d49f 100644 --- a/plugins/minimap/web/CanvasMinimapPage.tsx +++ b/plugins/minimap/web/CanvasMinimapPage.tsx @@ -1,7 +1,16 @@ import React, { useState, useEffect, useContext, useRef, useCallback } from "react"; import { Row, Col, Button, Card, Space, Select, Switch, Slider, Typography } from "antd"; import { ControlContext, useInstances, useItemMetadata } from "@clusterio/web_ui"; -import { GetRawTileRequest, GetChartTagsRequest, GetRawRecipeTileRequest, TileDataEvent, ChartTagData, ChartTagDataEvent, SignalID, RecipeDataEvent } from "../messages"; +import { + GetRawTileRequest, + GetChartTagsRequest, + GetRawRecipeTileRequest, + TileDataEvent, + ChartTagData, + ChartTagDataEvent, + SignalID, + RecipeDataEvent, +} from "../messages"; import * as zlib from "zlib"; import { renderTileToPixels, @@ -11,7 +20,7 @@ import { parseTileData, renderTileIncremental, ParsedTileData, - parseRecipeTileBinary + parseRecipeTileBinary, } from "../tile-utils"; const { Text } = Typography; @@ -56,7 +65,6 @@ const TILE_SIZE = 256; // 256x256 pixels per tile (8x8 chunks) const CHUNKS_PER_TILE = 8; - // Convert raw chart data to ImageData async function chartDataToImageData(chartData: string): Promise { // Decode base64 and decompress @@ -84,7 +92,7 @@ async function chartDataToImageData(chartData: string): Promise { // Fill remaining pixels with black if we have less data than expected for (let pixelIndex = maxPixels; pixelIndex < 32 * 32; pixelIndex++) { const bufferIndex = pixelIndex * 4; - imageData[bufferIndex] = 0; // R + imageData[bufferIndex] = 0; // R imageData[bufferIndex + 1] = 0; // G imageData[bufferIndex + 2] = 0; // B imageData[bufferIndex + 3] = 255; // A @@ -180,19 +188,19 @@ export default function CanvasMinimapPage() { // Detect spritesheet URL by inspecting CSS once metadata available useEffect(() => { - if (spriteImgRef.current || itemMetadata.size === undefined || itemMetadata.size === null) return; + if (spriteImgRef.current || itemMetadata.size === undefined || itemMetadata.size === null) { return; } // pick first metadata entry const firstEntry = itemMetadata.entries().next(); - if (firstEntry.done) return; + if (firstEntry.done) { return; } const testName = firstEntry.value[0]; - const span = document.createElement('span'); + const span = document.createElement("span"); span.className = `factorio-icon item-${testName}`; - span.style.position = 'absolute'; span.style.visibility = 'hidden'; + span.style.position = "absolute"; span.style.visibility = "hidden"; document.body.appendChild(span); const bg = getComputedStyle(span).backgroundImage; document.body.removeChild(span); const match = bg.match(/url\("?([^"\)]+)"?\)/); - if (!match) return; + if (!match) { return; } const url = match[1]; const img = new Image(); img.src = url; @@ -201,7 +209,7 @@ export default function CanvasMinimapPage() { for (const [name, canvas] of iconCanvasCache.current) { const meta = itemMetadata.get(name); if (meta) { - const ctx = canvas.getContext('2d')!; + const ctx = canvas.getContext("2d")!; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, meta.x, meta.y, meta.size, meta.size, 0, 0, meta.size, meta.size); } @@ -214,10 +222,10 @@ export default function CanvasMinimapPage() { const getIconCanvas = (name: string): HTMLCanvasElement | null => { // Wait until metadata is ready - if (!itemMetadataRef.current.size) return null; + if (!itemMetadataRef.current.size) { return null; } // Re-use cached canvas if we already built one for this name - if (iconCanvasCache.current.has(name)) return iconCanvasCache.current.get(name)!; + if (iconCanvasCache.current.has(name)) { return iconCanvasCache.current.get(name)!; } // Helper that tries to find metadata for a given key const resolveMeta = (key: string) => itemMetadataRef.current.get(key); @@ -235,7 +243,7 @@ export default function CanvasMinimapPage() { } // If still not found, give up - if (!meta) return null; + if (!meta) { return null; } // Build off-screen canvas containing the icon const canvas = document.createElement("canvas"); @@ -281,8 +289,8 @@ export default function CanvasMinimapPage() { // After getIconCanvas const signalToKey = (sig: any): string | null => { - if (!sig || !sig.name) return null; - const t = sig.type || 'item'; + if (!sig || !sig.name) { return null; } + const t = sig.type || "item"; return `${t}-${sig.name}`; }; @@ -356,24 +364,33 @@ export default function CanvasMinimapPage() { mergedChartTags, chartTagTimestamps, }; - }, [selectedInstance, selectedSurface, selectedForce, isTimelapseMode, currentTick, availableTicks, showChartTags, mergedChartTags, chartTagTimestamps, showRecipes]); + }, [ + selectedInstance, + selectedSurface, + selectedForce, + isTimelapseMode, + currentTick, + availableTicks, + showChartTags, + mergedChartTags, + chartTagTimestamps, + showRecipes, + ]); // Define pixel-perfect zoom levels to eliminate seaming issues const ZOOM_LEVELS = [0.125, 0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10]; - const getClosestZoomLevel = (targetZoom: number): number => { - return ZOOM_LEVELS.reduce((prev, curr) => - Math.abs(curr - targetZoom) < Math.abs(prev - targetZoom) ? curr : prev - ); - }; + const getClosestZoomLevel = (targetZoom: number): number => ZOOM_LEVELS.reduce( + (prev, curr) => (Math.abs(curr - targetZoom) < Math.abs(prev - targetZoom) ? curr : prev) + ); - const getNextZoomLevel = (currentZoom: number, direction: 'up' | 'down'): number => { + const getNextZoomLevel = (currentZoom: number, direction: "up" | "down"): number => { const currentIndex = ZOOM_LEVELS.findIndex(zoom => Math.abs(zoom - currentZoom) < 0.001); - if (direction === 'up') { + if (direction === "up") { return ZOOM_LEVELS[Math.min(currentIndex + 1, ZOOM_LEVELS.length - 1)]; - } else { - return ZOOM_LEVELS[Math.max(currentIndex - 1, 0)]; } + return ZOOM_LEVELS[Math.max(currentIndex - 1, 0)]; + }; // Throttled zoom display update to avoid excessive re-renders @@ -404,7 +421,7 @@ export default function CanvasMinimapPage() { // Calculate which tiles are visible based on current view state const calculateVisibleTiles = () => { const canvas = canvasRef.current; - if (!canvas) return null; + if (!canvas) { return null; } const viewState = viewStateRef.current; const scale = viewState.zoomLevel; @@ -434,10 +451,114 @@ export default function CanvasMinimapPage() { worldLeft, worldTop, worldRight, - worldBottom + worldBottom, }; }; + // Helper function to load ticks from tiles + const loadTicksFromTiles = async ( + leftTile: number, + topTile: number, + rightTile: number, + bottomTile: number + ): Promise<{ allTicks: Set; tilesChecked: number; tilesWithData: number }> => { + const allTicks = new Set(); + let tilesChecked = 0; + let tilesWithData = 0; + + for (let tileY = topTile; tileY <= bottomTile; tileY++) { + for (let tileX = leftTile; tileX <= rightTile; tileX++) { + const response = await control.send(new GetRawTileRequest( + selectedInstance!, + selectedSurface, + selectedForce, + tileX, + tileY + )); + + tilesChecked += 1; + + if (response.tile_data) { + const binaryString = atob(response.tile_data); + const tileData = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i += 1) { + tileData[i] = binaryString.charCodeAt(i); + } + + const ticks = extractAvailableTicks(tileData); + + if (ticks.length > 0) { + tilesWithData += 1; + ticks.forEach(tick => allTicks.add(tick)); + } + } + } + } + + return { allTicks, tilesChecked, tilesWithData }; + }; + + // Helper function to add chart tag timestamps + const addChartTagTimestamps = (allTicks: Set): number => { + const currentState = currentStateRef.current; + let tagsWithTicks = 0; + + if (currentState.mergedChartTags.length > 0) { + for (const tag of currentState.mergedChartTags) { + // Add start_tick if it exists (tag creation) + if (tag.start_tick !== undefined) { + allTicks.add(tag.start_tick); + tagsWithTicks += 1; + } + + // Add end_tick if it exists (tag deletion) + if (tag.end_tick !== undefined) { + allTicks.add(tag.end_tick); + tagsWithTicks += 1; + } + } + } + + return tagsWithTicks; + }; + + // Helper function to load recipe timestamps + const loadRecipeTimestamps = async ( + leftTile: number, + topTile: number, + rightTile: number, + bottomTile: number, + allTicks: Set + ): Promise => { + let recipeTicksAdded = 0; + + for (let tileY = topTile; tileY <= bottomTile; tileY++) { + for (let tileX = leftTile; tileX <= rightTile; tileX++) { + const recipeResp = await control.send(new GetRawRecipeTileRequest( + selectedInstance!, + selectedSurface, + selectedForce, + tileX, + tileY + )); + if (recipeResp.recipe_tile) { + const binStr = atob(recipeResp.recipe_tile); + const buf = new Uint8Array(binStr.length); + for (let i = 0; i < binStr.length; i += 1) { + buf[i] = binStr.charCodeAt(i); + } + const parsed = parseRecipeTileBinary(tileX, tileY, buf, null); + for (const t of parsed.ticks) { + allTicks.add(t); + recipeTicksAdded += 1; + } + } + } + } + + return recipeTicksAdded; + }; + // Timelapse functions const loadAvailableTicks = async () => { if (!selectedInstance) { @@ -446,109 +567,27 @@ export default function CanvasMinimapPage() { const visibleTiles = calculateVisibleTiles(); if (!visibleTiles) { - console.warn("Canvas not available for tile culling calculation"); return; } - try { - const { leftTile, topTile, rightTile, bottomTile } = visibleTiles; + const { leftTile, topTile, rightTile, bottomTile } = visibleTiles; - const allTicks = new Set(); - let tilesChecked = 0; - let tilesWithData = 0; + // Load ticks from tiles + const { allTicks } = await loadTicksFromTiles(leftTile, topTile, rightTile, bottomTile); - // 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 - )); + // Add chart tag timestamps + const tagsWithTicks = addChartTagTimestamps(allTicks); + setChartTagTimestamps(tagsWithTicks); - tilesChecked++; + // Load recipe timestamps + await loadRecipeTimestamps(leftTile, topTile, rightTile, bottomTile, allTicks); - 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 sortedTicks = Array.from(allTicks).sort((a, b) => a - b); + setAvailableTicks(sortedTicks); - const ticks = extractAvailableTicks(tileData); - - if (ticks.length > 0) { - tilesWithData++; - ticks.forEach(tick => allTicks.add(tick)); - } - } - } catch (err) { - console.warn(`Failed to load tile ${tileX},${tileY}:`, err); - } - } - } - - // Also include chart tag timestamps - const currentState = currentStateRef.current; - let tagsWithTicks = 0; - - if (currentState.mergedChartTags.length > 0) { - for (const tag of currentState.mergedChartTags) { - // Add start_tick if it exists (tag creation) - if (tag.start_tick !== undefined) { - allTicks.add(tag.start_tick); - tagsWithTicks++; - } - - // Add end_tick if it exists (tag deletion) - if (tag.end_tick !== undefined) { - allTicks.add(tag.end_tick); - tagsWithTicks++; - } - } - - console.log(`Added ${tagsWithTicks} chart tag timestamps to timeline`); - } - - setChartTagTimestamps(tagsWithTicks); - - // Collect recipe timestamps across visible recipe tiles - let recipeTicksAdded = 0; - for (let tileY = topTile; tileY <= bottomTile; tileY++) { - for (let tileX = leftTile; tileX <= rightTile; tileX++) { - try { - const recipeResp = await control.send(new GetRawRecipeTileRequest( - selectedInstance, - selectedSurface, - selectedForce, - tileX, - tileY - )); - if (recipeResp.recipe_tile) { - const binStr = atob(recipeResp.recipe_tile); - const buf = new Uint8Array(binStr.length); - for (let i = 0; i < binStr.length; i++) buf[i] = binStr.charCodeAt(i); - const parsed = parseRecipeTileBinary(tileX, tileY, buf, null); - for (const t of parsed.ticks) { allTicks.add(t); recipeTicksAdded++; } - } - } catch (e) { - console.warn(`Failed to load recipe tile ${tileX},${tileY}:`, e); - } - } - } - - const sortedTicks = Array.from(allTicks).sort((a, b) => a - b); - setAvailableTicks(sortedTicks); - - // Set current tick to latest if not set - if (sortedTicks.length > 0 && currentTick === null) { - setCurrentTick(sortedTicks[sortedTicks.length - 1]); - } - } catch (err) { - console.error("Failed to load available ticks:", err); + // Set current tick to latest if not set + if (sortedTicks.length > 0 && currentTick === null) { + setCurrentTick(sortedTicks[sortedTicks.length - 1]); } }; @@ -612,12 +651,12 @@ export default function CanvasMinimapPage() { // Update virtual tile canvas if it exists const virtualTile = virtualTiles.current.get(tileKey); if (virtualTile) { - const ctx = virtualTile.getContext('2d')!; + const ctx = virtualTile.getContext("2d")!; ctx.putImageData(newImageData, 0, 0); } // Update chunk cache for this tile - const [tileX, tileY] = tileKey.split(',').map(Number); + 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; @@ -627,11 +666,13 @@ export default function CanvasMinimapPage() { // 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++) { + // eslint-disable-next-line max-depth for (let x = 0; x < CHUNK_SIZE; x++) { + // eslint-disable-next-line max-len 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] = 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 @@ -643,7 +684,6 @@ export default function CanvasMinimapPage() { } } } 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); @@ -691,11 +731,9 @@ export default function CanvasMinimapPage() { setIsPlaying(false); } }, 1000 / playbackSpeed); - } else { - if (playbackTimerRef.current) { - clearInterval(playbackTimerRef.current); - playbackTimerRef.current = undefined; - } + } else if (playbackTimerRef.current) { + clearInterval(playbackTimerRef.current); + playbackTimerRef.current = undefined; } return () => { @@ -715,9 +753,9 @@ export default function CanvasMinimapPage() { return `${hours}h ${minutes % 60}m ${seconds % 60}s`; } else if (minutes > 0) { return `${minutes}m ${seconds % 60}s`; - } else { - return `${seconds}s`; } + return `${seconds}s`; + }; // Set default instance when instances become available @@ -741,41 +779,44 @@ export default function CanvasMinimapPage() { const key = e.key.toLowerCase(); // Track movement keys for smooth animation - if (['w', 'a', 's', 'd'].includes(key)) { + if (["w", "a", "s", "d"].includes(key)) { keysPressed.current.add(key); return; } // Handle zoom keys immediately (not animated) switch (key) { - case '+': - case '=': // Handle both + and = key (since + requires shift) + case "+": + case "=": // Handle both + and = key (since + requires shift) updateViewState({ - zoomLevel: getNextZoomLevel(viewStateRef.current.zoomLevel, 'up') + zoomLevel: getNextZoomLevel(viewStateRef.current.zoomLevel, "up"), }); e.preventDefault(); // Prevent browser zoom break; - case '-': - case '_': // Handle both - and _ key (since _ requires shift) + case "-": + case "_": // Handle both - and _ key (since _ requires shift) updateViewState({ - zoomLevel: getNextZoomLevel(viewStateRef.current.zoomLevel, 'down') + zoomLevel: getNextZoomLevel(viewStateRef.current.zoomLevel, "down"), }); e.preventDefault(); // Prevent browser zoom break; - case 'f': + case "f": // Step backward in timeline (only in timelapse mode) if (currentStateRef.current.isTimelapseMode && currentStateRef.current.availableTicks.length > 0) { stepBackward(); e.preventDefault(); } break; - case 'g': + case "g": // Step forward in timeline (only in timelapse mode) if (currentStateRef.current.isTimelapseMode && currentStateRef.current.availableTicks.length > 0) { stepForward(); e.preventDefault(); } break; + default: + // No action for other keys + break; } }; @@ -783,7 +824,7 @@ export default function CanvasMinimapPage() { const key = e.key.toLowerCase(); // Remove movement keys when released - if (['w', 'a', 's', 'd'].includes(key)) { + if (["w", "a", "s", "d"].includes(key)) { keysPressed.current.delete(key); } }; @@ -795,26 +836,26 @@ export default function CanvasMinimapPage() { const handleWheel = (e: WheelEvent) => { e.preventDefault(); - const direction = e.deltaY > 0 ? 'down' : 'up'; + const direction = e.deltaY > 0 ? "down" : "up"; const newZoom = getNextZoomLevel(viewStateRef.current.zoomLevel, direction); updateViewState({ zoomLevel: newZoom }); }; - document.addEventListener('keydown', handleKeyDown); - document.addEventListener('keyup', handleKeyUp); - window.addEventListener('blur', handleBlur); + document.addEventListener("keydown", handleKeyDown); + document.addEventListener("keyup", handleKeyUp); + window.addEventListener("blur", handleBlur); const canvasElement = canvasRef.current; if (canvasElement) { - canvasElement.addEventListener('wheel', handleWheel, { passive: false }); + canvasElement.addEventListener("wheel", handleWheel, { passive: false }); } return () => { - document.removeEventListener('keydown', handleKeyDown); - document.removeEventListener('keyup', handleKeyUp); - window.removeEventListener('blur', handleBlur); + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("keyup", handleKeyUp); + window.removeEventListener("blur", handleBlur); if (canvasElement) { - canvasElement.removeEventListener('wheel', handleWheel); + canvasElement.removeEventListener("wheel", handleWheel); } }; }, []); // No dependencies - use refs for current state @@ -824,7 +865,7 @@ export default function CanvasMinimapPage() { const canvasElement = canvasRef.current; if (!canvasElement) { - return; + return () => { }; } const handleMouseDown = (e: MouseEvent) => { @@ -847,7 +888,7 @@ export default function CanvasMinimapPage() { updateViewState({ centerX: viewStateRef.current.centerX + worldDeltaX, - centerY: viewStateRef.current.centerY + worldDeltaY + centerY: viewStateRef.current.centerY + worldDeltaY, }); lastMousePosRef.current = { x: e.clientX, y: e.clientY }; @@ -869,22 +910,22 @@ export default function CanvasMinimapPage() { updateCursor(false); // Update state for cursor change }; - canvasElement.addEventListener('mousedown', handleMouseDown); - canvasElement.addEventListener('mousemove', handleMouseMove); - canvasElement.addEventListener('mouseup', handleMouseUp); - canvasElement.addEventListener('mouseleave', handleMouseLeave); + canvasElement.addEventListener("mousedown", handleMouseDown); + canvasElement.addEventListener("mousemove", handleMouseMove); + canvasElement.addEventListener("mouseup", handleMouseUp); + canvasElement.addEventListener("mouseleave", handleMouseLeave); // Also listen to document for mouse events to handle dragging outside canvas - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); return () => { - canvasElement.removeEventListener('mousedown', handleMouseDown); - canvasElement.removeEventListener('mousemove', handleMouseMove); - canvasElement.removeEventListener('mouseup', handleMouseUp); - canvasElement.removeEventListener('mouseleave', handleMouseLeave); - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); + canvasElement.removeEventListener("mousedown", handleMouseDown); + canvasElement.removeEventListener("mousemove", handleMouseMove); + canvasElement.removeEventListener("mouseup", handleMouseUp); + canvasElement.removeEventListener("mouseleave", handleMouseLeave); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); }; }, [selectedInstance]); // Only need to re-setup when canvas becomes available @@ -907,21 +948,16 @@ export default function CanvasMinimapPage() { }, []); // No dependencies - render loop accesses current state via refs - const loadSurfaceForceData = async () => { - try { - const response = await fetch(`${window.location.origin}/api/minimap/surfaces`); - const data = await response.json(); - setSurfaceForceData(data); + const response = await fetch(`${window.location.origin}/api/minimap/surfaces`); + const data = await response.json(); + setSurfaceForceData(data); - if (data.surfaces.length > 0 && !selectedSurface) { - setSelectedSurface(data.surfaces.includes("nauvis") ? "nauvis" : data.surfaces[0]); - } - if (data.forces.length > 0 && !selectedForce) { - setSelectedForce(data.forces.includes("player") ? "player" : data.forces[0]); - } - } catch (err) { - console.error("Failed to load surface/force data:", err); + if (data.surfaces.length > 0 && !selectedSurface) { + setSelectedSurface(data.surfaces.includes("nauvis") ? "nauvis" : data.surfaces[0]); + } + if (data.forces.length > 0 && !selectedForce) { + setSelectedForce(data.forces.includes("player") ? "player" : data.forces[0]); } }; @@ -931,30 +967,24 @@ export default function CanvasMinimapPage() { return; } - try { - const response = await control.send(new GetChartTagsRequest( - selectedInstance, - selectedSurface, - selectedForce - )); + const response = await control.send(new GetChartTagsRequest( + selectedInstance, + selectedSurface, + selectedForce + )); - if (response.chart_tags && response.chart_tags.length > 0) { - // Convert to ChartTagDataWithInstance format - const existingTags: ChartTagDataWithInstance[] = response.chart_tags.map((tag: ChartTagData) => ({ - ...tag, - instance_id: selectedInstance, - })); + if (response.chart_tags && response.chart_tags.length > 0) { + // Convert to ChartTagDataWithInstance format + const existingTags: ChartTagDataWithInstance[] = response.chart_tags.map((tag: ChartTagData) => ({ + ...tag, + instance_id: selectedInstance, + })); - // Replace existing tags with loaded ones - setRawChartTags(existingTags); - console.log(`Loaded ${existingTags.length} existing chart tags`); - } else { - // Clear tags if no existing data - setRawChartTags([]); - } - } catch (err) { - console.error("Failed to load existing chart tags:", err); - // Don't clear tags on error, keep what we have + // Replace existing tags with loaded ones + setRawChartTags(existingTags); + } else { + // Clear tags if no existing data + setRawChartTags([]); } }; @@ -975,20 +1005,18 @@ export default function CanvasMinimapPage() { 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; + 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); - } + await renderTileIncremental( + tileState.parsedData, + tileState.pixels, + tileState.currentTick, + targetTick + ); + tileState.currentTick = targetTick; + tileState.imageData = pixelsToImageData(tileState.pixels); } return tileState.imageData; @@ -1027,9 +1055,9 @@ export default function CanvasMinimapPage() { const parsedData = parseTileData(tileData); // Determine target tick - const targetTick = isTimelapseMode ? - (currentTick || 0) : - (parsedData.allTicks[parsedData.allTicks.length - 1] || 0); + 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; @@ -1043,7 +1071,7 @@ export default function CanvasMinimapPage() { currentTick: targetTick, pixels: pixels, parsedData: parsedData, - imageData: tileImageData + imageData: tileImageData, }; tileStateCache.current.set(tileKey, tileState); @@ -1057,11 +1085,12 @@ export default function CanvasMinimapPage() { // 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++) { + // eslint-disable-next-line max-depth 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] = tileImageData.data[tilePixelIndex]; // R + chunkData[chunkPixelIndex] = tileImageData.data[tilePixelIndex]; // R chunkData[chunkPixelIndex + 1] = tileImageData.data[tilePixelIndex + 1]; // G chunkData[chunkPixelIndex + 2] = tileImageData.data[tilePixelIndex + 2]; // B chunkData[chunkPixelIndex + 3] = tileImageData.data[tilePixelIndex + 3]; // A @@ -1074,9 +1103,6 @@ export default function CanvasMinimapPage() { } return tileImageData; - } catch (err) { - console.error(`Failed to load tile ${tileX},${tileY}:`, err); - return null; } finally { // Remove from loading cache when done loadingTiles.current.delete(tileKey); @@ -1108,7 +1134,7 @@ export default function CanvasMinimapPage() { // Update the virtual tile directly if it exists const virtualTile = virtualTiles.current.get(tileKey); if (virtualTile) { - const ctx = virtualTile.getContext('2d')!; + const ctx = virtualTile.getContext("2d")!; ctx.putImageData(imageData, pixelX, pixelY); } @@ -1124,7 +1150,7 @@ export default function CanvasMinimapPage() { const chunkIndex = (y * CHUNK_SIZE + x) * 4; const tileIndex = ((pixelY + y) * TILE_SIZE + (pixelX + x)) * 4; - tileData[tileIndex] = chunkData[chunkIndex]; // R + tileData[tileIndex] = chunkData[chunkIndex]; // R tileData[tileIndex + 1] = chunkData[chunkIndex + 1]; // G tileData[tileIndex + 2] = chunkData[chunkIndex + 2]; // B tileData[tileIndex + 3] = chunkData[chunkIndex + 3]; // A @@ -1150,19 +1176,19 @@ export default function CanvasMinimapPage() { } // Create new virtual tile - const virtualCanvas = document.createElement('canvas'); + const virtualCanvas = document.createElement("canvas"); virtualCanvas.width = TILE_SIZE; virtualCanvas.height = TILE_SIZE; - const ctx = virtualCanvas.getContext('2d')!; + const ctx = virtualCanvas.getContext("2d")!; // Always start with black background - ctx.fillStyle = '#000000'; + ctx.fillStyle = "#000000"; ctx.fillRect(0, 0, TILE_SIZE, TILE_SIZE); // Load the entire tile at once (much more efficient than loading chunks individually) const [tileImageData, recipeImageData] = await Promise.all([ loadTile(tileX, tileY), - loadRecipeTile(tileX, tileY) + loadRecipeTile(tileX, tileY), ]); if (tileImageData) { @@ -1174,68 +1200,43 @@ export default function CanvasMinimapPage() { return virtualCanvas; }; - // Render the canvas - const renderCanvas = () => { - const canvas = canvasRef.current; - if (!canvas) return; + // Helper function to handle WASD movement + const handleMovementKeys = () => { + if (keysPressed.current.size === 0) { return; } - // Get current state from ref to ensure consistency - const currentState = currentStateRef.current; + // Adjust base speed for 60fps + const moveSpeed = 20 / viewStateRef.current.zoomLevel; + let deltaX = 0; + let deltaY = 0; - // Early return if no instance selected - if (!currentState.selectedInstance) { - // Draw dark background and return - const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; - ctx.fillStyle = '#1a1a1a'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - return; + // Calculate movement delta based on currently pressed keys + if (keysPressed.current.has("w")) { deltaY -= moveSpeed; } + if (keysPressed.current.has("s")) { deltaY += moveSpeed; } + if (keysPressed.current.has("a")) { deltaX -= moveSpeed; } + if (keysPressed.current.has("d")) { deltaX += moveSpeed; } + + // Reduce speed if moving diagonally + if (deltaX !== 0 && deltaY !== 0) { + deltaX *= 0.7071; // sqrt(2) / 2 + deltaY *= 0.7071; } - // Apply smooth WASD movement - if (keysPressed.current.size > 0) { - // Adjust base speed for 60fps - const moveSpeed = 20 / viewStateRef.current.zoomLevel; - let deltaX = 0; - let deltaY = 0; - - // Calculate movement delta based on currently pressed keys - if (keysPressed.current.has('w')) deltaY -= moveSpeed; - if (keysPressed.current.has('s')) deltaY += moveSpeed; - if (keysPressed.current.has('a')) deltaX -= moveSpeed; - if (keysPressed.current.has('d')) deltaX += moveSpeed; - - // Reduce speed if moving diagonally - if (deltaX !== 0 && deltaY !== 0) { - deltaX *= 0.7071; // sqrt(2) / 2 - deltaY *= 0.7071; - } - - // Apply movement if any keys are pressed - if (deltaX !== 0 || deltaY !== 0) { - updateViewState({ - centerX: viewStateRef.current.centerX + deltaX, - centerY: viewStateRef.current.centerY + deltaY - }); - } + // Apply movement if any keys are pressed + if (deltaX !== 0 || deltaY !== 0) { + updateViewState({ + centerX: viewStateRef.current.centerX + deltaX, + centerY: viewStateRef.current.centerY + deltaY, + }); } + }; - const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; - ctx.imageSmoothingEnabled = false; - - // Get canvas dimensions - const width = canvas.width; - const height = canvas.height; - - // Clear canvas completely - ctx.fillStyle = '#1a1a1a'; - ctx.fillRect(0, 0, width, height); - - // Calculate visible tiles using shared function - const visibleTiles = calculateVisibleTiles(); - if (!visibleTiles) return; - + // Helper function to render visible tiles + const renderVisibleTiles = ( + ctx: CanvasRenderingContext2D, + visibleTiles: any, + currentState: any + ) => { const { leftTile, topTile, rightTile, bottomTile, scale, worldLeft, worldTop } = visibleTiles; - const scaledTileSize = TILE_SIZE * scale; const firstTileScreenX = Math.round((leftTile * TILE_SIZE - worldLeft) * scale); const firstTileScreenY = Math.round((topTile * TILE_SIZE - worldTop) * scale); @@ -1256,84 +1257,166 @@ export default function CanvasMinimapPage() { // Render visible tiles using the pre-calculated edge grid for (let tileY = topTile; tileY <= bottomTile; tileY++) { for (let tileX = leftTile; tileX <= rightTile; tileX++) { - const tileKey = `${tileX},${tileY}`; - const virtualTile = virtualTiles.current.get(tileKey); - - // Get tile position from pre-calculated edges - const tileIndexX = tileX - leftTile; - const tileIndexY = tileY - topTile; - const screenX = tileEdgesX[tileIndexX]; - const screenY = tileEdgesY[tileIndexY]; - const screenWidth = tileEdgesX[tileIndexX + 1] - screenX; - const screenHeight = tileEdgesY[tileIndexY + 1] - screenY; - - // Calculate clipping bounds to ensure we don't draw outside canvas - const clippedX = Math.max(0, screenX); - const clippedY = Math.max(0, screenY); - const clippedWidth = Math.min(screenWidth, width - clippedX); - const clippedHeight = Math.min(screenHeight, height - clippedY); - - // Skip if tile is completely outside canvas - if (clippedWidth <= 0 || clippedHeight <= 0) { - continue; - } - - if (virtualTile) { - // Tile is loaded, draw it with clipping - ctx.imageSmoothingEnabled = false; - - // Round source coordinates to prevent subpixel sampling - const rawSourceX = clippedX > screenX ? (clippedX - screenX) / scale : 0; - const rawSourceY = clippedY > screenY ? (clippedY - screenY) / scale : 0; - const rawSourceWidth = clippedWidth / scale; - const rawSourceHeight = clippedHeight / scale; - - // Use clipped coordinates directly since screen coordinates are already rounded - const destX = clippedX; - const destY = clippedY; - const destWidth = clippedWidth; - const destHeight = clippedHeight; - - ctx.drawImage( - virtualTile, - rawSourceX, rawSourceY, rawSourceWidth, rawSourceHeight, - destX, destY, destWidth, destHeight - ); - } else { - // Tile not loaded yet, draw black - ctx.fillStyle = '#000000'; - ctx.fillRect(clippedX, clippedY, clippedWidth, clippedHeight); - - // Start loading the tile in the background (but don't wait for it) - // Only attempt to load if we have all required parameters - if (currentState.selectedInstance && currentState.selectedSurface && currentState.selectedForce) { - getVirtualTile(tileX, tileY).then(() => { - // When tile loads, trigger a re-render - // But only if we're still looking at the same area - const latestState = currentStateRef.current; - if (latestState.selectedInstance && latestState.selectedSurface && latestState.selectedForce) { - // Use a small delay to batch multiple tile loads - setTimeout(() => { - if (animationFrameRef.current) { - // Render will happen on next animation frame anyway - } - }, 16); - } - }).catch(err => { - console.error(`Failed to load tile ${tileX},${tileY}:`, err); - }); - } - } + renderSingleTile(ctx, tileX, tileY, tileEdgesX, tileEdgesY, leftTile, topTile, currentState); } } + }; + + // Helper function to render a single tile + const renderSingleTile = ( + ctx: CanvasRenderingContext2D, + tileX: number, + tileY: number, + tileEdgesX: number[], + tileEdgesY: number[], + leftTile: number, + topTile: number, + currentState: any + ) => { + const tileKey = `${tileX},${tileY}`; + const virtualTile = virtualTiles.current.get(tileKey); + + // Get tile position from pre-calculated edges + const tileIndexX = tileX - leftTile; + const tileIndexY = tileY - topTile; + const screenX = tileEdgesX[tileIndexX]; + const screenY = tileEdgesY[tileIndexY]; + const screenWidth = tileEdgesX[tileIndexX + 1] - screenX; + const screenHeight = tileEdgesY[tileIndexY + 1] - screenY; + + // Calculate clipping bounds to ensure we don't draw outside canvas + const clippedX = Math.max(0, screenX); + const clippedY = Math.max(0, screenY); + const clippedWidth = Math.min(screenWidth, ctx.canvas.width - clippedX); + const clippedHeight = Math.min(screenHeight, ctx.canvas.height - clippedY); + + // Skip if tile is completely outside canvas + if (clippedWidth <= 0 || clippedHeight <= 0) { + return; + } + + if (virtualTile) { + drawLoadedTile(ctx, virtualTile, screenX, screenY, clippedX, clippedY, clippedWidth, clippedHeight); + } else { + drawUnloadedTile(ctx, clippedX, clippedY, clippedWidth, clippedHeight, tileX, tileY, currentState); + } + }; + + // Helper function to draw a loaded tile + const drawLoadedTile = ( + ctx: CanvasRenderingContext2D, + virtualTile: HTMLCanvasElement, + screenX: number, + screenY: number, + clippedX: number, + clippedY: number, + clippedWidth: number, + clippedHeight: number + ) => { + // Tile is loaded, draw it with clipping + ctx.imageSmoothingEnabled = false; + + // Round source coordinates to prevent subpixel sampling + const scale = viewStateRef.current.zoomLevel; + const rawSourceX = clippedX > screenX ? (clippedX - screenX) / scale : 0; + const rawSourceY = clippedY > screenY ? (clippedY - screenY) / scale : 0; + const rawSourceWidth = clippedWidth / scale; + const rawSourceHeight = clippedHeight / scale; + + // Use clipped coordinates directly since screen coordinates are already rounded + const destX = clippedX; + const destY = clippedY; + const destWidth = clippedWidth; + const destHeight = clippedHeight; + + ctx.drawImage( + virtualTile, + rawSourceX, rawSourceY, rawSourceWidth, rawSourceHeight, + destX, destY, destWidth, destHeight + ); + }; + + // Helper function to draw unloaded tile and start loading + const drawUnloadedTile = ( + ctx: CanvasRenderingContext2D, + clippedX: number, + clippedY: number, + clippedWidth: number, + clippedHeight: number, + tileX: number, + tileY: number, + currentState: any + ) => { + // Tile not loaded yet, draw black + ctx.fillStyle = "#000000"; + ctx.fillRect(clippedX, clippedY, clippedWidth, clippedHeight); + + // Start loading the tile in the background (but don't wait for it) + // Only attempt to load if we have all required parameters + if (currentState.selectedInstance && currentState.selectedSurface && currentState.selectedForce) { + getVirtualTile(tileX, tileY).then(() => { + // When tile loads, trigger a re-render + // But only if we're still looking at the same area + const latestState = currentStateRef.current; + if (latestState.selectedInstance && latestState.selectedSurface && latestState.selectedForce) { + // Use a small delay to batch multiple tile loads + setTimeout(() => { + if (animationFrameRef.current) { + // Render will happen on next animation frame anyway + } + }, 16); + } + }); + } + }; + + // Render the canvas + // eslint-disable-next-line complexity + const renderCanvas = () => { + const canvas = canvasRef.current; + if (!canvas) { return; } + + // Get current state from ref to ensure consistency + const currentState = currentStateRef.current; + + // Early return if no instance selected + if (!currentState.selectedInstance) { + // Draw dark background and return + const ctx = canvas.getContext("2d") as CanvasRenderingContext2D; + ctx.fillStyle = "#1a1a1a"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + return; + } + + // Apply smooth WASD movement + handleMovementKeys(); + + const ctx = canvas.getContext("2d") as CanvasRenderingContext2D; + ctx.imageSmoothingEnabled = false; + + // Clear canvas completely + ctx.fillStyle = "#1a1a1a"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Calculate visible tiles using shared function + const visibleTiles = calculateVisibleTiles(); + if (!visibleTiles) { return; } + + // Render all visible tiles + renderVisibleTiles(ctx, visibleTiles, currentState); + + // Extract needed variables for chart tags and recipes + const { scale, worldLeft, worldTop } = visibleTiles; + const width = canvas.width; + const height = canvas.height; // Render chart tags if enabled if (currentState.showChartTags) { const visibleTags = getVisibleChartTags(); ctx.save(); - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; ctx.font = `${Math.max(10, 12 / scale)}px Arial`; for (const tag of visibleTags) { @@ -1354,9 +1437,9 @@ export default function CanvasMinimapPage() { if (iconKey) { drawIcon(ctx, screenX, screenY, iconKey, "screen"); } else { - ctx.fillStyle = tag.icon ? '#4CAF50' : '#2196F3'; + ctx.fillStyle = tag.icon ? "#4CAF50" : "#2196F3"; ctx.fillRect(screenX - halfSize, screenY - halfSize, tagSize, tagSize); - ctx.strokeStyle = '#FFFFFF'; + ctx.strokeStyle = "#FFFFFF"; ctx.lineWidth = 2; ctx.strokeRect(screenX - halfSize, screenY - halfSize, tagSize, tagSize); } @@ -1376,7 +1459,7 @@ export default function CanvasMinimapPage() { const textHeight = fontSize; const textPadding = 2; - ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.fillStyle = "rgba(0, 0, 0, 0.7)"; ctx.fillRect( textX - textPadding, textY - textHeight / 2 - textPadding, @@ -1385,10 +1468,10 @@ export default function CanvasMinimapPage() { ); // Draw text - ctx.fillStyle = '#FFFFFF'; - ctx.textAlign = 'left'; // Align text to start from the left edge + ctx.fillStyle = "#FFFFFF"; + ctx.textAlign = "left"; // Align text to start from the left edge ctx.fillText(tag.text, textX, textY); - ctx.textAlign = 'center'; // Reset for next iteration + ctx.textAlign = "center"; // Reset for next iteration } } @@ -1399,11 +1482,13 @@ export default function CanvasMinimapPage() { if (currentState.showRecipes) { ctx.save(); for (const [coord, recName] of recipeCache.current) { - const [rx, ry] = coord.split(',').map(Number); + const [rx, ry] = coord.split(",").map(Number); const screenX = Math.round((rx - worldLeft) * scale); const screenY = Math.round((ry - worldTop) * scale); - if (screenX < -30 || screenX > width + 30 || screenY < -30 || screenY > height + 30) continue; - const iconKey = recName.startsWith('item-') || recName.startsWith('fluid-') ? recName : signalToKey({ type: 'item', name: recName }); + if (screenX < -30 || screenX > width + 30 || screenY < -30 || screenY > height + 30) { continue; } + const iconKey = recName.startsWith("item-") || recName.startsWith("fluid-") + ? recName + : signalToKey({ type: "item", name: recName }); drawIcon(ctx, screenX, screenY, iconKey || recName, "world"); } ctx.restore(); @@ -1415,7 +1500,7 @@ export default function CanvasMinimapPage() { const handleResize = () => { const canvas = canvasRef.current; const container = containerRef.current; - if (!canvas || !container) return; + if (!canvas || !container) { return; } // Use ResizeObserver for more accurate container size tracking const rect = container.getBoundingClientRect(); @@ -1430,26 +1515,26 @@ export default function CanvasMinimapPage() { // Set up ResizeObserver for container size changes const container = containerRef.current; - if (container && 'ResizeObserver' in window) { + if (container && "ResizeObserver" in window) { const resizeObserver = new ResizeObserver(handleResize); resizeObserver.observe(container); // Also listen to window resize as fallback - window.addEventListener('resize', handleResize); + window.addEventListener("resize", handleResize); return () => { clearTimeout(timeoutId); resizeObserver.disconnect(); - window.removeEventListener('resize', handleResize); - }; - } else { - // Fallback for browsers without ResizeObserver - window.addEventListener('resize', handleResize); - return () => { - clearTimeout(timeoutId); - window.removeEventListener('resize', handleResize); + window.removeEventListener("resize", handleResize); }; } + // Fallback for browsers without ResizeObserver + window.addEventListener("resize", handleResize); + return () => { + clearTimeout(timeoutId); + window.removeEventListener("resize", handleResize); + }; + }, []); // Ensure proper canvas sizing when instance selection changes @@ -1497,6 +1582,7 @@ export default function CanvasMinimapPage() { return () => clearTimeout(timeoutId); } + return () => { }; }, [selectedInstance, selectedSurface, selectedForce, isTimelapseMode]); // Load existing chart tags when instance/surface/force selection changes @@ -1512,7 +1598,7 @@ export default function CanvasMinimapPage() { // Snap current zoom level to nearest pixel-perfect level on mount useEffect(() => { updateViewState({ - zoomLevel: getClosestZoomLevel(viewStateRef.current.zoomLevel) + zoomLevel: getClosestZoomLevel(viewStateRef.current.zoomLevel), }); // Cleanup throttled zoom update on unmount @@ -1534,9 +1620,9 @@ export default function CanvasMinimapPage() { return currentState.mergedChartTags.filter((tag: MergedChartTag) => { // Filter by instance, surface, and force - if (tag.instance_id !== currentState.selectedInstance || - tag.surface !== currentState.selectedSurface || - tag.force !== currentState.selectedForce) { + if (tag.instance_id !== currentState.selectedInstance + || tag.surface !== currentState.selectedSurface + || tag.force !== currentState.selectedForce) { return false; } @@ -1550,11 +1636,9 @@ export default function CanvasMinimapPage() { if (tag.end_tick !== undefined && tag.end_tick <= currentState.currentTick) { return false; } - } else { + } else if (tag.end_tick !== undefined) { // In live mode, only hide tags that have been deleted (have an end_tick) - if (tag.end_tick !== undefined) { - return false; - } + return false; } return true; @@ -1565,42 +1649,35 @@ export default function CanvasMinimapPage() { useEffect(() => { const plugin = control.plugins.get("minimap") as import("./index").WebPlugin; if (!plugin) { - console.error("Minimap plugin not found"); - return; + return () => { }; } const handleChunkUpdate = (event: TileDataEvent) => { const currentState = currentStateRef.current; // Only process updates for the currently selected instance/surface/force - if (event.instance_id === currentState.selectedInstance && - event.surface === currentState.selectedSurface && - event.force === currentState.selectedForce && - !currentState.isTimelapseMode) { + if (event.instance_id === currentState.selectedInstance + && event.surface === currentState.selectedSurface + && event.force === currentState.selectedForce + && !currentState.isTimelapseMode) { - try { - // Calculate chunk coordinates from world coordinates - const chunkX = Math.floor(event.x / 32); - const chunkY = Math.floor(event.y / 32); + // Calculate chunk coordinates from world coordinates + const chunkX = Math.floor(event.x / 32); + const chunkY = Math.floor(event.y / 32); - // Convert raw chart data to ImageData - chartDataToImageData(event.chunk.chart_data).then(imageData => { - // Update the chunk in our cache - updateChunk(chunkX, chunkY, imageData); - }).catch(err => { - console.error("Failed to convert chart data to ImageData:", err); - }); - } catch (err) { - console.error("Failed to process live chunk update:", err); - } + // Convert raw chart data to ImageData + chartDataToImageData(event.chunk.chart_data).then(imageData => { + // Update the chunk in our cache + updateChunk(chunkX, chunkY, imageData); + }); } }; const handleChartTagUpdate = (event: ChartTagDataEvent) => { const currentState = currentStateRef.current; // Only process updates for the currently selected instance/surface/force - if (event.instance_id === currentState.selectedInstance && - event.tag_data.surface === currentState.selectedSurface && - event.tag_data.force === currentState.selectedForce) { + if (event.instance_id === currentState.selectedInstance + && event.tag_data.surface === currentState.selectedSurface + && event.tag_data.force === currentState.selectedForce) { // Add the new chart tag data to our collection const newTagData: ChartTagDataWithInstance = { @@ -1610,11 +1687,10 @@ export default function CanvasMinimapPage() { setRawChartTags(prevTags => { // Check if we already have this tag (by tag_number and instance) - const existingIndex = prevTags.findIndex(tag => - tag.tag_number === newTagData.tag_number && - tag.instance_id === newTagData.instance_id && - tag.surface === newTagData.surface && - tag.force === newTagData.force + const existingIndex = prevTags.findIndex(tag => tag.tag_number === newTagData.tag_number + && tag.instance_id === newTagData.instance_id + && tag.surface === newTagData.surface + && tag.force === newTagData.force ); if (existingIndex >= 0) { @@ -1622,10 +1698,10 @@ export default function CanvasMinimapPage() { const newTags = [...prevTags]; newTags[existingIndex] = newTagData; return newTags; - } else { - // Add new tag - return [...prevTags, newTagData]; } + // Add new tag + return [...prevTags, newTagData]; + }); } }; @@ -1662,7 +1738,7 @@ export default function CanvasMinimapPage() { // 6 world-pixels wide at zoom 1 feels about right with some overdraw to make it more visible size = 6 * zoom; // Clamp so icons don't disappear completely at extreme zoom-outs - if (size < 4) size = 4; + if (size < 4) { size = 4; } } const half = size / 2; @@ -1681,29 +1757,32 @@ export default function CanvasMinimapPage() { // Function to load recipe tile const loadRecipeTile = async (tileX: number, tileY: number) => { const cs = currentStateRef.current; - if (!cs.selectedInstance) return; - try { - const resp = await control.send(new GetRawRecipeTileRequest( - cs.selectedInstance, cs.selectedSurface, cs.selectedForce, tileX, tileY - )); - if (!resp.recipe_tile) return; - const binStr = atob(resp.recipe_tile); - const buf = new Uint8Array(binStr.length); - for (let i = 0; i < binStr.length; i++) buf[i] = binStr.charCodeAt(i); + if (!cs.selectedInstance) { return; } + const resp = await control.send(new GetRawRecipeTileRequest( + cs.selectedInstance, cs.selectedSurface, cs.selectedForce, tileX, tileY + )); + if (!resp.recipe_tile) { return; } + const binStr = atob(resp.recipe_tile); + const buf = new Uint8Array(binStr.length); + for (let i = 0; i < binStr.length; i++) { buf[i] = binStr.charCodeAt(i); } - const parsed = parseRecipeTileBinary(tileX, tileY, buf, cs.isTimelapseMode ? cs.currentTick : null); + const parsed = parseRecipeTileBinary(tileX, tileY, buf, cs.isTimelapseMode ? cs.currentTick : null); - // Apply parsed active recipes to cache - for (const [coord, recName] of parsed.activeRecipes) { - recipeCache.current.set(coord, recName); - } - } catch (e) { console.warn('loadRecipeTile', e); } + // Apply parsed active recipes to cache + for (const [coord, recName] of parsed.activeRecipes) { + recipeCache.current.set(coord, recName); + } }; // Live recipe update handler const handleRecipeUpdate = (event: RecipeDataEvent) => { const cs = currentStateRef.current; - if (event.instance_id !== cs.selectedInstance || event.recipe_data.surface !== cs.selectedSurface || event.recipe_data.force !== cs.selectedForce) return; + const isWrongInstance = event.instance_id !== cs.selectedInstance; + const isWrongSurface = event.recipe_data.surface !== cs.selectedSurface; + const isWrongForce = event.recipe_data.force !== cs.selectedForce; + if (isWrongInstance || isWrongSurface || isWrongForce) { + return; + } // Handle recipe end events: remove icon if recipe interval finished if (event.recipe_data.end_tick !== undefined) { const endKey = `${event.recipe_data.position[0]},${event.recipe_data.position[1]}`; @@ -1724,7 +1803,9 @@ export default function CanvasMinimapPage() { // Subscribe to updates (tile, tag, recipe) once plugin is available useEffect(() => { const plugin = control.plugins.get("minimap") as import("./index").WebPlugin; - if (!plugin) return; + if (!plugin) { + return () => { }; + } plugin.onRecipeUpdate(handleRecipeUpdate); return () => { plugin.offRecipeUpdate(handleRecipeUpdate); }; @@ -1737,7 +1818,7 @@ export default function CanvasMinimapPage() { recipeCache.current.clear(); const visible = calculateVisibleTiles(); - if (!visible) return; + if (!visible) { return; } const { leftTile, topTile, rightTile, bottomTile } = visible; for (let ty = topTile; ty <= bottomTile; ty++) { @@ -1764,7 +1845,7 @@ export default function CanvasMinimapPage() { loading={loading} options={[...instances.values()].map(instance => ({ value: instance.id, - label: instance.name + label: instance.name, }))} /> ({ value: instance.id, - label: instance.name + label: instance.name, }))} />