Switch to custom canvas based map renderer

This commit is contained in:
Danielv123 committed 2025-08-10 00:28:48 +02:00
1 parent 063e622721
commit 3bf7a84de4
5 files changed
+795 -10

No files matched your search

+39 -7
View File
@@ -4,6 +4,7 @@ import {
TileDataEvent,
GetInstanceBoundsRequest,
ChartData,
ChunkUpdateEvent,
} from "./messages";
import * as fs from "fs-extra";
import * as path from "path";
@@ -209,12 +210,27 @@ export class ControllerPlugin extends BaseControllerPlugin {
// Create filename using chunk coordinates
const filename = `${instanceId}/${surface}/${force}/${chunkX}_${chunkY}.png`;
// Process the 32x32 chunk directly
await this.saveChunkImage(filename, chart_data);
// Process the 32x32 chunk directly and get the processed image data
const imageDataBase64 = await this.saveChunkImage(filename, chart_data);
// Send live update to connected web clients
if (imageDataBase64) {
const updateEvent = new ChunkUpdateEvent(
instanceId,
surface,
force,
chunkX,
chunkY,
imageDataBase64
);
// Broadcast to all connected control clients
this.controller.sendEvent(updateEvent, new lib.Address(lib.Address.broadcast, lib.Address.control));
}
}
}
private async saveChunkImage(filename: string, chartData: string) {
private async saveChunkImage(filename: string, chartData: string): Promise<string | null> {
const imagePath = path.resolve(this.tilesPath, filename);
// Ensure the directory structure exists
@@ -262,22 +278,38 @@ export class ControllerPlugin extends BaseControllerPlugin {
}
}
// Save the chunk image directly
await sharp(raw, {
// Create the PNG buffer for both saving and live updates
const pngBuffer = await sharp(raw, {
raw: {
width: CHUNK_SIZE,
height: CHUNK_SIZE,
channels: 4,
},
}).png().toFile(imagePath);
}).png().toBuffer();
// Save the chunk image to disk
await fs.writeFile(imagePath, pngBuffer);
// Convert ImageData to base64 for live updates
// Create ImageData-like structure that can be sent over WebSocket
const imageDataArray = new Uint8ClampedArray(raw);
const imageDataJson = {
data: Array.from(imageDataArray),
width: CHUNK_SIZE,
height: CHUNK_SIZE,
};
// Get the final PNG file size for comparison
const stats = await fs.stat(imagePath);
const pngSize = stats.size;
this.logger.info(`Chunk ${filename}: raw ${raw.length}, decompressed ${binaryBuffer.length}, compressed ${compressedBuffer.length}, PNG ${pngSize} bytes`);
// Return base64 encoded ImageData for live updates
return Buffer.from(JSON.stringify(imageDataJson)).toString('base64');
} catch (err) {
this.logger.error(`Error processing chunk image ${filename}: ${err}`);
return null;
}
}
+2
View File
@@ -3,6 +3,7 @@ import * as lib from "@clusterio/lib";
import {
TileDataEvent,
GetInstanceBoundsRequest,
ChunkUpdateEvent,
} from "./messages";
// Define permissions
@@ -24,6 +25,7 @@ export const plugin: lib.PluginDeclaration = {
messages: [
TileDataEvent,
GetInstanceBoundsRequest,
ChunkUpdateEvent,
],
routes: ["/minimap"],
};
+39
View File
@@ -43,6 +43,45 @@ export class TileDataEvent {
}
}
export class ChunkUpdateEvent {
declare ["constructor"]: typeof ChunkUpdateEvent;
static type = "event" as const;
static src = "controller" as const;
static dst = "control" as const;
static plugin = "minimap" as const;
static permission = "minimap.view";
constructor(
public instanceId: number,
public surface: string,
public force: string,
public chunkX: number,
public chunkY: number,
public imageData: string, // base64 encoded ImageData
) {
}
static jsonSchema = Type.Object({
"instanceId": Type.Number(),
"surface": Type.String(),
"force": Type.String(),
"chunkX": Type.Number(),
"chunkY": Type.Number(),
"imageData": Type.String(),
});
static fromJSON(json: Static<typeof ChunkUpdateEvent.jsonSchema>) {
return new this(
json.instanceId,
json.surface,
json.force,
json.chunkX,
json.chunkY,
json.imageData,
);
}
}
export class GetInstanceBoundsRequest {
declare ["constructor"]: typeof GetInstanceBoundsRequest;
static type = "request" as const;
+687
View File
@@ -0,0 +1,687 @@
import React, { useState, useEffect, useContext, useRef, useCallback } from "react";
import { Row, Col, Button, Card, Space, Select } from "antd";
import { ControlContext } from "@clusterio/web_ui";
import { GetInstanceBoundsRequest, ChunkUpdateEvent } from "../messages";
interface Instance {
instanceId: number;
name: string;
bounds: {
x1: number;
y1: number;
x2: number;
y2: number;
};
}
interface SurfaceForceData {
surfaces: string[];
forces: string[];
}
interface ChunkData {
surface: string;
force: string;
chunkX: number;
chunkY: number;
imageData: ImageData;
}
interface ViewState {
centerX: number;
centerY: number;
zoomLevel: number;
}
const CHUNK_SIZE = 32; // 32x32 pixels per chunk
const TILE_SIZE = 512; // 512x512 pixels per tile (16x16 chunks)
const CHUNKS_PER_TILE = 16;
export default function CanvasMinimapPage() {
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 [loading, setLoading] = useState(false);
const [viewState, setViewState] = useState<ViewState>({
centerX: 0,
centerY: 0,
zoomLevel: 1,
});
const [resizeCounter, setResizeCounter] = useState(0);
// Canvas refs
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Virtual tile storage - maps "tileX,tileY" to canvas element
const virtualTiles = useRef<Map<string, HTMLCanvasElement>>(new Map());
// Tile cache - maps "tileX,tileY" to ImageData for entire 512x512 tiles
const tileCache = useRef<Map<string, ImageData>>(new Map());
// Chunk cache - maps "chunkX,chunkY" to ImageData
const chunkCache = useRef<Map<string, ImageData>>(new Map());
// Loading state cache - prevents duplicate API calls for the same tile
const loadingTiles = useRef<Map<string, Promise<ImageData | null>>>(new Map());
// Animation frame for smooth rendering
const animationFrameRef = useRef<number>();
const control = useContext(ControlContext);
// Load instance bounds on component mount
useEffect(() => {
loadInstances();
loadSurfaceForceData();
}, []);
// Set up keyboard and mouse event listeners
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const moveSpeed = 50 / viewState.zoomLevel;
const zoomFactor = 1.2;
switch (e.key.toLowerCase()) {
case 'w':
setViewState(prev => ({ ...prev, centerY: prev.centerY - moveSpeed }));
break;
case 's':
setViewState(prev => ({ ...prev, centerY: prev.centerY + moveSpeed }));
break;
case 'a':
setViewState(prev => ({ ...prev, centerX: prev.centerX - moveSpeed }));
break;
case 'd':
setViewState(prev => ({ ...prev, centerX: prev.centerX + moveSpeed }));
break;
case '+':
case '=': // Handle both + and = key (since + requires shift)
setViewState(prev => ({
...prev,
zoomLevel: Math.min(10, prev.zoomLevel * zoomFactor)
}));
e.preventDefault(); // Prevent browser zoom
break;
case '-':
case '_': // Handle both - and _ key (since _ requires shift)
setViewState(prev => ({
...prev,
zoomLevel: Math.max(0.1, prev.zoomLevel / zoomFactor)
}));
e.preventDefault(); // Prevent browser zoom
break;
}
};
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
const zoomFactor = 1.1;
const newZoom = e.deltaY > 0
? Math.max(0.1, viewState.zoomLevel / zoomFactor)
: Math.min(10, viewState.zoomLevel * zoomFactor);
setViewState(prev => ({ ...prev, zoomLevel: newZoom }));
};
document.addEventListener('keydown', handleKeyDown);
const canvasElement = canvasRef.current;
if (canvasElement) {
canvasElement.addEventListener('wheel', handleWheel, { passive: false });
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
if (canvasElement) {
canvasElement.removeEventListener('wheel', handleWheel);
}
};
}, [viewState.zoomLevel]);
// Render loop
useEffect(() => {
const render = () => {
if (canvasRef.current && selectedInstance) {
renderCanvas();
}
animationFrameRef.current = requestAnimationFrame(render);
};
animationFrameRef.current = requestAnimationFrame(render);
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [selectedInstance, selectedSurface, selectedForce, viewState, resizeCounter]);
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`);
const data = await response.json();
setSurfaceForceData(data);
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 load surface/force data:", err);
}
};
// Load a tile from the server (512x512 pixels containing 16x16 chunks)
const loadTile = async (tileX: number, tileY: number): Promise<ImageData | null> => {
const tileKey = `${tileX},${tileY}`;
// Check tile cache first
if (tileCache.current.has(tileKey)) {
return tileCache.current.get(tileKey)!;
}
// Check if we're already loading this tile
if (loadingTiles.current.has(tileKey)) {
return loadingTiles.current.get(tileKey)!;
}
// Start loading the tile
const loadPromise = (async () => {
try {
// Load the entire tile from server
const response = await fetch(
`${window.location.origin}/api/minimap/chart/${selectedInstance}/${selectedSurface}/${selectedForce}/10/${tileX}/${tileY}.png`
);
if (!response.ok) {
return null;
}
const blob = await response.blob();
const img = new Image();
return new Promise<ImageData | null>((resolve) => {
img.onload = () => {
// Create a canvas to get the tile ImageData
const tempCanvas = document.createElement('canvas');
tempCanvas.width = TILE_SIZE;
tempCanvas.height = TILE_SIZE;
const tempCtx = tempCanvas.getContext('2d')!;
tempCtx.drawImage(img, 0, 0);
// Get the full tile as ImageData
const tileImageData = tempCtx.getImageData(0, 0, TILE_SIZE, TILE_SIZE);
// Cache the tile
tileCache.current.set(tileKey, tileImageData);
// Extract and cache all chunks from this tile
for (let cy = 0; cy < CHUNKS_PER_TILE; cy++) {
for (let cx = 0; cx < CHUNKS_PER_TILE; cx++) {
const actualChunkX = tileX * CHUNKS_PER_TILE + cx;
const actualChunkY = tileY * CHUNKS_PER_TILE + cy;
const actualChunkKey = `${actualChunkX},${actualChunkY}`;
const chunkImageData = tempCtx.getImageData(
cx * CHUNK_SIZE,
cy * CHUNK_SIZE,
CHUNK_SIZE,
CHUNK_SIZE
);
chunkCache.current.set(actualChunkKey, chunkImageData);
}
}
resolve(tileImageData);
};
img.onerror = () => {
console.error(`Failed to load image for tile ${tileX},${tileY}`);
resolve(null);
};
img.src = URL.createObjectURL(blob);
});
} catch (err) {
console.error(`Failed to load tile ${tileX},${tileY}:`, err);
return null;
} finally {
// Remove from loading cache when done
loadingTiles.current.delete(tileKey);
}
})();
// Cache the loading promise
loadingTiles.current.set(tileKey, loadPromise);
return loadPromise;
};
// Update a single chunk (for live updates) - directly modify the virtual tile in memory
const updateChunk = useCallback((chunkX: number, chunkY: number, imageData: ImageData) => {
const chunkKey = `${chunkX},${chunkY}`;
chunkCache.current.set(chunkKey, imageData);
// Calculate which tile contains this chunk
const tileX = Math.floor(chunkX / CHUNKS_PER_TILE);
const tileY = Math.floor(chunkY / CHUNKS_PER_TILE);
const tileKey = `${tileX},${tileY}`;
// Calculate position within the tile
const localChunkX = chunkX % CHUNKS_PER_TILE;
const localChunkY = chunkY % CHUNKS_PER_TILE;
const pixelX = localChunkX * CHUNK_SIZE;
const pixelY = localChunkY * CHUNK_SIZE;
// Update the virtual tile directly if it exists
const virtualTile = virtualTiles.current.get(tileKey);
if (virtualTile) {
const ctx = virtualTile.getContext('2d')!;
ctx.putImageData(imageData, pixelX, pixelY);
}
// Update the tile cache if it exists
const cachedTile = tileCache.current.get(tileKey);
if (cachedTile) {
// Update the tile ImageData directly
const tileData = cachedTile.data;
const chunkData = imageData.data;
for (let y = 0; y < CHUNK_SIZE; y++) {
for (let x = 0; x < CHUNK_SIZE; x++) {
const chunkIndex = (y * CHUNK_SIZE + x) * 4;
const tileIndex = ((pixelY + y) * TILE_SIZE + (pixelX + x)) * 4;
tileData[tileIndex] = chunkData[chunkIndex]; // R
tileData[tileIndex + 1] = chunkData[chunkIndex + 1]; // G
tileData[tileIndex + 2] = chunkData[chunkIndex + 2]; // B
tileData[tileIndex + 3] = chunkData[chunkIndex + 3]; // A
}
}
}
}, []);
// Get or create a virtual tile
const getVirtualTile = async (tileX: number, tileY: number): Promise<HTMLCanvasElement> => {
const tileKey = `${tileX},${tileY}`;
if (virtualTiles.current.has(tileKey)) {
return virtualTiles.current.get(tileKey)!;
}
// Create new virtual tile
const virtualCanvas = document.createElement('canvas');
virtualCanvas.width = TILE_SIZE;
virtualCanvas.height = TILE_SIZE;
const ctx = virtualCanvas.getContext('2d')!;
// Always start with black background
ctx.fillStyle = '#000000';
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);
if (tileImageData) {
// Draw the entire tile directly to the virtual canvas
ctx.putImageData(tileImageData, 0, 0);
}
virtualTiles.current.set(tileKey, virtualCanvas);
return virtualCanvas;
};
// Render the canvas
const renderCanvas = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d')!;
// Get canvas dimensions
const width = canvas.width;
const height = canvas.height;
// Clear canvas completely
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, width, height);
// Calculate scale factor (pixels per world unit) - this must be the same for X and Y to preserve 1:1 aspect ratio
const scale = viewState.zoomLevel;
// Calculate visible world area - ensuring 1:1 aspect ratio
const worldWidth = width / scale;
const worldHeight = height / scale;
const worldLeft = viewState.centerX - worldWidth / 2;
const worldTop = viewState.centerY - worldHeight / 2;
const worldRight = worldLeft + worldWidth;
const worldBottom = worldTop + worldHeight;
// Calculate which tiles are visible (with extra margin to ensure full coverage)
const leftTile = Math.floor(worldLeft / TILE_SIZE) - 1;
const topTile = Math.floor(worldTop / TILE_SIZE) - 1;
const rightTile = Math.ceil(worldRight / TILE_SIZE) + 1;
const bottomTile = Math.ceil(worldBottom / TILE_SIZE) + 1;
// Render visible tiles (only those that are already loaded)
for (let tileY = topTile; tileY <= bottomTile; tileY++) {
for (let tileX = leftTile; tileX <= rightTile; tileX++) {
const tileKey = `${tileX},${tileY}`;
const virtualTile = virtualTiles.current.get(tileKey);
// Calculate screen position using consistent scale factor
const worldTileX = tileX * TILE_SIZE;
const worldTileY = tileY * TILE_SIZE;
const screenX = (worldTileX - worldLeft) * scale;
const screenY = (worldTileY - worldTop) * scale;
const screenWidth = TILE_SIZE * scale;
const screenHeight = TILE_SIZE * scale;
// Calculate clipping bounds to ensure we don't draw outside canvas
const clippedX = Math.max(0, screenX);
const clippedY = Math.max(0, screenY);
const clippedWidth = Math.min(screenWidth, width - clippedX);
const clippedHeight = Math.min(screenHeight, height - clippedY);
// Skip if tile is completely outside canvas
if (clippedWidth <= 0 || clippedHeight <= 0) {
continue;
}
if (virtualTile) {
// Tile is loaded, draw it with clipping
// Always use nearest neighbor (pixelated) scaling
ctx.imageSmoothingEnabled = false;
// Calculate source coordinates maintaining 1:1 aspect ratio
const sourceX = clippedX > screenX ? (clippedX - screenX) / scale : 0;
const sourceY = clippedY > screenY ? (clippedY - screenY) / scale : 0;
const sourceWidth = clippedWidth / scale;
const sourceHeight = clippedHeight / scale;
// Ensure pixel-perfect alignment by rounding to integers
// This preserves aspect ratio by maintaining the same rounding for both dimensions
const destX = Math.round(clippedX);
const destY = Math.round(clippedY);
const destWidth = Math.round(clippedWidth);
const destHeight = Math.round(clippedHeight);
ctx.drawImage(
virtualTile,
sourceX, sourceY, sourceWidth, sourceHeight,
destX, destY, destWidth, destHeight
);
} else {
// Tile not loaded yet, draw black
ctx.fillStyle = '#000000';
ctx.fillRect(Math.round(clippedX), Math.round(clippedY), Math.round(clippedWidth), Math.round(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);
});
}
}
}
};
// Handle canvas resize
useEffect(() => {
const handleResize = () => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
// Use ResizeObserver for more accurate container size tracking
const rect = container.getBoundingClientRect();
// Set canvas size to match container (simple 1:1 ratio)
canvas.width = rect.width;
canvas.height = rect.height;
// Trigger re-render
setResizeCounter(prev => prev + 1);
};
// Defer initial resize to ensure layout is complete
const timeoutId = setTimeout(handleResize, 0);
// Set up ResizeObserver for container size changes
const container = containerRef.current;
if (container && 'ResizeObserver' in window) {
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(container);
// Also listen to window resize as fallback
window.addEventListener('resize', handleResize);
return () => {
clearTimeout(timeoutId);
resizeObserver.disconnect();
window.removeEventListener('resize', handleResize);
};
} else {
// Fallback for browsers without ResizeObserver
window.addEventListener('resize', handleResize);
return () => {
clearTimeout(timeoutId);
window.removeEventListener('resize', handleResize);
};
}
}, []);
// Ensure proper canvas sizing when instance selection changes
useEffect(() => {
if (selectedInstance) {
// Force a resize check when instance becomes available
const canvas = canvasRef.current;
const container = containerRef.current;
if (canvas && container) {
const rect = container.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
setResizeCounter(prev => prev + 1);
}
}
}, [selectedInstance]);
// Reset view when instance changes
useEffect(() => {
if (selectedInstance) {
const instanceData = instances.find(inst => inst.instanceId === selectedInstance);
if (instanceData) {
setViewState({
centerX: (instanceData.bounds.x1 + instanceData.bounds.x2) / 2,
centerY: (instanceData.bounds.y1 + instanceData.bounds.y2) / 2,
zoomLevel: 1,
});
}
// Clear caches when switching instances
virtualTiles.current.clear();
tileCache.current.clear();
chunkCache.current.clear();
loadingTiles.current.clear();
}
}, [selectedInstance, instances]);
// Set up live chunk update listener
useEffect(() => {
if (!selectedInstance || !selectedSurface || !selectedForce) {
return;
}
const plugin = control.plugins.get("minimap") as import("./index").WebPlugin;
if (!plugin) {
console.error("Minimap plugin not found");
return;
}
const handleChunkUpdate = (event: ChunkUpdateEvent) => {
// Only process updates for the currently selected instance/surface/force
if (event.instanceId === selectedInstance &&
event.surface === selectedSurface &&
event.force === selectedForce) {
try {
// Decode the base64 ImageData
const imageDataJson = JSON.parse(atob(event.imageData));
const imageData = new ImageData(
new Uint8ClampedArray(imageDataJson.data),
imageDataJson.width,
imageDataJson.height
);
// Update the chunk in our cache
updateChunk(event.chunkX, event.chunkY, imageData);
} catch (err) {
console.error("Failed to process live chunk update:", err);
}
}
};
// Subscribe to chunk update events through the plugin
plugin.onChunkUpdate(handleChunkUpdate);
return () => {
plugin.offChunkUpdate(handleChunkUpdate);
};
}, [selectedInstance, selectedSurface, selectedForce, updateChunk, control]);
return (
<div style={{ padding: "20px" }}>
<Row gutter={[16, 16]}>
<Col span={24}>
<Card
title="Factorio Instance Minimap (Canvas)"
extra={
<Space wrap>
<Select
style={{ width: 200 }}
placeholder="Select instance"
value={selectedInstance}
onChange={setSelectedInstance}
loading={loading}
>
{instances.map(instance => (
<Select.Option key={instance.instanceId} value={instance.instanceId}>
{instance.name}
</Select.Option>
))}
</Select>
<Select
style={{ width: 150 }}
placeholder="Select surface"
value={selectedSurface}
onChange={setSelectedSurface}
>
{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>
</Space>
}
>
{selectedInstance ? (
<div>
<div style={{ marginBottom: "10px", fontSize: "14px", color: "#666" }}>
Use WASD to move, +/- or scroll wheel to zoom. Current zoom: {viewState.zoomLevel.toFixed(2)}x
</div>
<div
ref={containerRef}
style={{
height: "calc(100vh - 300px)", // Dynamic height based on viewport
minHeight: "500px",
width: "100%",
border: "1px solid #d9d9d9",
borderRadius: "6px",
overflow: "hidden",
position: "relative",
}}
>
<canvas
ref={canvasRef}
style={{
display: "block",
cursor: "grab",
width: "100%",
height: "100%",
}}
tabIndex={0}
/>
</div>
</div>
) : (
<div
style={{
height: "400px",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f5f5f5",
border: "1px dashed #d9d9d9",
borderRadius: "6px",
}}
>
<div style={{ textAlign: "center" }}>
<h3>No Instance Selected</h3>
<p>Select a running instance to view its minimap</p>
</div>
</div>
)}
</Card>
</Col>
</Row>
</div>
);
}
+28 -3
View File
@@ -1,16 +1,41 @@
import React from "react";
import { BaseWebPlugin } from "@clusterio/web_ui";
import MinimapPage from "./MinimapPage";
import CanvasMinimapPage from "./CanvasMinimapPage";
import { ChunkUpdateEvent } from "../messages";
export class WebPlugin extends BaseWebPlugin {
chunkUpdateCallbacks: Array<(event: ChunkUpdateEvent) => void> = [];
async init() {
this.pages = [
{
path: "/minimap",
sidebarName: "Minimap",
sidebarName: "Minimap",
permission: "minimap.view",
content: <MinimapPage />,
content: <CanvasMinimapPage />,
},
];
// Handle chunk update events from the controller
this.control.handle(ChunkUpdateEvent, this.handleChunkUpdateEvent.bind(this));
}
async handleChunkUpdateEvent(event: ChunkUpdateEvent) {
// Notify all registered callbacks
for (let callback of this.chunkUpdateCallbacks) {
callback(event);
}
}
onChunkUpdate(callback: (event: ChunkUpdateEvent) => void) {
this.chunkUpdateCallbacks.push(callback);
}
offChunkUpdate(callback: (event: ChunkUpdateEvent) => void) {
let index = this.chunkUpdateCallbacks.lastIndexOf(callback);
if (index === -1) {
throw new Error("callback is not registered");
}
this.chunkUpdateCallbacks.splice(index, 1);
}
}