Lint and refactor

This commit is contained in:
Danielv123 committed 2025-08-10 00:28:48 +02:00
1 parent ae7a1744ad
commit 2bce506d96
7 files changed
+1233 -880

No files matched your search

+149 -102
View File
@@ -22,7 +22,7 @@ import {
renderTileToPixels,
pixelsToRGBA,
parseRecipeTileBinary,
ParsedRecipeTile
ParsedRecipeTile,
} from "./tile-utils";
const CHUNK_SIZE = 32;
@@ -51,7 +51,9 @@ export class ControllerPlugin extends BaseControllerPlugin {
icon?: any;
last_user?: string;
}>>();
private lastSeenRecipeContent = new Map<string, Map<string, string | undefined>>(); // tileKey -> posKey -> recipe
// tileKey -> posKey -> recipe
private lastSeenRecipeContent = new Map<string, Map<string, string | undefined>>();
private savingTiles: boolean = false;
private savingChartTags = new Set<string>();
private saveInterval: NodeJS.Timeout | null = null;
@@ -109,7 +111,12 @@ export class ControllerPlugin extends BaseControllerPlugin {
}
// Compare two pixel arrays and return changes
private comparePixels(oldPixels: Uint16Array, newPixels: Uint16Array, chunkX: number, chunkY: number): PixelChange[] {
private comparePixels(
oldPixels: Uint16Array,
newPixels: Uint16Array,
chunkX: number,
chunkY: number
): PixelChange[] {
const changes: PixelChange[] = [];
for (let y = 0; y < 32; y++) {
@@ -146,7 +153,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
const groups = new Map<string, PixelChange[]>();
for (const ch of changes) {
const key = `${ch.newColor}_${ch.oldColor}`;
if (!groups.has(key)) groups.set(key, []);
if (!groups.has(key)) { groups.set(key, []); }
groups.get(key)!.push(ch);
}
@@ -184,8 +191,8 @@ export class ControllerPlugin extends BaseControllerPlugin {
app.get("/api/minimap/tile/:instanceId/:surface/:force/:z/:x/:y.png", async (req, res) => {
try {
const { instanceId, surface, force, z, x, y } = req.params;
const tileX = parseInt(x);
const tileY = parseInt(y);
const tileX = parseInt(x, 10);
const tileY = parseInt(y, 10);
const tileName = `${instanceId}_${surface}_${force}_${tileX}_${tileY}.bin`;
const tilePath = path.join(this.tilesPath, tileName);
@@ -237,13 +244,13 @@ export class ControllerPlugin extends BaseControllerPlugin {
// Use renderTileToPixels to get the current state, then convert to image
const currentPixels = await renderTileToPixels(tileData);
// Convert RGB565 pixel data to RGBA buffer for Sharp
// Convert RGB565 pixel data to RGBA buffer for Sharp
const rgbaData = pixelsToRGBA(currentPixels);
const raw = Buffer.from(rgbaData);
// Create and return the image directly
return sharp(raw, {
raw: { width: 256, height: 256, channels: 4 }
raw: { width: 256, height: 256, channels: 4 },
}).png().toBuffer();
}
@@ -285,7 +292,10 @@ export class ControllerPlugin extends BaseControllerPlugin {
}
}
async updateTile(existingTile: Buffer, newChunks: Map<string, { data: ChartData, tick: number }>): Promise<Buffer | undefined> {
async updateTile(
existingTile: Buffer,
newChunks: Map<string, { data: ChartData, tick: number }>
): Promise<Buffer | undefined> {
const existingChunks = new Map<string, { tick: number, data: Buffer }>();
// Parse existing tile data to find existing chunks
@@ -300,49 +310,17 @@ export class ControllerPlugin extends BaseControllerPlugin {
offset += 1;
if (type === 1) { // Chunk
if (offset + 7 > existingTile.length) {
this.logger.error(`Invalid tile data: insufficient chunk header data at offset ${offset}`);
const parseResult = this.parseChunkData(existingTile, offset, existingChunks);
if (!parseResult.success) {
break;
}
const tick = existingTile.readUInt32BE(offset);
offset += 4;
const chunkCoordsByte = existingTile.readUInt8(offset);
offset += 1;
const length = existingTile.readUInt16BE(offset);
offset += 2;
if (offset + length > existingTile.length) {
this.logger.error(`Invalid tile data: insufficient chunk data at offset ${offset}`);
break;
}
const chunkX = chunkCoordsByte >> 4;
const chunkY = chunkCoordsByte & 0x0F;
const chunkName = `${chunkX}_${chunkY}`;
const data = existingTile.slice(offset, offset + length);
offset += length;
existingChunks.set(chunkName, { tick, data });
offset = parseResult.newOffset;
} else if (type === 2) { // Pixels
if (offset + 6 > existingTile.length) {
this.logger.error(`Invalid tile data: insufficient pixel header data at offset ${offset}`);
const parseResult = this.parsePixelData(existingTile, offset);
if (!parseResult.success) {
break;
}
const tick = existingTile.readUInt32BE(offset);
offset += 4;
const pixelCount = existingTile.readUInt16BE(offset);
offset += 2;
const pixelDataLength = pixelCount * 6;
if (offset + pixelDataLength > existingTile.length) {
this.logger.error(`Invalid tile data: insufficient pixel data at offset ${offset}`);
break;
}
// Skip pixel data since we're not rebuilding the file
offset += pixelDataLength;
offset = parseResult.newOffset;
} else {
this.logger.error(`Unknown tile data type: ${type} at offset ${offset}`);
break;
@@ -399,13 +377,71 @@ export class ControllerPlugin extends BaseControllerPlugin {
}
if (!hasChanges) {
return;
return undefined;
}
// Append new data to existing tile (never modify existing data)
return Buffer.concat([existingTile, ...appendBuffers]);
}
private parseChunkData(
existingTile: Buffer,
offset: number,
existingChunks: Map<string, { tick: number, data: Buffer }>
): { success: boolean, newOffset: number } {
if (offset + 7 > existingTile.length) {
this.logger.error(`Invalid tile data: insufficient chunk header data at offset ${offset}`);
return { success: false, newOffset: offset };
}
const tick = existingTile.readUInt32BE(offset);
offset += 4;
const chunkCoordsByte = existingTile.readUInt8(offset);
offset += 1;
const length = existingTile.readUInt16BE(offset);
offset += 2;
if (offset + length > existingTile.length) {
this.logger.error(`Invalid tile data: insufficient chunk data at offset ${offset}`);
return { success: false, newOffset: offset };
}
const chunkX = chunkCoordsByte >> 4;
const chunkY = chunkCoordsByte & 0x0F;
const chunkName = `${chunkX}_${chunkY}`;
const data = existingTile.slice(offset, offset + length);
offset += length;
existingChunks.set(chunkName, { tick, data });
return { success: true, newOffset: offset };
}
private parsePixelData(
existingTile: Buffer,
offset: number
): { success: boolean, newOffset: number } {
if (offset + 6 > existingTile.length) {
this.logger.error(`Invalid tile data: insufficient pixel header data at offset ${offset}`);
return { success: false, newOffset: offset };
}
const tick = existingTile.readUInt32BE(offset);
offset += 4;
const pixelCount = existingTile.readUInt16BE(offset);
offset += 2;
const pixelDataLength = pixelCount * 6;
if (offset + pixelDataLength > existingTile.length) {
this.logger.error(`Invalid tile data: insufficient pixel data at offset ${offset}`);
return { success: false, newOffset: offset };
}
// Skip pixel data since we're not rebuilding the file
offset += pixelDataLength;
return { success: true, newOffset: offset };
}
async loadExistingTagContent() {
try {
if (!await fs.pathExists(this.chartTagsPath)) {
@@ -413,48 +449,59 @@ export class ControllerPlugin extends BaseControllerPlugin {
}
const files = await fs.readdir(this.chartTagsPath);
const tagFiles = files.filter(file => file.endsWith('_chart_tags.json'));
const tagFiles = files.filter(file => file.endsWith("_chart_tags.json"));
for (const file of tagFiles) {
const fileKey = file.replace('_chart_tags.json', '');
const filePath = path.join(this.chartTagsPath, file);
if (!this.lastSeenTagContent.has(fileKey)) {
this.lastSeenTagContent.set(fileKey, new Map());
}
const seenTags = this.lastSeenTagContent.get(fileKey)!;
try {
const content = await fs.readFile(filePath, 'utf-8');
const lines = content.trim().split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const tagData = JSON.parse(line);
if (typeof tagData.tag_number === 'number' && !tagData.end_tick) {
// Only store the latest content for tags that haven't been deleted
seenTags.set(tagData.tag_number, {
position: tagData.position,
text: tagData.text,
icon: tagData.icon,
last_user: tagData.last_user
});
}
} catch (parseErr) {
this.logger.warn(`Failed to parse chart tag line in ${file}: ${parseErr}`);
}
}
this.logger.info(`Loaded ${seenTags.size} existing tag contents from ${file}`);
} catch (readErr) {
this.logger.warn(`Failed to read chart tag file ${file}: ${readErr}`);
}
await this.loadTagContentFromFile(file);
}
} catch (err) {
this.logger.error(`Error loading existing tag content: ${err}`);
}
}
private async loadTagContentFromFile(file: string) {
const fileKey = file.replace("_chart_tags.json", "");
const filePath = path.join(this.chartTagsPath, file);
if (!this.lastSeenTagContent.has(fileKey)) {
this.lastSeenTagContent.set(fileKey, new Map());
}
const seenTags = this.lastSeenTagContent.get(fileKey)!;
try {
const content = await fs.readFile(filePath, "utf-8");
const lines = content.trim().split("\n").filter(line => line.trim());
for (const line of lines) {
this.processTagLine(line, seenTags);
}
this.logger.info(`Loaded ${seenTags.size} existing tag contents from ${file}`);
} catch (readErr) {
this.logger.warn(`Failed to read chart tag file ${file}: ${readErr}`);
}
}
private processTagLine(line: string, seenTags: Map<number, any>) {
try {
const tagData = JSON.parse(line);
const isNumberTag = typeof tagData.tag_number === "number";
const notDeleted = !tagData.end_tick;
if (isNumberTag && notDeleted) {
// Only store the latest content for tags that haven't been deleted
seenTags.set(tagData.tag_number, {
position: tagData.position,
text: tagData.text,
icon: tagData.icon,
last_user: tagData.last_user,
});
}
} catch (parseErr) {
this.logger.warn(`Failed to parse chart tag line: ${parseErr}`);
}
}
/**
* Parses existing recipe jsonl files and builds an in-memory map that tracks
* the latest active (non-ended) recipe for every position. This allows incoming
@@ -496,8 +543,8 @@ export class ControllerPlugin extends BaseControllerPlugin {
private async parseRecipeTileForState(tileKey: string, buffer: Buffer) {
// Derive tile coordinates from key (last two underscore-separated parts)
const parts = tileKey.split("_");
const tileY = parseInt(parts.pop()!); // last
const tileX = parseInt(parts.pop()!); // second last
const tileY = parseInt(parts.pop()!, 10); // last
const tileX = parseInt(parts.pop()!, 10); // second last
const parsed: ParsedRecipeTile = parseRecipeTileBinary(tileX, tileY, buffer, null);
// Populate dictionary mapping (name -> index)
@@ -505,7 +552,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
for (const [id, name] of parsed.dictionary) {
dict.set(name, id);
const currentNext = this.nextRecipeIndex.get(tileKey) ?? 0;
if (id + 1 > currentNext) this.nextRecipeIndex.set(tileKey, id + 1);
if (id + 1 > currentNext) { this.nextRecipeIndex.set(tileKey, id + 1); }
}
// Populate active recipe cache
@@ -583,9 +630,9 @@ export class ControllerPlugin extends BaseControllerPlugin {
const filePath = path.join(this.chartTagsPath, `${fileKey}_chart_tags.json`);
// Convert chart tags to newline-delimited JSON format
const jsonLines = tagsToSave.map(tag => JSON.stringify(tag)).join('\n');
const jsonLines = tagsToSave.map(tag => JSON.stringify(tag)).join("\n");
await fs.appendFile(filePath, jsonLines + '\n');
await fs.appendFile(filePath, `${jsonLines}\n`);
// Mark tags as successfully saved
if (!this.lastSeenTagContent.has(fileKey)) {
@@ -597,7 +644,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
position: tag.position,
text: tag.text,
icon: tag.icon,
last_user: tag.last_user
last_user: tag.last_user,
});
}
@@ -638,11 +685,11 @@ export class ControllerPlugin extends BaseControllerPlugin {
} else if (lastSeenContent) {
// For creation/modification events, check if content has actually changed
const contentUnchanged =
lastSeenContent.position[0] === tag_data.position[0] &&
lastSeenContent.position[1] === tag_data.position[1] &&
lastSeenContent.text === tag_data.text &&
JSON.stringify(lastSeenContent.icon) === JSON.stringify(tag_data.icon) &&
lastSeenContent.last_user === tag_data.last_user;
lastSeenContent.position[0] === tag_data.position[0]
&& lastSeenContent.position[1] === tag_data.position[1]
&& lastSeenContent.text === tag_data.text
&& JSON.stringify(lastSeenContent.icon) === JSON.stringify(tag_data.icon)
&& lastSeenContent.last_user === tag_data.last_user;
if (contentUnchanged) {
return; // Skip if content hasn't changed
@@ -652,7 +699,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
// Add instance_id to the tag data for storage
const enrichedTagData = {
...tag_data,
instance_id
instance_id,
};
// Immediately update last seen content to improve deduplication before save completes
@@ -663,7 +710,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
position: tag_data.position,
text: tag_data.text,
icon: tag_data.icon,
last_user: tag_data.last_user
last_user: tag_data.last_user,
});
}
@@ -678,7 +725,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
// Send live update to connected web clients
const updateEvent = new ChartTagDataEvent(instance_id, tag_data);
// Broadcast to web clients (overriding the formal src/dst routing)
// Broadcast to web clients
this.controller.sendTo(new lib.Address(lib.Address.broadcast, lib.Address.control), updateEvent);
} catch (err) {
@@ -716,7 +763,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
chunk
);
// Broadcast to web clients (overriding the formal src/dst routing)
// Broadcast to web clients
this.controller.sendTo(new lib.Address(lib.Address.broadcast, lib.Address.control), updateEvent);
} catch (err) {
@@ -737,10 +784,10 @@ export class ControllerPlugin extends BaseControllerPlugin {
try {
const tileData = await fs.readFile(tilePath);
const base64Data = tileData.toString('base64');
const base64Data = tileData.toString("base64");
return {
tile_data: base64Data
tile_data: base64Data,
};
} catch (err) {
this.logger.error(`Error reading tile file: ${err}`);
@@ -759,8 +806,8 @@ export class ControllerPlugin extends BaseControllerPlugin {
}
try {
const content = await fs.readFile(filePath, 'utf-8');
const lines = content.trim().split('\n').filter(line => line.trim());
const content = await fs.readFile(filePath, "utf-8");
const lines = content.trim().split("\n").filter(line => line.trim());
const chartTags: ChartTagData[] = [];
for (const line of lines) {
@@ -852,7 +899,7 @@ export class ControllerPlugin extends BaseControllerPlugin {
buffersToWrite = this.encodeSet(tileKey, recipe_data.start_tick ?? 0, px, py, recipe_data.recipe);
}
if (buffersToWrite.length === 0) return;
if (buffersToWrite.length === 0) { return; }
if (!this.recipeSavingQueue.has(tileKey)) {
this.recipeSavingQueue.set(tileKey, []);
+11 -12
View File
@@ -65,11 +65,11 @@ export class InstancePlugin extends BaseInstancePlugin {
async handleTileDataFromLua(data: TileDataIpc) {
try {
const { type, data: rawData, position, tick } = data;
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;
@@ -86,9 +86,9 @@ export class InstancePlugin extends BaseInstancePlugin {
chart
));
return Promise.all(TileDataEvents.map(event => this.instance.sendTo("controller", event)));
TileDataEvents.map(event => this.instance.sendTo("controller", event));
}
} catch (err) {
this.logger.error(`Failed to process tile data from Lua: ${err}`);
}
@@ -96,7 +96,7 @@ export class InstancePlugin extends BaseInstancePlugin {
async handleChartTagDataFromLua(data: ChartTagDataIpc) {
// Validate the chart tag data
if (!data || typeof data.tag_number !== 'number') {
if (!data || typeof data.tag_number !== "number") {
this.logger.error("Invalid chart tag data received");
return;
}
@@ -108,14 +108,14 @@ export class InstancePlugin extends BaseInstancePlugin {
);
// Send to controller
return this.instance.sendTo("controller", chartTagEvent);
this.instance.sendTo("controller", chartTagEvent);
}
async handleRecipeDataFromLua(data: RecipeDataIpc) {
try {
if (!data) {
this.logger.error("Invalid recipe data received")
return
this.logger.error("Invalid recipe data received");
return;
}
const recipeData: RecipeData = {
@@ -133,8 +133,7 @@ export class InstancePlugin extends BaseInstancePlugin {
recipeData
);
return this.instance.sendTo("controller", event);
this.instance.sendTo("controller", event);
} catch (err: unknown) {
this.logger.error(`Failed to process recipe data from Lua: ${err}`);
}
@@ -144,7 +143,7 @@ export class InstancePlugin extends BaseInstancePlugin {
// Initialize the Lua module when the instance starts
try {
await this.sendRcon("/sc if not storage.minimap then storage.minimap = {} end");
// Set up periodic tile updates every 30 seconds
await this.sendRcon(`/sc
if not storage.minimap.update_timer then
@@ -165,4 +164,4 @@ export class InstancePlugin extends BaseInstancePlugin {
// Could handle custom factorio output here if the Lua module sends special messages
}
}
}
}
+65 -65
View File
@@ -35,25 +35,25 @@ 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),
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
*
*
* Contains compressed RGB565 chart data from Factorio's internal mapping system.
* When sent from instance to controller: triggers storage in persistent tile files.
* When sent from controller to web clients: enables real-time minimap updates.
*
* Data flow:
*
* Data flow:
* - Lua module -> Instance plugin -> Controller plugin (for persistence)
* - Controller plugin -> Web UI plugin -> Canvas renderer (for live updates)
*/
@@ -100,12 +100,12 @@ export class TileDataEvent {
/**
* ChartTagDataEvent: Used for both Factorio Instance -> Controller and Controller -> Web Clients
*
*
* Contains chart tag information from Factorio's map tags.
* When sent from instance to controller: triggers storage for tag history and timelapse.
* When sent from controller to web clients: enables real-time tag updates.
*
* Data flow:
*
* Data flow:
* - Lua module -> Instance plugin -> Controller plugin (for persistence)
* - Controller plugin -> Web UI plugin -> Tag renderer (for live updates)
*/
@@ -136,34 +136,34 @@ 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;
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(
constructor(
public instance_id: number,
public recipe_data: RecipeData,
) {}
) {}
static jsonSchema = Type.Object({
"instance_id": Type.Number(),
"recipe_data": RecipeDataSchema,
});
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
);
}
static fromJSON(json: Static<typeof RecipeDataEvent.jsonSchema>) {
return new this(
json.instance_id,
json.recipe_data
);
}
}
/**
* GetRawTileRequest: Request raw tile data from controller storage
*
*
* Returns base64-encoded tile data that can be processed in timelapse mode
* or for initial tile loading when canvas minimap starts up.
*/
@@ -187,7 +187,7 @@ export class GetRawTileRequest {
static jsonSchema = Type.Object({
"instance_id": Type.Number(),
"surface": Type.String(),
"force": Type.String(),
"force": Type.String(),
"tile_x": Type.Number(),
"tile_y": Type.Number(),
"tick": Type.Optional(Type.Number()),
@@ -211,7 +211,7 @@ export class GetRawTileRequest {
/**
* GetChartTagsRequest: Request existing chart tags from controller storage
*
*
* Returns all saved chart tags for a specific instance/surface/force combination.
* Used to load existing chart tags when the web UI starts up or selection changes.
*/
@@ -246,46 +246,46 @@ export class GetChartTagsRequest {
static Response = lib.plainJson(Type.Object({
"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";
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(
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 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 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()]),
}));
}
static Response = lib.plainJson(Type.Object({
"recipe_tile": Type.Union([Type.String(), Type.Null()]),
}));
}
+455 -236
View File
@@ -1,7 +1,7 @@
import * as zlib from "zlib";
// Environment detection
const isNode = typeof Buffer !== 'undefined' && typeof process !== 'undefined' && process.versions?.node;
const isNode = typeof Buffer !== "undefined" && typeof process !== "undefined" && process.versions?.node;
// Cross-platform type for binary data
type BinaryData = Buffer | Uint8Array;
@@ -49,26 +49,41 @@ async function inflateData(data: BinaryData): Promise<Buffer | Uint8Array> {
const { promisify } = await import("util");
const inflateAsync = promisify(zlib.inflate);
return inflateAsync(data as Buffer);
} else {
// Browser environment - use sync version (it's actually async-friendly due to polyfill)
const buffer = data instanceof Buffer ? data : Buffer.from(data);
return zlib.inflateSync(buffer);
}
// Browser environment - use promisified inflate
return new Promise((resolve, reject) => {
try {
const buffer = data instanceof Buffer ? data : Buffer.from(data);
zlib.inflate(buffer, (err, result) => {
if (err) {
reject(err);
} else {
resolve(result);
}
});
} catch (err) {
reject(err);
}
});
}
/**
* Convert RGB565 to RGB888 values using Factorio's exact method
* @param rgb565Value The RGB565 color value to convert
* @returns Tuple of RGB values [r, g, b]
*/
export function rgb565ToRgb888(rgb565Value: number): [number, number, number] {
// This matches ByteColor(uint16_t rgb5) constructor in Factorio source
const r = ((rgb565Value >> 11) & 0x1F) << 3; // 5 bits -> 8 bits
const g = ((rgb565Value >> 5) & 0x3F) << 2; // 6 bits -> 8 bits
const b = (rgb565Value & 0x1F) << 3; // 5 bits -> 8 bits
const r = ((rgb565Value >> 11) & 0x1F) << 3; // 5 bits -> 8 bits
const g = ((rgb565Value >> 5) & 0x3F) << 2; // 6 bits -> 8 bits
const b = (rgb565Value & 0x1F) << 3; // 5 bits -> 8 bits
return [r, g, b];
}
/**
* Render chunk data to get RGB565 pixel array
* @param chunkData The compressed chunk data to render
* @returns RGB565 pixel array for 32x32 chunk
*/
export async function renderChunkToPixels(chunkData: BinaryData): Promise<Uint16Array> {
try {
@@ -89,7 +104,6 @@ export async function renderChunkToPixels(chunkData: BinaryData): Promise<Uint16
return pixels;
} catch (decompressError) {
console.error(`Failed to decompress chunk data: ${decompressError}`);
// Return black pixels on error
return new Uint16Array(32 * 32);
}
@@ -97,6 +111,10 @@ export async function renderChunkToPixels(chunkData: BinaryData): Promise<Uint16
/**
* Extract a 32x32 chunk area from a 256x256 tile
* @param tilePixels The full tile pixel data
* @param chunkX Chunk X coordinate within tile
* @param chunkY Chunk Y coordinate within tile
* @returns RGB565 pixel array for 32x32 chunk
*/
export function extractChunkFromTile(tilePixels: Uint16Array, chunkX: number, chunkY: number): Uint16Array {
const chunkPixels = new Uint16Array(32 * 32);
@@ -117,6 +135,9 @@ export function extractChunkFromTile(tilePixels: Uint16Array, chunkX: number, ch
/**
* Render tile data to RGB565 pixel array (256x256)
* Supports rendering up to a specific tick for timelapse functionality
* @param tileData The binary tile data to render
* @param maxTick Optional maximum tick to render up to
* @returns RGB565 pixel array for 256x256 tile
*/
export async function renderTileToPixels(tileData: BinaryData, maxTick?: number): Promise<Uint16Array> {
const currentPixels = new Uint16Array(256 * 256); // 256x256 tile
@@ -131,89 +152,19 @@ export async function renderTileToPixels(tileData: BinaryData, maxTick?: number)
offset += 1;
if (type === 1) { // Chunk
if (offset + 7 > tileData.length) {
const chunkResult = await processChunkData(
tileData, offset, currentPixels, maxTick
);
if (!chunkResult.success) {
break;
}
const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick
offset += 4;
// Skip this chunk if it's beyond our target tick
if (maxTick !== undefined && tick > maxTick) {
const chunkCoordsByte = readUInt8(tileData, offset);
offset += 1;
const length = readUInt16BE(tileData, offset);
offset += 2;
offset += length; // Skip chunk data
continue;
}
const chunkCoordsByte = readUInt8(tileData, offset);
offset += 1;
const length = readUInt16BE(tileData, offset);
offset += 2;
if (offset + length > tileData.length) {
break;
}
const chunkX = chunkCoordsByte >> 4;
const chunkY = chunkCoordsByte & 0x0F;
const chunkData = sliceData(tileData, offset, offset + length);
try {
const chunkPixels = await renderChunkToPixels(chunkData);
// Update current pixel state
for (let y = 0; y < 32; y++) {
for (let x = 0; x < 32; x++) {
const chunkIndex = y * 32 + x;
const tileX = chunkX * 32 + x;
const tileY = chunkY * 32 + y;
const tileIndex = tileY * 256 + tileX;
currentPixels[tileIndex] = chunkPixels[chunkIndex];
}
}
} catch (decompressError) {
console.error(`Failed to decompress chunk data: ${decompressError}`);
}
offset += length;
offset = chunkResult.newOffset;
} else if (type === 2) { // Pixels
if (offset + 6 > tileData.length) {
const pixelResult = processPixelData(tileData, offset, currentPixels, maxTick);
if (!pixelResult.success) {
break;
}
const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick
offset += 4;
const pixelCount = readUInt16BE(tileData, offset);
offset += 2;
const newColor = readUInt16BE(tileData, offset);
offset += 2;
const oldColor = readUInt16BE(tileData, offset);
offset += 2;
const expectedDataLength = pixelCount * 2; // x,y per pixel
if (offset + expectedDataLength > tileData.length) {
break;
}
// Skip if beyond target tick
if (maxTick !== undefined && tick > maxTick) {
offset += expectedDataLength;
continue;
}
for (let i = 0; i < pixelCount; i++) {
const pixelOffset = offset + i * 2;
const x = readUInt8(tileData, pixelOffset);
const y = readUInt8(tileData, pixelOffset + 1);
if (x < 256 && y < 256) {
currentPixels[y * 256 + x] = newColor;
}
}
offset += expectedDataLength;
offset = pixelResult.newOffset;
} else {
break;
}
@@ -222,9 +173,140 @@ export async function renderTileToPixels(tileData: BinaryData, maxTick?: number)
return currentPixels;
}
/**
* Process chunk data during tile rendering
* @param tileData The binary tile data being processed
* @param offset Current offset in the tile data
* @param currentPixels The pixel array to update
* @param maxTick Optional maximum tick to render up to
* @returns Processing result with success status and new offset
*/
async function processChunkData(
tileData: BinaryData,
offset: number,
currentPixels: Uint16Array,
maxTick?: number
): Promise<{ success: boolean; newOffset: number }> {
if (offset + 7 > tileData.length) {
return { success: false, newOffset: offset };
}
const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick
offset += 4;
// Skip this chunk if it's beyond our target tick
if (maxTick !== undefined && tick > maxTick) {
const chunkCoordsByte = readUInt8(tileData, offset);
offset += 1;
const length = readUInt16BE(tileData, offset);
offset += 2;
offset += length; // Skip chunk data
return { success: true, newOffset: offset };
}
const chunkCoordsByte = readUInt8(tileData, offset);
offset += 1;
const length = readUInt16BE(tileData, offset);
offset += 2;
if (offset + length > tileData.length) {
return { success: false, newOffset: offset };
}
const chunkX = chunkCoordsByte >> 4;
const chunkY = chunkCoordsByte & 0x0F;
const chunkData = sliceData(tileData, offset, offset + length);
try {
const chunkPixels = await renderChunkToPixels(chunkData);
// Update current pixel state
updateTilePixelsWithChunk(currentPixels, chunkPixels, chunkX, chunkY);
} catch (decompressError) {
// Continue processing other chunks on error
}
return { success: true, newOffset: offset + length };
}
/**
* Process pixel data during tile rendering
* @param tileData The binary tile data being processed
* @param offset Current offset in the tile data
* @param currentPixels The pixel array to update
* @param maxTick Optional maximum tick to render up to
* @returns Processing result with success status and new offset
*/
function processPixelData(
tileData: BinaryData,
offset: number,
currentPixels: Uint16Array,
maxTick?: number
): { success: boolean; newOffset: number } {
if (offset + 6 > tileData.length) {
return { success: false, newOffset: offset };
}
const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick
offset += 4;
const pixelCount = readUInt16BE(tileData, offset);
offset += 2;
const newColor = readUInt16BE(tileData, offset);
offset += 2;
const oldColor = readUInt16BE(tileData, offset);
offset += 2;
const expectedDataLength = pixelCount * 2; // x,y per pixel
if (offset + expectedDataLength > tileData.length) {
return { success: false, newOffset: offset };
}
// Skip if beyond target tick
if (maxTick !== undefined && tick > maxTick) {
return { success: true, newOffset: offset + expectedDataLength };
}
for (let i = 0; i < pixelCount; i++) {
const pixelOffset = offset + i * 2;
const x = readUInt8(tileData, pixelOffset);
const y = readUInt8(tileData, pixelOffset + 1);
if (x < 256 && y < 256) {
currentPixels[y * 256 + x] = newColor;
}
}
return { success: true, newOffset: offset + expectedDataLength };
}
/**
* Update tile pixels with chunk data
* @param currentPixels The tile pixel array to update
* @param chunkPixels The chunk pixel data to apply
* @param chunkX Chunk X coordinate within tile
* @param chunkY Chunk Y coordinate within tile
*/
function updateTilePixelsWithChunk(
currentPixels: Uint16Array,
chunkPixels: Uint16Array,
chunkX: number,
chunkY: number
): void {
for (let y = 0; y < 32; y++) {
for (let x = 0; x < 32; x++) {
const chunkIndex = y * 32 + x;
const tileX = chunkX * 32 + x;
const tileY = chunkY * 32 + y;
const tileIndex = tileY * 256 + tileX;
currentPixels[tileIndex] = chunkPixels[chunkIndex];
}
}
}
/**
* Convert RGB565 pixel array to RGBA pixel data for canvas rendering
* Returns Uint8ClampedArray that can be used to create ImageData in browser
* @param pixels The RGB565 pixel array to convert
* @returns RGBA pixel data as Uint8ClampedArray
*/
export function pixelsToRGBA(pixels: Uint16Array): Uint8ClampedArray {
const imageData = new Uint8ClampedArray(256 * 256 * 4);
@@ -245,10 +327,12 @@ export function pixelsToRGBA(pixels: Uint16Array): Uint8ClampedArray {
/**
* Convert RGB565 pixel array to ImageData for canvas rendering (browser only)
* @param pixels The RGB565 pixel array to convert
* @returns ImageData object for canvas rendering
*/
export function pixelsToImageData(pixels: Uint16Array): any {
if (typeof (globalThis as any).ImageData === 'undefined') {
throw new Error('ImageData is not available in this environment. Use pixelsToRGBA() instead.');
if (typeof (globalThis as any).ImageData === "undefined") {
throw new Error("ImageData is not available in this environment. Use pixelsToRGBA() instead.");
}
const rgbaData = pixelsToRGBA(pixels);
return new (globalThis as any).ImageData(rgbaData, 256, 256);
@@ -256,6 +340,8 @@ export function pixelsToImageData(pixels: Uint16Array): any {
/**
* Parse tile data and extract all available ticks for timelapse functionality
* @param tileData The binary tile data to parse
* @returns Array of available ticks sorted in ascending order
*/
export function extractAvailableTicks(tileData: BinaryData): number[] {
const ticks = new Set<number>();
@@ -347,6 +433,8 @@ export interface ParsedTileData {
/**
* Parse tile data into structured change records for efficient timelapse navigation
* @param tileData The binary tile data to parse
* @returns Parsed tile data with chunks, pixel changes, and all ticks
*/
export function parseTileData(tileData: BinaryData): ParsedTileData {
const chunks: ChunkRecord[] = [];
@@ -363,65 +451,17 @@ export function parseTileData(tileData: BinaryData): ParsedTileData {
offset += 1;
if (type === 1) { // Chunk
if (offset + 7 > tileData.length) {
const result = parseChunkRecord(tileData, offset, chunks, ticks);
if (!result.success) {
break;
}
const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick
ticks.add(tick);
offset += 4;
const chunkCoordsByte = readUInt8(tileData, offset);
offset += 1;
const length = readUInt16BE(tileData, offset);
offset += 2;
if (offset + length > tileData.length) {
break;
}
const chunkX = chunkCoordsByte >> 4;
const chunkY = chunkCoordsByte & 0x0F;
const data = sliceData(tileData, offset, offset + length);
chunks.push({
tick,
chunkX,
chunkY,
data
});
offset += length;
offset = result.newOffset;
} else if (type === 2) { // Pixels
if (offset + 6 > tileData.length) {
const result = parsePixelChangeRecord(tileData, offset, pixelChanges, ticks);
if (!result.success) {
break;
}
const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick
ticks.add(tick);
offset += 4;
const pixelCount = readUInt16BE(tileData, offset);
offset += 2;
const newColor = readUInt16BE(tileData, offset);
offset += 2;
const oldColor = readUInt16BE(tileData, offset);
offset += 2;
const expectedDataLength = pixelCount * 2;
if (offset + expectedDataLength > tileData.length) {
break;
}
const changes: PixelChange[] = [];
for (let i = 0; i < pixelCount; i++) {
const pixelOffset = offset + i * 2;
const x = readUInt8(tileData, pixelOffset);
const y = readUInt8(tileData, pixelOffset + 1);
changes.push({ x, y, newColor, oldColor });
}
pixelChanges.push({ tick, changes });
offset += expectedDataLength;
offset = result.newOffset;
} else {
break;
}
@@ -430,34 +470,118 @@ export function parseTileData(tileData: BinaryData): ParsedTileData {
return {
chunks,
pixelChanges,
allTicks: Array.from(ticks).sort((a, b) => a - b)
allTicks: Array.from(ticks).sort((a, b) => a - b),
};
}
/**
* Parse chunk record from tile data
* @param tileData The binary tile data being processed
* @param offset Current offset in the tile data
* @param chunks Array to add the parsed chunk to
* @param ticks Set to add the chunk tick to
* @returns Processing result with success status and new offset
*/
function parseChunkRecord(
tileData: BinaryData,
offset: number,
chunks: ChunkRecord[],
ticks: Set<number>
): { success: boolean; newOffset: number } {
if (offset + 7 > tileData.length) {
return { success: false, newOffset: offset };
}
const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick
ticks.add(tick);
offset += 4;
const chunkCoordsByte = readUInt8(tileData, offset);
offset += 1;
const length = readUInt16BE(tileData, offset);
offset += 2;
if (offset + length > tileData.length) {
return { success: false, newOffset: offset };
}
const chunkX = chunkCoordsByte >> 4;
const chunkY = chunkCoordsByte & 0x0F;
const data = sliceData(tileData, offset, offset + length);
chunks.push({
tick,
chunkX,
chunkY,
data,
});
return { success: true, newOffset: offset + length };
}
/**
* Parse pixel change record from tile data
* @param tileData The binary tile data being processed
* @param offset Current offset in the tile data
* @param pixelChanges Array to add the parsed pixel changes to
* @param ticks Set to add the pixel change tick to
* @returns Processing result with success status and new offset
*/
function parsePixelChangeRecord(
tileData: BinaryData,
offset: number,
pixelChanges: PixelChangeRecord[],
ticks: Set<number>
): { success: boolean; newOffset: number } {
if (offset + 6 > tileData.length) {
return { success: false, newOffset: offset };
}
const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick
ticks.add(tick);
offset += 4;
const pixelCount = readUInt16BE(tileData, offset);
offset += 2;
const newColor = readUInt16BE(tileData, offset);
offset += 2;
const oldColor = readUInt16BE(tileData, offset);
offset += 2;
const expectedDataLength = pixelCount * 2;
if (offset + expectedDataLength > tileData.length) {
return { success: false, newOffset: offset };
}
const changes: PixelChange[] = [];
for (let i = 0; i < pixelCount; i++) {
const pixelOffset = offset + i * 2;
const x = readUInt8(tileData, pixelOffset);
const y = readUInt8(tileData, pixelOffset + 1);
changes.push({ x, y, newColor, oldColor });
}
pixelChanges.push({ tick, changes });
return { success: true, newOffset: offset + expectedDataLength };
}
/**
* Apply chunk data to RGB565 pixel array
* @param pixels The tile pixel array to update
* @param chunkRecord The chunk record to apply
*/
export async function applyChunkToPixels(pixels: Uint16Array, chunkRecord: ChunkRecord): Promise<void> {
try {
const chunkPixels = await renderChunkToPixels(chunkRecord.data);
// Update tile pixels with chunk data
for (let y = 0; y < 32; y++) {
for (let x = 0; x < 32; x++) {
const chunkIndex = y * 32 + x;
const tileX = chunkRecord.chunkX * 32 + x;
const tileY = chunkRecord.chunkY * 32 + y;
const tileIndex = tileY * 256 + tileX;
pixels[tileIndex] = chunkPixels[chunkIndex];
}
}
updateTilePixelsWithChunk(pixels, chunkPixels, chunkRecord.chunkX, chunkRecord.chunkY);
} catch (err) {
console.error(`Failed to apply chunk ${chunkRecord.chunkX},${chunkRecord.chunkY}:`, err);
// Error applying chunk - continue without this chunk
}
}
/**
* Clear chunk area to black (for rewinding past chunk creation)
* @param pixels The tile pixel array to update
* @param chunkRecord The chunk record to clear
*/
export function clearChunkInPixels(pixels: Uint16Array, chunkRecord: ChunkRecord): void {
for (let y = 0; y < 32; y++) {
@@ -472,6 +596,8 @@ export function clearChunkInPixels(pixels: Uint16Array, chunkRecord: ChunkRecord
/**
* Apply pixel changes to RGB565 pixel array (forward direction)
* @param pixels The tile pixel array to update
* @param changes Array of pixel changes to apply
*/
export function applyPixelChanges(pixels: Uint16Array, changes: PixelChange[]): void {
for (const change of changes) {
@@ -482,6 +608,8 @@ export function applyPixelChanges(pixels: Uint16Array, changes: PixelChange[]):
/**
* Revert pixel changes from RGB565 pixel array (backward direction)
* @param pixels The tile pixel array to update
* @param changes Array of pixel changes to revert
*/
export function revertPixelChanges(pixels: Uint16Array, changes: PixelChange[]): void {
for (const change of changes) {
@@ -493,6 +621,10 @@ export function revertPixelChanges(pixels: Uint16Array, changes: PixelChange[]):
/**
* Render tile data incrementally from one tick to another
* Much more efficient than re-rendering from scratch
* @param parsedData The parsed tile data
* @param currentPixels The current pixel state
* @param fromTick The starting tick
* @param toTick The target tick
*/
export async function renderTileIncremental(
parsedData: ParsedTileData,
@@ -509,38 +641,70 @@ export async function renderTileIncremental(
const forward = toTick > fromTick;
if (forward) {
// Apply changes forward from fromTick to toTick
// Apply chunks that occur in this range
for (const chunk of chunks) {
if (chunk.tick > fromTick && chunk.tick <= toTick) {
await applyChunkToPixels(currentPixels, chunk);
}
}
// Apply pixel changes that occur in this range
for (const record of pixelChanges) {
if (record.tick > fromTick && record.tick <= toTick) {
applyPixelChanges(currentPixels, record.changes);
}
}
await applyChangesForward(chunks, pixelChanges, currentPixels, fromTick, toTick);
} else {
// Revert changes backward from fromTick to toTick
await applyChangesBackward(chunks, pixelChanges, currentPixels, fromTick, toTick);
}
}
// Revert pixel changes in reverse order
for (let i = pixelChanges.length - 1; i >= 0; i--) {
const record = pixelChanges[i];
if (record.tick <= fromTick && record.tick > toTick) {
revertPixelChanges(currentPixels, record.changes);
}
/**
* Apply changes in forward direction
* @param chunks Array of chunk records to process
* @param pixelChanges Array of pixel change records to process
* @param currentPixels The pixel array to update
* @param fromTick The starting tick
* @param toTick The target tick
*/
async function applyChangesForward(
chunks: ChunkRecord[],
pixelChanges: PixelChangeRecord[],
currentPixels: Uint16Array,
fromTick: number,
toTick: number
): Promise<void> {
// Apply chunks that occur in this range
for (const chunk of chunks) {
if (chunk.tick > fromTick && chunk.tick <= toTick) {
await applyChunkToPixels(currentPixels, chunk);
}
}
// Revert chunks (clear to black if we're going before their creation time)
for (let i = chunks.length - 1; i >= 0; i--) {
const chunk = chunks[i];
if (chunk.tick <= fromTick && chunk.tick > toTick) {
clearChunkInPixels(currentPixels, chunk);
}
// Apply pixel changes that occur in this range
for (const record of pixelChanges) {
if (record.tick > fromTick && record.tick <= toTick) {
applyPixelChanges(currentPixels, record.changes);
}
}
}
/**
* Apply changes in backward direction
* @param chunks Array of chunk records to process
* @param pixelChanges Array of pixel change records to process
* @param currentPixels The pixel array to update
* @param fromTick The starting tick
* @param toTick The target tick
*/
async function applyChangesBackward(
chunks: ChunkRecord[],
pixelChanges: PixelChangeRecord[],
currentPixels: Uint16Array,
fromTick: number,
toTick: number
): Promise<void> {
// Revert pixel changes in reverse order
for (let i = pixelChanges.length - 1; i >= 0; i--) {
const record = pixelChanges[i];
if (record.tick <= fromTick && record.tick > toTick) {
revertPixelChanges(currentPixels, record.changes);
}
}
// Revert chunks (clear to black if we're going before their creation time)
for (let i = chunks.length - 1; i >= 0; i--) {
const chunk = chunks[i];
if (chunk.tick <= fromTick && chunk.tick > toTick) {
clearChunkInPixels(currentPixels, chunk);
}
}
}
@@ -557,12 +721,17 @@ export interface ParsedRecipeTile {
/**
* Parse binary recipe tile data and return timeline ticks and active recipes at target tick
* @param tileX Tile X coordinate for world position calculation
* @param tileY Tile Y coordinate for world position calculation
* @param tileY Tile Y coordinate for world position calculation
* @param buf Binary recipe tile data
* @param targetTick Target tick for filtering (null = latest state)
* @returns Parsed recipe data with ticks and active recipes
*/
export function parseRecipeTileBinary(tileX: number, tileY: number, buf: BinaryData, targetTick: number | null): ParsedRecipeTile {
export function parseRecipeTileBinary(
tileX: number,
tileY: number,
buf: BinaryData,
targetTick: number | null
): ParsedRecipeTile {
const ticks: number[] = [];
const dict = new Map<number, string>();
const active = new Map<string, string>();
@@ -574,60 +743,110 @@ export function parseRecipeTileBinary(tileX: number, tileY: number, buf: BinaryD
offset += 1;
if (type === 0) { // Dictionary entry
const idx = readUInt16BE(buf, offset);
offset += 2;
const len = readUInt8(buf, offset);
offset += 1;
const nameBytes = sliceData(buf, offset, offset + len);
offset += len;
// Convert to string using cross-platform method
let recipeName: string;
if (isNode && nameBytes instanceof Buffer) {
recipeName = nameBytes.toString('utf-8');
} else {
recipeName = new TextDecoder().decode(nameBytes as Uint8Array);
}
dict.set(idx, recipeName);
const result = parseDictionaryEntry(buf, offset, dict);
offset = result.newOffset;
} else if (type === 1 || type === 2) { // Set recipe (1) or Clear recipe (2)
const sec = readUInt32BE(buf, offset);
offset += 4;
const px = readUInt8(buf, offset);
offset += 1;
const py = readUInt8(buf, offset);
offset += 1;
let idx: number | undefined;
if (type === 1) {
idx = readUInt16BE(buf, offset);
offset += 2;
}
const tickVal = sec * 60;
ticks.push(tickVal);
if (sec > targetSec) {
// Future to target time – we can skip rest if file is chronological
// but to be safe just continue without applying
continue;
}
const worldX = tileX * 256 + px;
const worldY = tileY * 256 + py;
const posKey = `${worldX},${worldY}`;
if (type === 1 && idx !== undefined) {
const name = dict.get(idx);
if (name) active.set(posKey, name);
} else if (type === 2) {
active.delete(posKey);
}
const result = parseRecipeEntry(
buf, offset, type, tileX, tileY, ticks, active, targetSec, dict
);
offset = result.newOffset;
} else {
console.warn("Unknown recipe record type", type);
// Unknown recipe record type - stop parsing
break;
}
}
return { ticks, activeRecipes: active, dictionary: dict };
}
}
/**
* Parse dictionary entry from recipe tile
* @param buf The binary recipe tile data
* @param offset Current offset in the data
* @param dict Dictionary map to add the entry to
* @returns Processing result with new offset
*/
function parseDictionaryEntry(
buf: BinaryData,
offset: number,
dict: Map<number, string>
): { newOffset: number } {
const idx = readUInt16BE(buf, offset);
offset += 2;
const len = readUInt8(buf, offset);
offset += 1;
const nameBytes = sliceData(buf, offset, offset + len);
offset += len;
// Convert to string using cross-platform method
let recipeName: string;
if (isNode && nameBytes instanceof Buffer) {
recipeName = nameBytes.toString("utf-8");
} else {
recipeName = new TextDecoder().decode(nameBytes as Uint8Array);
}
dict.set(idx, recipeName);
return { newOffset: offset };
}
/**
* Parse recipe entry from recipe tile
* @param buf The binary recipe tile data
* @param offset Current offset in the data
* @param type Entry type (1 = set recipe, 2 = clear recipe)
* @param tileX Tile X coordinate for world position calculation
* @param tileY Tile Y coordinate for world position calculation
* @param ticks Array to add the entry tick to
* @param active Map of active recipes to update
* @param targetSec Target second for filtering
* @param dict Dictionary map for recipe name lookup
* @returns Processing result with new offset
*/
function parseRecipeEntry(
buf: BinaryData,
offset: number,
type: number,
tileX: number,
tileY: number,
ticks: number[],
active: Map<string, string>,
targetSec: number,
dict: Map<number, string>
): { newOffset: number } {
const sec = readUInt32BE(buf, offset);
offset += 4;
const px = readUInt8(buf, offset);
offset += 1;
const py = readUInt8(buf, offset);
offset += 1;
let idx: number | undefined;
if (type === 1) {
idx = readUInt16BE(buf, offset);
offset += 2;
}
const tickVal = sec * 60;
ticks.push(tickVal);
if (sec > targetSec) {
// Future to target time – skip without applying
return { newOffset: offset };
}
const worldX = tileX * 256 + px;
const worldY = tileY * 256 + py;
const posKey = `${worldX},${worldY}`;
if (type === 1 && idx !== undefined) {
const name = dict.get(idx);
if (name) {
active.set(posKey, name);
}
} else if (type === 2) {
active.delete(posKey);
}
return { newOffset: offset };
}
File diff suppressed because it is too large. Load diff
+24 -28
View File
@@ -38,21 +38,16 @@ export default function MinimapPage() {
}, []);
const loadSurfaceForceData = async () => {
try {
const response = await fetch(`${window.location.origin}/api/minimap/surfaces`);
console.log(response);
const data = await response.json();
setSurfaceForceData(data);
// 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 load surface/force data:", err);
const response = await fetch(`${window.location.origin}/api/minimap/surfaces`);
const data = await response.json();
setSurfaceForceData(data);
// 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]);
}
};
@@ -66,8 +61,8 @@ export default function MinimapPage() {
<div style={{ padding: "20px" }}>
<Row gutter={[16, 16]}>
<Col span={24}>
<Card
title="Factorio Instance Minimap"
<Card
title="Factorio Instance Minimap"
extra={
<Space wrap>
<Select
@@ -78,7 +73,7 @@ export default function MinimapPage() {
loading={loading}
options={[...instances.values()].map(instance => ({
value: instance.id,
label: instance.name
label: instance.name,
}))}
/>
<Select
@@ -109,10 +104,10 @@ export default function MinimapPage() {
}
>
{selectedInstance && mapBounds ? (
<div
style={{
height: "700px",
width: "100%"
<div
style={{
height: "700px",
width: "100%",
}}
className="minimap-container"
>
@@ -137,6 +132,7 @@ export default function MinimapPage() {
>
{/* Chart layer with surface and force data */}
<TileLayer
// eslint-disable-next-line max-len
url={`${window.location.origin}/api/minimap/chart/${selectedInstance}/${selectedSurface}/${selectedForce}/{z}/{x}/{y}.png?refresh=${refreshTiles}`}
maxNativeZoom={10}
minNativeZoom={10}
@@ -146,11 +142,11 @@ export default function MinimapPage() {
</MapContainer>
</div>
) : (
<div
style={{
height: "400px",
display: "flex",
alignItems: "center",
<div
style={{
height: "400px",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f5f5f5",
border: "1px dashed #d9d9d9",
@@ -168,4 +164,4 @@ export default function MinimapPage() {
</Row>
</div>
);
}
}
+3 -3
View File
@@ -12,12 +12,12 @@ export class WebPlugin extends BaseWebPlugin {
this.pages = [
{
path: "/minimap",
sidebarName: "Minimap",
sidebarName: "Minimap",
permission: "minimap.view",
content: <CanvasMinimapPage />,
},
];
// Handle tile update events from the controller
this.control.handle(TileDataEvent, this.handleTileDataEvent.bind(this));
this.control.handle(ChartTagDataEvent, this.handleChartTagDataEvent.bind(this));
@@ -79,4 +79,4 @@ export class WebPlugin extends BaseWebPlugin {
}
this.recipeUpdateCallbacks.splice(index, 1);
}
}
}