Add button to delete minimap data

This commit is contained in:
Danielv123 committed 2026-01-27 23:49:25 +01:00
1 parent 264a32431e
commit ec0c068d44
4 files changed
+428 -20

No files matched your search

+228 -6
View File
@@ -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)) {
+5
View File
@@ -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",
+52
View File
@@ -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<typeof ClearMinimapSurfaceDataRequest.jsonSchema>) {
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<typeof ClearMinimapDataRequest.jsonSchema>) {
return new this();
}
static Response = lib.plainJson(Type.Object({
"success": Type.Boolean(),
}));
}
/**
* GetChartTagsRequest: Request existing chart tags from controller storage
*
+143 -14
View File
@@ -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<string>("nauvis");
const [selectedForce, setSelectedForce] = useState<string>("player");
const [surfaceForceData, setSurfaceForceData] = useState<SurfaceForceData>({ 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<NodeJS.Timeout>();
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() {
)}
</Space>
}
extra={
<Space>
<Button
size="small"
loading={isClearingSurface}
onClick={handleClearSurfaceData}
disabled={
!canManageMinimap || !selectedInstance || !selectedSurface
}
>
Clear surface data
</Button>
<Dropdown
menu={{
items: manageDropdownItems,
onClick: handleManageDropdownClick,
}}
trigger={["click"]}
>
<Tooltip
title={
canManageMinimap
? "Danger zone"
: "Requires minimap.manage permission"
}
>
<Button
size="small"
icon={<EllipsisOutlined />}
loading={isClearingAll}
disabled={!canManageMinimap}
/>
</Tooltip>
</Dropdown>
</Space>
}
styles={{
header: { padding: "16px 24px" },
body: { padding: "16px 24px" },
@@ -2142,7 +2273,6 @@ export default function CanvasMinimapPage() {
}
}, 100);
}}
loading={loading}
options={[...instances.values()].map(instance => ({
value: instance.id,
label: instance.name,
@@ -2230,7 +2360,6 @@ export default function CanvasMinimapPage() {
onChange={(checked) => {
toggleTimelapseMode(checked);
}}
loading={loading}
disabled={!selectedInstance}
/>
<Text