UNPKG

iobroker.roborock

Version:
1,474 lines (1,313 loc) 65.5 kB
import * as d3 from "d3"; import { localCoordsToRobotCoords, robotCoordsToLocalCoords } from "../common/coordTransformation"; import { drawMapV1 } from "../common/mapDrawing/drawMapV1"; import { IMG_CHARGER, IMG_GO_TO_PIN, IMG_ROBOT_ORIGINAL } from "../common/images"; import type { DrawObstacleInput, DrawRoomLabelInput, DrawVirtualWallInput } from "../common/mapDrawing/types"; import type { B01MapData } from "../lib/map/b01/types"; import { Q10_CANVAS_SCALE, Q10MapGeometry } from "../lib/map/q10/Q10MapGeometry"; import { Connection } from "./conn.js"; import { SVGMapRenderer } from "./SVGMapRenderer"; // Interfaces // ----------------------------------------------------------------------------- interface MapData { IMAGE: { position: { left: number; top: number }; dimensions: { height: number; width: number }; segments: { list: SegmentInfo[]; }; }; ROBOT_POSITION?: PositionBlock; CHARGER_LOCATION?: PositionBlock; PATH?: PathBlock; MOP_PATH?: number[]; OBSTACLES2?: Array<[number, number, ...any]>; CARPET_MAP?: number[]; model?: string; // e.g. roborock.vacuum.a147, for asset paths } type Q10FrontendMapData = B01MapData & { model?: string }; type FrontendMapData = MapData | Q10FrontendMapData; interface PositionBlock { position: [number, number]; angle: number; } interface PathBlock { current_angle: number; points: [number, number][]; } interface SegmentInfo { id: number; name: string; center: [number, number]; // Robot coordinates } interface Robot { duid: string; name: string; } interface Point { x: number; y: number; } interface Rect { id: number; // Unique ID for D3 data binding x: number; y: number; width: number; height: number; } interface Q10OverlayObstacleData { kind: "q10Obstacle"; type: "obstacle" | "skip" | "threshold" | "easycard" | "cliff"; x: number; y: number; obstacleId?: string | number; } interface ConnCallbacks { onConnChange?: (isConnected: boolean) => void; onUpdate?: (id: string, state: any | null | undefined) => void; onRefresh?: ((...args: any[]) => any) | null; onAuth?: ((...args: any[]) => any) | null; onCommand?: (instance: string, command: string, data: any) => any; onError?: (err: any) => void; onObjectChange?: (id: string, obj: any) => void; } interface MapParams { scaleFactor: number; left: number; topMap: number; mapMaxY: number; imageHeight: number; imageWidth: number; } // ----------------------------------------------------------------------------- // Constants // ----------------------------------------------------------------------------- const VISUAL_BLOCK_SIZE = 3; // Scale factor for visualization const UI_CONSTANTS = { ROBOT_SIZE_BASE: 5, CHARGER_SIZE_BASE: 3, OBSTACLE_RADIUS_BASE: 3, ZONE_STROKE_BASE: 1.5, ZONE_HANDLE_RADIUS_BASE: 5, PIN_WIDTH_BASE: 29, PIN_HEIGHT_BASE: 24, PIN_Y_OFFSET_BASE: 5, PATH_MOP_WIDTH_BASE: 6.5, PATH_MAIN_WIDTH_RATIO_BASE: 0.8, PATH_BACKWASH_WIDTH_BASE: 0.5, }; /** Type → suffix (429.js); asset obstacle_new_p{suffix}.png */ const Q10_ROOM_TAG_BASE = [ q10PackedArgbToCss(4279123053), q10PackedArgbToCss(4283645184), q10PackedArgbToCss(4286455337), q10PackedArgbToCss(4278537798) ] as const; const Q10_ROOM_TAG_STROKE = [ q10PackedArgbToCss(4278528336), q10PackedArgbToCss(4281147648), q10PackedArgbToCss(4284156949), q10PackedArgbToCss(4278202925) ] as const; const Q10_ROOM_LABEL_LAYOUT = { bubbleRadius: 6, iconSize: 6, gap: 4, font: '900 12px "Segoe UI", sans-serif', widthPadding: 1 } as const; function q10RoomTagAssetFileName(roomType: number): string { const normalized = Number.isInteger(roomType) && roomType >= 0 && roomType <= 11 ? roomType : 0; return `src_resources_map_images_light_maproomtag${normalized}.png`; } function q10PackedArgbToCss(color: number): string { const a = ((color >>> 24) & 0xff) / 255; const r = (color >>> 16) & 0xff; const g = (color >>> 8) & 0xff; const b = color & 0xff; return `rgba(${r}, ${g}, ${b}, ${a})`; } let q10RoomLabelMeasureContext: CanvasRenderingContext2D | null = null; function measureQ10RoomLabelWidth(label: string): number { if (!q10RoomLabelMeasureContext) { const canvas = document.createElement("canvas"); q10RoomLabelMeasureContext = canvas.getContext("2d"); } if (!q10RoomLabelMeasureContext) return label.length * 8; q10RoomLabelMeasureContext.font = Q10_ROOM_LABEL_LAYOUT.font; return q10RoomLabelMeasureContext.measureText(label).width; } const OBSTACLE_MAPPING: 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", // robot type 50 → p49 icon (p50 wrong for this type) 51: "51", 54: "54", 65: "65", 67: "67", 69: "69", 70: "70", 99: "99", }; function obstacleAssetFileName(suffix: string): string { return `projects_comroborocktanos_resources_obstacle_new_p${suffix}.png`; } function obstacleAssetFileNameAlt(suffix: string): string { return `projects_comroborocktanos_resources_map_object_top_${suffix}.png`; } function isQ10MapData(map: FrontendMapData | undefined): map is Q10FrontendMapData { return !!map && typeof map === "object" && "header" in map && !!(map as Q10FrontendMapData).q10CreatorData?.q10Detected; } // ----------------------------------------------------------------------------- // Map Application Class // ----------------------------------------------------------------------------- class MapApplication { // State private connection: Connection; private instanceId: string = ""; private currentRobotDuid: string | null = null; private onStateChange: ((id: string, state: any | null | undefined) => void) | null = null; private currentMapSubscriptions: string[] = []; // Map Data private map: FrontendMapData | undefined; private mapImage: MapData["IMAGE"] | undefined; private mapMinX: number = 0; private mapMinY: number = 0; private mapSizeX: number = 0; private mapSizeY: number = 0; private mapMaxY: number = 0; private goToTarget = false; private zoomLevel = 0.55; private currentMapBase64Clean: string | null = null; private q10Status: number | null = null; private q10CleaningInfo: Record<string, unknown> | null = null; private q10CurrentCleanRoomIds: number[] = []; // D3 & SVG State private image = new Image(); private initialTransform: d3.ZoomTransform | undefined; private svg: d3.Selection<d3.BaseType, unknown, HTMLElement, any>; private svgContainer: d3.Selection<d3.BaseType, unknown, HTMLElement, any>; private mainGroup: d3.Selection<SVGGElement, unknown, HTMLElement, any>; private mapImageElement: d3.Selection<SVGImageElement, unknown, HTMLElement, any>; // Layers private carpetGroup: d3.Selection<SVGGElement, unknown, HTMLElement, any>; private pathGroup: d3.Selection<SVGGElement, unknown, HTMLElement, any>; private mopPathGroup: d3.Selection<SVGGElement, unknown, HTMLElement, any>; private backwashPathGroup: d3.Selection<SVGGElement, unknown, HTMLElement, any>; private pureCleanPathGroup: d3.Selection<SVGGElement, unknown, HTMLElement, any>; // Element Groups private chargerGroup: d3.Selection<SVGGElement, unknown, HTMLElement, any>; private robotGroup: d3.Selection<SVGGElement, unknown, HTMLElement, any>; private roomNameGroup: d3.Selection<SVGGElement, unknown, HTMLElement, any>; private zoneGroup: d3.Selection<SVGGElement, unknown, HTMLElement, any>; private zonesOverlayGroup: d3.Selection<SVGGElement, unknown, HTMLElement, any>; private obstacleGroup: d3.Selection<SVGGElement, unknown, HTMLElement, any>; private pinGroup: d3.Selection<SVGGElement, unknown, HTMLElement, any>; private zoom: d3.ZoomBehavior<Element, unknown>; private wheelZoom = 1; private readonly minZoom = 0.1; private readonly maxZoom = 10; // UI Interaction State private popupTimeout: number | null = null; private popupX: number = 0; private popupY: number = 0; private selectedObstacleID: any; private model: string | null = null; private robotModels: Record<string, string> = {}; private rects: Rect[] = []; private zones: number[][] = []; private rectCounter = 0; /** Cache: "duid.segmentId" -> room name (from get_room_names for cloud maps). */ private roomNamesFromStates: Record<string, string> = {}; private roomNamesRequestedForDuid: string | null = null; // DOM Elements private popup!: HTMLElement; private popupImage!: HTMLImageElement; private triangle!: HTMLElement; private largePhoto!: HTMLElement; private largePhotoImage!: HTMLImageElement; private largePhotoBBox!: HTMLElement; private robotSelect!: HTMLSelectElement; private deleteButton!: HTMLButtonElement; private addButton!: HTMLButtonElement; private startButton!: HTMLButtonElement; private pauseButton!: HTMLButtonElement; private stopButton!: HTMLButtonElement; private dockButton!: HTMLButtonElement; private goToButton!: HTMLButtonElement; private resetZoomButton!: HTMLButtonElement; constructor() { this.connection = new Connection(); // Initialize D3 selections with empty selections initially or in init() // We will initialize them properly in init() after DOM is ready this.svg = d3.select(null) as any; this.svgContainer = d3.select(null) as any; this.mainGroup = d3.select(null) as any; this.mapImageElement = d3.select(null) as any; this.carpetGroup = d3.select(null) as any; this.pathGroup = d3.select(null) as any; this.mopPathGroup = d3.select(null) as any; this.backwashPathGroup = d3.select(null) as any; this.pureCleanPathGroup = d3.select(null) as any; this.chargerGroup = d3.select(null) as any; this.robotGroup = d3.select(null) as any; this.roomNameGroup = d3.select(null) as any; this.zoneGroup = d3.select(null) as any; this.zonesOverlayGroup = d3.select(null) as any; this.obstacleGroup = d3.select(null) as any; this.pinGroup = d3.select(null) as any; this.zoom = d3.zoom(); } public async init() { this.bindDomElements(); this.setupD3(); this.setupConnection(); this.bindUiEvents(); } private bindDomElements() { const getElement = <T extends HTMLElement>(id: string): T => { const el = document.getElementById(id); if (!el) throw new Error(`Missing DOM element: ${id}`); return el as T; }; this.popup = getElement("popup"); this.popupImage = getElement("popup-image"); this.triangle = getElement("triangle"); this.largePhoto = getElement("largePhoto"); this.largePhotoImage = getElement("largePhoto-image"); this.largePhotoBBox = getElement("largePhoto-bbox"); this.robotSelect = getElement("robotSelect"); this.deleteButton = getElement("deleteButton"); this.addButton = getElement("addButton"); this.startButton = getElement("startButton"); this.pauseButton = getElement("pauseButton"); this.stopButton = getElement("stopButton"); this.dockButton = getElement("dockButton"); this.goToButton = getElement("goToButton"); this.resetZoomButton = getElement("resetZoomButton"); } private setupD3() { this.svgContainer = d3.select("#mapSvgContainer"); this.svg = d3.select("#mapSvg"); this.mainGroup = this.svg.append("g").attr("class", "main-group"); this.mapImageElement = this.mainGroup.append("image").attr("class", "map-image"); // Add carpet layer (Vector SVG) this.carpetGroup = this.mainGroup.append("g").attr("class", "carpet"); this.mopPathGroup = this.mainGroup .append("g") .attr("class", "mop-paths") .style("opacity", 0.18); this.pathGroup = this.mainGroup .append("g") .attr("class", "paths") .style("opacity", 0.5); this.backwashPathGroup = this.mainGroup .append("g") .attr("class", "backwash-paths") .style("opacity", 0.2); this.pureCleanPathGroup = this.mainGroup .append("g") .attr("class", "pure-clean-paths"); this.chargerGroup = this.mainGroup.append("g").attr("class", "charger"); this.obstacleGroup = this.mainGroup.append("g").attr("class", "obstacles"); this.zoneGroup = this.mainGroup.append("g").attr("class", "zones"); this.zonesOverlayGroup = this.mainGroup.append("g").attr("class", "zones-overlay"); this.robotGroup = this.mainGroup.append("g").attr("class", "robot"); this.pinGroup = this.mainGroup.append("g").attr("class", "pins"); this.roomNameGroup = this.mainGroup.append("g").attr("class", "room-names"); this.pinGroup .append("image") .attr("class", "goto-pin") .attr("href", IMG_GO_TO_PIN) .attr("width", 29) .attr("height", 24) .style("opacity", 0) .style("display", "none") .style("pointer-events", "none"); this.zoom = d3 .zoom() .scaleExtent([this.minZoom, this.maxZoom]) .on("zoom", (event: any) => this.handleZoom(event)); this.svgContainer.call(this.zoom as any); } private setupConnection() { const instance = this.getQueryParam("instance"); if (instance === null) { document.body.innerHTML = "<h1>Error: No instance specified in URL.</h1>"; return; } this.instanceId = `roborock.${instance}`; const connCallbacks: ConnCallbacks = { onConnChange: async (isConnected: boolean) => { if (isConnected) { this.fetchRobotList(); } }, onUpdate: (id, state) => { if (this.onStateChange) this.onStateChange(id, state as any); }, onError: (err) => { console.error("Connection error:", err); }, }; const socketUrl = `${window.location.protocol}//${window.location.hostname}:${window.location.port}`; this.connection.init({ name: this.instanceId, connLink: socketUrl }, connCallbacks, true); } private fetchRobotList() { const startKey = `${this.instanceId}.Devices.`; const endKey = `${this.instanceId}.Devices.\u9999`; this.connection .getObjectView("system", "device", { startkey: startKey, endkey: endKey }) .then((res: { rows: { id: string; value: any }[] }) => { const robots: Robot[] = []; if (res && res.rows) { res.rows.forEach((row) => { const idParts = row.id.split("."); const duid = idParts[idParts.length - 1]; const name = row.value && row.value.common && row.value.common.name ? row.value.common.name : duid; // Extract model from native object const model = row.value?.native?.model || row.value?.native?.deviceInfo?.model || null; if (duid) { robots.push({ duid: duid, name: name }); if (model) { this.robotModels[duid] = model; } } }); } if (robots.length === 0) { const instanceDuid = this.getQueryParam("instance"); if (instanceDuid) { this.robotSelect.innerHTML = ""; const option = document.createElement("option"); option.value = instanceDuid; option.text = `Roborock (Instance ${instanceDuid})`; this.robotSelect.appendChild(option); this.currentRobotDuid = instanceDuid; this.setupSocketListeners(instanceDuid); } return; } this.robotSelect.innerHTML = ""; robots.forEach((robot: Robot) => { const option = document.createElement("option"); option.value = robot.duid; option.text = robot.name; this.robotSelect.appendChild(option); }); if (robots.length > 0) { const duid = robots[0].duid; this.currentRobotDuid = duid; this.robotSelect.value = duid; this.setupSocketListeners(duid); } // Fetch HomeData to resolve models reliably const homeDataId = `${this.instanceId}.HomeData`; this.connection.getStates([homeDataId]).then((states: Record<string, any>) => { const state = states[homeDataId]; if (state && state.val) { try { const homeData = typeof state.val === "string" ? JSON.parse(state.val) : state.val; const productMap: Record<string, string> = {}; if (homeData.products) { homeData.products.forEach((p: any) => { if (p.id && p.model) productMap[p.id] = p.model; }); } const processDeviceList = (list: any[]) => { if (list) { list.forEach((d: any) => { if (d.duid && d.productId && productMap[d.productId]) { this.robotModels[d.duid] = productMap[d.productId]; } }); } }; processDeviceList(homeData.devices); processDeviceList(homeData.receivedDevices); // Re-trigger listeners if we have a model now (refresh overlays with correct asset URLs) if (this.currentRobotDuid && this.robotModels[this.currentRobotDuid]) { this.model = this.robotModels[this.currentRobotDuid]; if (this.map) this.drawOverlaysFromMap(); } } catch (e) { console.error("Failed to parse HomeData:", e); } } }); }) .catch((err) => console.error("Error fetching robot list:", err)); } private setupSocketListeners(duid: string) { if (this.onStateChange && this.currentMapSubscriptions.length > 0) { this.currentMapSubscriptions.forEach((id) => { this.connection.unsubscribeState(id); }); this.currentMapSubscriptions = []; } this.roomNamesRequestedForDuid = null; this.map = undefined; this.mapImage = undefined; this.mapImageElement.attr("href", null); this.carpetGroup.selectAll("*").remove(); this.obstacleGroup.selectAll("*").remove(); this.zonesOverlayGroup.selectAll("*").remove(); this.pinGroup.select("image.goto-pin").style("display", "none").style("opacity", 0); this.robotGroup.selectAll("*").remove(); this.chargerGroup.selectAll("*").remove(); this.roomNameGroup.selectAll("*").remove(); this.pathGroup.selectAll("*").remove(); this.mopPathGroup.selectAll("*").remove(); this.backwashPathGroup.selectAll("*").remove(); this.pureCleanPathGroup.selectAll("*").remove(); this.rects = []; this.drawZones(); this.deleteButton.disabled = true; this.addButton.disabled = false; this.currentMapBase64Clean = null; this.q10Status = null; this.q10CleaningInfo = null; this.q10CurrentCleanRoomIds = []; const mapBase64CleanStateId = `${this.instanceId}.Devices.${duid}.map.mapBase64Clean`; const mapDataStateId = `${this.instanceId}.Devices.${duid}.map.mapData`; const q10StatusStateId = `${this.instanceId}.Devices.${duid}.deviceStatus.status`; const q10CleaningInfoStateId = `${this.instanceId}.Devices.${duid}.deviceStatus.cleaning_info`; const q10CurrentCleanRoomIdsStateId = `${this.instanceId}.Devices.${duid}.deviceStatus.current_clean_room_ids`; this.currentMapSubscriptions = [ mapBase64CleanStateId, mapDataStateId, q10StatusStateId, q10CleaningInfoStateId, q10CurrentCleanRoomIdsStateId ]; this.onStateChange = (id: string, state: any | null | undefined) => { if (!state || state.val === null || state.val === undefined) { if (id === mapBase64CleanStateId) { this.currentMapBase64Clean = null; this.updateBackgroundImageFromStateCache(); } if (id === mapDataStateId) { this.map = undefined; this.zonesOverlayGroup.selectAll("*").remove(); this.robotGroup.selectAll("*").remove(); } if (id === q10StatusStateId) this.q10Status = null; if (id === q10CleaningInfoStateId) this.q10CleaningInfo = null; if (id === q10CurrentCleanRoomIdsStateId) this.q10CurrentCleanRoomIds = []; if ( (id === q10StatusStateId || id === q10CleaningInfoStateId || id === q10CurrentCleanRoomIdsStateId) && isQ10MapData(this.map) ) { this.drawOverlaysFromMap(); } return; } switch (id) { case mapBase64CleanStateId: this.currentMapBase64Clean = String(state.val); this.updateBackgroundImageFromStateCache(); break; case mapDataStateId: try { this.map = typeof state.val === "string" ? JSON.parse(state.val) : state.val; if (this.map && "IMAGE" in this.map && this.map.IMAGE) { this.model = this.map.model ?? this.robotModels[this.currentRobotDuid] ?? null; this.mapImage = this.map.IMAGE; this.updateMapImageSize(); this.drawOverlaysFromMap(); } else if (isQ10MapData(this.map)) { this.model = this.map.model ?? this.robotModels[this.currentRobotDuid] ?? null; this.mapImage = undefined; this.drawOverlaysFromMap(); } } catch (e) { console.error("Failed to parse map data JSON:", state.val, e); } break; case q10StatusStateId: this.q10Status = Number(state.val); if (isQ10MapData(this.map)) this.drawOverlaysFromMap(); break; case q10CleaningInfoStateId: this.q10CleaningInfo = this.parseQ10CleaningInfoState(state.val); if (isQ10MapData(this.map)) this.drawOverlaysFromMap(); break; case q10CurrentCleanRoomIdsStateId: this.q10CurrentCleanRoomIds = this.parseQ10RoomIdsState(state.val); if (isQ10MapData(this.map)) this.drawOverlaysFromMap(); break; } }; this.connection.subscribeState(mapBase64CleanStateId); this.connection.subscribeState(mapDataStateId); this.connection.subscribeState(q10StatusStateId); this.connection.subscribeState(q10CleaningInfoStateId); this.connection.subscribeState(q10CurrentCleanRoomIdsStateId); this.connection.getStates(this.currentMapSubscriptions).then((states: Record<string, any | null | undefined>) => { if (!this.onStateChange) return; // Try to resolve model from map if already populated if (this.robotModels[duid]) { this.model = this.robotModels[duid]; } this.onStateChange(mapBase64CleanStateId, states[mapBase64CleanStateId]); this.onStateChange(mapDataStateId, states[mapDataStateId]); this.onStateChange(q10StatusStateId, states[q10StatusStateId]); this.onStateChange(q10CleaningInfoStateId, states[q10CleaningInfoStateId]); this.onStateChange(q10CurrentCleanRoomIdsStateId, states[q10CurrentCleanRoomIdsStateId]); }); } // ----------------------------------------------------------------------------- // Drawing Methods (single source: drawMapV1 + SVGMapRenderer) // ----------------------------------------------------------------------------- /** Draws all map overlays via shared drawMapV1. Call when map or mapData changes. */ private drawOverlaysFromMap(): void { if (!this.map) return; this.pinGroup.select("image.goto-pin").style("display", "none").style("opacity", "0"); if (isQ10MapData(this.map)) { this.setPathGroupOpacityMode(true); this.drawQ10Overlays(this.map); this.applyRoomLabelZoomBehavior(); return; } this.setPathGroupOpacityMode(false); if (!this.mapImage?.dimensions) return; const params = this.getMapParams(); if (!params) return; const list = this.map.IMAGE?.segments?.list; const duid = this.currentRobotDuid; const cacheKey = (id: number) => (duid ? `${duid}.${id}` : ""); const segmentName = (s: SegmentInfo) => s.name || (duid ? this.roomNamesFromStates[cacheKey(s.id)] : "") || ""; let roomLabels = list ?.filter((s: SegmentInfo) => segmentName(s)) .map((s: SegmentInfo) => ({ segmentId: s.id, x: this.robotToSvg({ x: s.center[0], y: s.center[1] }, params).x, y: this.robotToSvg({ x: s.center[0], y: s.center[1] }, params).y, text: segmentName(s), })); // Cloud maps: segment names may be empty; fetch from adapter room states and redraw once if (duid && Array.isArray(list)) { const missing = list.filter((s: SegmentInfo) => !s.name && !this.roomNamesFromStates[cacheKey(s.id)]); if (missing.length > 0 && this.roomNamesRequestedForDuid !== duid) { this.roomNamesRequestedForDuid = duid; const segmentIds = missing.map((s: SegmentInfo) => s.id); this.connection .sendTo(this.instanceId, "get_room_names", { duid, floor: 0, segmentIds }) .then((res: any) => { if (res && typeof res === "object" && !res.error) { for (const [id, name] of Object.entries(res)) { if (name && String(name).trim()) this.roomNamesFromStates[`${duid}.${id}`] = String(name).trim(); } this.drawOverlaysFromMap(); } }) .catch(() => {}); } } const modelFolder = this.model || (this.currentRobotDuid && this.robotModels[this.currentRobotDuid]) || (Object.keys(this.robotModels).length ? this.robotModels[Object.keys(this.robotModels)[0]] : null) || "roborock.vacuum.a147"; const baseUrl = `assets/${modelFolder}/drawable-mdpi/`; const renderer = this.createSvgRenderer(baseUrl, params); drawMapV1(this.map as any, renderer, { scaleFactor: VISUAL_BLOCK_SIZE, dimensionsAreScaled: false, roomLabels: roomLabels?.length ? roomLabels : undefined, }); this.applyRoomLabelZoomBehavior(); } private createSvgRenderer(baseUrl: string, params: MapParams | null): SVGMapRenderer { return this.createSvgRendererWithOptions(baseUrl, params, {}); } private createSvgRendererWithOptions( baseUrl: string, params: MapParams | null, options: Partial<{ obstacleRadius: number; obstacleImageSize: number; robotSize: number; chargerSize: number }> ): SVGMapRenderer { return new SVGMapRenderer({ groups: { carpetGroup: this.carpetGroup, pathGroup: this.pathGroup, mopPathGroup: this.mopPathGroup, backwashPathGroup: this.backwashPathGroup, pureCleanPathGroup: this.pureCleanPathGroup, chargerGroup: this.chargerGroup, robotGroup: this.robotGroup, pinGroup: this.pinGroup, obstacleGroup: this.obstacleGroup, roomNameGroup: this.roomNameGroup, zonesOverlayGroup: this.zonesOverlayGroup, }, pathMainWidth: this.rescaler.pathMainWidth(), pathMopWidth: this.rescaler.pathMopWidth(), pathBackwashWidth: this.rescaler.pathBackwashWidth(), robotSize: options.robotSize ?? this.rescaler.robotSize(), chargerSize: options.chargerSize ?? this.rescaler.chargerSize(), pinWidth: this.rescaler.pinWidth(), pinHeight: this.rescaler.pinHeight(), pinYOffset: this.rescaler.pinYOffset(), obstacleRadius: options.obstacleRadius ?? this.rescaler.scale() * UI_CONSTANTS.OBSTACLE_RADIUS_BASE, obstacleImageSize: options.obstacleImageSize ?? this.rescaler.scale() * UI_CONSTANTS.OBSTACLE_RADIUS_BASE * 1.8, obstacleAssetBaseUrl: baseUrl, obstacleMapping: OBSTACLE_MAPPING, obstacleFileName: obstacleAssetFileName, obstacleFileNameAlt: obstacleAssetFileNameAlt, onObstacleClick: (event: MouseEvent, obstacleData: unknown) => { this.handleObstacleClick(event, obstacleData, params); }, robotImageHref: IMG_ROBOT_ORIGINAL, chargerImageHref: IMG_CHARGER, goToPinImageHref: IMG_GO_TO_PIN, }); } private handleObstacleClick(event: MouseEvent, obstacleData: unknown, params: MapParams | null): void { if (!this.currentRobotDuid) return; event.stopPropagation(); if (Array.isArray(obstacleData)) { const d = obstacleData as [number, number, number, unknown, unknown, unknown, unknown]; if (!params) return; this.selectedObstacleID = d?.[6]; const robotPoint = { x: d[0], y: d[1] }; const worldPoint = robotCoordsToLocalCoords(robotPoint, params); this.popupX = worldPoint.x; this.popupY = worldPoint.y; this.showObstaclePopup(this.selectedObstacleID, 1); return; } if (!obstacleData || typeof obstacleData !== "object") return; const q10Obstacle = obstacleData as Q10OverlayObstacleData; if (q10Obstacle.kind !== "q10Obstacle") return; this.popupX = q10Obstacle.x; this.popupY = q10Obstacle.y; if (q10Obstacle.obstacleId == null) return; this.selectedObstacleID = q10Obstacle.obstacleId; this.showObstaclePopup(this.selectedObstacleID, 1); } private showObstaclePopup(obstacleId: unknown, type: number): void { if (obstacleId == null || !this.currentRobotDuid) return; if (this.popupTimeout) clearTimeout(this.popupTimeout); this.connection .sendTo(this.instanceId, "get_obstacle_image", { obstacleId, duid: this.currentRobotDuid, type, }) .then((response: any) => { if (response?.image) { let imageData = response.image as string; if (typeof imageData === "string" && !imageData.startsWith("data:image/")) { imageData = "data:image/png;base64," + imageData; } this.popupImage.src = imageData; this.popup.style.display = "block"; this.triangle.style.display = "block"; this.updatePopupPosition(); this.popupTimeout = window.setTimeout(() => { this.popup.style.display = "none"; this.triangle.style.display = "none"; this.popupTimeout = null; }, 3000); } }) .catch((err) => console.error("Error getting obstacle image:", err)); this.updatePopupPosition(); } private setPathGroupOpacityMode(isQ10: boolean): void { if (isQ10) { this.mopPathGroup.style("opacity", 1); this.pathGroup.style("opacity", 1); this.backwashPathGroup.style("opacity", 1); this.pureCleanPathGroup.style("opacity", 1); return; } this.mopPathGroup.style("opacity", 0.18); this.pathGroup.style("opacity", 0.5); this.backwashPathGroup.style("opacity", 0.2); this.pureCleanPathGroup.style("opacity", 1); } private normalizeQ10NativePathType(type: number | undefined): number { if (type === 0 || type === 1 || type === 2 || type === 3 || type === 4) return type; return 0; } private historyUpdateToQ10NativePathType(update: number | undefined): number { if (update === 6) return 0; if (update === 4) return 1; if (update === 5) return 2; return 0; } private getQ10PathOverlayPoints(map: Q10FrontendMapData): Array<{ x: number; y: number; type: number }> { const creator = map.q10CreatorData; if (creator?.pathPixels?.length) { return creator.pathPixels.map((point) => ({ x: point.x, y: point.y, type: this.normalizeQ10NativePathType(point.type) })); } const resolution = Math.max(map.header.resolution, 0.001); const sourcePathPoints = map.q10SourceData?.pathPoints ?? []; if (sourcePathPoints.length) { return sourcePathPoints.map((point) => ({ x: point.x / resolution, y: point.y / resolution, type: this.normalizeQ10NativePathType(point.type) })); } const historyPoints = map.history ?? []; return historyPoints.map((point) => ({ x: (point.x - map.header.minX) / resolution, y: (map.header.maxY - point.y) / resolution, type: this.historyUpdateToQ10NativePathType(point.update) })); } private packageQ10PathPointsLikeNative(points: Array<{ x: number; y: number; type: number }>): Array<Array<Array<{ x: number; y: number }>>> { const paths: Array<Array<Array<{ x: number; y: number }>>> = [[], [], [], [], []]; let previous: { x: number; y: number; type: number } | null = null; for (const point of points) { const bucket = paths[point.type] ?? paths[0]!; const changedType = previous?.type !== point.type; if (changedType) { const subPath: Array<{ x: number; y: number }> = []; if (previous && previous.type !== -1) { subPath.push({ x: previous.x, y: previous.y }); } else { subPath.push({ x: point.x, y: point.y }); } subPath.push({ x: point.x, y: point.y }); bucket.push(subPath); } else if (bucket.length > 0) { bucket[bucket.length - 1]!.push({ x: point.x, y: point.y }); } previous = point; } return paths; } private q10PathSegmentsToSvgPath(segments: Array<Array<{ x: number; y: number }>>, geometry: Q10MapGeometry): string { const drawable = segments.filter((segment) => segment.length >= 2); if (!drawable.length) return ""; return drawable .map((segment) => { const start = geometry.mapPoint(segment[0]!); const commands = [`M${start.x} ${start.y}`]; for (let index = 1; index < segment.length; index++) { const point = geometry.mapPoint(segment[index]!); commands.push(`L${point.x} ${point.y}`); } return commands.join(" "); }) .join(" "); } private appendQ10SvgPath( group: d3.Selection<SVGGElement, unknown, HTMLElement, any>, pathData: string, className: string, stroke: string, lineWidth: number, dash?: number[], dashOffset = 0 ): void { if (!pathData) return; group .append("path") .attr("class", className) .attr("d", pathData) .style("fill", "none") .style("stroke", stroke) .style("stroke-width", `${lineWidth}px`) .style("stroke-linecap", "round") .style("stroke-linejoin", "round") .style("stroke-dasharray", dash ? dash.join(",") : null) .style("stroke-dashoffset", dash ? `${dashOffset}px` : null); } private drawQ10PathOverlays(map: Q10FrontendMapData, geometry: Q10MapGeometry): void { const points = this.getQ10PathOverlayPoints(map); if (!points.length) return; const paths = this.packageQ10PathPointsLikeNative(points); const primaryWidth = geometry.mapCanvasSize().width / 375; const glowWidth = geometry.mapLength(0.3 / Math.max(map.header.resolution, 0.001)); const wideGlowColor = q10PackedArgbToCss(1728053247); const solidWhite = q10PackedArgbToCss(4294967295); const thinGlowColor = q10PackedArgbToCss(1728053247); const dashedColor = q10PackedArgbToCss(2583691263); const pathStyles = [ { group: this.pathGroup, classPrefix: "q10-main-type0", segments: paths[0]!, layers: [ { stroke: wideGlowColor, width: glowWidth }, { stroke: solidWhite, width: primaryWidth } ] }, { group: this.mopPathGroup, classPrefix: "q10-main-type1", segments: paths[1]!, layers: [ { stroke: wideGlowColor, width: glowWidth }, { stroke: thinGlowColor, width: primaryWidth } ] }, { group: this.backwashPathGroup, classPrefix: "q10-main-type2", segments: paths[2]!, layers: [ { stroke: solidWhite, width: primaryWidth } ] }, { group: this.pureCleanPathGroup, classPrefix: "q10-main-type3", segments: paths[3]!, layers: [ { stroke: dashedColor, width: primaryWidth, dash: [primaryWidth, primaryWidth * 3], dashOffset: primaryWidth * 3 } ] } ] as const; for (const pathStyle of pathStyles) { const pathData = this.q10PathSegmentsToSvgPath(pathStyle.segments, geometry); if (!pathData) continue; for (let index = 0; index < pathStyle.layers.length; index++) { const layer = pathStyle.layers[index]!; this.appendQ10SvgPath( pathStyle.group, pathData, `${pathStyle.classPrefix}-${index}`, layer.stroke, layer.width, "dash" in layer ? layer.dash : undefined, "dashOffset" in layer ? layer.dashOffset : 0 ); } } } private drawQ10Overlays(map: Q10FrontendMapData): void { const creator = map.q10CreatorData; if (!creator?.q10Detected) return; this.carpetGroup.selectAll("*").remove(); this.pathGroup.selectAll("*").remove(); this.mopPathGroup.selectAll("*").remove(); this.backwashPathGroup.selectAll("*").remove(); this.pureCleanPathGroup.selectAll("*").remove(); this.chargerGroup.selectAll("*").remove(); this.robotGroup.selectAll("*").remove(); this.zonesOverlayGroup.selectAll("*").remove(); this.obstacleGroup.selectAll("*").remove(); this.roomNameGroup.selectAll("*").remove(); this.drawZones(); const modelFolder = this.model || (this.currentRobotDuid && this.robotModels[this.currentRobotDuid]) || "roborock.vacuum.ss09"; const baseUrl = `assets/${modelFolder}/drawable-mdpi/`; const geometry = new Q10MapGeometry(map, 1, this.getQ10CanvasScale(map)); const renderer = this.createSvgRendererWithOptions(baseUrl, null, { obstacleRadius: 0, obstacleImageSize: geometry.imgRateLength(6), robotSize: geometry.imgRateLength(8), chargerSize: geometry.imgRateLength(8) }); this.drawQ10PathOverlays(map, geometry); this.drawQ10RoomSelectionMask(creator, geometry); const virtualWalls: DrawVirtualWallInput[] = creator.virtualWalls .filter((wall) => wall.points.length >= 2) .map((wall) => { const start = geometry.mapPoint(wall.points[0]!); const end = geometry.mapPoint(wall.points[1]!); return { x1: start.x, y1: start.y, x2: end.x, y2: end.y, stroke: "rgba(255, 69, 58, 1)", lineWidth: Math.max(2, geometry.layoutLength(2)) }; }); if (virtualWalls.length) { renderer.drawRestrictedZones([], virtualWalls); } if (creator.chargerPixel) { const chargerPoint = geometry.mapPoint(creator.chargerPixel); renderer.drawCharger({ x: chargerPoint.x, y: chargerPoint.y }); } if (creator.robotPixel) { const robotPose = geometry.mapPose(creator.robotPixel); if (robotPose) { renderer.drawRobot({ x: robotPose.x, y: robotPose.y, angle: robotPose.phi ?? 0 }); } } const obstacleItems: DrawObstacleInput[] = []; for (const entry of creator.obstaclePixels) { const point = geometry.mapPoint(entry.point); obstacleItems.push({ x: point.x, y: point.y, typeOrSuffix: "q10", imageHref: `${baseUrl}src_resources_map_images_light_mapobstacle.png`, imageSize: geometry.imgRateLength(6), hideBackground: true, obstacleData: { kind: "q10Obstacle", type: "obstacle", x: point.x, y: point.y } satisfies Q10OverlayObstacleData }); } for (const entry of creator.skipPixels) { const point = geometry.mapPoint(entry.point); obstacleItems.push({ x: point.x, y: point.y, typeOrSuffix: "q10-skip", imageHref: `${baseUrl}src_resources_map_images_light_map_tiaoguo_icon.png`, imageSize: geometry.imgRateLength(6), hideBackground: true, obstacleData: { kind: "q10Obstacle", type: "skip", x: point.x, y: point.y } satisfies Q10OverlayObstacleData }); } for (const entry of creator.suspectedPoints) { const point = geometry.mapPoint(entry.point); const imageHref = entry.type === "threshold" ? `${baseUrl}src_resources_map_images_light_map_yisi_menkan.png` : entry.type === "easycard" ? `${baseUrl}src_resources_map_images_light_map_yisi_yika.png` : `${baseUrl}src_resources_map_images_light_map_yisi_xuanya.png`; obstacleItems.push({ x: point.x, y: point.y, typeOrSuffix: `q10-${entry.type}`, imageHref, imageSize: geometry.layoutLength(16), hideBackground: true, obstacleData: { kind: "q10Obstacle", type: entry.type, x: point.x, y: point.y } satisfies Q10OverlayObstacleData }); } const roomLabels: DrawRoomLabelInput[] = creator.roomModels .filter((room) => room.roomName && room.roomName.trim()) .map((room) => { const label = room.roomName.trim(); const point = geometry.mapPoint(room.transCenterPoint); const colorIndex = room.colorID >= 0 && room.colorID < Q10_ROOM_TAG_BASE.length ? room.colorID : 0; const textWidth = measureQ10RoomLabelWidth(label); const bubbleDiameter = Q10_ROOM_LABEL_LAYOUT.bubbleRadius * 2; const totalWidth = bubbleDiameter + Q10_ROOM_LABEL_LAYOUT.gap + textWidth + Q10_ROOM_LABEL_LAYOUT.widthPadding; const bubbleCenterOffsetX = -totalWidth / 2 + Q10_ROOM_LABEL_LAYOUT.bubbleRadius; const textOffsetX = -totalWidth / 2 + bubbleDiameter + Q10_ROOM_LABEL_LAYOUT.gap; return { segmentId: room.roomID, x: point.x, y: point.y, text: label, iconHref: `${baseUrl}${q10RoomTagAssetFileName(room.roomType)}`, bubbleFill: Q10_ROOM_TAG_BASE[colorIndex], bubbleStroke: Q10_ROOM_TAG_STROKE[colorIndex], textFill: Q10_ROOM_TAG_BASE[colorIndex], badgeText: room.cleanOrder > 0 ? String(room.cleanOrder) : null, bubbleRadius: Q10_ROOM_LABEL_LAYOUT.bubbleRadius, iconSize: Q10_ROOM_LABEL_LAYOUT.iconSize, gap: Q10_ROOM_LABEL_LAYOUT.gap, bubbleCenterOffsetX, textOffsetX, badgeCenterOffsetX: bubbleCenterOffsetX - 3, badgeCenterOffsetY: 12 }; }); renderer.drawObstacles(obstacleItems); renderer.drawRoomLabels(roomLabels); } private updateBackgroundImageFromStateCache(): void { const image = this.currentMapBase64Clean; if (!image) { this.mapImageElement.attr("href", null); return; } this.pinGroup.select("image.goto-pin").style("display", "none").style("opacity", 0); this.drawBackgroundImage(image); } private parseQ10CleaningInfoState(raw: unknown): Record<string, unknown> | null { if (!raw) return null; if (typeof raw === "string") { try { const parsed = JSON.parse(raw); return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null; } catch { return null; } } return typeof raw === "object" && !Array.isArray(raw) ? raw as Record<string, unknown> : null; } private parseQ10RoomIdsState(raw: unknown): number[] { if (!raw) return []; const normalize = (value: unknown): number[] => { if (!Array.isArray(value)) return []; return value .map((entry) => Number(entry)) .filter((entry) => Number.isInteger(entry) && entry > 0); }; if (typeof raw === "string") { try { return normalize(JSON.parse(raw)); } catch { return []; } } return normalize(raw); } private getQ10SelectedRoomIds(): Set<number> { if (this.q10Status !== 18) return new Set<number>(); if (this.q10CurrentCleanRoomIds.length > 0) { return new Set(this.q10CurrentCleanRoomIds); } const cleanInfo = this.q10CleaningInfo; if (!cleanInfo) return new Set<number>(); const cleanInfoRoomIds = this.parseQ10RoomIdsState(cleanInfo.room_id_list); if (cleanInfoRoomIds.length > 0) { return new Set(cleanInfoRoomIds); } const targetSegmentId = Number(cleanInfo.target_segment_id); if (Number.isInteger(targetSegmentId) && targetSegmentId > 0) { return new Set([targetSegmentId]); } return new Set<number>(); } private getQ10CanvasScale(map: Q10FrontendMapData): number { const naturalWidth = this.image?.naturalWidth ?? 0; const sizeX = map.header?.sizeX ?? 0; if (naturalWidth > 0 && sizeX > 0) { return naturalWidth / sizeX; } return Q10_CANVAS_SCALE; } private q10PolygonToSvgPath(points: Array<{ x: number; y: number }>, geometry: Q10MapGeometry): string { if (points.length < 2) return ""; const start = geometry.mapPoint(points[0]!); const segments = [`M${start.x} ${start.y}`]; for (let index = 1; index < points.length; index++) { const point = geometry.mapPoint(points[index]!); segments.push(`L${point.x} ${point.y}`); } segments.push("Z"); return segments.join(" "); } private drawQ10RoomSelectionMask( creator: NonNullable<Q10FrontendMapData["q10CreatorData"]>, geometry: Q10MapGeometry ): void { this.zonesOverlayGroup.selectAll("*").remove(); const selectedRoomIds = this.getQ10SelectedRoomIds(); if (!selectedRoomIds.size) return; const roomMaskPath = creator.roomModels .filter((room) => !selectedRoomIds.has(room.roomID)) .flatMap((room) => room.borderArr) .map((polygon) => this.q10PolygonToSvgPath(polygon, geometry)) .filter(Boolean) .join(" "); if (!roomMaskPath) return; this.zonesOverlayGroup .append("path") .attr("class", "q10-room-selection-mask") .attr("d", roomMaskPath) .attr("fill", "rgba(0, 0, 0, 0.36)") .attr("fill-rule", "evenodd"); } private applyRoomLabelZoomBehavior(): void { const zoomScale = 1 / Math.max(this.wheelZoom, 0.001); this.roomNameGroup.selectAll<SVGGElement, unknown>("g.room-label").each(function () { const element = d3.select(this); const x = Number(element.attr("data-x") || 0); const y = Number(element.attr("data-y") || 0); element.attr("transform", `translate(${x}, ${y}) scale(${zoomScale})`); }); } private updateMapImageSize() { if (!this.image.naturalWidth || !this.image.naturalHeight) return; // Use natural size of the image (1:1 scale) const displayWidth = this.image.naturalWidth; const displayHeight = this.image.naturalHeight; this.mapImageElement .attr("href", this.image.src) .attr("width", displayWidth) .attr("height", displayHeight) .attr("transform", null) .style("image-rendering", "pixelated"); } private drawBackgroundImage(mapBase64: string) { if (!mapBase64) { this.mapImageElement.attr("href", null); return; } this.image.src = mapBase64; this.image.onload = () => { const tempCanvas = document.createElement("canvas"); const tempCtx = tempCanvas.getContext("2d", { willReadFrequently: true }); if (!tempCtx) return; tempCanvas.width = this.image.width; tempCanvas.height = this.image.height; tempCtx.imageSmoothingEnabled = false; tempCtx.drawImage(this.image, 0, 0); let mapMaxX = 0; this.mapMaxY = 0; this.mapMinX = this.image.width; this.mapMinY = this.image.height; const imageData = tempCtx.getImageData(0, 0, this.image.width, this.image.height); const pixels = imageData.data; for (let i = 0; i < pixels.length; i += 4) { const alpha = pixels[i + 3]; if (alpha > 50) { const x = (i / 4) % this.image.width; const y = Math.floor(i / 4 / this.image.width); if (x < this.mapMinX) this.mapMinX = x; if (x > mapMaxX) mapMaxX = x; if (y < this.mapMinY) this.mapMinY = y; if (y > this.mapMaxY) this.mapMaxY = y; } } if (this.mapMinX > mapMaxX) { this.mapMinX = 0; mapMaxX = this.image.width; this.mapMinY = 0; this.mapMaxY = this.image.height; } // Calculate content dimensions based on detected pixels this.mapSizeX = mapMaxX - this.mapMinX; this.mapSizeY = this.mapMaxY - this.mapMinY; // Sanity check if (this.mapSizeX <= 0) this.mapSizeX = this.image.width; if (this.mapSizeY <= 0) this.mapSizeY = this.image.height; this.updateMapImageSize(); this.carpetGroup.attr("transform", null); const svgWidth = parseFloat(this.svg.attr("width")) || 800; const svgHeight = parseFloat(this.svg.attr("height")) || 600; // Zoom-to-fit calculations const aspectRatio = svgWidth / svgHeight; const contentAspectRatio = this.mapSizeX / this.mapSizeY; if (contentAspectRatio > aspectRatio) { this.zoomLevel = this.roundTwoDecimals((svgWidth * 0.95) / this.mapSizeX); // 95% fit } else { this.zoomLevel = this.roundTwoDecimals((svgHeight * 0.95) / this.mapSizeY); } if (this.zoomLevel < 0.1) this.zoomLevel = 0.1; // Center the content within the SVG const contentCenterX = this.mapMinX + this.mapSizeX / 2; const contentCenterY = this.mapMinY + this.mapSizeY / 2; this.initialTransform = d3.zoomIdentity .translate(svgWidth / 2, svgHeight / 2) .scale(this.zoomLevel) .translate(-contentCenterX, -contentCenterY); this.svgContainer.call(this.zoom.transform as any, this.initialTransform); if (this.map) { this.drawOverlaysFromMap(); } }; } private drawZones() { const dragHandler = d3 .drag<SVGGElement, Rect>() .on("start", (event: any) => { const element = event.sourceEvent.target.closest("g.zone"); if (element) d3.select(element).raise().style("cursor", "grabbing"); this.deleteButton.disabled = false; }) .on("drag", (event: any, d: Rect) => { if (!this.hasDrawableMapBounds()) return; const minBoundX = this.mapMinX, minBoundY = this.mapMinY, maxBoundX = this.mapMinX + this.mapSizeX, maxBoundY = this.mapMinY + this.mapSizeY; let newX = Math.max(minBoundX, d.x + event.dx); let newY = Math.max(minBoundY, d.y + event.dy); if (newX + d.width > maxBoundX) newX = maxBoundX - d.width; if (newY + d.height > maxBoundY) newY = maxBoundY - d.height; d.x = newX; d.y = newY; const element = event.sourceEvent.target.closest("g.zone"); if (element) d3.select(element).attr("transform", `translate(${d.x}, ${d.y})`); }) .on("end", (event: any) => { const element = event.sourceEvent.target.closest("g.zone"); if (element) d3.select(element).style("cursor", "move"); this.updateRobotZones(); }); const resizeHandler = d3 .drag<SVGCircleElement, Rect>() .on("start", (event: any) => { event.sourceEvent.stopPropagation(); const element = event.sourceEvent.target; if (element) d3.select(element).raise(); }) .on("drag", (event: any, d: Rect) => { if (!this.hasDrawableMapBounds()) return; const maxBoundX = this.mapMinX + this.mapSizeX, maxBoundY = this.mapMinY + this.mapSizeY; let newWidth = Math.max(d.width + event.dx, 20); let newHeight = Math.max(d.height + event.dy, 20); if (d.x + newWidth > maxBoundX) newWidth = maxBoundX - d.x; if (d.y + newHeight > maxBoundY) newHeight = maxBoundY - d.y; d.width = newWidth; d.height = newHeight; const element = event.sourceEvent.target; if (element) { const parentGroup = d3.select(element.parentNode as SVGGElement); parentGroup.select("rect").attr("width", d.width).attr("height", d.height); parentGroup.select("circle.zone-handle").attr("cx", d.width).attr("cy", d.height); } }) .on("end", () => this.updateRobotZones()); const selection = this.zoneGroup.selectAll("g.zone").data(this.rects, (d: any) => d.id); selection.exit().remove(); const enterGroup = selection.enter().append("g").attr("class", "zone").call(dragHandler as any); enterGroup.append("rect").attr("class", "zone-rect").attr("x", 0).attr("y", 0).style("stroke-width", this.rescaler.zoneStrokeWidth()); enterGroup.append("circle").attr("class", "zone-handle").attr("r", this.rescaler.zoneHandleRadius()).call(resizeHandler as any); const mergedSelection = selection.merge(enterGroup as any); mergedSelection.attr("transform", (d: Rect) => `translate(${d.x}, ${d.y})`); mergedSelection .select("rect") .attr("width", (d: Rect) => d.width) .attr("height", (d: Rect) => d.height) .style("stroke-width", this.rescaler.zoneStrokeWidth()); mergedSelection .select("circle.zone-handle") .attr("cx", (d: Rect) => d.width) .attr("cy", (d: Rect) => d.height) .attr("r", this.rescaler.zoneHandleRadius()); } // ----------------------------------------------------------------------------- // Helper Methods // ----------------------------------------------------------------------------- private updateRobotZones() { const params = this.getMapParams(); if (!params) return; const cleanCountInput = document.getElementById("cleanCount") as HTMLInputElement; this.zones = []; const clea