From fc466ec7997a08de7294cb49d8c5febe988ffa2c Mon Sep 17 00:00:00 2001 From: Danielv123 Date: Sat, 19 Jul 2025 14:54:58 +0200 Subject: [PATCH] Add chart tag support Also fixes react state management issues, moving a few things over to useRef and showing all instances instead of using a custom solution --- plugins/minimap/controller.ts | 218 +++++++++-- plugins/minimap/index.ts | 6 +- plugins/minimap/instance.ts | 38 +- plugins/minimap/messages.ts | 135 +++++-- plugins/minimap/module/minimap.lua | 94 ++++- plugins/minimap/web/CanvasMinimapPage.tsx | 444 ++++++++++++++++++---- plugins/minimap/web/MinimapPage.tsx | 74 ++-- plugins/minimap/web/index.tsx | 23 +- 8 files changed, 843 insertions(+), 189 deletions(-) diff --git a/plugins/minimap/controller.ts b/plugins/minimap/controller.ts index fdcb99d8..83445447 100644 --- a/plugins/minimap/controller.ts +++ b/plugins/minimap/controller.ts @@ -2,9 +2,11 @@ import * as lib from "@clusterio/lib"; import { BaseControllerPlugin } from "@clusterio/controller"; import { TileDataEvent, - GetInstanceBoundsRequest, GetRawTileRequest, + GetChartTagsRequest, ChartData, + ChartTagDataEvent, + ChartTagData, } from "./messages"; import * as fs from "fs-extra"; import * as path from "path"; @@ -30,31 +32,53 @@ interface PixelChange { export class ControllerPlugin extends BaseControllerPlugin { private tilesPath: string = ""; + private chartTagsPath: string = ""; private chunkSavingQueue = new Map>(); + private chartTagQueues = new Map(); + private seenTagNumbers = new Map>(); private savingTiles: boolean = false; - // Set to true if colors appear inverted - this will try big-endian byte order - private useBigEndian: boolean = false; + private savingChartTags = new Set(); + private saveInterval: NodeJS.Timeout | null = null; async init() { this.tilesPath = path.resolve( this.controller.config.get("controller.database_directory"), "minimap_tiles" ); + this.chartTagsPath = path.resolve( + this.controller.config.get("controller.database_directory"), + "minimap_chart_tags" + ); await fs.ensureDir(this.tilesPath); + await fs.ensureDir(this.chartTagsPath); this.controller.handle(TileDataEvent, this.handleTileDataEvent.bind(this)); - this.controller.handle(GetInstanceBoundsRequest, this.handleGetInstanceBoundsRequest.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)); // Set up HTTP routes for serving tiles this.setupTileRoutes(); - setInterval(() => { + // Load existing tag numbers for deduplication + await this.loadExistingTagNumbers(); + + 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}`)); }, 5000); } - + async onShutdown() { + if (this.saveInterval) { + clearInterval(this.saveInterval); + this.saveInterval = null; + } + + // Perform final save before shutdown + await this.saveTiles(); + await this.saveChartTags(); + } // Compare two pixel arrays and return changes private comparePixels(oldPixels: Uint16Array, newPixels: Uint16Array, chunkX: number, chunkY: number): PixelChange[] { @@ -353,9 +377,143 @@ export class ControllerPlugin extends BaseControllerPlugin { return Buffer.concat([existingTile, ...appendBuffers]); } + async loadExistingTagNumbers() { + try { + if (!await fs.pathExists(this.chartTagsPath)) { + return; + } + const files = await fs.readdir(this.chartTagsPath); + 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.seenTagNumbers.has(fileKey)) { + this.seenTagNumbers.set(fileKey, new Set()); + } + const seenTags = this.seenTagNumbers.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') { + seenTags.add(tagData.tag_number); + } + } catch (parseErr) { + this.logger.warn(`Failed to parse chart tag line in ${file}: ${parseErr}`); + } + } + + this.logger.info(`Loaded ${seenTags.size} existing tag numbers from ${file}`); + } catch (readErr) { + this.logger.warn(`Failed to read chart tag file ${file}: ${readErr}`); + } + } + } catch (err) { + this.logger.error(`Error loading existing tag numbers: ${err}`); + } + } + + async saveChartTags() { + if (this.chartTagQueues.size === 0) { + return; + } + + const savePromises = []; + for (const [fileKey, tags] of this.chartTagQueues) { + if (tags.length === 0 || this.savingChartTags.has(fileKey)) { + // Remove empty queues to prevent repeated processing + if (tags.length === 0 && !this.savingChartTags.has(fileKey)) { + this.chartTagQueues.delete(fileKey); + } + continue; + } + + const promise = (async () => { + this.savingChartTags.add(fileKey); + const tagsToSave = [...tags]; + tags.length = 0; // Clear the queue + + try { + 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'); + + await fs.appendFile(filePath, jsonLines + '\n'); + + // Mark tags as successfully saved + if (!this.seenTagNumbers.has(fileKey)) { + this.seenTagNumbers.set(fileKey, new Set()); + } + const seenTags = this.seenTagNumbers.get(fileKey)!; + for (const tag of tagsToSave) { + seenTags.add(tag.tag_number); + } + + this.logger.info(`Saved ${tagsToSave.length} chart tag entries to ${fileKey}`); + } catch (err) { + this.logger.error(`Error saving chart tags for ${fileKey}: ${err}`); + // Re-queue the failed tags + tags.unshift(...tagsToSave); + } finally { + this.savingChartTags.delete(fileKey); + } + })(); + savePromises.push(promise); + } + + await Promise.all(savePromises); + } + + async handleChartTagDataEvent(event: ChartTagDataEvent) { + try { + const { instance_id, tag_data } = event; + + // Create file key based on instance, surface, and force + const fileKey = `${instance_id}_${tag_data.surface}_${tag_data.force}`; + + // Initialize seen tags set if it doesn't exist + if (!this.seenTagNumbers.has(fileKey)) { + this.seenTagNumbers.set(fileKey, new Set()); + } + + // Check for duplicate tag_number + const seenTags = this.seenTagNumbers.get(fileKey)!; + if (seenTags.has(tag_data.tag_number)) { + return; + } + + // Add instance_id to the tag data for storage + const enrichedTagData = { + ...tag_data, + instance_id + }; + + // Initialize queue if it doesn't exist + if (!this.chartTagQueues.has(fileKey)) { + this.chartTagQueues.set(fileKey, []); + } + + // Queue for batch saving + this.chartTagQueues.get(fileKey)!.push(enrichedTagData); + + // 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) + this.controller.sendTo(new lib.Address(lib.Address.broadcast, lib.Address.control), updateEvent); + + } catch (err) { + this.logger.error(`Error processing chart tag data: ${err}`); + } + } async handleTileDataEvent(event: TileDataEvent) { try { @@ -395,23 +553,6 @@ export class ControllerPlugin extends BaseControllerPlugin { } } - async handleGetInstanceBoundsRequest(request: GetInstanceBoundsRequest) { - const instances = Array.from(this.controller.instances.values()) - .filter(instance => instance.status === "running") - .map(instance => ({ - instanceId: instance.config.get("instance.id"), - name: instance.config.get("instance.name"), - bounds: { - x1: -512, // Default bounds, could be made configurable - y1: -512, - x2: 512, - y2: 512, - }, - })); - - return { instances }; - } - async handleGetRawTileRequest(request: GetRawTileRequest) { const { instance_id, surface, force, tile_x, tile_y, tick } = request; @@ -435,4 +576,35 @@ export class ControllerPlugin extends BaseControllerPlugin { return { tile_data: null }; } } + + async handleGetChartTagsRequest(request: GetChartTagsRequest) { + const { instance_id, surface, force } = request; + + const fileKey = `${instance_id}_${surface}_${force}`; + const filePath = path.join(this.chartTagsPath, `${fileKey}_chart_tags.json`); + + if (!await fs.pathExists(filePath)) { + return { chart_tags: [] }; + } + + try { + 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) { + try { + const tagData = JSON.parse(line); + chartTags.push(tagData); + } catch (parseErr) { + this.logger.warn(`Failed to parse chart tag line: ${parseErr}`); + } + } + + return { chart_tags: chartTags }; + } catch (err) { + this.logger.error(`Error reading chart tag file: ${err}`); + return { chart_tags: [] }; + } + } } diff --git a/plugins/minimap/index.ts b/plugins/minimap/index.ts index c497259e..e22b031a 100644 --- a/plugins/minimap/index.ts +++ b/plugins/minimap/index.ts @@ -2,8 +2,9 @@ import * as lib from "@clusterio/lib"; import { TileDataEvent, - GetInstanceBoundsRequest, + ChartTagDataEvent, GetRawTileRequest, + GetChartTagsRequest, } from "./messages"; // Define permissions @@ -24,8 +25,9 @@ export const plugin: lib.PluginDeclaration = { messages: [ TileDataEvent, - GetInstanceBoundsRequest, + ChartTagDataEvent, GetRawTileRequest, + GetChartTagsRequest, ], routes: ["/minimap"], }; diff --git a/plugins/minimap/instance.ts b/plugins/minimap/instance.ts index 182dc262..b1814cb0 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 } from "./messages"; +import { TileDataEvent, ChartData, ChartTagDataEvent, ChartTagData } from "./messages"; interface TileDataIpc { type: "chart"; @@ -9,6 +9,18 @@ interface TileDataIpc { tick: number; } +interface ChartTagDataIpc { + tag_number: number; + start_tick: number | null; + end_tick: number | null; + force: string; + surface: string; + position: [number, number]; + text: string; + icon: string | null; + last_user: string | null; +} + export class InstancePlugin extends BaseInstancePlugin { pendingTileUpdates = new Map>(); @@ -23,6 +35,13 @@ export class InstancePlugin extends BaseInstancePlugin { `Error handling tile data from Lua:\n${err}` )); }); + + // Listen for chart tag data from the Lua module + this.instance.server.on("ipc-minimap:chart_tag_data", (data: ChartTagDataIpc) => { + this.handleChartTagDataFromLua(data).catch(err => this.logger.error( + `Error handling chart tag data from Lua:\n${err}` + )); + }); } async handleTileDataFromLua(data: TileDataIpc) { @@ -57,6 +76,23 @@ export class InstancePlugin extends BaseInstancePlugin { } } + async handleChartTagDataFromLua(data: ChartTagDataIpc) { + // Validate the chart tag data + if (!data || typeof data.tag_number !== 'number') { + this.logger.error("Invalid chart tag data received"); + return; + } + + // Create chart tag data event + const chartTagEvent = new ChartTagDataEvent( + this.instance.config.get("instance.id"), + data as ChartTagData + ); + + // Send to controller + return this.instance.sendTo("controller", chartTagEvent); + } + async onStart() { // Initialize the Lua module when the instance starts try { diff --git a/plugins/minimap/messages.ts b/plugins/minimap/messages.ts index 238b3a22..fec8dcff 100644 --- a/plugins/minimap/messages.ts +++ b/plugins/minimap/messages.ts @@ -10,6 +10,30 @@ export const ChartDataSchema = Type.Object({ export type ChartData = Static; +// Define the SignalID structure (based on Factorio API) +export const SignalIDSchema = Type.Object({ + type: Type.Optional(Type.String()), // SignalIDType, nil when reading for "item" type + name: Type.Optional(Type.String()), // Name of the prototype + quality: Type.Optional(Type.String()), // Quality name, defaults to "normal" +}); + +export type SignalID = Static; + +// Define the chart tag data structure +export const ChartTagDataSchema = Type.Object({ + tag_number: Type.Number(), + start_tick: Type.Optional(Type.Number()), + end_tick: Type.Optional(Type.Number()), + force: Type.String(), + surface: Type.String(), + position: Type.Tuple([Type.Number(), Type.Number()]), + text: Type.String(), + icon: Type.Union([SignalIDSchema, Type.Null()]), + last_user: Type.Union([Type.String(), Type.Null()]), +}); + +export type ChartTagData = Static; + /** * TileDataEvent: Used for both Factorio Instance -> Controller and Controller -> Web Clients * @@ -62,37 +86,51 @@ export class TileDataEvent { } } -export class GetInstanceBoundsRequest { - declare ["constructor"]: typeof GetInstanceBoundsRequest; - static type = "request" as const; - static src = "control" as const; - static dst = "controller" as const; +/** + * 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: + * - Lua module -> Instance plugin -> Controller plugin (for persistence) + * - Controller plugin -> Web UI plugin -> Tag renderer (for live updates) + */ +export class ChartTagDataEvent { + declare ["constructor"]: typeof ChartTagDataEvent; + static type = "event" as const; + static src = ["instance", "controller"] as const; + static dst = ["controller", "control"] as const; static plugin = "minimap" as const; - static permission = "minimap.view"; + static permission = null; - constructor() { + constructor( + public instance_id: number, + public tag_data: ChartTagData, + ) { } + + static jsonSchema = Type.Object({ + "instance_id": Type.Number(), + "tag_data": ChartTagDataSchema, + }); + + static fromJSON(json: Static) { + return new this( + json.instance_id, + json.tag_data + ); } - - static jsonSchema = Type.Object({}); - - static fromJSON(json: Static) { - return new this(); - } - - static Response = lib.plainJson(Type.Object({ - "instances": Type.Array(Type.Object({ - "instanceId": Type.Number(), - "name": Type.String(), - "bounds": Type.Object({ - "x1": Type.Number(), - "y1": Type.Number(), - "x2": Type.Number(), - "y2": Type.Number(), - }), - })), - })); } + + +/** + * 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. + */ export class GetRawTileRequest { declare ["constructor"]: typeof GetRawTileRequest; static type = "request" as const; @@ -107,13 +145,13 @@ export class GetRawTileRequest { public force: string, public tile_x: number, public tile_y: number, - public tick?: number, // Optional tick for timelapse support - ) {} + public tick?: number, + ) { } 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()), @@ -133,4 +171,43 @@ export class GetRawTileRequest { static Response = lib.plainJson(Type.Object({ "tile_data": Type.Union([Type.String(), Type.Null()]), // Base64 encoded tile file or null if not found })); +} + +/** + * 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. + */ +export class GetChartTagsRequest { + declare ["constructor"]: typeof GetChartTagsRequest; + 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, + ) { } + + static jsonSchema = Type.Object({ + "instance_id": Type.Number(), + "surface": Type.String(), + "force": Type.String(), + }); + + static fromJSON(json: Static) { + return new this( + json.instance_id, + json.surface, + json.force + ); + } + + static Response = lib.plainJson(Type.Object({ + "chart_tags": Type.Array(ChartTagDataSchema), + })); } diff --git a/plugins/minimap/module/minimap.lua b/plugins/minimap/module/minimap.lua index 650cc18c..4dcb301c 100644 --- a/plugins/minimap/module/minimap.lua +++ b/plugins/minimap/module/minimap.lua @@ -26,6 +26,26 @@ local function queue_chunk_for_update(chunk_position) table.insert(storage.minimap.chunk_update_queue, chunk_position) end +-- Send chart tag data to plugin +local function send_chart_tag_data(tag_data) + clusterio_api.send_json("minimap:chart_tag_data", tag_data) +end + +-- Create tag data structure +local function create_tag_data(tag, start_tick, end_tick) + return { + tag_number = tag.tag_number, + start_tick = start_tick, + end_tick = end_tick, + force = tag.force.name, + surface = tag.surface.name, + position = {tag.position.x, tag.position.y}, + text = tag.text, + icon = tag.icon or nil, + last_user = tag.last_user and tag.last_user.name or nil + } +end + local function dump_chunk_chart(chunk_position) local data = {} local surfaces = game.surfaces @@ -54,6 +74,63 @@ local function dump_chunk_chart(chunk_position) }) end +-- Chart tag event handlers +local function on_chart_tag_added(event) + if not storage.minimap or not storage.minimap.enabled then + return + end + + local tag = event.tag + if not tag or not tag.valid then + return + end + + local start_tick = game.tick + local tag_data = create_tag_data(tag, start_tick, nil) + + -- Send to plugin + send_chart_tag_data(tag_data) +end + +local function on_chart_tag_modified(event) + if not storage.minimap or not storage.minimap.enabled then + return + end + + local tag = event.tag + if not tag or not tag.valid then + return + end + + -- End the old version + local end_tick = game.tick + local old_tag_data = create_tag_data(tag, nil, end_tick) + send_chart_tag_data(old_tag_data) + + -- Create new tag entry + local start_tick = game.tick + local tag_data = create_tag_data(tag, start_tick, nil) + + -- Send to plugin + send_chart_tag_data(tag_data) +end + +local function on_chart_tag_removed(event) + if not storage.minimap or not storage.minimap.enabled then + return + end + + local tag = event.tag + if not tag then + return + end + + -- End the tag + local end_tick = game.tick + local tag_data = create_tag_data(tag, nil, end_tick) + send_chart_tag_data(tag_data) +end + -- Event handlers local function on_chunk_charted(event) if not storage.minimap or not storage.minimap.enabled then @@ -118,10 +195,8 @@ local function on_tile_changed(event) end end -log("minimap.lua loaded") -- Initialize the module local function init() - log("minimap.lua init") if not storage.minimap then storage.minimap = { enabled = true, @@ -151,10 +226,18 @@ local function init() chunks_queued = chunks_queued + 1 end end + + -- Initialize existing chart tags + local existing_tags = force.find_chart_tags(surface) + for _, tag in pairs(existing_tags) do + local start_tick = game.tick + local tag_data = create_tag_data(tag, start_tick, nil) + + -- Send to plugin + send_chart_tag_data(tag_data) + end end end - - log("Minimap: Queued " .. chunks_queued .. " chunks for initial map generation") end local function on_tick(event) @@ -186,5 +269,8 @@ minimap.events[defines.events.on_robot_built_tile] = on_tile_changed minimap.events[defines.events.on_player_mined_tile] = on_tile_changed minimap.events[defines.events.on_robot_mined_tile] = on_tile_changed 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 return minimap diff --git a/plugins/minimap/web/CanvasMinimapPage.tsx b/plugins/minimap/web/CanvasMinimapPage.tsx index b27e1d21..593c3466 100644 --- a/plugins/minimap/web/CanvasMinimapPage.tsx +++ b/plugins/minimap/web/CanvasMinimapPage.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useContext, useRef, useCallback } from "react"; -import { Row, Col, Button, Card, Space, Select, Switch, Slider, Typography, Tooltip } from "antd"; -import { ControlContext } from "@clusterio/web_ui"; -import { GetInstanceBoundsRequest, GetRawTileRequest, TileDataEvent } from "../messages"; +import { 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 * as zlib from "zlib"; import { renderTileToPixels, @@ -15,15 +15,21 @@ import { const { Text } = Typography; -interface Instance { - instanceId: number; - name: string; - bounds: { - x1: number; - y1: number; - x2: number; - y2: number; - }; +interface ChartTagDataWithInstance extends ChartTagData { + instance_id: number; +} + +interface MergedChartTag { + tag_number: number; + start_tick: number | undefined; + end_tick: number | undefined; + force: string; + surface: string; + position: [number, number]; + text: string; + icon: SignalID | null; + last_user: string | null; + instance_id: number; } interface SurfaceForceData { @@ -88,7 +94,7 @@ async function chartDataToImageData(chartData: string): Promise { export default function CanvasMinimapPage() { // UI-related states (these need React re-renders) - const [instances, setInstances] = useState([]); + const [instances] = useInstances(); const [selectedInstance, setSelectedInstance] = useState(null); const [selectedSurface, setSelectedSurface] = useState("nauvis"); const [selectedForce, setSelectedForce] = useState("player"); @@ -102,6 +108,11 @@ export default function CanvasMinimapPage() { const [isPlaying, setIsPlaying] = useState(false); const [playbackSpeed, setPlaybackSpeed] = useState(1); + // Chart tag states + const [showChartTags, setShowChartTags] = useState(true); + const [rawChartTags, setRawChartTags] = useState([]); + const [mergedChartTags, setMergedChartTags] = useState([]); + // Display state for zoom level (throttled updates to avoid excessive re-renders) const [displayZoom, setDisplayZoom] = useState(1); @@ -143,10 +154,68 @@ export default function CanvasMinimapPage() { isTimelapseMode: false, currentTick: null as number | null, availableTicks: [] as number[], + showChartTags: true, + mergedChartTags: [] as MergedChartTag[], }); const control = useContext(ControlContext); + // Merge raw chart tag data into timeline-friendly format + const mergeChartTags = useCallback((rawTags: ChartTagDataWithInstance[]): MergedChartTag[] => { + const tagMap = new Map(); + + 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, { + tag_number: rawTag.tag_number, + start_tick: rawTag.start_tick, + end_tick: rawTag.end_tick, + force: rawTag.force, + surface: rawTag.surface, + position: rawTag.position, + text: rawTag.text, + icon: rawTag.icon, + last_user: rawTag.last_user, + instance_id: rawTag.instance_id, + }); + } 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; + // Also update other properties from the start entry + existing.position = rawTag.position; + existing.text = rawTag.text; + 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; + // Use the properties from the end entry as they might have been modified + existing.position = rawTag.position; + existing.text = rawTag.text; + existing.icon = rawTag.icon; + existing.last_user = rawTag.last_user; + } + } + } + + return Array.from(tagMap.values()); + }, []); + + // Update merged chart tags when raw tags change + useEffect(() => { + setMergedChartTags(mergeChartTags(rawChartTags)); + }, [rawChartTags, mergeChartTags]); + // Update current state ref when state changes useEffect(() => { currentStateRef.current = { @@ -156,8 +225,10 @@ export default function CanvasMinimapPage() { isTimelapseMode, currentTick, availableTicks, + showChartTags, + mergedChartTags, }; - }, [selectedInstance, selectedSurface, selectedForce, isTimelapseMode, currentTick, availableTicks]); + }, [selectedInstance, selectedSurface, selectedForce, isTimelapseMode, currentTick, availableTicks, showChartTags, mergedChartTags]); // 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]; @@ -241,7 +312,9 @@ export default function CanvasMinimapPage() { // Timelapse functions const loadAvailableTicks = async () => { - if (!selectedInstance) return; + if (!selectedInstance) { + return; + } const visibleTiles = calculateVisibleTiles(); if (!visibleTiles) { @@ -278,7 +351,6 @@ export default function CanvasMinimapPage() { } const ticks = extractAvailableTicks(tileData); - console.log(`Tile ${tileX},${tileY}: Found ${ticks.length} ticks:`, ticks); if (ticks.length > 0) { tilesWithData++; @@ -321,9 +393,10 @@ export default function CanvasMinimapPage() { }; const stepToTick = async (tickIndex: number) => { - if (tickIndex >= 0 && tickIndex < availableTicks.length) { - const newTick = availableTicks[tickIndex]; - const oldTick = currentTick; + const currentState = currentStateRef.current; + if (tickIndex >= 0 && tickIndex < currentState.availableTicks.length) { + const newTick = currentState.availableTicks[tickIndex]; + const oldTick = currentState.currentTick; if (oldTick === newTick) { return; // No change needed @@ -400,14 +473,16 @@ export default function CanvasMinimapPage() { }; const stepForward = async () => { - const currentIndex = availableTicks.findIndex(tick => tick === currentTick); - if (currentIndex >= 0 && currentIndex < availableTicks.length - 1) { + const currentState = currentStateRef.current; + const currentIndex = currentState.availableTicks.findIndex(tick => tick === currentState.currentTick); + if (currentIndex >= 0 && currentIndex < currentState.availableTicks.length - 1) { await stepToTick(currentIndex + 1); } }; const stepBackward = async () => { - const currentIndex = availableTicks.findIndex(tick => tick === currentTick); + const currentState = currentStateRef.current; + const currentIndex = currentState.availableTicks.findIndex(tick => tick === currentState.currentTick); if (currentIndex > 0) { await stepToTick(currentIndex - 1); } @@ -421,8 +496,9 @@ export default function CanvasMinimapPage() { useEffect(() => { if (isPlaying && isTimelapseMode && availableTicks.length > 0) { playbackTimerRef.current = setInterval(async () => { - const currentIndex = availableTicks.findIndex(tick => tick === currentTick); - if (currentIndex >= 0 && currentIndex < availableTicks.length - 1) { + const currentState = currentStateRef.current; + const currentIndex = currentState.availableTicks.findIndex(tick => tick === currentState.currentTick); + if (currentIndex >= 0 && currentIndex < currentState.availableTicks.length - 1) { await stepToTick(currentIndex + 1); } else { // Reached the end, stop playing @@ -458,9 +534,18 @@ export default function CanvasMinimapPage() { } }; - // Load instance bounds on component mount + // Set default instance when instances become available + useEffect(() => { + if (instances.size > 0 && !selectedInstance) { + const firstInstance = instances.values().next().value; + if (firstInstance) { + setSelectedInstance(firstInstance.id); + } + } + }, [instances, selectedInstance]); + + // Load surface and force data on component mount useEffect(() => { - loadInstances(); loadSurfaceForceData(); }, []); @@ -620,7 +705,7 @@ export default function CanvasMinimapPage() { // Render loop (one-time setup) useEffect(() => { const render = () => { - if (canvasRef.current && currentStateRef.current.selectedInstance) { + if (canvasRef.current) { renderCanvas(); } animationFrameRef.current = requestAnimationFrame(render); @@ -635,21 +720,7 @@ export default function CanvasMinimapPage() { }; }, []); // No dependencies - render loop accesses current state via refs - const loadInstances = async () => { - try { - setLoading(true); - const response = await control.send(new GetInstanceBoundsRequest()); - setInstances(response.instances); - if (response.instances.length > 0 && !selectedInstance) { - setSelectedInstance(response.instances[0].instanceId); - } - } catch (err) { - console.error("Failed to load instances:", err); - } finally { - setLoading(false); - } - }; const loadSurfaceForceData = async () => { try { @@ -668,8 +739,49 @@ export default function CanvasMinimapPage() { } }; + // Load existing chart tags from disk + const loadExistingChartTags = async () => { + if (!selectedInstance || !selectedSurface || !selectedForce) { + return; + } + + try { + 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, + })); + + // 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 + } + }; + // Load a tile from the server using raw tile data 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; + } + const tileKey = `${tileX},${tileY}`; // Check tile state cache first @@ -706,12 +818,12 @@ export default function CanvasMinimapPage() { try { // Load the raw tile data from server const response = await control.send(new GetRawTileRequest( - selectedInstance!, - selectedSurface, - selectedForce, + currentState.selectedInstance!, // We already checked for null above + currentState.selectedSurface, + currentState.selectedForce, tileX, tileY, - isTimelapseMode ? currentTick || undefined : undefined + currentState.isTimelapseMode ? currentState.currentTick || undefined : undefined )); if (!response.tile_data) { @@ -837,6 +949,14 @@ export default function CanvasMinimapPage() { // Get or create a virtual tile 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"); + } + const tileKey = `${tileX},${tileY}`; if (virtualTiles.current.has(tileKey)) { @@ -870,6 +990,18 @@ export default function CanvasMinimapPage() { 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 if (keysPressed.current.size > 0) { // Adjust base speed for 60fps @@ -984,23 +1116,93 @@ export default function CanvasMinimapPage() { ctx.fillRect(clippedX, clippedY, clippedWidth, clippedHeight); // Start loading the tile in the background (but don't wait for it) - getVirtualTile(tileX, tileY).then(() => { - // When tile loads, trigger a re-render - // But only if we're still looking at the same area - if (selectedInstance && selectedSurface && 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); - }); + // 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); + }); + } } } } + + // 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); + + // 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, + textY - textHeight / 2 - textPadding, + textWidth + textPadding * 2, + textHeight + textPadding * 2 + ); + + // Draw text + 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.restore(); + } }; // Handle canvas resize (one-time setup) @@ -1062,14 +1264,12 @@ export default function CanvasMinimapPage() { // Reset view when instance changes and snap to pixel-perfect zoom useEffect(() => { if (selectedInstance) { - const instanceData = instances.find(inst => inst.instanceId === selectedInstance); - if (instanceData) { - updateViewState({ - centerX: (instanceData.bounds.x1 + instanceData.bounds.x2) / 2, - centerY: (instanceData.bounds.y1 + instanceData.bounds.y2) / 2, - zoomLevel: getClosestZoomLevel(1), // Snap to nearest pixel-perfect zoom - }); - } + // Reset to center view with default zoom when switching instances + updateViewState({ + centerX: 0, + centerY: 0, + zoomLevel: getClosestZoomLevel(1), // Snap to nearest pixel-perfect zoom + }); // Clear caches when switching instances virtualTiles.current.clear(); @@ -1077,7 +1277,7 @@ export default function CanvasMinimapPage() { chunkCache.current.clear(); loadingTiles.current.clear(); } - }, [selectedInstance, instances]); + }, [selectedInstance]); // Reload ticks when instance, surface, force changes or timelapse mode toggles useEffect(() => { @@ -1091,6 +1291,16 @@ export default function CanvasMinimapPage() { } }, [selectedInstance, selectedSurface, selectedForce, isTimelapseMode]); + // Load existing chart tags when instance/surface/force selection changes + useEffect(() => { + if (selectedInstance && selectedSurface && selectedForce) { + loadExistingChartTags(); + } else { + // Clear chart tags if no valid selection + setRawChartTags([]); + } + }, [selectedInstance, selectedSurface, selectedForce]); + // Snap current zoom level to nearest pixel-perfect level on mount useEffect(() => { updateViewState({ @@ -1105,6 +1315,38 @@ export default function CanvasMinimapPage() { }; }, []); + // Get visible chart tags based on current state + 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 []; + } + + 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) { + return false; + } + + // In timelapse mode, filter by current tick + if (currentState.isTimelapseMode && currentState.currentTick !== null) { + // Tag is visible if it started before or at current tick and hasn't ended yet + if (tag.start_tick !== undefined && tag.start_tick > currentState.currentTick) { + return false; + } + if (tag.end_tick !== undefined && tag.end_tick <= currentState.currentTick) { + return false; + } + } + + return true; + }); + }, []); + // Set up live chunk update listener (only in live mode) useEffect(() => { const plugin = control.plugins.get("minimap") as import("./index").WebPlugin; @@ -1139,11 +1381,48 @@ export default function CanvasMinimapPage() { } }; - // Subscribe to chunk update events through the plugin + 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) { + + // Add the new chart tag data to our collection + const newTagData: ChartTagDataWithInstance = { + ...event.tag_data, + instance_id: event.instance_id, + }; + + 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 + ); + + if (existingIndex >= 0) { + // Update existing tag + const newTags = [...prevTags]; + newTags[existingIndex] = newTagData; + return newTags; + } else { + // Add new tag + return [...prevTags, newTagData]; + } + }); + } + }; + + // Subscribe to update events through the plugin plugin.onTileUpdate(handleChunkUpdate); + plugin.onChartTagUpdate(handleChartTagUpdate); return () => { plugin.offTileUpdate(handleChunkUpdate); + plugin.offChartTagUpdate(handleChartTagUpdate); }; }, [updateChunk, control]); @@ -1161,13 +1440,11 @@ export default function CanvasMinimapPage() { value={selectedInstance} onChange={setSelectedInstance} loading={loading} - > - {instances.map(instance => ( - - {instance.name} - - ))} - + options={[...instances.values()].map(instance => ({ + value: instance.id, + label: instance.name + }))} + /> - Timelapse: @@ -1209,6 +1483,18 @@ export default function CanvasMinimapPage() { )} + + Chart Tags: + + {showChartTags && ( + + {getVisibleChartTags().length} visible + + )} + } diff --git a/plugins/minimap/web/MinimapPage.tsx b/plugins/minimap/web/MinimapPage.tsx index 789c3367..76d72f9c 100644 --- a/plugins/minimap/web/MinimapPage.tsx +++ b/plugins/minimap/web/MinimapPage.tsx @@ -2,30 +2,18 @@ import React, { useState, useEffect, useContext } from "react"; import { Row, Col, Button, Card, Space, Select } from "antd"; import { MapContainer, TileLayer } from "react-leaflet"; import L from "leaflet"; -import { ControlContext } from "@clusterio/web_ui"; -import { GetInstanceBoundsRequest } from "../messages"; +import { ControlContext, useInstances } from "@clusterio/web_ui"; // Import Leaflet CSS import "leaflet/dist/leaflet.css"; -interface Instance { - instanceId: number; - name: string; - bounds: { - x1: number; - y1: number; - x2: number; - y2: number; - }; -} - interface SurfaceForceData { surfaces: string[]; forces: string[]; } export default function MinimapPage() { - const [instances, setInstances] = useState([]); + const [instances] = useInstances(); const [selectedInstance, setSelectedInstance] = useState(null); const [selectedSurface, setSelectedSurface] = useState("nauvis"); const [selectedForce, setSelectedForce] = useState("player"); @@ -34,28 +22,21 @@ export default function MinimapPage() { const [loading, setLoading] = useState(false); const control = useContext(ControlContext); - // Load instance bounds on component mount + // Set default instance when instances become available + useEffect(() => { + if (instances.size > 0 && !selectedInstance) { + const firstInstance = instances.values().next().value; + if (firstInstance) { + setSelectedInstance(firstInstance.id); + } + } + }, [instances, selectedInstance]); + + // Load surface and force data on component mount useEffect(() => { - loadInstances(); loadSurfaceForceData(); }, []); - const loadInstances = async () => { - try { - setLoading(true); - const response = await control.send(new GetInstanceBoundsRequest()); - setInstances(response.instances); - - if (response.instances.length > 0 && !selectedInstance) { - setSelectedInstance(response.instances[0].instanceId); - } - } catch (err) { - console.error("Failed to load instances:", err); - } finally { - setLoading(false); - } - }; - const loadSurfaceForceData = async () => { try { const response = await fetch(`${window.location.origin}/api/minimap/surfaces`); @@ -75,12 +56,11 @@ export default function MinimapPage() { } }; - const selectedInstanceData = instances.find(inst => inst.instanceId === selectedInstance); - - const mapBounds = selectedInstanceData ? [ - [selectedInstanceData.bounds.y1 / 256, selectedInstanceData.bounds.x1 / 256], - [selectedInstanceData.bounds.y2 / 256, selectedInstanceData.bounds.x2 / 256], - ] as L.LatLngBoundsExpression : undefined; + // Use default map bounds centered around origin + const mapBounds = [ + [-512 / 256, -512 / 256], + [512 / 256, 512 / 256], + ] as L.LatLngBoundsExpression; return (
@@ -96,13 +76,11 @@ export default function MinimapPage() { value={selectedInstance} onChange={setSelectedInstance} loading={loading} - > - {instances.map(instance => ( - - {instance.name} - - ))} - + options={[...instances.values()].map(instance => ({ + value: instance.id, + label: instance.name + }))} + /> - - } > - {selectedInstanceData && mapBounds ? ( + {selectedInstance && mapBounds ? (
void> = []; + chartTagUpdateCallbacks: Array<(event: ChartTagDataEvent) => void> = []; async init() { this.pages = [ @@ -18,6 +19,7 @@ export class WebPlugin extends BaseWebPlugin { // Handle tile update events from the controller this.control.handle(TileDataEvent, this.handleTileDataEvent.bind(this)); + this.control.handle(ChartTagDataEvent, this.handleChartTagDataEvent.bind(this)); } async handleTileDataEvent(event: TileDataEvent) { @@ -27,6 +29,13 @@ export class WebPlugin extends BaseWebPlugin { } } + async handleChartTagDataEvent(event: ChartTagDataEvent) { + // Notify all registered callbacks + for (let callback of this.chartTagUpdateCallbacks) { + callback(event); + } + } + onTileUpdate(callback: (event: TileDataEvent) => void) { this.tileUpdateCallbacks.push(callback); } @@ -38,4 +47,16 @@ export class WebPlugin extends BaseWebPlugin { } this.tileUpdateCallbacks.splice(index, 1); } + + onChartTagUpdate(callback: (event: ChartTagDataEvent) => void) { + this.chartTagUpdateCallbacks.push(callback); + } + + offChartTagUpdate(callback: (event: ChartTagDataEvent) => void) { + let index = this.chartTagUpdateCallbacks.lastIndexOf(callback); + if (index === -1) { + throw new Error("callback is not registered"); + } + this.chartTagUpdateCallbacks.splice(index, 1); + } }