diff --git a/plugins/minimap/controller.ts b/plugins/minimap/controller.ts index 73cd7a05..d02b0e6b 100644 --- a/plugins/minimap/controller.ts +++ b/plugins/minimap/controller.ts @@ -7,6 +7,9 @@ import { ChartData, ChartTagDataEvent, ChartTagData, + RecipeDataEvent, + RecipeData, + GetRawRecipeTileRequest, } from "./messages"; import * as fs from "fs-extra"; import * as path from "path"; @@ -33,8 +36,10 @@ interface PixelChange { export class ControllerPlugin extends BaseControllerPlugin { private tilesPath: string = ""; private chartTagsPath: string = ""; + private recipeTilesPath: string = ""; private chunkSavingQueue = new Map>(); private chartTagQueues = new Map(); + private recipeSavingQueue = new Map(); private lastSeenTagContent = new Map(); private saveInterval: NodeJS.Timeout | null = null; + private saveRecipeTilesInProgress: boolean = false; async init() { this.tilesPath = path.resolve( @@ -54,13 +60,20 @@ export class ControllerPlugin extends BaseControllerPlugin { this.controller.config.get("controller.database_directory"), "minimap_chart_tags" ); + this.recipeTilesPath = path.resolve( + this.controller.config.get("controller.database_directory"), + "minimap_recipe_tiles" + ); await fs.ensureDir(this.tilesPath); await fs.ensureDir(this.chartTagsPath); + await fs.ensureDir(this.recipeTilesPath); this.controller.handle(TileDataEvent, this.handleTileDataEvent.bind(this)); this.controller.handle(ChartTagDataEvent, this.handleChartTagDataEvent.bind(this)); this.controller.handle(GetRawTileRequest, this.handleGetRawTileRequest.bind(this)); this.controller.handle(GetChartTagsRequest, this.handleGetChartTagsRequest.bind(this)); + this.controller.handle(RecipeDataEvent, this.handleRecipeDataEvent.bind(this)); + this.controller.handle(GetRawRecipeTileRequest, this.handleGetRawRecipeTileRequest.bind(this)); // Set up HTTP routes for serving tiles this.setupTileRoutes(); @@ -71,6 +84,7 @@ export class ControllerPlugin extends BaseControllerPlugin { this.saveInterval = setInterval(() => { this.saveTiles().catch(err => this.logger.error(`Error saving tiles: ${err}`)); this.saveChartTags().catch(err => this.logger.error(`Error saving chart tags: ${err}`)); + this.saveRecipeTiles().catch(err => this.logger.error(`Error saving recipe tiles: ${err}`)); }, 5000); } @@ -83,6 +97,7 @@ export class ControllerPlugin extends BaseControllerPlugin { // Perform final save before shutdown await this.saveTiles(); await this.saveChartTags(); + await this.saveRecipeTiles(); } // Compare two pixel arrays and return changes @@ -527,6 +542,18 @@ export class ControllerPlugin extends BaseControllerPlugin { instance_id }; + // Immediately update last seen content to improve deduplication before save completes + if (tag_data.end_tick !== undefined) { + seenTags.delete(tag_data.tag_number); + } else { + seenTags.set(tag_data.tag_number, { + position: tag_data.position, + text: tag_data.text, + icon: tag_data.icon, + last_user: tag_data.last_user + }); + } + // Initialize queue if it doesn't exist if (!this.chartTagQueues.has(fileKey)) { this.chartTagQueues.set(fileKey, []); @@ -638,4 +665,76 @@ export class ControllerPlugin extends BaseControllerPlugin { return { chart_tags: [] }; } } + + async saveRecipeTiles() { + if (this.saveRecipeTilesInProgress) { + return; + } + + if (this.recipeSavingQueue.size === 0) { + return; + } + + this.saveRecipeTilesInProgress = true; + const queue = this.recipeSavingQueue; + this.recipeSavingQueue = new Map(); + + try { + const promises = [] as Promise[]; + for (const [tileName, list] of queue) { + const promise = (async () => { + const tilePath = path.join(this.recipeTilesPath, `${tileName}_recipes.jsonl`); + const lines = list.map(rec => JSON.stringify(rec)).join("\n") + "\n"; + await fs.appendFile(tilePath, lines, "utf-8"); + })(); + promises.push(promise); + } + await Promise.all(promises); + } catch (err) { + this.logger.error(`Error saving recipe tiles: ${err}`); + } finally { + this.saveRecipeTilesInProgress = false; + } + } + + async handleRecipeDataEvent(event: RecipeDataEvent) { + try { + const { instance_id, recipe_data } = event; + const x = recipe_data.position[0]; + const y = recipe_data.position[1]; + const tileX = Math.floor(x / 256); + const tileY = Math.floor(y / 256); + const tileKey = `${instance_id}_${recipe_data.surface}_${recipe_data.force}_${tileX}_${tileY}`; + + if (!this.recipeSavingQueue.has(tileKey)) { + this.recipeSavingQueue.set(tileKey, []); + } + this.recipeSavingQueue.get(tileKey)!.push(recipe_data); + + // broadcast live update + this.controller.sendTo(new lib.Address(lib.Address.broadcast, lib.Address.control), event); + + } catch (err) { + this.logger.error(`Error processing recipe data: ${err}`); + } + } + + async handleGetRawRecipeTileRequest(request: GetRawRecipeTileRequest) { + const { instance_id, surface, force, tile_x, tile_y } = request; + const tileKey = `${instance_id}_${surface}_${force}_${tile_x}_${tile_y}`; + const filePath = path.join(this.recipeTilesPath, `${tileKey}_recipes.jsonl`); + + if (!await fs.pathExists(filePath)) { + return { recipe_tile: null }; + } + + try { + const data = await fs.readFile(filePath, "utf-8"); + const base64 = Buffer.from(data, "utf-8").toString("base64"); + return { recipe_tile: base64 }; + } catch (err) { + this.logger.error(`Error reading recipe tile: ${err}`); + return { recipe_tile: null }; + } + } } diff --git a/plugins/minimap/index.ts b/plugins/minimap/index.ts index e22b031a..615fdf93 100644 --- a/plugins/minimap/index.ts +++ b/plugins/minimap/index.ts @@ -5,6 +5,8 @@ import { ChartTagDataEvent, GetRawTileRequest, GetChartTagsRequest, + RecipeDataEvent, + GetRawRecipeTileRequest, } from "./messages"; // Define permissions @@ -26,8 +28,10 @@ export const plugin: lib.PluginDeclaration = { messages: [ TileDataEvent, ChartTagDataEvent, + RecipeDataEvent, GetRawTileRequest, GetChartTagsRequest, + GetRawRecipeTileRequest, ], routes: ["/minimap"], }; diff --git a/plugins/minimap/instance.ts b/plugins/minimap/instance.ts index b1814cb0..ff59b1ac 100644 --- a/plugins/minimap/instance.ts +++ b/plugins/minimap/instance.ts @@ -1,6 +1,6 @@ import * as lib from "@clusterio/lib"; import { BaseInstancePlugin } from "@clusterio/host"; -import { TileDataEvent, ChartData, ChartTagDataEvent, ChartTagData } from "./messages"; +import { TileDataEvent, ChartData, ChartTagDataEvent, ChartTagData, RecipeDataEvent, RecipeData } from "./messages"; interface TileDataIpc { type: "chart"; @@ -21,6 +21,17 @@ interface ChartTagDataIpc { last_user: string | null; } +interface RecipeDataIpc { + tag_number?: number + start_tick: number | null + end_tick: number | null + surface: string + force: string + position: [number, number] + recipe: string | null + icon?: { type?: string, name?: string, quality?: string } +} + export class InstancePlugin extends BaseInstancePlugin { pendingTileUpdates = new Map>(); @@ -42,6 +53,13 @@ export class InstancePlugin extends BaseInstancePlugin { `Error handling chart tag data from Lua:\n${err}` )); }); + + // Listen for recipe data from Lua module + this.instance.server.on("ipc-minimap:recipe_data", (data: RecipeDataIpc) => { + this.handleRecipeDataFromLua(data).catch(err => this.logger.error( + `Error handling recipe data from Lua:\n${err}` + )); + }); } async handleTileDataFromLua(data: TileDataIpc) { @@ -93,6 +111,35 @@ export class InstancePlugin extends BaseInstancePlugin { return this.instance.sendTo("controller", chartTagEvent); } + async handleRecipeDataFromLua(data: RecipeDataIpc) { + try { + if (!data) { + this.logger.error("Invalid recipe data received") + return + } + + const recipeData: RecipeData = { + start_tick: data.start_tick ?? undefined, + end_tick: data.end_tick ?? undefined, + surface: data.surface, + force: data.force, + position: data.position, + recipe: data.recipe ?? undefined, + icon: data.icon ?? undefined, + }; + + const event = new RecipeDataEvent( + this.instance.config.get("instance.id"), + recipeData + ); + + return this.instance.sendTo("controller", event); + + } catch (err: unknown) { + this.logger.error(`Failed to process recipe data from Lua: ${err}`); + } + } + async onStart() { // Initialize the Lua module when the instance starts try { diff --git a/plugins/minimap/messages.ts b/plugins/minimap/messages.ts index e427c8b5..53717be7 100644 --- a/plugins/minimap/messages.ts +++ b/plugins/minimap/messages.ts @@ -34,6 +34,18 @@ 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), +}); + +export type RecipeData = Static; + /** * TileDataEvent: Used for both Factorio Instance -> Controller and Controller -> Web Clients * @@ -123,7 +135,31 @@ 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; + constructor( + public instance_id: number, + public recipe_data: RecipeData, + ) {} + + static jsonSchema = Type.Object({ + "instance_id": Type.Number(), + "recipe_data": RecipeDataSchema, + }); + + static fromJSON(json: Static) { + return new this( + json.instance_id, + json.recipe_data + ); + } +} /** * GetRawTileRequest: Request raw tile data from controller storage @@ -211,3 +247,45 @@ export class GetChartTagsRequest { "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"; + + 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 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()]), + })); +} diff --git a/plugins/minimap/module/minimap.lua b/plugins/minimap/module/minimap.lua index 5c3b4c90..f95dde32 100644 --- a/plugins/minimap/module/minimap.lua +++ b/plugins/minimap/module/minimap.lua @@ -31,6 +31,100 @@ local function send_chart_tag_data(tag_data) clusterio_api.send_json("minimap:chart_tag_data", tag_data) end +-- Send recipe data to plugin +local function send_recipe_data(recipe_data) + clusterio_api.send_json("minimap:recipe_data", recipe_data) +end + +-- Helper for creating and sending recipe start/stop events +local function update_entity_recipe(entity) + if not entity or not entity.valid then + return + end + + -- Ensure storage.minimap exists + if not storage.minimap then + storage.minimap = { enabled = true, chunk_update_queue = {}, recipe_cache = {} } + end + + if not storage.minimap.recipe_cache then + storage.minimap.recipe_cache = {} + end + + -- Only consider entities that can have recipes + local recipe = nil + if entity.type == "assembling-machine" then + local rec_obj = entity.get_recipe() + if rec_obj then + recipe = rec_obj.name + end + elseif entity.type == "furnace" then + -- Furnaces often idle with get_recipe() = nil; fall back to previous_recipe + local prev = entity.previous_recipe + if prev and prev.name then + recipe = prev.name.name -- Recipe prototype name + end + end + + local cache = storage.minimap.recipe_cache + if not cache then + cache = {} + storage.minimap.recipe_cache = cache + end + local unit_number = entity.unit_number + if not unit_number then + return -- Cannot track entities without persistent ID + end + + local previous = cache[unit_number] + if previous and previous.recipe_name == recipe then + return -- No change + end + + local position = entity.position + local data_common = { + position = { position.x, position.y }, + surface = entity.surface.name, + force = entity.force.name, + } + + -- Close previous recipe if it existed + if previous and previous.recipe_name then + local end_data = { + position = data_common.position, + surface = data_common.surface, + force = data_common.force, + start_tick = nil, + end_tick = game.tick, + recipe = nil, + } + send_recipe_data(end_data) + end + + -- Open new recipe interval if recipe now set + if recipe then + local icon_signal = nil + local recipe_proto = prototypes.recipe[recipe] + if recipe_proto and recipe_proto.main_product then + icon_signal = { type = recipe_proto.main_product.type, name = recipe_proto.main_product.name } + end + local start_data = { + position = data_common.position, + surface = data_common.surface, + force = data_common.force, + start_tick = game.tick, + end_tick = nil, + recipe = recipe, + icon = icon_signal, + } + send_recipe_data(start_data) + cache[unit_number] = { recipe_name = recipe } + else + -- Recipe cleared + cache[unit_number] = nil + end +end + -- Create tag data structure local function create_tag_data(tag, start_tick, end_tick) return { @@ -150,11 +244,14 @@ local function on_entity_built(event) end if event.created_entity and event.created_entity.valid then - local position = event.created_entity.position + local entity = event.created_entity + local position = entity.position local chunk_x = math.floor(position.x / 32) local chunk_y = math.floor(position.y / 32) queue_chunk_for_update({x = chunk_x, y = chunk_y}) + -- Track recipe for newly built crafting entities + update_entity_recipe(entity) end end @@ -164,11 +261,14 @@ local function on_entity_removed(event) end if event.entity and event.entity.position then - local position = event.entity.position + local entity = event.entity + local position = entity.position local chunk_x = math.floor(position.x / 32) local chunk_y = math.floor(position.y / 32) queue_chunk_for_update({x = chunk_x, y = chunk_y}) + -- End recipe interval for removed entity + update_entity_recipe(entity) end end @@ -195,12 +295,39 @@ local function on_tile_changed(event) end end +-- Handle recipe change via settings paste +local function on_entity_settings_pasted(event) + if not storage.minimap or not storage.minimap.enabled then + return + end + if event.destination and event.destination.valid then + update_entity_recipe(event.destination) + end +end + +local function on_gui_closed(event) + if not storage.minimap or not storage.minimap.enabled then + return + end + + -- We're only interested in entity GUIs + if event.gui_type ~= defines.gui_type.entity then + return + end + + local entity = event.entity + if entity and entity.valid and (entity.type == "assembling-machine" or entity.type == "furnace") then + update_entity_recipe(entity) + end +end + -- Initialize the module local function init() if not storage.minimap then storage.minimap = { enabled = true, - chunk_update_queue = {} + chunk_update_queue = {}, + recipe_cache = {} } end @@ -236,6 +363,48 @@ local function init() -- Send to plugin send_chart_tag_data(tag_data) end + + -- Initialize existing crafting recipes on this surface and force + local crafting_entities = surface.find_entities_filtered{ + type = { + "assembling-machine", + "furnace", + } + } + for _, entity in pairs(crafting_entities) do + if entity.valid and entity.force == force then + local recipe + if entity.type == "assembling-machine" then + recipe = entity.get_recipe() + elseif entity.type == "furnace" then + local prev = entity.previous_recipe + if prev and prev.name then + recipe = { name = prev.name.name } + end + end + if recipe then + local data = { + position = { entity.position.x, entity.position.y }, + surface = surface.name, + force = force.name, + start_tick = game.tick, + end_tick = nil, + recipe = recipe.name, + icon = (function() + local rp = prototypes.recipe[recipe.name] + if rp and rp.main_product then + return { type = rp.main_product.type, name = rp.main_product.name } + end + return nil + end)() + } + send_recipe_data(data) + + -- Cache for tracking + storage.minimap.recipe_cache[entity.unit_number] = { recipe_name = recipe.name } + end + end + end end end end @@ -272,5 +441,7 @@ minimap.events[defines.events.script_raised_set_tiles] = on_tile_changed minimap.events[defines.events.on_chart_tag_added] = on_chart_tag_added minimap.events[defines.events.on_chart_tag_modified] = on_chart_tag_modified minimap.events[defines.events.on_chart_tag_removed] = on_chart_tag_removed +minimap.events[defines.events.on_entity_settings_pasted] = on_entity_settings_pasted +minimap.events[defines.events.on_gui_closed] = on_gui_closed return minimap diff --git a/plugins/minimap/web/CanvasMinimapPage.tsx b/plugins/minimap/web/CanvasMinimapPage.tsx index e3941589..454cb945 100644 --- a/plugins/minimap/web/CanvasMinimapPage.tsx +++ b/plugins/minimap/web/CanvasMinimapPage.tsx @@ -1,12 +1,12 @@ import React, { useState, useEffect, useContext, useRef, useCallback } from "react"; import { Row, Col, Button, Card, Space, Select, Switch, Slider, Typography } from "antd"; -import { ControlContext, useInstances } from "@clusterio/web_ui"; -import { GetRawTileRequest, GetChartTagsRequest, TileDataEvent, ChartTagData, ChartTagDataEvent, SignalID } from "../messages"; +import { ControlContext, useInstances, useItemMetadata } from "@clusterio/web_ui"; +import { GetRawTileRequest, GetChartTagsRequest, GetRawRecipeTileRequest, TileDataEvent, ChartTagData, ChartTagDataEvent, SignalID, RecipeDataEvent } from "../messages"; import * as zlib from "zlib"; -import { - renderTileToPixels, - pixelsToImageData, - rgb565ToRgb888, +import { + renderTileToPixels, + pixelsToImageData, + rgb565ToRgb888, extractAvailableTicks, parseTileData, renderTileIncremental, @@ -61,25 +61,25 @@ async function chartDataToImageData(chartData: string): Promise { // Decode base64 and decompress const compressedData = Buffer.from(chartData, "base64"); const decompressed = zlib.inflateSync(compressedData); - + // Create RGBA image data (32x32 pixels) const imageData = new Uint8ClampedArray(32 * 32 * 4); - + // Ensure we don't read beyond available data const maxPixels = Math.min(decompressed.length / 2, 32 * 32); - + for (let i = 0; i < maxPixels * 2; i += 2) { const rgb565Value = decompressed.readUInt16LE(i); const [r, g, b] = rgb565ToRgb888(rgb565Value); const pixelIndex = i / 2; const bufferIndex = pixelIndex * 4; - + imageData[bufferIndex] = r; imageData[bufferIndex + 1] = g; imageData[bufferIndex + 2] = b; imageData[bufferIndex + 3] = 255; // Alpha } - + // Fill remaining pixels with black if we have less data than expected for (let pixelIndex = maxPixels; pixelIndex < 32 * 32; pixelIndex++) { const bufferIndex = pixelIndex * 4; @@ -88,7 +88,7 @@ async function chartDataToImageData(chartData: string): Promise { imageData[bufferIndex + 2] = 0; // B imageData[bufferIndex + 3] = 255; // A } - + return new ImageData(imageData, 32, 32); } @@ -111,6 +111,7 @@ export default function CanvasMinimapPage() { // Chart tag states const [showChartTags, setShowChartTags] = useState(true); + const [showRecipes, setShowRecipes] = useState(true); const [rawChartTags, setRawChartTags] = useState([]); const [mergedChartTags, setMergedChartTags] = useState([]); @@ -141,6 +142,9 @@ export default function CanvasMinimapPage() { const chunkCache = useRef>(new Map()); const loadingTiles = useRef>>(new Map()); + // Cache for recipes currently active + const recipeCache = useRef>(new Map()); + // Animation frame ref const animationFrameRef = useRef(); @@ -156,11 +160,130 @@ export default function CanvasMinimapPage() { currentTick: null as number | null, availableTicks: [] as number[], showChartTags: true, + showRecipes: true, mergedChartTags: [] as MergedChartTag[], chartTagTimestamps: 0, }); const control = useContext(ControlContext); + const itemMetadata = useItemMetadata(); + + // Keep latest metadata in a ref so long-living callbacks (render loop) can access fresh data + const itemMetadataRef = useRef(itemMetadata); + useEffect(() => { + itemMetadataRef.current = itemMetadata; + }, [itemMetadata]); + + // Spritesheet image ref (shared) derived from CSS + const spriteImgRef = useRef(null); + + // Detect spritesheet URL by inspecting CSS once metadata available + useEffect(() => { + if (spriteImgRef.current || itemMetadata.size === undefined || itemMetadata.size === null) return; + // pick first metadata entry + const firstEntry = itemMetadata.entries().next(); + if (firstEntry.done) return; + const testName = firstEntry.value[0]; + const span = document.createElement('span'); + span.className = `factorio-icon item-${testName}`; + 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; + const url = match[1]; + const img = new Image(); + img.src = url; + spriteImgRef.current = img; + img.onload = () => { + for (const [name, canvas] of iconCanvasCache.current) { + const meta = itemMetadata.get(name); + if (meta) { + 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); + } + } + }; + }, [itemMetadata]); + + // Cache icons canvases + const iconCanvasCache = useRef>(new Map()); + + const getIconCanvas = (name: string): HTMLCanvasElement | null => { + // Wait until metadata is ready + 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)!; + + // Helper that tries to find metadata for a given key + const resolveMeta = (key: string) => itemMetadataRef.current.get(key); + + // Try direct lookup first + let meta = resolveMeta(name); + + // Fallback: strip common type prefixes ("item-", "fluid-", "signal-") + if (!meta) { + const dashIndex = name.indexOf("-"); + if (dashIndex !== -1) { + const stripped = name.substring(dashIndex + 1); + meta = resolveMeta(stripped); + } + } + + // If still not found, give up + if (!meta) return null; + + // Build off-screen canvas containing the icon + const canvas = document.createElement("canvas"); + canvas.width = meta.size; + canvas.height = meta.size; + const ctx = canvas.getContext("2d")!; + + const sprite = spriteImgRef.current; + if (sprite && sprite.complete) { + ctx.drawImage( + sprite, + meta.x, + meta.y, + meta.size, + meta.size, + 0, + 0, + meta.size, + meta.size + ); + } else if (sprite) { + // Draw once the spritesheet finishes loading to avoid blank icons + sprite.onload = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage( + sprite, + meta.x, + meta.y, + meta.size, + meta.size, + 0, + 0, + meta.size, + meta.size + ); + }; + } + + // Cache under the original key so we don't rebuild each frame + iconCanvasCache.current.set(name, canvas); + return canvas; + }; + + // After getIconCanvas + const signalToKey = (sig: any): string | null => { + if (!sig || !sig.name) return null; + const t = sig.type || 'item'; + return `${t}-${sig.name}`; + }; // Merge raw chart tag data into timeline-friendly format const mergeChartTags = useCallback((rawTags: ChartTagDataWithInstance[]): MergedChartTag[] => { @@ -169,7 +292,7 @@ export default function CanvasMinimapPage() { for (const rawTag of rawTags) { // Create a unique key for each tag (tag_number + surface + force + instance) const key = `${rawTag.tag_number}_${rawTag.surface}_${rawTag.force}_${rawTag.instance_id}`; - + if (!tagMap.has(key)) { // Create new merged tag entry tagMap.set(key, { @@ -187,7 +310,7 @@ export default function CanvasMinimapPage() { } else { // Merge with existing entry const existing = tagMap.get(key)!; - + // If this entry has a start_tick, use it if (rawTag.start_tick !== undefined) { existing.start_tick = rawTag.start_tick; @@ -197,7 +320,7 @@ export default function CanvasMinimapPage() { existing.icon = rawTag.icon; existing.last_user = rawTag.last_user; } - + // If this entry has an end_tick, use it if (rawTag.end_tick !== undefined) { existing.end_tick = rawTag.end_tick; @@ -228,10 +351,11 @@ export default function CanvasMinimapPage() { currentTick, availableTicks, showChartTags, + showRecipes, mergedChartTags, chartTagTimestamps, }; - }, [selectedInstance, selectedSurface, selectedForce, isTimelapseMode, currentTick, availableTicks, showChartTags, mergedChartTags, chartTagTimestamps]); + }, [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]; @@ -253,11 +377,11 @@ export default function CanvasMinimapPage() { // Throttled zoom display update to avoid excessive re-renders const throttledZoomUpdate = useRef(); - + // Helper to update view state without React re-render const updateViewState = (updates: Partial) => { Object.assign(viewStateRef.current, updates); - + // Update display zoom if zoom changed (throttled) if (updates.zoomLevel !== undefined) { if (throttledZoomUpdate.current) { @@ -327,7 +451,7 @@ export default function CanvasMinimapPage() { try { const { leftTile, topTile, rightTile, bottomTile } = visibleTiles; - + const allTicks = new Set(); let tilesChecked = 0; let tilesWithData = 0; @@ -354,7 +478,7 @@ export default function CanvasMinimapPage() { } const ticks = extractAvailableTicks(tileData); - + if (ticks.length > 0) { tilesWithData++; ticks.forEach(tick => allTicks.add(tick)); @@ -369,7 +493,7 @@ export default function CanvasMinimapPage() { // 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) @@ -377,22 +501,22 @@ export default function CanvasMinimapPage() { 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); 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]); @@ -405,7 +529,7 @@ export default function CanvasMinimapPage() { const toggleTimelapseMode = async (enabled: boolean) => { setIsTimelapseMode(enabled); setIsPlaying(false); - + if (enabled) { // Ensure chart tags are loaded before getting available ticks if (selectedInstance && selectedSurface && selectedForce) { @@ -416,7 +540,7 @@ export default function CanvasMinimapPage() { setCurrentTick(null); setChartTagTimestamps(0); } - + // Clear caches when switching modes virtualTiles.current.clear(); tileStateCache.current.clear(); @@ -429,14 +553,14 @@ export default function CanvasMinimapPage() { if (tickIndex >= 0 && tickIndex < currentState.availableTicks.length) { const newTick = currentState.availableTicks[tickIndex]; const oldTick = currentState.currentTick; - + if (oldTick === newTick) { return; // No change needed } - + // Use incremental updates for loaded tiles instead of clearing cache const updatePromises: Promise[] = []; - + for (const [tileKey, tileState] of tileStateCache.current) { updatePromises.push((async () => { try { @@ -447,21 +571,21 @@ export default function CanvasMinimapPage() { tileState.currentTick, newTick ); - + // Update tile state tileState.currentTick = newTick; - + // Convert to ImageData and update virtual tile const newImageData = pixelsToImageData(tileState.pixels); tileState.imageData = newImageData; - + // Update virtual tile canvas if it exists const virtualTile = virtualTiles.current.get(tileKey); if (virtualTile) { const ctx = virtualTile.getContext('2d')!; ctx.putImageData(newImageData, 0, 0); } - + // Update chunk cache for this tile const [tileX, tileY] = tileKey.split(',').map(Number); for (let cy = 0; cy < CHUNKS_PER_TILE; cy++) { @@ -476,7 +600,7 @@ export default function CanvasMinimapPage() { for (let x = 0; x < CHUNK_SIZE; x++) { const tilePixelIndex = ((cy * CHUNK_SIZE + y) * TILE_SIZE + (cx * CHUNK_SIZE + x)) * 4; const chunkPixelIndex = (y * CHUNK_SIZE + x) * 4; - + chunkData[chunkPixelIndex] = newImageData.data[tilePixelIndex]; // R chunkData[chunkPixelIndex + 1] = newImageData.data[tilePixelIndex + 1]; // G chunkData[chunkPixelIndex + 2] = newImageData.data[tilePixelIndex + 2]; // B @@ -496,10 +620,10 @@ export default function CanvasMinimapPage() { } })()); } - + // Wait for all tile updates to complete await Promise.all(updatePromises); - + setCurrentTick(newTick); } }; @@ -556,7 +680,7 @@ export default function CanvasMinimapPage() { const seconds = Math.floor(tick / 60); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); - + if (hours > 0) { return `${hours}h ${minutes % 60}m ${seconds % 60}s`; } else if (minutes > 0) { @@ -627,7 +751,7 @@ export default function CanvasMinimapPage() { const handleKeyUp = (e: KeyboardEvent) => { const key = e.key.toLowerCase(); - + // Remove movement keys when released if (['w', 'a', 's', 'd'].includes(key)) { keysPressed.current.delete(key); @@ -668,7 +792,7 @@ export default function CanvasMinimapPage() { // Mouse event handlers for click and drag panning (one-time setup) useEffect(() => { const canvasElement = canvasRef.current; - + if (!canvasElement) { return; } @@ -808,7 +932,7 @@ export default function CanvasMinimapPage() { const loadTile = async (tileX: number, tileY: number): Promise => { // Get current state from ref const currentState = currentStateRef.current; - + // Early return if no instance is selected if (!currentState.selectedInstance) { return null; @@ -819,7 +943,7 @@ export default function CanvasMinimapPage() { // Check tile state cache first if (tileStateCache.current.has(tileKey)) { const tileState = tileStateCache.current.get(tileKey)!; - + // Check if we need to update to current tick const targetTick = isTimelapseMode ? currentTick || 0 : tileState.parsedData.allTicks[tileState.parsedData.allTicks.length - 1] || 0; if (tileState.currentTick !== targetTick) { @@ -836,7 +960,7 @@ export default function CanvasMinimapPage() { console.error(`Failed to update tile ${tileKey} to tick ${targetTick}:`, err); } } - + return tileState.imageData; } @@ -871,16 +995,16 @@ export default function CanvasMinimapPage() { // Parse the tile data into structured change records const parsedData = parseTileData(tileData); - + // Determine target tick - const targetTick = isTimelapseMode ? - (currentTick || 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; const pixels = await renderTileToPixels(tileData, renderTick); - + // Convert to ImageData const tileImageData = pixelsToImageData(pixels); @@ -906,7 +1030,7 @@ export default function CanvasMinimapPage() { 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 + 1] = tileImageData.data[tilePixelIndex + 1]; // G chunkData[chunkPixelIndex + 2] = tileImageData.data[tilePixelIndex + 2]; // B @@ -983,7 +1107,7 @@ export default function CanvasMinimapPage() { const getVirtualTile = async (tileX: number, tileY: number): Promise => { // Get current state from ref const currentState = currentStateRef.current; - + // Don't create virtual tiles if no instance is selected if (!currentState.selectedInstance) { throw new Error("Cannot create virtual tile: no instance selected"); @@ -1006,7 +1130,10 @@ export default function CanvasMinimapPage() { ctx.fillRect(0, 0, TILE_SIZE, TILE_SIZE); // Load the entire tile at once (much more efficient than loading chunks individually) - const tileImageData = await loadTile(tileX, tileY); + const [tileImageData, recipeImageData] = await Promise.all([ + loadTile(tileX, tileY), + loadRecipeTile(tileX, tileY) + ]); if (tileImageData) { // Draw the entire tile directly to the virtual canvas @@ -1024,7 +1151,7 @@ export default function CanvasMinimapPage() { // 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 @@ -1173,50 +1300,52 @@ export default function CanvasMinimapPage() { // Render chart tags if enabled if (currentState.showChartTags) { const visibleTags = getVisibleChartTags(); - + ctx.save(); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = `${Math.max(10, 12 / scale)}px Arial`; - + for (const tag of visibleTags) { // Convert world coordinates to screen coordinates const screenX = Math.round((tag.position[0] - worldLeft) * scale); const screenY = Math.round((tag.position[1] - worldTop) * scale); - + // Skip if tag is outside visible area if (screenX < -50 || screenX > width + 50 || screenY < -50 || screenY > height + 50) { continue; } - + // Constant tag size regardless of zoom level const tagSize = 16; const halfSize = tagSize / 2; - - // Draw tag background - ctx.fillStyle = tag.icon ? '#4CAF50' : '#2196F3'; - ctx.fillRect(screenX - halfSize, screenY - halfSize, tagSize, tagSize); - - // Draw tag border - ctx.strokeStyle = '#FFFFFF'; - ctx.lineWidth = 2; - ctx.strokeRect(screenX - halfSize, screenY - halfSize, tagSize, tagSize); - + + const iconKey = signalToKey(tag.icon) || tag.text; + if (iconKey) { + drawIcon(ctx, screenX, screenY, iconKey, "screen"); + } else { + ctx.fillStyle = tag.icon ? '#4CAF50' : '#2196F3'; + ctx.fillRect(screenX - halfSize, screenY - halfSize, tagSize, tagSize); + ctx.strokeStyle = '#FFFFFF'; + ctx.lineWidth = 2; + ctx.strokeRect(screenX - halfSize, screenY - halfSize, tagSize, tagSize); + } + // Draw tag text if zoom level is high enough if (scale > 0.5 && tag.text) { const fontSize = 12; ctx.font = `${fontSize}px Arial`; - + // Position text to the right of the square const textX = screenX + halfSize + 4; // 4px padding from square edge const textY = screenY; // Vertically centered with square - + // Draw text background const textMetrics = ctx.measureText(tag.text); const textWidth = textMetrics.width; const textHeight = fontSize; const textPadding = 2; - + ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; ctx.fillRect( textX - textPadding, @@ -1224,7 +1353,7 @@ export default function CanvasMinimapPage() { textWidth + textPadding * 2, textHeight + textPadding * 2 ); - + // Draw text ctx.fillStyle = '#FFFFFF'; ctx.textAlign = 'left'; // Align text to start from the left edge @@ -1232,7 +1361,21 @@ export default function CanvasMinimapPage() { ctx.textAlign = 'center'; // Reset for next iteration } } - + + ctx.restore(); + } + + // Render recipe icons + if (currentState.showRecipes) { + ctx.save(); + for (const [coord, recName] of recipeCache.current) { + 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 }); + drawIcon(ctx, screenX, screenY, iconKey || recName, "world"); + } ctx.restore(); } }; @@ -1320,7 +1463,7 @@ export default function CanvasMinimapPage() { await loadExistingChartTags(); await loadAvailableTicks(); }, 500); - + return () => clearTimeout(timeoutId); } }, [selectedInstance, selectedSurface, selectedForce, isTimelapseMode]); @@ -1353,7 +1496,7 @@ export default function CanvasMinimapPage() { const getVisibleChartTags = useCallback(() => { // Use the same ref state that the render function uses for consistency const currentState = currentStateRef.current; - + if (!currentState.selectedInstance || !currentState.showChartTags) { return []; } @@ -1407,7 +1550,7 @@ export default function CanvasMinimapPage() { // 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 @@ -1436,7 +1579,7 @@ export default function CanvasMinimapPage() { setRawChartTags(prevTags => { // Check if we already have this tag (by tag_number and instance) - const existingIndex = prevTags.findIndex(tag => + const existingIndex = prevTags.findIndex(tag => tag.tag_number === newTagData.tag_number && tag.instance_id === newTagData.instance_id && tag.surface === newTagData.surface && @@ -1466,6 +1609,103 @@ export default function CanvasMinimapPage() { }; }, [updateChunk, control]); + // Helper to draw recipe icon (placeholder) + type IconSpace = "screen" | "world"; // screen = constant pixel size, world = scales with zoom + + const drawIcon = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + iconName: string, + space: IconSpace = "screen", + ) => { + const zoom = viewStateRef.current.zoomLevel; + + // Size calculation depending on desired behaviour + let size: number; + if (space === "screen") { + // Keep a readable size regardless of zoom (chart tags) + size = 16; + } else { + // Keep size constant relative to the world – grows/shrinks with zoom (recipe icons) + // 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; + } + + const half = size / 2; + + const canvas = getIconCanvas(iconName); + if (canvas) { + ctx.drawImage(canvas, x - half, y - half, size, size); + return; + } + + // Fallback placeholder if icon missing + ctx.fillStyle = "#FFD700"; + ctx.fillRect(x - half, y - half, size, size); + }; + + // 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 text = atob(resp.recipe_tile); + const lines = text.trim().split('\n').filter(l => l.trim()); + for (const line of lines) { + try { + const rec = JSON.parse(line); + const key = `${rec.position[0]},${rec.position[1]}`; + // Timelapse filter + if (cs.isTimelapseMode && cs.currentTick !== null) { + const t = cs.currentTick; + if (rec.start_tick !== undefined && rec.start_tick > t) continue; + if (rec.end_tick !== undefined && rec.end_tick <= t) { recipeCache.current.delete(key); continue; } + } else { + if (rec.end_tick !== undefined) { recipeCache.current.delete(key); continue; } + } + if (rec.recipe) { recipeCache.current.set(key, rec.recipe); } + } catch { } + } + } catch (e) { console.warn('loadRecipeTile', e); } + }; + + // 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; + // 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]}`; + recipeCache.current.delete(endKey); + return; + } + const key = `${event.recipe_data.position[0]},${event.recipe_data.position[1]}`; + let iconKey: string; + if ((event.recipe_data as any).icon) { + const sigKey = signalToKey((event.recipe_data as any).icon); + iconKey = sigKey ?? `item-${event.recipe_data.recipe ?? "unknown"}`; + } else { + iconKey = `item-${event.recipe_data.recipe ?? "unknown"}`; + } + recipeCache.current.set(key, iconKey); + }; + + // Subscribe to updates (tile, tag, recipe) once plugin is available + useEffect(() => { + const plugin = control.plugins.get("minimap") as import("./index").WebPlugin; + if (!plugin) return; + + plugin.onRecipeUpdate(handleRecipeUpdate); + return () => { plugin.offRecipeUpdate(handleRecipeUpdate); }; + }, [control]); + return (
@@ -1512,8 +1752,8 @@ export default function CanvasMinimapPage() { Timelapse: - @@ -1525,8 +1765,8 @@ export default function CanvasMinimapPage() { Chart Tags: - {showChartTags && ( @@ -1535,6 +1775,13 @@ export default function CanvasMinimapPage() { )} + + Recipes: + + {showRecipes && ( + {recipeCache.current.size} active + )} + } @@ -1545,21 +1792,21 @@ export default function CanvasMinimapPage() {
- - - Speed: -