From ec0c068d44bec9305ae939fbc5421f0715a19c28 Mon Sep 17 00:00:00 2001 From: Danielv123 Date: Tue, 27 Jan 2026 23:44:55 +0100 Subject: [PATCH] Add button to delete minimap data --- plugins/minimap/controller.ts | 234 +++++++++++++++++++++- plugins/minimap/index.ts | 5 + plugins/minimap/messages.ts | 52 +++++ plugins/minimap/web/CanvasMinimapPage.tsx | 157 +++++++++++++-- 4 files changed, 428 insertions(+), 20 deletions(-) diff --git a/plugins/minimap/controller.ts b/plugins/minimap/controller.ts index 1187759d..95baf02d 100644 --- a/plugins/minimap/controller.ts +++ b/plugins/minimap/controller.ts @@ -4,14 +4,16 @@ import { TileDataEvent, GetRawTileRequest, GetChartTagsRequest, - ChartData, + type ChartData, ChartTagDataEvent, - ChartTagData, + type ChartTagData, RecipeDataEvent, GetRawRecipeTileRequest, PlayerPositionEvent, - PlayerData, + type PlayerData, GetPlayerPathRequest, + ClearMinimapSurfaceDataRequest, + ClearMinimapDataRequest, } from "./messages"; import * as fs from "fs-extra"; import * as path from "path"; @@ -24,12 +26,21 @@ import { renderTileToPixels, pixelsToRGBA, parseRecipeTileBinary, - ParsedRecipeTile, + type ParsedRecipeTile, } from "./utils/tile-utils"; import { parsePlayerPositionsBinary } from "./utils/player-utils"; -const CHUNK_SIZE = 32; -const inflateAsync = promisify(zlib.inflate); +interface TileMetadata { + instanceId: number; + surface: string; + force: string; +} + +interface ChartTagMetadata { + instanceId: number; + surface: string; + force: string; +} interface PixelChange { x: number; @@ -107,6 +118,14 @@ export class ControllerPlugin extends BaseControllerPlugin { this.controller.handle(GetRawRecipeTileRequest, this.handleGetRawRecipeTileRequest.bind(this)); this.controller.handle(PlayerPositionEvent, this.handlePlayerPositionEvent.bind(this)); this.controller.handle(GetPlayerPathRequest, this.handleGetPlayerPathRequest.bind(this)); + this.controller.handle( + ClearMinimapSurfaceDataRequest, + this.handleClearMinimapSurfaceDataRequest.bind(this) + ); + this.controller.handle( + ClearMinimapDataRequest, + this.handleClearAllMinimapDataRequest.bind(this) + ); // Register events as subscribable for web clients this.controller.subscriptions.handle(TileDataEvent); @@ -132,6 +151,199 @@ export class ControllerPlugin extends BaseControllerPlugin { }, 5000); } + private parseTileKey(name: string): TileMetadata | null { + const trimmed = name.replace(/(\.bin|\.recipes)$/, ""); + const parts = trimmed.split("_"); + if (parts.length < 5) { + return null; + } + + parts.pop(); // tileY + parts.pop(); // tileX + const force = parts.pop()!; + const instancePart = parts.shift(); + if (!instancePart) { + return null; + } + + const surface = parts.join("_"); + const instanceId = Number.parseInt(instancePart, 10); + if (!Number.isFinite(instanceId)) { + return null; + } + + return { instanceId, surface, force }; + } + + private parseChartTagMetadata(name: string): ChartTagMetadata | null { + const suffix = "_chart_tags.json"; + if (!name.endsWith(suffix)) { + return null; + } + + const trimmed = name.slice(0, -suffix.length); + const parts = trimmed.split("_"); + if (parts.length < 3) { + return null; + } + + const force = parts.pop()!; + const instancePart = parts.shift(); + if (!instancePart) { + return null; + } + + const surface = parts.join("_"); + const instanceId = Number.parseInt(instancePart, 10); + if (!Number.isFinite(instanceId)) { + return null; + } + + return { instanceId, surface, force }; + } + + private matchesSurfaceForce( + meta: TileMetadata | null, + instanceId: number, + surface: string, + force?: string + ): meta is TileMetadata { + if (!meta) { + return false; + } + + if (meta.instanceId !== instanceId || meta.surface !== surface) { + return false; + } + if (force && meta.force !== force) { + return false; + } + + return true; + } + + private matchesChartTag( + meta: ChartTagMetadata | null, + instanceId: number, + surface: string, + force?: string + ): meta is ChartTagMetadata { + if (!meta) { + return false; + } + if (meta.instanceId !== instanceId || meta.surface !== surface) { + return false; + } + if (force && meta.force !== force) { + return false; + } + return true; + } + + private async deleteFilesMatching(dir: string, predicate: (file: string) => boolean) { + const files = await fs.readdir(dir); + for (const file of files) { + if (predicate(file)) { + await fs.remove(path.join(dir, file)); + } + } + } + + private removeSurfaceQueueEntries(instanceId: number, surface: string, force?: string) { + for (const tileName of Array.from(this.chunkSavingQueue.keys())) { + if (this.matchesSurfaceForce(this.parseTileKey(tileName), instanceId, surface, force)) { + this.chunkSavingQueue.delete(tileName); + } + } + for (const tileName of Array.from(this.recipeSavingQueue.keys())) { + if (this.matchesSurfaceForce(this.parseTileKey(tileName), instanceId, surface, force)) { + this.recipeSavingQueue.delete(tileName); + } + } + for (const key of Array.from(this.chartTagQueues.keys())) { + if (this.matchesChartTag(this.parseChartTagMetadata(key), instanceId, surface, force)) { + this.chartTagQueues.delete(key); + } + } + for (const key of Array.from(this.playerPositionQueues.keys())) { + if (key === `${instanceId}_${surface}`) { + this.playerPositionQueues.delete(key); + } + } + } + + private removeSurfaceCaches(instanceId: number, surface: string, force?: string) { + for (const key of Array.from(this.lastSeenTagContent.keys())) { + if (this.matchesChartTag(this.parseChartTagMetadata(key), instanceId, surface, force)) { + this.lastSeenTagContent.delete(key); + } + } + for (const key of Array.from(this.lastSeenRecipeContent.keys())) { + if (this.matchesSurfaceForce(this.parseTileKey(key), instanceId, surface, force)) { + this.lastSeenRecipeContent.delete(key); + this.recipeDictionaries.delete(key); + this.nextRecipeIndex.delete(key); + } + } + + const playerKey = `${instanceId}_${surface}`; + this.playerSessions.delete(playerKey); + this.nextPlayerIds.delete(playerKey); + this.activePlayerSessions.delete(playerKey); + } + + private async clearSurfaceData(instanceId: number, surface: string, force?: string) { + const matchesTile = (file: string) => this.matchesSurfaceForce( + this.parseTileKey(file), + instanceId, + surface, + force + ); + + await this.deleteFilesMatching(this.tilesPath, matchesTile); + await this.deleteFilesMatching(this.recipeTilesPath, matchesTile); + + await this.deleteFilesMatching(this.chartTagsPath, (file) => ( + this.matchesChartTag(this.parseChartTagMetadata(file), instanceId, surface, force) + )); + + if (!force) { + const positionsFile = `${instanceId}_${surface}.positions`; + await fs.remove(path.join(this.playerPositionsPath, positionsFile)); + } + + this.removeSurfaceQueueEntries(instanceId, surface, force); + this.removeSurfaceCaches(instanceId, surface, force); + + this.logger.info(`Cleared minimap data: ${instanceId}, surface ${surface}${force ? `, force ${force}` : ""}`); + } + + private async clearAllMinimapData() { + await Promise.all([ + fs.emptyDir(this.tilesPath), + fs.emptyDir(this.chartTagsPath), + fs.emptyDir(this.recipeTilesPath), + fs.emptyDir(this.playerPositionsPath), + ]); + + this.chunkSavingQueue.clear(); + this.chartTagQueues.clear(); + this.recipeSavingQueue.clear(); + this.playerPositionQueues.clear(); + + this.lastSeenTagContent.clear(); + this.lastSeenRecipeContent.clear(); + this.recipeDictionaries.clear(); + this.nextRecipeIndex.clear(); + + this.playerSessions.clear(); + this.nextPlayerIds.clear(); + this.activePlayerSessions.clear(); + + this.logger.info("Cleared all minimap data"); + } + + async onShutdown() { if (this.saveInterval) { clearInterval(this.saveInterval); @@ -1113,6 +1325,16 @@ export class ControllerPlugin extends BaseControllerPlugin { } } + async handleClearMinimapSurfaceDataRequest(request: ClearMinimapSurfaceDataRequest) { + await this.clearSurfaceData(request.instance_id, request.surface, request.force); + return { success: true }; + } + + async handleClearAllMinimapDataRequest(_request: ClearMinimapDataRequest) { + await this.clearAllMinimapData(); + return { success: true }; + } + private async loadExistingPlayerSessions() { try { if (!await fs.pathExists(this.playerPositionsPath)) { diff --git a/plugins/minimap/index.ts b/plugins/minimap/index.ts index c73abed2..27d3b574 100644 --- a/plugins/minimap/index.ts +++ b/plugins/minimap/index.ts @@ -18,6 +18,11 @@ lib.definePermission({ description: "View the interactive minimap of Factorio instances.", grantByDefault: true, }); +lib.definePermission({ + name: "minimap.manage", + title: "Manage Minimap Data", + description: "Delete cached minimap data through the web interface.", +}); export const plugin: lib.PluginDeclaration = { name: "minimap", diff --git a/plugins/minimap/messages.ts b/plugins/minimap/messages.ts index aa989de0..3b5faf42 100644 --- a/plugins/minimap/messages.ts +++ b/plugins/minimap/messages.ts @@ -256,6 +256,58 @@ export class GetRawTileRequest { })); } +export class ClearMinimapSurfaceDataRequest { + declare ["constructor"]: typeof ClearMinimapSurfaceDataRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static plugin = "minimap" as const; + static permission = "minimap.manage"; + + constructor( + public instance_id: number, + public surface: string, + public force?: string, + ) { } + + static jsonSchema = Type.Object({ + "instance_id": Type.Number(), + "surface": Type.String(), + "force": Type.Optional(Type.String()), + }); + + static fromJSON(json: Static) { + return new this( + json.instance_id, + json.surface, + json.force + ); + } + + static Response = lib.plainJson(Type.Object({ + "success": Type.Boolean(), + })); +} + +export class ClearMinimapDataRequest { + declare ["constructor"]: typeof ClearMinimapDataRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static plugin = "minimap" as const; + static permission = "minimap.manage"; + + static jsonSchema = Type.Object({}); + + static fromJSON(_: Static) { + return new this(); + } + + static Response = lib.plainJson(Type.Object({ + "success": Type.Boolean(), + })); +} + /** * GetChartTagsRequest: Request existing chart tags from controller storage * diff --git a/plugins/minimap/web/CanvasMinimapPage.tsx b/plugins/minimap/web/CanvasMinimapPage.tsx index b07ca9c1..6b0bf086 100644 --- a/plugins/minimap/web/CanvasMinimapPage.tsx +++ b/plugins/minimap/web/CanvasMinimapPage.tsx @@ -1,18 +1,34 @@ import React, { useState, useEffect, useContext, useRef, useCallback } from "react"; -import { Row, Col, Button, Card, Space, Select, Switch, Slider, Typography } from "antd"; -import { ControlContext, useInstances, useItemMetadata } from "@clusterio/web_ui"; +import { + Row, + Col, + Button, + Card, + Space, + Select, + Switch, + Slider, + Typography, + Dropdown, + Tooltip, + type MenuProps, +} from "antd"; +import { EllipsisOutlined } from "@ant-design/icons"; +import { ControlContext, useInstances, useItemMetadata, useAccount, notify } from "@clusterio/web_ui"; import { GetRawTileRequest, GetChartTagsRequest, GetRawRecipeTileRequest, GetPlayerPathRequest, - TileDataEvent, - ChartTagData, - ChartTagDataEvent, - SignalID, - RecipeDataEvent, - PlayerPositionEvent, - PlayerData, + ClearMinimapSurfaceDataRequest, + ClearMinimapDataRequest, + type TileDataEvent, + type ChartTagData, + type ChartTagDataEvent, + type SignalID, + type RecipeDataEvent, + type PlayerPositionEvent, + type PlayerData, } from "../messages"; import * as zlib from "zlib"; import { @@ -22,10 +38,10 @@ import { extractAvailableTicks, parseTileData, renderTileIncremental, - ParsedTileData, + type ParsedTileData, parseRecipeTileBinary, } from "../utils/tile-utils"; -import { parseAndDeduplicatePlayerPositions, ParsedPlayerPos } from "../utils/player-utils"; +import { parseAndDeduplicatePlayerPositions, type ParsedPlayerPos } from "../utils/player-utils"; const { Text } = Typography; @@ -119,7 +135,10 @@ export default function CanvasMinimapPage() { const [selectedSurface, setSelectedSurface] = useState("nauvis"); const [selectedForce, setSelectedForce] = useState("player"); const [surfaceForceData, setSurfaceForceData] = useState({ surfaces: [], forces: [] }); - const [loading, setLoading] = useState(false); + const account = useAccount(); + const canManageMinimap = account.hasPermission("minimap.manage") === true; + const [isClearingSurface, setIsClearingSurface] = useState(false); + const [isClearingAll, setIsClearingAll] = useState(false); // Timelapse UI states (these need React re-renders for UI) const [isTimelapseMode, setIsTimelapseMode] = useState(false); @@ -179,6 +198,82 @@ export default function CanvasMinimapPage() { // Timelapse playback timer ref const playbackTimerRef = useRef(); + const resetViewerState = () => { + virtualTiles.current.clear(); + tileStateCache.current.clear(); + chunkCache.current.clear(); + loadingTiles.current.clear(); + recipeCache.current.clear(); + playerPathBufRef.current = null; + deduplicatedPlayerPositions.current.clear(); + setPlayerPositions(new Map()); + setRawChartTags([]); + setMergedChartTags([]); + setAvailableTicks([]); + setCurrentTick(null); + setChartTagTimestamps(0); + setIsPlaying(false); + setIsTimelapseMode(false); + }; + + const handleClearSurfaceData = async () => { + if (!canManageMinimap) { + return; + } + if (!selectedInstance || !selectedSurface) { + notify("Select an instance and surface before clearing minimap data", "warning"); + return; + } + + setIsClearingSurface(true); + try { + await control.send(new ClearMinimapSurfaceDataRequest( + selectedInstance, + selectedSurface, + selectedForce || undefined + )); + + notify("Cleared minimap surface data", "success"); + resetViewerState(); + } catch (err: any) { + notify(err instanceof Error ? err : new Error("Failed to clear minimap surface data"), "error"); + } finally { + setIsClearingSurface(false); + } + }; + + const handleClearAllData = async () => { + if (!canManageMinimap) { + return; + } + + setIsClearingAll(true); + try { + await control.send(new ClearMinimapDataRequest()); + notify("Cleared all minimap data", "success"); + resetViewerState(); + } catch (err: any) { + notify(err instanceof Error ? err : new Error("Failed to clear minimap data"), "error"); + } finally { + setIsClearingAll(false); + } + }; + + const handleManageDropdownClick: MenuProps["onClick"] = ({ key }) => { + if (key === "clear_all") { + void handleClearAllData(); + } + }; + + const manageDropdownItems: MenuProps["items"] = [ + { + key: "clear_all", + label: "Clear all minimap data", + danger: true, + disabled: !canManageMinimap || isClearingAll, + }, + ]; + // Refs for current state access in event handlers const currentStateRef = useRef({ selectedInstance: null as number | null, @@ -2112,6 +2207,42 @@ export default function CanvasMinimapPage() { )} } + extra={ + + + + +