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
This commit is contained in:
Danielv123 committed 2025-08-10 00:28:48 +02:00
1 parent f5aeaff256
commit fc466ec799
8 files changed
+843 -189

No files matched your search

+195 -23
View File
@@ -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<string, Map<string, { data: ChartData, tick: number }>>();
private chartTagQueues = new Map<string, ChartTagData[]>();
private seenTagNumbers = new Map<string, Set<number>>();
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<string>();
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<number>());
}
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<number>());
}
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<number>());
}
// 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: [] };
}
}
}
+4 -2
View File
@@ -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"],
};
+37 -1
View File
@@ -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<string, Set<[number, number, [number, number, number, number]]>>();
@@ -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 {
+106 -29
View File
@@ -10,6 +10,30 @@ export const ChartDataSchema = Type.Object({
export type ChartData = Static<typeof ChartDataSchema>;
// 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<typeof SignalIDSchema>;
// 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<typeof ChartTagDataSchema>;
/**
* 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<typeof ChartTagDataEvent.jsonSchema>) {
return new this(
json.instance_id,
json.tag_data
);
}
static jsonSchema = Type.Object({});
static fromJSON(json: Static<typeof GetInstanceBoundsRequest.jsonSchema>) {
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<typeof GetChartTagsRequest.jsonSchema>) {
return new this(
json.instance_id,
json.surface,
json.force
);
}
static Response = lib.plainJson(Type.Object({
"chart_tags": Type.Array(ChartTagDataSchema),
}));
}
+90 -4
View File
@@ -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
+365 -79
View File
@@ -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<ImageData> {
export default function CanvasMinimapPage() {
// UI-related states (these need React re-renders)
const [instances, setInstances] = useState<Instance[]>([]);
const [instances] = useInstances();
const [selectedInstance, setSelectedInstance] = useState<number | null>(null);
const [selectedSurface, setSelectedSurface] = useState<string>("nauvis");
const [selectedForce, setSelectedForce] = useState<string>("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<ChartTagDataWithInstance[]>([]);
const [mergedChartTags, setMergedChartTags] = useState<MergedChartTag[]>([]);
// 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<string, MergedChartTag>();
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<ImageData | null> => {
// 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<HTMLCanvasElement> => {
// 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 => (
<Select.Option key={instance.instanceId} value={instance.instanceId}>
{instance.name}
</Select.Option>
))}
</Select>
options={[...instances.values()].map(instance => ({
value: instance.id,
label: instance.name
}))}
/>
<Select
style={{ width: 150 }}
placeholder="Select surface"
@@ -1192,9 +1469,6 @@ export default function CanvasMinimapPage() {
</Select.Option>
))}
</Select>
<Button onClick={loadInstances} loading={loading}>
Reload Instances
</Button>
<Space direction="vertical" size="small">
<Space>
<Text strong>Timelapse:</Text>
@@ -1209,6 +1483,18 @@ export default function CanvasMinimapPage() {
</Text>
)}
</Space>
<Space>
<Text strong>Chart Tags:</Text>
<Switch
checked={showChartTags}
onChange={setShowChartTags}
/>
{showChartTags && (
<Text type="secondary">
{getVisibleChartTags().length} visible
</Text>
)}
</Space>
</Space>
</Space>
}
+24 -50
View File
@@ -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<Instance[]>([]);
const [instances] = useInstances();
const [selectedInstance, setSelectedInstance] = useState<number | null>(null);
const [selectedSurface, setSelectedSurface] = useState<string>("nauvis");
const [selectedForce, setSelectedForce] = useState<string>("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 (
<div style={{ padding: "20px" }}>
@@ -96,13 +76,11 @@ export default function MinimapPage() {
value={selectedInstance}
onChange={setSelectedInstance}
loading={loading}
>
{instances.map(instance => (
<Select.Option key={instance.instanceId} value={instance.instanceId}>
{instance.name}
</Select.Option>
))}
</Select>
options={[...instances.values()].map(instance => ({
value: instance.id,
label: instance.name
}))}
/>
<Select
style={{ width: 150 }}
placeholder="Select surface"
@@ -127,14 +105,10 @@ export default function MinimapPage() {
</Select.Option>
))}
</Select>
<Button onClick={loadInstances} loading={loading}>
Reload Instances
</Button>
</Space>
}
>
{selectedInstanceData && mapBounds ? (
{selectedInstance && mapBounds ? (
<div
style={{
height: "700px",
+22 -1
View File
@@ -1,10 +1,11 @@
import React from "react";
import { BaseWebPlugin } from "@clusterio/web_ui";
import CanvasMinimapPage from "./CanvasMinimapPage";
import { TileDataEvent } from "../messages";
import { TileDataEvent, ChartTagDataEvent } from "../messages";
export class WebPlugin extends BaseWebPlugin {
tileUpdateCallbacks: Array<(event: TileDataEvent) => 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);
}
}