mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 15:04:52 +09:00
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:
1 parent
f5aeaff256
commit
fc466ec799
8 files changed
+843
-189
No files matched your search
+195
-23
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
};
|
||||
@@ -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
@@ -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),
|
||||
}));
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user