Cleanup, web interface now shows images (but corrupted)

This commit is contained in:
Danielv123 committed 2025-08-10 00:28:48 +02:00
1 parent 6cbd3229f6
commit e9a4627118
7 files changed
+207 -296

No files matched your search

+80 -134
View File
@@ -2,9 +2,8 @@ import * as lib from "@clusterio/lib";
import { BaseControllerPlugin } from "@clusterio/controller";
import {
TileDataEvent,
RefreshTileDataRequest,
GetTileDataRequest,
GetInstanceBoundsRequest,
ChartData,
} from "./messages";
import * as fs from "fs-extra";
import * as path from "path";
@@ -31,13 +30,21 @@ export class ControllerPlugin extends BaseControllerPlugin {
await fs.ensureDir(this.tilesPath);
this.controller.handle(TileDataEvent, this.handleTileDataEvent.bind(this));
this.controller.handle(RefreshTileDataRequest, this.handleRefreshTileDataRequest.bind(this));
this.controller.handle(GetInstanceBoundsRequest, this.handleGetInstanceBoundsRequest.bind(this));
// Set up HTTP routes for serving tiles
this.setupTileRoutes();
}
// Convert RGB565 to RGB888 values
private rgb565ToRgb888(rgb565Value: number): [number, number, number] {
const r = Math.floor(((rgb565Value >> 11) & 0x1F) * 255 / 31);
const g = Math.floor(((rgb565Value >> 5) & 0x3F) * 255 / 63);
const b = Math.floor((rgb565Value & 0x1F) * 255 / 31);
return [r, g, b];
}
private setupTileRoutes() {
const app = this.controller.app;
@@ -53,11 +60,11 @@ export class ControllerPlugin extends BaseControllerPlugin {
}).png().toBuffer();
};
// Serve terrain tiles
app.get("/api/minimap/tiles/:z/:x/:y.png", async (req, res) => {
// Serve chart tiles with surface and force support
app.get("/api/minimap/chart/:surface/:force/:z/:x/:y.png", async (req, res) => {
try {
const { z, x, y } = req.params;
const filename = `tiles_z${z}x${x}y${y}.png`;
const { surface, force, z, x, y } = req.params;
const filename = `${surface}_${force}_z${z}x${x}y${y}.png`;
const filePath = path.resolve(this.tilesPath, filename);
if (await fs.pathExists(filePath)) {
@@ -70,135 +77,103 @@ export class ControllerPlugin extends BaseControllerPlugin {
res.send(blackImage);
}
} catch (err) {
this.logger.error(`Error serving tile: ${err}`);
this.logger.error(`Error serving chart tile: ${err}`);
const blackImage = await createBlackImage();
res.setHeader("Content-Type", "image/png");
res.send(blackImage);
}
});
// Serve entity tiles
app.get("/api/minimap/entities/:z/:x/:y.png", async (req, res) => {
// List available surfaces and forces
app.get("/api/minimap/surfaces", async (req, res) => {
try {
const { z, x, y } = req.params;
const filename = `entities_z${z}x${x}y${y}.png`;
const filePath = path.resolve(this.tilesPath, filename);
const files = await fs.readdir(this.tilesPath);
const surfaces = new Set<string>();
const forces = new Set<string>();
if (await fs.pathExists(filePath)) {
const file = await fs.readFile(filePath);
res.setHeader("Content-Type", "image/png");
res.send(file);
} else {
const blackImage = await createBlackImage();
res.setHeader("Content-Type", "image/png");
res.send(blackImage);
// Parse filenames to extract surface and force information
for (const file of files) {
const match = file.match(/^([^_]+)_([^_]+)_.*\.png$/);
if (match) {
surfaces.add(match[1]);
forces.add(match[2]);
}
}
res.json({
surfaces: Array.from(surfaces),
forces: Array.from(forces),
});
} catch (err) {
this.logger.error(`Error serving entity tile: ${err}`);
const blackImage = await createBlackImage();
res.setHeader("Content-Type", "image/png");
res.send(blackImage);
this.logger.error(`Error listing surfaces: ${err}`);
res.status(500).json({ error: "Failed to list surfaces" });
}
});
}
async handleTileDataEvent(event: TileDataEvent) {
try {
const { type, data, position, size, instanceId, layer } = event;
const { type, data, position, instanceId } = event;
if (type === "pixels") {
await this.processPixelData(data, instanceId, layer);
} else if (type === "tiles" && position && size) {
await this.processTileData(data, position, size, instanceId, layer);
if (type === "chart") {
await this.processChartData(data as ChartData[], position, instanceId);
}
} catch (err) {
this.logger.error(`Error processing tile data: ${err}`);
}
}
private async processPixelData(data: string[], instanceId: number, layer: string) {
if (data.length % 3 !== 0) {
this.logger.error(`Invalid pixel data length: ${data.length}`);
private async processChartData(chartData: ChartData[], position: [number, number], instanceId: number) {
if (!position || !instanceId) {
this.logger.error("Chart data requires position and instanceId");
return;
}
const updates = new Map<string, Set<PixelUpdate>>();
for (let i = 0; i < data.length; i += 3) {
const x = Math.floor(Number(data[i]));
const y = Math.floor(Number(data[i + 1]));
const colorHex = data[i + 2];
if (colorHex.length !== 8) {
continue; // Skip invalid color data
}
const rgba: [number, number, number, number] = [
parseInt(colorHex.slice(0, 2), 16),
parseInt(colorHex.slice(2, 4), 16),
parseInt(colorHex.slice(4, 6), 16),
parseInt(colorHex.slice(6, 8), 16),
];
// Calculate which tile this pixel belongs to
const tileX = Math.floor(x / TILE_SIZE) + (x < 0 ? -1 : 0);
const tileY = Math.floor(y / TILE_SIZE) + (y < 0 ? -1 : 0);
const filename = `${layer}z10x${tileX}y${tileY}.png`;
if (!updates.has(filename)) {
updates.set(filename, new Set());
}
updates.get(filename)!.add({
x: ((x % TILE_SIZE) + TILE_SIZE) % TILE_SIZE,
y: ((y % TILE_SIZE) + TILE_SIZE) % TILE_SIZE,
rgba,
});
}
// Apply updates to tiles
for (const [filename, pixels] of updates) {
await this.updateTileImage(filename, pixels);
}
}
private async processTileData(data: string[], position: [number, number], size: number, instanceId: number, layer: string) {
const size = 32;
const [originX, originY] = position;
const updates = new Map<string, Set<PixelUpdate>>();
for (let i = 0; i < data.length; i++) {
const x = i % size;
const y = Math.floor(i / size);
const colorHex = data[i];
// Process each surface/force combination
for (const chart of chartData) {
const { surface, force, chart_data } = chart;
// Process each pixel in the 32x32 chunk
for (let i = 0; i < chart_data.length; i += 2) {
const byte1 = chart_data.charCodeAt(i);
const byte2 = chart_data.charCodeAt(i + 1);
if (isNaN(byte1) || isNaN(byte2)) {
continue;
}
if (colorHex.length !== 6) {
continue; // Skip invalid color data
const rgb565Value = byte1 + (byte2 << 8);
const [r, g, b] = this.rgb565ToRgb888(rgb565Value);
// Calculate pixel position within the chunk
const pixelIndex = i / 2;
const x = pixelIndex % size;
const y = Math.floor(pixelIndex / size);
const worldX = originX + x;
const worldY = originY + y;
// Calculate which tile this pixel belongs to
const tileX = Math.floor(worldX / TILE_SIZE) + (worldX < 0 ? -1 : 0);
const tileY = Math.floor(worldY / TILE_SIZE) + (worldY < 0 ? -1 : 0);
// Use surface and force in the filename for differentiation
const filename = `${surface}_${force}_z10x${tileX}y${tileY}.png`;
if (!updates.has(filename)) {
updates.set(filename, new Set());
}
updates.get(filename)!.add({
x: ((worldX % TILE_SIZE) + TILE_SIZE) % TILE_SIZE,
y: ((worldY % TILE_SIZE) + TILE_SIZE) % TILE_SIZE,
rgba: [r, g, b, 255], // Full alpha for chart data
});
}
const rgba: [number, number, number, number] = [
parseInt(colorHex.slice(0, 2), 16),
parseInt(colorHex.slice(2, 4), 16),
parseInt(colorHex.slice(4, 6), 16),
255, // Full alpha for tile data
];
const worldX = originX + x;
const worldY = originY + y;
// Calculate which tile this pixel belongs to
const tileX = Math.floor(worldX / TILE_SIZE) + (worldX < 0 ? -1 : 0);
const tileY = Math.floor(worldY / TILE_SIZE) + (worldY < 0 ? -1 : 0);
const filename = `${layer}z10x${tileX}y${tileY}.png`;
if (!updates.has(filename)) {
updates.set(filename, new Set());
}
updates.get(filename)!.add({
x: ((worldX % TILE_SIZE) + TILE_SIZE) % TILE_SIZE,
y: ((worldY % TILE_SIZE) + TILE_SIZE) % TILE_SIZE,
rgba,
});
}
// Apply updates to tiles
@@ -275,35 +250,6 @@ export class ControllerPlugin extends BaseControllerPlugin {
}).png().toFile(imagePath);
}
async handleRefreshTileDataRequest(request: RefreshTileDataRequest): Promise<{ success: boolean; message?: string }> {
try {
const { instanceId, area } = request;
const instance = this.controller.instances.get(instanceId);
if (!instance) {
return { success: false, message: "Instance not found" };
}
if (instance.status !== "running") {
return { success: false, message: "Instance is not running" };
}
// Send request to instance to get tile data
const defaultArea = { x1: -512, y1: -512, x2: 512, y2: 512 };
const tileArea = area || defaultArea;
await this.controller.sendTo(
{ instanceId },
new GetTileDataRequest(tileArea)
);
return { success: true, message: "Tile refresh initiated" };
} catch (err) {
this.logger.error(`Error refreshing tile data: ${err}`);
return { success: false, message: `Error: ${err}` };
}
}
async handleGetInstanceBoundsRequest(request: GetInstanceBoundsRequest) {
const instances = Array.from(this.controller.instances.values())
.filter(instance => instance.status === "running")
+1 -11
View File
@@ -2,8 +2,6 @@ import * as lib from "@clusterio/lib";
import {
TileDataEvent,
RefreshTileDataRequest,
GetTileDataRequest,
GetInstanceBoundsRequest,
} from "./messages";
@@ -15,13 +13,6 @@ lib.definePermission({
grantByDefault: true,
});
lib.definePermission({
name: "minimap.refresh",
title: "Refresh Minimap",
description: "Refresh the minimap data for Factorio instances.",
grantByDefault: true,
});
export const plugin: lib.PluginDeclaration = {
name: "minimap",
title: "Minimap",
@@ -32,8 +23,7 @@ export const plugin: lib.PluginDeclaration = {
messages: [
TileDataEvent,
RefreshTileDataRequest,
GetTileDataRequest,
GetInstanceBoundsRequest,
],
routes: ["/minimap"],
};
+23 -41
View File
@@ -1,13 +1,11 @@
import * as lib from "@clusterio/lib";
import { BaseInstancePlugin } from "@clusterio/host";
import { TileDataEvent, GetTileDataRequest } from "./messages";
import { TileDataEvent, ChartData } from "./messages";
interface TileDataIpc {
type: "tiles" | "pixels";
data: string;
position?: [number, number];
size?: number;
layer?: string;
type: "chart";
data: ChartData[];
position: [number, number];
}
export class InstancePlugin extends BaseInstancePlugin {
@@ -18,8 +16,6 @@ export class InstancePlugin extends BaseInstancePlugin {
throw new Error("minimap plugin requires script commands to be enabled.");
}
this.instance.handle(GetTileDataRequest, this.handleGetTileDataRequest.bind(this));
// Listen for tile data from the Lua module
this.instance.server.on("ipc-minimap:tile_data", (data: TileDataIpc) => {
this.handleTileDataFromLua(data).catch(err => this.logger.error(
@@ -28,43 +24,29 @@ export class InstancePlugin extends BaseInstancePlugin {
});
}
async handleGetTileDataRequest(request: GetTileDataRequest): Promise<{ tileData: string[] }> {
const { area } = request;
try {
// Send command to Lua to dump tile data for the specified area
const command = `/sc minimap.dump_mapview({${area.x1}, ${area.y1}}, {${area.x2}, ${area.y2}})`;
const response = await this.sendRcon(command);
// Parse the response - it should be a semicolon-separated string of hex colors
const tileData = response ? response.split(";").filter(data => data.length > 0) : [];
return { tileData };
} catch (err) {
this.logger.error(`Failed to get tile data: ${err}`);
return { tileData: [] };
}
}
async handleTileDataFromLua(data: TileDataIpc) {
try {
// Parse the incoming data
const tileData = data.data.split(";").filter(segment => segment.length > 0);
const { type, data: rawData, position } = data;
if (tileData.length === 0) {
return;
if (type === "chart") {
// Handle new chart data format
const chartData = rawData as ChartData[];
if (!chartData || !Array.isArray(chartData)) {
this.logger.error("Invalid chart data received");
return;
}
// Send chart data to controller
const event = new TileDataEvent(
"chart",
chartData,
position!,
this.instance.config.get("instance.id")
);
return this.instance.sendTo("controller", event);
}
// Send tile data to controller
const event = new TileDataEvent(
data.type,
tileData,
data.position || null,
data.size || null,
this.instance.config.get("instance.id"),
data.layer || ""
);
await this.instance.sendTo("controller", event);
} catch (err) {
this.logger.error(`Failed to process tile data from Lua: ${err}`);
+18 -80
View File
@@ -1,6 +1,15 @@
import { Type, Static } from "@sinclair/typebox";
import * as lib from "@clusterio/lib";
// Define the chart data structure
export const ChartDataSchema = Type.Object({
surface: Type.String(),
force: Type.String(),
chart_data: Type.String(),
});
export type ChartData = Static<typeof ChartDataSchema>;
export class TileDataEvent {
declare ["constructor"]: typeof TileDataEvent;
static type = "event" as const;
@@ -10,101 +19,30 @@ export class TileDataEvent {
static permission = null;
constructor(
public type: "tiles" | "pixels",
public data: string[],
public position: [number, number] | null,
public size: number | null,
public instanceId: number,
public layer: string = ""
public type: "chart",
public data: ChartData[],
public position: [number, number],
public instanceId: number
) {
}
static jsonSchema = Type.Object({
"type": Type.Union([Type.Literal("tiles"), Type.Literal("pixels")]),
"data": Type.Array(Type.String()),
"position": Type.Union([Type.Array(Type.Number(), { minItems: 2, maxItems: 2 }), Type.Null()]),
"size": Type.Union([Type.Number(), Type.Null()]),
"type": Type.Union([Type.Literal("chart")]),
"data": Type.Array(ChartDataSchema),
"position": Type.Array(Type.Number(), { minItems: 2, maxItems: 2 }),
"instanceId": Type.Number(),
"layer": Type.String(),
});
static fromJSON(json: Static<typeof TileDataEvent.jsonSchema>) {
return new this(
json.type,
json.data,
json.position as [number, number] | null,
json.size,
json.instanceId,
json.layer
json.position as [number, number],
json.instanceId,
);
}
}
export class RefreshTileDataRequest {
declare ["constructor"]: typeof RefreshTileDataRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static plugin = "minimap" as const;
static permission = "minimap.refresh";
constructor(
public instanceId: number,
public area?: { x1: number; y1: number; x2: number; y2: number }
) {
}
static jsonSchema = Type.Object({
"instanceId": Type.Number(),
"area": Type.Optional(Type.Object({
"x1": Type.Number(),
"y1": Type.Number(),
"x2": Type.Number(),
"y2": Type.Number(),
})),
});
static fromJSON(json: Static<typeof RefreshTileDataRequest.jsonSchema>) {
return new this(json.instanceId, json.area);
}
static Response = lib.plainJson(Type.Object({
"success": Type.Boolean(),
"message": Type.Optional(Type.String()),
}));
}
export class GetTileDataRequest {
declare ["constructor"]: typeof GetTileDataRequest;
static type = "request" as const;
static src = "controller" as const;
static dst = "instance" as const;
static plugin = "minimap" as const;
static permission = null;
constructor(
public area: { x1: number; y1: number; x2: number; y2: number }
) {
}
static jsonSchema = Type.Object({
"area": Type.Object({
"x1": Type.Number(),
"y1": Type.Number(),
"x2": Type.Number(),
"y2": Type.Number(),
}),
});
static fromJSON(json: Static<typeof GetTileDataRequest.jsonSchema>) {
return new this(json.area);
}
static Response = lib.plainJson(Type.Object({
"tileData": Type.Array(Type.String()),
}));
}
export class GetInstanceBoundsRequest {
declare ["constructor"]: typeof GetInstanceBoundsRequest;
static type = "request" as const;
+29 -1
View File
@@ -47,7 +47,6 @@ local function dump_chunk_chart(chunk_position)
clusterio_api.send_json("minimap:tile_data", {
type = "chart",
position = {world_x, world_y},
size = 32,
data = data,
})
end
@@ -116,14 +115,43 @@ 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,
chunk_update_queue = {}
}
end
if not storage.minimap.enabled then
return
end
-- Remove previous queue
storage.minimap.chunk_update_queue = {}
-- Queue all existing charted chunks for update
local chunks_queued = 0
for _, force in pairs(game.forces) do
for _, surface in pairs(game.surfaces) do
-- Iterate through all generated chunks on this surface
for chunk in surface.get_chunks() do
local chunk_position = {x = chunk.x, y = chunk.y}
-- Check if this chunk is charted for this force
if force.is_chunk_charted(surface, chunk_position) then
table.insert(storage.minimap.chunk_update_queue, chunk_position)
chunks_queued = chunks_queued + 1
end
end
end
end
log("Minimap: Queued " .. chunks_queued .. " chunks for initial map generation")
end
local function on_tick(event)
+2
View File
@@ -15,6 +15,8 @@
],
"scripts": {
"prepare": "tsc --build && webpack-cli --env production",
"watch": "tsc --build --watch",
"watch-web": "webpack-cli --env development --watch",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"bugs": {
+54 -29
View File
@@ -3,7 +3,10 @@ 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, RefreshTileDataRequest } from "../messages";
import { GetInstanceBoundsRequest } from "../messages";
// Import Leaflet CSS
import "leaflet/dist/leaflet.css";
interface Instance {
instanceId: number;
@@ -16,9 +19,17 @@ interface Instance {
};
}
interface SurfaceForceData {
surfaces: string[];
forces: string[];
}
export default function MinimapPage() {
const [instances, setInstances] = useState<Instance[]>([]);
const [selectedInstance, setSelectedInstance] = useState<number | null>(null);
const [selectedSurface, setSelectedSurface] = useState<string>("nauvis");
const [selectedForce, setSelectedForce] = useState<string>("player");
const [surfaceForceData, setSurfaceForceData] = useState<SurfaceForceData>({ surfaces: [], forces: [] });
const [refreshTiles, setRefreshTiles] = useState(0);
const [loading, setLoading] = useState(false);
const control = useContext(ControlContext);
@@ -26,6 +37,7 @@ export default function MinimapPage() {
// Load instance bounds on component mount
useEffect(() => {
loadInstances();
loadSurfaceForceData();
}, []);
const loadInstances = async () => {
@@ -44,19 +56,22 @@ export default function MinimapPage() {
}
};
const refreshTileData = async () => {
if (!selectedInstance) return;
const loadSurfaceForceData = async () => {
try {
setLoading(true);
await control.send(new RefreshTileDataRequest(selectedInstance));
const response = await fetch(`${window.location.origin}/api/minimap/surfaces`);
console.log(response);
const data = await response.json();
setSurfaceForceData(data);
// Force tile refresh by incrementing the refresh parameter
setRefreshTiles(prev => prev + 1);
// Set default selections if available
if (data.surfaces.length > 0 && !selectedSurface) {
setSelectedSurface(data.surfaces.includes("nauvis") ? "nauvis" : data.surfaces[0]);
}
if (data.forces.length > 0 && !selectedForce) {
setSelectedForce(data.forces.includes("player") ? "player" : data.forces[0]);
}
} catch (err) {
console.error("Failed to refresh tile data:", err);
} finally {
setLoading(false);
console.error("Failed to load surface/force data:", err);
}
};
@@ -74,7 +89,7 @@ export default function MinimapPage() {
<Card
title="Factorio Instance Minimap"
extra={
<Space>
<Space wrap>
<Select
style={{ width: 200 }}
placeholder="Select instance"
@@ -88,14 +103,31 @@ export default function MinimapPage() {
</Select.Option>
))}
</Select>
<Button
type="primary"
onClick={refreshTileData}
loading={loading}
disabled={!selectedInstance}
<Select
style={{ width: 150 }}
placeholder="Select surface"
value={selectedSurface}
onChange={setSelectedSurface}
>
Refresh Map
</Button>
{surfaceForceData.surfaces.map(surface => (
<Select.Option key={surface} value={surface}>
{surface}
</Select.Option>
))}
</Select>
<Select
style={{ width: 150 }}
placeholder="Select force"
value={selectedForce}
onChange={setSelectedForce}
>
{surfaceForceData.forces.map(force => (
<Select.Option key={force} value={force}>
{force}
</Select.Option>
))}
</Select>
<Button onClick={loadInstances} loading={loading}>
Reload Instances
</Button>
@@ -112,20 +144,13 @@ export default function MinimapPage() {
minZoom={7}
crs={L.CRS.Simple}
>
{/* Terrain layer */}
{/* Chart layer with surface and force data */}
<TileLayer
url={`${window.location.origin}/api/minimap/tiles/{z}/{x}/{y}.png?refresh=${refreshTiles}`}
url={`${window.location.origin}/api/minimap/chart/${selectedSurface}/${selectedForce}/{z}/{x}/{y}.png?refresh=${refreshTiles}`}
maxNativeZoom={10}
minNativeZoom={7}
minNativeZoom={10}
opacity={1}
/>
{/* Entity layer */}
<TileLayer
url={`${window.location.origin}/api/minimap/entities/{z}/{x}/{y}.png?refresh=${refreshTiles}`}
maxNativeZoom={10}
minNativeZoom={7}
opacity={0.8}
/>
</MapContainer>
</div>
) : (