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