UNPKG

iobroker.roborock

Version:
823 lines (715 loc) 28.6 kB
import { createCanvas, Image, loadImage } from "@napi-rs/canvas"; import { robotToPixel } from "../../../common/coordTransformation"; import { B01Area, B01DeviceStatus, B01MapData, B01Point } from "./types"; // --- CONSTANTS from original Roborock modules --- import { APP_COLORS, CleanModeType, JOB_STATUS, PALETTE, ROOM_TYPE_MAP, SC_MAP_COLORS, SCCleanType, SUBTITLE_STATUS } from "./constants"; export class MapBuilder { private readonly SCALE = 8; // High Res output private assets: { robot: Record<string, Image>; charger: Record<string, Image>; rooms: Record<string, Image> } | null = null; private assetsLoaded = false; private adapter: any; // Optional reference to adapter for logging private offsetCache = new Map<any, { x: number; y: number }>(); constructor(adapter?: any) { this.adapter = adapter; } /** @param needRobotCharger If false (e.g. history map), do not log errors for missing robot/charger assets. */ private async loadAssets(model?: string, duid?: string, needRobotCharger: boolean = true): Promise<void> { if (this.assetsLoaded) return; this.assets = { rooms: {}, robot: {}, charger: {} }; try { // assets/<model> from adapter file storage; model comes from the device (e.g. roborock.vacuum.sc05) const searchPaths: string[] = []; if (model && this.adapter) { const modelPath = `assets/${model}`; try { await this.adapter.readDirAsync(this.adapter.name, modelPath); searchPaths.push(modelPath); } catch { /* dir missing */ } // Fallback namespace (e.g. files/roborock/ when instance is roborock.0) if (searchPaths.length === 0 && this.adapter.name && this.adapter.name.includes(".")) { try { await this.adapter.readDirAsync(this.adapter.name.split(".")[0], modelPath); searchPaths.push(modelPath); } catch { /* dir missing */ } } // Always add path so we can try both namespaces with subdirsToTry even if readDirAsync failed for both if (searchPaths.length === 0) searchPaths.push(modelPath); } const storageNamespaces = this.adapter.name && this.adapter.name.includes(".") ? [this.adapter.name, this.adapter.name.split(".")[0]] : [this.adapter.name]; const subdirsToTry = ["drawable-mdpi", "drawable-hdpi", "drawable-xhdpi", "drawable-xxhdpi", "raw"]; const findAssetInPaths = async (candidates: string[]) => { for (const dir of searchPaths) { for (const ns of storageNamespaces) { for (const c of candidates) { const rootPath = `${dir}/${c}`; try { if (await this.adapter.fileExistsAsync(ns, rootPath)) { const res = await this.adapter.readFileAsync(ns, rootPath); const buf = typeof res === "object" && res !== null && "file" in res ? (res as { file: Buffer }).file : Buffer.from(res as ArrayBuffer); return await loadImage(buf); } } catch { /* try next */ } } // Prefer subdirs from listing; if readDirAsync fails (e.g. other namespace), still try subdirsToTry so we don't skip file checks let subdirs = subdirsToTry; try { const entries = await this.adapter.readDirAsync(ns, dir); const dirs: string[] = Array.isArray(entries) ? entries : (entries as { dirs?: string[] }).dirs ?? []; const fromList = dirs.filter(d => d.startsWith("drawable-") || d === "raw"); if (fromList.length > 0) subdirs = fromList; } catch { /* use subdirsToTry for this namespace */ } for (const subdir of subdirs) { for (const c of candidates) { const p = `${dir}/${subdir}/${c}`; try { if (await this.adapter.fileExistsAsync(ns, p)) { const res = await this.adapter.readFileAsync(ns, p); const buf = typeof res === "object" && res !== null && "file" in res ? (res as { file: Buffer }).file : Buffer.from(res as ArrayBuffer); return await loadImage(buf); } } catch { /* try next */ } } } } } return null; }; // --- 1. Load Robot Assets (theme_dark preference) --- // Load B01-specific robot asset const b01RobotImg = await findAssetInPaths(["src_sc_components_resource_images_common_robot.png"]); if (b01RobotImg) this.assets!.robot["b01_fixed"] = b01RobotImg; const robotStates = ["charging", "cleaning", "error", "sleeping", "offline", "waiting"]; for (const state of robotStates) { const candidates = [ // sc01 style `src_sc_components_resource_images_common_robot_${state === "charging" ? "rechage" : (state === "error" ? "fault" : state)}.png`, `src_sc_components_resource_images_common_robot_${state === "charging" ? "rechage" : (state === "error" ? "fault" : state)}_dark.png`, // a147 style `projects_comroborocktanos_theme_dark_resources_robot_icon_${state}.png`, `projects_comroborocktanos_resources_robot_icon_${state}.png`, `robot_icon_${state}.png` ]; const img = await findAssetInPaths(candidates); if (img) this.assets!.robot[state] = img; } // Robot Default if (!Object.keys(this.assets!.robot).length) { const fallback = await findAssetInPaths([ "projects_comroborocktanos_theme_dark_resources_robot_icon_cleaning.png", "projects_comroborocktanos_resources_robot_icon_cleaning.png" ]); if (fallback) this.assets!.robot["cleaning"] = fallback; } // --- 2. Load Charger Assets --- const chargerCandidates = [ // Android style asset "src_sc_components_resource_images_common_charge_android.png", // sc01 style "src_sc_components_resource_images_home_home_charge_charging.png", "src_sc_components_resource_images_home_home_charge_charging_dark.png", "src_sc_components_resource_images_home_home_charge_reccharg.png", // a147 style "projects_comroborocktanos_resources_charger_special.png", "projects_comroborocktanos_resources_charger_bubble_special.png", "projects_comroborocktanos_resources_charger_bubble_normal.png", "projects_comroborocktanos_theme_dark_resources_ic_home_topazs_charge_light.png" ]; const chargerImg = await findAssetInPaths(chargerCandidates); if (chargerImg) this.assets!.charger["normal"] = chargerImg; // --- 3. Load Room Icons (Bubble Tags) --- // The user has `roomtag_bubble_X.png`. We load specific IDs (1-32 is a safe range for room types) // plus the mapped names from ROOM_TYPE_MAP just in case. const roomIdsToLoad = Array.from({ length: 32 }, (_, i) => i + 1); // 1 to 32 for (const id of roomIdsToLoad) { // IMPORTANT: The 'tag' version is the white symbol without the blue bubble background. // We prioritize it so our colored background (circle) is visible. const candidates = [ `projects_comroborocktanos_resources_roomtag_bubble_tag_${id}.png`, `projects_comroborocktanos_resources_roomtag_bubble_${id}.png`, `roomtag_bubble_tag_${id}.png`, `roomtag_bubble_${id}.png` ]; const img = await findAssetInPaths(candidates); if (img) { this.assets!.rooms[`bubble_${id}`] = img; // B01 IDs are often 2000 + assetID this.assets!.rooms[`bubble_${id + 2000}`] = img; } } // Load numbered robot icons from snippet const snippetRobotIcons = [85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104]; for (const id of snippetRobotIcons) { const candidates = [ `projects_comroborocktanos_resources_robot_icon_${id}.png`, `robot_icon_${id}.png` ]; const img = await findAssetInPaths(candidates); if (img) { this.assets!.robot[`icon_${id}`] = img; } } // Also try the named ones from ROOM_TYPE_MAP (kitchen, bedroom, etc) IF they exist in some form, // though file listing suggests they rely on ID-based bubbles. // Also try the named ones from ROOM_TYPE_MAP (kitchen, bedroom, etc) IF they exist in some form for (const name of Object.values(ROOM_TYPE_MAP)) { if (!name || name === "other") continue; const candidates = [ `src_sc_components_resource_images_maproom_select_${name}.png`, `src_sc_components_resource_images_mapedit_map_edit_${name}.png`, `src_sc_components_resource_images_maproom_select_${name}_select.png` ]; const img = await findAssetInPaths(candidates); if (img) this.assets!.rooms[name] = img; } // Load 'other' fallback const otherImg = await findAssetInPaths([ "src_sc_components_resource_images_maproom_select_other.png", "projects_comroborocktanos_resources_roomtag_bubble_tag_1.png" ]); if (otherImg) this.assets!.rooms["other"] = otherImg; // Only log missing robot/charger when this map needs them (live map); history maps have no robot/station if (needRobotCharger) { const missingRobot = Object.keys(this.assets!.robot).length === 0; const missingCharger = Object.keys(this.assets!.charger).length === 0; const pathsHint = searchPaths.length ? ` Searched in: ${searchPaths.join(", ")} (with subdirs drawable-*, raw).` : ""; const installHint = " Assets are downloaded at adapter start (Cloud/App Plugin) or can be placed in adapter file storage."; if (missingCharger && this.adapter) { this.adapter.rLog("MapManager", duid, "Error", undefined, undefined, `Charger asset NOT found.${pathsHint}${installHint}`, "error"); } if (missingRobot && this.adapter) { this.adapter.rLog("MapManager", duid, "Error", undefined, undefined, `No Robot assets found.${pathsHint}${installHint}`, "error"); } } } catch (e: any) { if (this.adapter) this.adapter.rLog("MapManager", duid, "Error", undefined, undefined, `Error loading assets: ${e.message}`, "error"); } this.assetsLoaded = true; } private getRobotIconAssetKey(status: B01DeviceStatus): string { // Check for B01 specific asset if (this.assets?.robot?.["b01_fixed"]) { return "b01_fixed"; } const { deviceState, deviceWorkMode, deviceCleanMode, isDustCollect, deviceFault } = status; const isDarkMode = false; // Default to light mode let key = "cleaning"; // Default fallback icon // Select robot icon based on current operative state if (isDustCollect) { return isDarkMode ? "icon_85" : "icon_86"; } if (deviceState === SUBTITLE_STATUS.WAIT_INSTRUCTION || deviceState === SUBTITLE_STATUS.IDEL) { return "icon_87"; } if (deviceState === SUBTITLE_STATUS.RECHARGING || deviceState === SUBTITLE_STATUS.CHARGE_FULL || deviceState === SUBTITLE_STATUS.BREAK_CHARGING) { return "icon_88"; } // Fault handling const isFault = deviceFault && deviceFault > 0; // Simplified if (isFault) { return isDarkMode ? "icon_89" : "icon_90"; } if (deviceState === SUBTITLE_STATUS.SLEEP) { return isDarkMode ? "icon_91" : "icon_92"; } // WorkMode logic const isCleaning = [JOB_STATUS.CLEANING, JOB_STATUS.ZONED_CLEANING, JOB_STATUS.SPOT_CLEANING].includes(deviceWorkMode); const isPause = deviceWorkMode === JOB_STATUS.PAUSED; const isGoCharging = deviceWorkMode === 6; // RETURNING const isBuildModel = status.deviceCustomType === CleanModeType.ALL; // Map custom type to icon if (isBuildModel && isCleaning) { key = "icon_93"; } if (isGoCharging || deviceState === SUBTITLE_STATUS.BREAK_RECHARGING) { key = isDarkMode ? "icon_94" : "icon_95"; } // Additional operative state logic (Segment/Point modes) if (deviceState === SUBTITLE_STATUS.PAUSE) { key = isDarkMode ? "icon_96" : "icon_97"; } if (isCleaning || isPause) { if (deviceState === SUBTITLE_STATUS.PAUSE) { key = isDarkMode ? "icon_96" : "icon_97"; } else { key = isDarkMode ? "icon_98" : "icon_99"; } } if (deviceState === SUBTITLE_STATUS.PAUSE) { key = isDarkMode ? "icon_100" : "icon_101"; } if (isCleaning) { if (deviceCleanMode === SCCleanType.clean) key = "icon_102"; if (deviceCleanMode === SCCleanType.both) key = "icon_103"; if (deviceCleanMode === SCCleanType.mop) key = "icon_104"; } if (this.adapter) this.adapter.rLog("MapManager", undefined, "Debug", undefined, undefined, `Selected Robot Icon: ${key} for state=${deviceState}, work=${deviceWorkMode}, clean=${deviceCleanMode}`, "debug"); return key; } private getVisibleOffset(img: any): { x: number; y: number } { if (this.offsetCache.has(img)) return this.offsetCache.get(img)!; const w = img.naturalWidth; const h = img.naturalHeight; const cnv = createCanvas(w, h); const ctx = cnv.getContext("2d"); ctx.drawImage(img, 0, 0); const data = ctx.getImageData(0, 0, w, h).data; let minX = w, maxX = 0, minY = h, maxY = 0; let found = false; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const alpha = data[(y * w + x) * 4 + 3]; if (alpha > 200) { // Threshold (Ignore Shadows) if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; found = true; } } } if (!found) { this.offsetCache.set(img, { x: 0, y: 0 }); return { x: 0, y: 0 }; } // Visible Center const visibleCX = (minX + maxX) / 2; const visibleCY = (minY + maxY) / 2; // Geometric Center const geoCX = w / 2; const geoCY = h / 2; // We want Visible Center to be at (0,0) relative to drawn POS. // Currently Geometric Center is at (0,0) (due to -w/2 translate). // Correction: Shift so Visible Center matches Geometric Center // Offset = Geometric - Visible const offsetX = geoCX - visibleCX; const offsetY = geoCY - visibleCY; const result = { x: offsetX, y: offsetY }; this.offsetCache.set(img, result); if (this.adapter) { this.adapter.rLog("MapManager", undefined, "Debug", undefined, undefined, `Auto-Trim: Found Bounds [${minX}, ${minY}, ${maxX}, ${maxY}] -> Offset(${offsetX}, ${offsetY}) for ${w}x${h} Image`, "debug"); } return result; } private drawMapAsset( ctx: any, pos: B01Point, phi: number, img: any, unitWidth: number, offsetX: number, offsetY: number, toPixel: (wx: number, wy: number) => { x: number; y: number } ) { const pt = toPixel(pos.x, pos.y); const rx = pt.x; const ry = pt.y; const scale = unitWidth / img.naturalWidth; const w = unitWidth; const h = img.naturalHeight * scale; // Auto-Center Logic const autoOffset = this.getVisibleOffset(img); // Scale the offset to map dimensions const finalOffsetX = offsetX + (autoOffset.x * scale); const finalOffsetY = offsetY + (autoOffset.y * scale); ctx.save(); ctx.translate(rx + finalOffsetX, ry + finalOffsetY); ctx.rotate(-(phi * Math.PI) / 180); ctx.drawImage(img, -w / 2, -h / 2, w, h); ctx.restore(); } public async buildMap(data: B01MapData, robotModel: string, duid?: string, deviceStatus?: B01DeviceStatus): Promise<Buffer> { await this.loadAssets(robotModel, duid, !!(data.robotPos || data.chargerPos)); let { width, height } = { width: data.header.sizeX, height: data.header.sizeY }; // Fallback dimensions logic if (width === 0 || height === 0) { const len = data.mapGrid.length; if (len > 0) { const size = Math.ceil(Math.sqrt(len)); width = size; height = size; } else { width = 256; height = 256; } } const canvas = createCanvas(width * this.SCALE, height * this.SCALE); const ctx = canvas.getContext("2d"); // Disable anti-aliasing for pixel-perfect walls ctx.imageSmoothingEnabled = false; ctx.scale(this.SCALE, this.SCALE); // 1. Fill Background ctx.fillStyle = "#000000"; // Black background ctx.fillRect(0, 0, width, height); // 2. Render Map Pixels (Offscreen Canvas) const roomColorMap: Record<number, number> = {}; if (data.rooms) { data.rooms.forEach(r => { if (r.colorId !== undefined) roomColorMap[r.gridValue ?? r.roomId] = r.colorId; }); } // Create offscreen canvas for correct scaling (putImageData ignores transforms) const tempCanvas = createCanvas(width, height); const tempCtx = tempCanvas.getContext("2d"); const imgData = tempCtx.createImageData(width, height); const buffer = imgData.data; const COLOR_SET = SC_MAP_COLORS.LEVEL_1; // Helper to convert hex to [r,g,b,a] const hexToBytes = (hex: string): [number, number, number, number] => { if (!hex || !hex.startsWith("#")) return [0, 0, 0, 0]; const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); // Fix alpha logic (hex.length 9 is #RRGGBBAA) const a = hex.length === 9 ? parseInt(hex.slice(7, 9), 16) : 255; return [r, g, b, a]; }; const wallColor = hexToBytes(PALETTE.WALL); const floorColor = hexToBytes(PALETTE.FLOOR); const unknownColor = hexToBytes(PALETTE.UNKNOWN); // Pre-compute room colors const roomColors = COLOR_SET.map((c: string) => hexToBytes(c)); for (let i = 0; i < data.mapGrid.length; i++) { const val = data.mapGrid[i]; if (val === 0) continue; // Skip transparency (default 0) const x = i % width; const y = Math.floor(i / width); const cy = height - 1 - y; const idx = (cy * width + x) * 4; let color: [number, number, number, number] | undefined; if (val >= 128) { // Wall color = wallColor; } else if (val === 127 || val === 1) { // Floor color = floorColor; } else { // Room const colorIdx = this.getRoomFillColorIndex(val, roomColorMap[val]); color = roomColors[colorIdx % roomColors.length] || unknownColor; } if (color) { buffer[idx] = color[0]; buffer[idx + 1] = color[1]; buffer[idx + 2] = color[2]; buffer[idx + 3] = color[3]; } } tempCtx.putImageData(imgData, 0, 0); // Draw the 1x1 map onto the 8x8 main canvas (scaled) ctx.drawImage(tempCanvas, 0, 0); const toPixel = (wx: number, wy: number) => { return robotToPixel({ x: wx, y: wy, minX: data.header.minX, minY: data.header.minY, sizeY: data.header.sizeY, resolution: data.header.resolution, scale: 1 }); }; // 3. Zones & Walls const drawNoGoZone = (area: B01Area, isForbidden: boolean) => { if (!area || !area.points || area.points.length < 4) return; const lineWidth = 1.0; // 1. Geometry Calculation const p = area.points.map(pt => toPixel(pt.x, pt.y)); const p0 = p[0], p1 = p[1], p2 = p[2], p3 = p[3]; const getDist = (a: any, b: any) => Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2)); const width = getDist(p0, p3); const height = getDist(p0, p1); // Vertical Shift -1.0 const PIXEL_SNAP = 0.0625; const OFFSET_Y = -1.0 + PIXEL_SNAP; const OFFSET_X = 0.00 + PIXEL_SNAP; const centerX = (p0.x + p2.x) / 2 + OFFSET_X; const centerY = (p0.y + p2.y) / 2 + OFFSET_Y; const angle = Math.atan2(p3.y - p0.y, p3.x - p0.x); const spineW = width; const spineH = height; ctx.save(); ctx.translate(centerX, centerY); ctx.rotate(angle); ctx.beginPath(); ctx.rect(-spineW / 2, -spineH / 2, spineW, spineH); if (isForbidden) { ctx.strokeStyle = "#FF453A"; // Roborock Red ctx.fillStyle = "rgba(255, 69, 58, 0.15)"; } else { ctx.strokeStyle = "#007AFF"; // Roborock Blue ctx.fillStyle = "rgba(0, 122, 255, 0.15)"; } ctx.lineWidth = lineWidth; ctx.lineJoin = "miter"; ctx.lineCap = "butt"; ctx.fill(); ctx.stroke(); ctx.restore(); }; const drawVirtualWall = (points: B01Point[]) => { if (!points || points.length < 2) return; const lineWidth = 1.0; ctx.save(); ctx.beginPath(); points.forEach((p, idx) => { const pix = toPixel(p.x, p.y); pix.y -= 1.0; // Vertical Shift (-1.0) if (idx===0) ctx.moveTo(pix.x, pix.y); else ctx.lineTo(pix.x, pix.y); }); ctx.strokeStyle = "#FF453A"; ctx.lineWidth = lineWidth; ctx.lineCap = "butt"; ctx.lineJoin = "miter"; ctx.stroke(); ctx.restore(); }; // Render Zones if (data.areasInfo) data.areasInfo.forEach(area => drawNoGoZone(area, false)); if (data.virtualWalls) { data.virtualWalls.forEach(wall => { if (wall.points && wall.points.length >= 4) { drawNoGoZone(wall, true); } else { drawVirtualWall(wall.points); } }); } if (data.recmForbitZone) data.recmForbitZone.forEach(zone => drawNoGoZone(zone, true)); // Render Carpets if (data.carpetInfo) { data.carpetInfo.forEach(carpet => { if (carpet.points && carpet.points.length > 0) { let sumX = 0, sumY = 0; carpet.points.forEach(p => { sumX += p.x; sumY += p.y; }); const cx = sumX / carpet.points.length; const cy = sumY / carpet.points.length; const pt = toPixel(cx, cy); ctx.fillStyle = APP_COLORS.circleColor; ctx.beginPath(); ctx.arc(pt.x, pt.y, 2, 0, 2 * Math.PI); ctx.fill(); } }); } // 4. Trajectory (History) - Segmented Logic if (data.history && data.history.length > 0) { const points = data.history; const segments: { type: string, points: {x: number, y: number}[] }[] = []; let currentSegment: { type: string, points: {x: number, y: number}[] } | null = null; points.forEach((p, idx) => { const pt = toPixel(p.x, p.y); const prevP = points[idx - 1]; let breakSegment = false; if (prevP) { const dist = Math.sqrt(Math.pow(p.x - prevP.x, 2) + Math.pow(p.y - prevP.y, 2)); if (dist > 0.5) breakSegment = true; // Jump detection } const type = (p.update === 5) ? "solidPath" : (p.update === 4) ? "mopPaths" : (p.update === 6) ? "mixPaths" : "dottPath"; if (!currentSegment || currentSegment.type !== type || breakSegment) { currentSegment = { type, points: [] }; segments.push(currentSegment); } currentSegment.points.push(pt); }); const drawPathSegment = (seg: any, color: string, width: number, dash: number[] = []) => { if (seg.points.length < 2) return; ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = width; ctx.setLineDash(dash); ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.moveTo(seg.points[0].x, seg.points[0].y); for (let i = 1; i < seg.points.length; i++) ctx.lineTo(seg.points[i].x, seg.points[i].y); ctx.stroke(); }; // Pass 1: Background Highlights (Mop/Mix) segments.forEach(seg => { if (seg.type === "mopPaths" || seg.type === "mixPaths") drawPathSegment(seg, "#FFFFFF66", 3.3); }); // Pass 2: Foregrounds segments.forEach(seg => { if (seg.type === "solidPath") drawPathSegment(seg, "rgba(255, 255, 255, 1)", 0.75); else if (seg.type === "mopPaths") drawPathSegment(seg, "#FFFFFF99", 0.75); else if (seg.type === "mixPaths") drawPathSegment(seg, "#FFFFFF", 0.75); else if (seg.type === "dottPath") drawPathSegment(seg, "rgba(255, 255, 255, 0.6)", 0.45, [1, 2]); }); ctx.setLineDash([]); } // 5. Charger Drawing logic from User Script if (data.chargerPos) { const chargerImg = this.assets?.charger?.["normal"]; if (chargerImg) { const drawW = this.SCALE * (21 / 32) * 1.1; this.drawMapAsset( ctx, data.chargerPos, data.chargerPos.phi, chargerImg, drawW, 0, // Zero Offset 0, // Zero Offset toPixel ); } else { // Fallback const { x: fx, y: fy } = toPixel(data.chargerPos.x, data.chargerPos.y); ctx.fillStyle = "#4CAF50"; ctx.strokeStyle = "white"; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(fx, fy, 4, 0, 2 * Math.PI); ctx.fill(); ctx.stroke(); ctx.fillStyle = "white"; ctx.font = "6px sans-serif"; ctx.textAlign = "center"; ctx.fillText("⚡", fx, fy + 2); } } // 6. Robot position (only on live maps; history maps and station have no robot position) if (data.robotPos) { const status = deviceStatus || { deviceState: SUBTITLE_STATUS.IDEL, deviceWorkMode: JOB_STATUS.IDLE, deviceCleanMode: SCCleanType.clean }; // Logic to shift robot position when docked if (data.chargerPos) { const isChargingState = status.deviceState === 8 || status.deviceState === 6; // Charging or Returning if ((Math.abs(data.robotPos.x - data.chargerPos.x) < 0.2 && Math.abs(data.robotPos.y - data.chargerPos.y) < 0.2) || isChargingState) { const phi = data.chargerPos.phi || 0; const phiRad = (phi * Math.PI) / 180; // Shift Robot "Forward" from the charger center data.robotPos.x += 0.10 * Math.cos(phiRad); data.robotPos.y += 0.10 * Math.sin(phiRad); } } const assetKey = this.getRobotIconAssetKey(status); const robotImg = this.assets?.robot?.[assetKey] || this.assets?.robot?.[this.getRobotIconAssetKey(status)] || this.assets?.robot?.["cleaning"]; if (robotImg) { const ROBOT_MAP_SIZE = this.SCALE * 1.1; this.drawMapAsset( ctx, data.robotPos, data.robotPos.phi || 0, robotImg, ROBOT_MAP_SIZE, 0, // Zero Offset 0, // Zero Offset toPixel ); } else { // Fallback circle const { x: fx, y: fy } = toPixel(data.robotPos.x, data.robotPos.y); ctx.fillStyle = "#FFFFFF"; ctx.shadowColor = "#00000040"; ctx.shadowBlur = 2; ctx.beginPath(); ctx.arc(fx, fy, 4.5, 0, 2 * Math.PI); ctx.fill(); ctx.shadowBlur = 0; } } else if (this.adapter && data.chargerPos) { this.adapter.rLog("MapManager", duid, "Warn", "B01", undefined, "No Robot Position in Map Data", "warn"); } // History maps have no robot and no station – nothing to draw, no log // 7. Room Labels if (data.rooms) { const LABEL_SCALE = 0.5; const fontSize = 10 * LABEL_SCALE; ctx.font = `600 ${fontSize}px sans-serif`; ctx.textBaseline = "middle"; const shadowColor = "#FFFFFFB2"; const textColor = "#333333ee"; const iconSize = 12 * LABEL_SCALE; const iconMargin = 4 * LABEL_SCALE; data.rooms.forEach(r => { if (r.labelPos && r.roomName) { // 1. Determine Semantic Type: Priority = ID const finalType = ROOM_TYPE_MAP[r.roomTypeId || 0] || "other"; // 2. Select Icon Asset // Priority 1: Asset matching Semantic Type (Name-derived or ID-derived) let roomIcon = this.assets?.rooms[finalType] || this.assets?.rooms[`bubble_${finalType}`]; // Priority 2: Asset matching specific ID (if not found above) // Only use ID asset if we don't have a semantic override (or if semantic asset missing) if (!roomIcon) { roomIcon = this.assets?.rooms[`bubble_${r.roomTypeId}`]; } // Priority 3: Fallback "other" if (!roomIcon && finalType === "other") { roomIcon = this.assets?.rooms["other"]; } // 3. Select Color (Based STRICTLY on Room Color ID, not Name) const colorIdx = this.getRoomLabelColorIndex(r.colorId); const BG_COLOR_SET = SC_MAP_COLORS.ROOM_ICON_BG; const BORDER_COLOR_SET = SC_MAP_COLORS.ROOM_ICON_BORDER; const bgColor = BG_COLOR_SET[colorIdx % BG_COLOR_SET.length] || BG_COLOR_SET[0]; const borderColor = BORDER_COLOR_SET[colorIdx % BORDER_COLOR_SET.length] || BORDER_COLOR_SET[0]; const pt = toPixel(r.labelPos.x, r.labelPos.y); const textWidth = ctx.measureText(r.roomName).width; const totalWidth = iconSize + iconMargin + textWidth; let startX = pt.x - (totalWidth / 2); const centerY = pt.y; ctx.beginPath(); ctx.arc(startX + iconSize / 2, centerY, iconSize / 2, 0, Math.PI * 2); ctx.fillStyle = bgColor; ctx.fill(); ctx.strokeStyle = borderColor; ctx.lineWidth = 0.5 * LABEL_SCALE; ctx.stroke(); if (roomIcon) { const imgSize = 10 * LABEL_SCALE; // Traced: 10px inside 12px ctx.drawImage( roomIcon, startX + (iconSize - imgSize) / 2, centerY - imgSize / 2, imgSize, imgSize ); } startX += iconSize + iconMargin; const offset = 0.33 * LABEL_SCALE; ctx.textAlign = "left"; ctx.fillStyle = shadowColor; ctx.fillText(r.roomName, startX - offset, centerY - offset); ctx.fillText(r.roomName, startX + offset, centerY + offset); ctx.fillStyle = textColor; ctx.fillText(r.roomName, startX, centerY); } else { if (this.adapter && typeof this.adapter.rLog === "function") { this.adapter.rLog("MapManager", null, "Warn", "B01", undefined, `Skipping room label for ${r.roomName}: missing labelPos`, "warn"); } } }); } return canvas.toBuffer("image/png"); } private getRoomFillColorIndex(roomId: number, colorId?: number): number { if (colorId !== undefined) { return colorId > 0 ? colorId - 1 : 0; } return roomId > 0 ? roomId - 1 : 0; } private getRoomLabelColorIndex(colorId?: number): number { if (colorId !== undefined && colorId > 0) { return colorId - 1; } return 0; } }