UNPKG

iobroker.roborock

Version:
370 lines (348 loc) 11.6 kB
/** * Backend V1 map renderer: draws via @napi-rs/canvas. Used only by drawMapV1 (single source). */ import type { Canvas, Image } from "@napi-rs/canvas"; import { createCanvas } from "@napi-rs/canvas"; import type { DrawCarpetInput, DrawObstacleInput, DrawPredictedPathInput, DrawRect, DrawRoomLabelInput, DrawVirtualWallInput, DrawZoneRectInput, IMapRenderer, } from "../../../common/mapDrawing/types"; import { VISUAL_BLOCK_SIZE } from "../../../common/mapDrawing/constants"; /** Node canvas 2D context (no DOM types). */ export interface NodeCanvasContext2D { canvas: Canvas; drawImage(image: Canvas | Image, dx: number, dy: number, dw?: number, dh?: number): void; getImageData(sx: number, sy: number, sw: number, sh: number): ImageData; putImageData(imagedata: ImageData, dx: number, dy: number): void; fillStyle: string; strokeStyle: string; lineWidth: number; lineCap: string; lineJoin: string; globalAlpha: number; imageSmoothingEnabled: boolean; antialias?: string; save(): void; restore(): void; beginPath(): void; moveTo(x: number, y: number): void; lineTo(x: number, y: number): void; stroke(): void; fill(): void; fillRect(x: number, y: number, w: number, h: number): void; strokeRect(x: number, y: number, w: number, h: number): void; arc(x: number, y: number, radius: number, startAngle: number, endAngle: number): void; setLineDash(segments: number[]): void; translate(x: number, y: number): void; rotate(angle: number): void; font: string; textAlign: string; textBaseline: string; strokeText(text: string, x: number, y: number): void; fillText(text: string, x: number, y: number): void; } export interface CanvasMapRendererOptions { ctx: NodeCanvasContext2D; robotImage: Canvas | Image; chargerImage: Canvas | Image; goToPinImage: Canvas | Image; loadObstacleImage?(suffix: string, model?: string): Promise<Canvas | Image | null>; model?: string; /** Log warnings (e.g. missing obstacle image). */ logWarn?(msg: string): void; } export class CanvasMapRenderer implements IMapRenderer { private ctx: NodeCanvasContext2D; private robotImage: Canvas | Image; private chargerImage: Canvas | Image; private goToPinImage: Canvas | Image; private loadObstacleImage?: (suffix: string, model?: string) => Promise<Canvas | Image | null>; private model?: string; private logWarn?: (msg: string) => void; private cleanSnapshotBase64: string | null = null; private carpetSprite: Canvas | null = null; constructor(options: CanvasMapRendererOptions) { this.ctx = options.ctx; this.robotImage = options.robotImage; this.chargerImage = options.chargerImage; this.goToPinImage = options.goToPinImage; this.loadObstacleImage = options.loadObstacleImage; this.model = options.model; this.logWarn = options.logWarn; } getCleanSnapshot(): string | null { if (this.cleanSnapshotBase64 === null) { this.cleanSnapshotBase64 = (this.ctx.canvas as Canvas).toDataURL(); } return this.cleanSnapshotBase64; } private createCarpetSprite(): Canvas { if (this.carpetSprite) return this.carpetSprite; const spriteCanvas = createCanvas(VISUAL_BLOCK_SIZE, VISUAL_BLOCK_SIZE); const ctx = spriteCanvas.getContext("2d") as unknown as NodeCanvasContext2D; ctx.imageSmoothingEnabled = false; if ("antialias" in ctx) ctx.antialias = "none"; ctx.fillStyle = "rgba(0, 0, 0, 0.4)"; const STRIDE = 3; for (let dx = 0; dx < VISUAL_BLOCK_SIZE; dx++) { for (let dy = 0; dy < VISUAL_BLOCK_SIZE; dy++) { if ((dx + dy) % STRIDE === 2) ctx.fillRect(dx, dy, 1, 1); } } this.carpetSprite = spriteCanvas; return spriteCanvas; } private drawPathSegments( segments: { x: number; y: number }[][], stroke: string, lineWidth: number, opacity: number, dashed: boolean ): void { if (!segments?.length) return; const tempCanvas = createCanvas(this.ctx.canvas.width, this.ctx.canvas.height); const tempCtx = tempCanvas.getContext("2d"); tempCtx.strokeStyle = stroke; tempCtx.lineWidth = lineWidth; tempCtx.lineCap = "round"; tempCtx.lineJoin = "round"; tempCtx.setLineDash(dashed ? [VISUAL_BLOCK_SIZE, 2 * VISUAL_BLOCK_SIZE] : []); tempCtx.beginPath(); for (const segment of segments) { if (segment.length > 0) { tempCtx.moveTo(segment[0].x, segment[0].y); for (let i = 1; i < segment.length; i++) tempCtx.lineTo(segment[i].x, segment[i].y); } } tempCtx.stroke(); this.ctx.save(); this.ctx.globalAlpha = opacity; this.ctx.drawImage(tempCanvas as unknown as Canvas, 0, 0); this.ctx.restore(); } drawFloor(rects: DrawRect[]): void { const width = this.ctx.canvas.width; const height = this.ctx.canvas.height; const imgData = this.ctx.getImageData(0, 0, width, height); const data = imgData.data; for (const r of rects) { const [fr, fg, fb, fa] = parseRgba(r.fill); for (let dy = 0; dy < r.h; dy++) { const py = r.y + dy; if (py >= height) continue; for (let dx = 0; dx < r.w; dx++) { const px = r.x + dx; if (px >= width) continue; const idx = (py * width + px) * 4; data[idx] = fr; data[idx + 1] = fg; data[idx + 2] = fb; data[idx + 3] = fa; } } } this.ctx.putImageData(imgData, 0, 0); } drawSegmentRects(rects: DrawRect[]): void { const width = this.ctx.canvas.width; const height = this.ctx.canvas.height; const imgData = this.ctx.getImageData(0, 0, width, height); const data = imgData.data; for (const r of rects) { const [fr, fg, fb, fa] = parseRgba(r.fill); for (let dy = 0; dy < r.h; dy++) { const py = r.y + dy; if (py >= height) continue; for (let dx = 0; dx < r.w; dx++) { const px = r.x + dx; if (px >= width) continue; const idx = (py * width + px) * 4; data[idx] = fr; data[idx + 1] = fg; data[idx + 2] = fb; data[idx + 3] = fa; } } } this.ctx.putImageData(imgData, 0, 0); } drawCarpet(input: DrawCarpetInput): void { if (!input.positions.length) return; this.ctx.imageSmoothingEnabled = false; if ("antialias" in this.ctx) this.ctx.antialias = "none"; const sprite = this.createCarpetSprite(); for (const pos of input.positions) { this.ctx.drawImage(sprite, pos.x, pos.y); } } drawPath(input: { segments: { x: number; y: number }[][]; stroke: string; lineWidth: number; opacity?: number; dashed?: boolean }): void { this.drawPathSegments( input.segments, input.stroke, input.lineWidth, input.opacity ?? 1, input.dashed ?? false ); } drawRobot(input: { x: number; y: number; angle: number }): void { const drawAngle = -input.angle + 90; const robotSize = VISUAL_BLOCK_SIZE * 5; this.ctx.save(); this.ctx.translate(input.x, input.y); this.ctx.rotate((drawAngle * Math.PI) / 180); this.ctx.drawImage(this.robotImage, -robotSize / 2, -robotSize / 2, robotSize, robotSize); this.ctx.restore(); } drawCharger(input: { x: number; y: number }): void { const w = VISUAL_BLOCK_SIZE * 3; const h = VISUAL_BLOCK_SIZE * 3; this.ctx.drawImage(this.chargerImage, input.x - w / 2, input.y - h / 2, w, h); } drawGoToPin(input: { x: number; y: number }): void { const pinW = VISUAL_BLOCK_SIZE * 3; const pinH = (pinW / 29) * 24; this.ctx.drawImage(this.goToPinImage, input.x - pinW / 2, input.y - (pinH + VISUAL_BLOCK_SIZE / 2), pinW, pinH); } async drawObstacles(items: DrawObstacleInput[]): Promise<void> { if (!items.length) return; const suffixMap: Record<number, string> = { [-99]: "99", 0: "0", 1: "1", 2: "2", 3: "3", 4: "3", 5: "5_cn", 9: "9", 10: "10", 18: "18", 25: "25", 26: "26", 27: "26", 34: "10", 42: "18", 48: "48", 49: "49", 50: "49", 51: "51", 54: "54", 65: "65", 67: "67", 69: "69", 70: "70", 99: "99", }; const radius = VISUAL_BLOCK_SIZE * 3.5; const size = VISUAL_BLOCK_SIZE * 5; for (const ob of items) { const suffix = typeof ob.typeOrSuffix === "number" ? (suffixMap[ob.typeOrSuffix] ?? "18") : ob.typeOrSuffix; this.ctx.beginPath(); this.ctx.arc(ob.x, ob.y, radius, 0, 2 * Math.PI); this.ctx.fillStyle = "rgba(100, 100, 100, 0.2)"; this.ctx.fill(); this.ctx.lineWidth = 0.5; this.ctx.strokeStyle = "white"; this.ctx.stroke(); if (ob.imageHref) { // Pre-loaded image passed (e.g. data URL or buffer loaded by caller) try { const img = await loadImageFromData(ob.imageHref); if (img) this.ctx.drawImage(img, ob.x - size / 2, ob.y - size / 2, size, size); } catch { // ignore } } else if (this.loadObstacleImage) { const img = await this.loadObstacleImage(suffix, this.model); if (img) this.ctx.drawImage(img, ob.x - size / 2, ob.y - size / 2, size, size); else if (this.logWarn) this.logWarn(`Could not find obstacle image for suffix ${suffix}`); } } } drawRoomLabels(labels: DrawRoomLabelInput[]): void { if (!labels.length) return; this.ctx.font = `bold ${VISUAL_BLOCK_SIZE * 6}px Arial`; this.ctx.textAlign = "center"; this.ctx.textBaseline = "middle"; this.ctx.lineWidth = 1; this.ctx.strokeStyle = "white"; this.ctx.fillStyle = "black"; for (const l of labels) { this.ctx.strokeText(l.text, l.x, l.y); this.ctx.fillText(l.text, l.x, l.y); } } drawActiveZones(zones: DrawZoneRectInput[]): void { for (const z of zones) { this.ctx.fillStyle = z.fill; this.ctx.fillRect(z.x, z.y, z.w, z.h); this.ctx.strokeStyle = z.stroke; this.ctx.lineWidth = 4; this.ctx.strokeRect(z.x, z.y, z.w, z.h); } } drawRestrictedZones(zones: DrawZoneRectInput[], virtualWalls: DrawVirtualWallInput[]): void { for (const z of zones) { this.ctx.fillStyle = z.fill; this.ctx.fillRect(z.x, z.y, z.w, z.h); this.ctx.strokeStyle = z.stroke; this.ctx.lineWidth = (1 * VISUAL_BLOCK_SIZE) / 2; this.ctx.strokeRect(z.x, z.y, z.w, z.h); } for (const w of virtualWalls) { this.ctx.strokeStyle = w.stroke; this.ctx.lineWidth = w.lineWidth; this.ctx.beginPath(); this.ctx.moveTo(w.x1, w.y1); this.ctx.lineTo(w.x2, w.y2); this.ctx.stroke(); } } drawPredictedPath(input: DrawPredictedPathInput): void { if (!input.points.length) return; this.ctx.lineWidth = input.lineWidth; this.ctx.strokeStyle = input.stroke; this.ctx.setLineDash(input.dashArray); this.ctx.lineCap = "round"; this.ctx.beginPath(); let lastX = -1, lastY = -1; input.points.forEach((p, index) => { if (index === 0) { this.ctx.fillStyle = "rgba(255, 255, 255, 1)"; this.ctx.fillRect(p.x, p.y, (1 * VISUAL_BLOCK_SIZE) / 2, (1 * VISUAL_BLOCK_SIZE) / 2); this.ctx.moveTo(p.x, p.y); } else if (p.x !== lastX || p.y !== lastY) { this.ctx.lineTo(p.x, p.y); } lastX = p.x; lastY = p.y; }); this.ctx.stroke(); this.ctx.setLineDash([]); this.ctx.lineCap = "butt"; } } function parseRgba(css: string): [number, number, number, number] { const m = css.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/); if (m) { const a = m[4] != null ? Math.round(parseFloat(m[4]) * 255) : 255; return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10), a]; } return [0, 0, 0, 255]; } async function loadImageFromData(data: string): Promise<Canvas | Image | null> { // data can be base64 data URL or path; @napi-rs/canvas loadImage can accept buffer const { loadImage } = await import("@napi-rs/canvas"); if (data.startsWith("data:")) { const base64 = data.replace(/^data:image\/\w+;base64,/, ""); const buf = Buffer.from(base64, "base64"); return await loadImage(buf); } return await loadImage(data); }