iobroker.roborock
Version:
361 lines (338 loc) • 11.9 kB
text/typescript
/**
* Single orchestration for V1 map drawing. All layers (floor, segments, carpet, paths, robot, etc.)
* are computed here and drawn via IMapRenderer. No duplicate logic elsewhere.
*/
import { robotToPixel } from "../coordTransformation";
import { processPaths } from "../pathProcessor";
import type { PathResult } from "../pathProcessor";
import { getPixelFromScaledDimensions } from "./coordHelpers";
import { hexToRgbaString, LEGACY_COLORS, VISUAL_BLOCK_SIZE } from "./constants";
import type {
DrawMapV1Options,
DrawObstacleInput,
DrawRect,
DrawRoomLabelInput,
DrawVirtualWallInput,
DrawZoneRectInput,
IMapRenderer,
} from "./types";
const MM_PER_PIXEL = 50;
/** Minimal V1 map data shape required for drawing. */
export interface V1MapDataForDrawing {
IMAGE: {
position: { left: number; top: number };
dimensions: { width: number; height: number };
pixels?: {
floor?: number[];
obstacle?: number[];
segments?: number[];
};
};
CARPET_MAP?: number[];
PATH?: { points: [number, number][] };
MOP_PATH?: number[];
CURRENTLY_CLEANED_ZONES?: number[][];
FORBIDDEN_ZONES?: number[][];
NO_MOP_ZONE?: number[][];
CURTAIN?: number[][];
MISS_ZONE?: number[][];
VIRTUAL_WALLS?: number[][];
GOTO_PREDICTED_PATH?: { points: [number, number][] };
OBSTACLES2?: Array<[number, number, number, ...unknown[]]>;
ROBOT_POSITION?: { position: [number, number]; angle?: number };
CHARGER_LOCATION?: { position: [number, number] };
GOTO_TARGET?: [number, number];
}
function buildRobotToPixel(
image: V1MapDataForDrawing["IMAGE"],
scaleFactor: number,
dimensionsAreScaled: boolean
): (robotX: number, robotY: number) => { x: number; y: number } {
const left = image.position.left;
const top = image.position.top;
const dims = image.dimensions;
const sizeY = dimensionsAreScaled ? dims.height / scaleFactor : dims.height;
return (robotX: number, robotY: number) =>
robotToPixel({
x: robotX,
y: robotY,
minX: left * MM_PER_PIXEL,
minY: top * MM_PER_PIXEL,
sizeY,
resolution: MM_PER_PIXEL,
scale: scaleFactor,
});
}
function getRoomName(segmentId: number, options: DrawMapV1Options): string | undefined {
const rn = options.roomNames;
if (!rn) return undefined;
if (rn instanceof Map) return rn.get(segmentId);
return (rn as Record<number, string>)[segmentId];
}
export interface DrawMapV1Result {
/** Bounds of floor pixels (for backend crop). */
bounds?: { minX: number; minY: number; maxX: number; maxY: number };
}
/**
* Draws the full V1 map through the given renderer. Single source for all layers.
* Backend: pass dimensionsAreScaled: true if mapData.IMAGE.dimensions are already in pixel size.
* Frontend: pass dimensionsAreScaled: false and scaleFactor (grid dimensions).
*/
export async function drawMapV1(
mapData: V1MapDataForDrawing,
renderer: IMapRenderer,
options: DrawMapV1Options = {}
): Promise<DrawMapV1Result> {
const scaleFactor = options.scaleFactor ?? VISUAL_BLOCK_SIZE;
const dimensionsAreScaled = options.dimensionsAreScaled ?? false;
const image = mapData.IMAGE;
const dims = image.dimensions;
const scaledWidth = dimensionsAreScaled ? dims.width : dims.width * scaleFactor;
const scaledHeight = dimensionsAreScaled ? dims.height : dims.height * scaleFactor;
const robotToPx = buildRobotToPixel(image, scaleFactor, dimensionsAreScaled);
const pixel = (px: number) => getPixelFromScaledDimensions(scaledWidth, scaledHeight, px);
// --- Floor + obstacle rects ---
const floorRects: DrawRect[] = [];
let bounds: DrawMapV1Result["bounds"] | undefined;
const floorColor = hexToRgbaString("#E9E9E9");
const obstacleColor = hexToRgbaString("#6B7174");
if (image.pixels?.floor) {
for (const px of image.pixels.floor) {
const { x, y } = pixel(px);
floorRects.push({ x, y, w: VISUAL_BLOCK_SIZE, h: VISUAL_BLOCK_SIZE, fill: floorColor });
if (!bounds) bounds = { minX: x, minY: y, maxX: x + VISUAL_BLOCK_SIZE, maxY: y + VISUAL_BLOCK_SIZE };
else {
bounds.minX = Math.min(bounds.minX, x);
bounds.minY = Math.min(bounds.minY, y);
bounds.maxX = Math.max(bounds.maxX, x + VISUAL_BLOCK_SIZE);
bounds.maxY = Math.max(bounds.maxY, y + VISUAL_BLOCK_SIZE);
}
}
}
if (image.pixels?.obstacle) {
for (const px of image.pixels.obstacle) {
const { x, y } = pixel(px);
floorRects.push({ x, y, w: VISUAL_BLOCK_SIZE, h: VISUAL_BLOCK_SIZE, fill: obstacleColor });
if (!bounds) bounds = { minX: x, minY: y, maxX: x + VISUAL_BLOCK_SIZE, maxY: y + VISUAL_BLOCK_SIZE };
else {
bounds.minX = Math.min(bounds.minX, x);
bounds.minY = Math.min(bounds.minY, y);
bounds.maxX = Math.max(bounds.maxX, x + VISUAL_BLOCK_SIZE);
bounds.maxY = Math.max(bounds.maxY, y + VISUAL_BLOCK_SIZE);
}
}
}
renderer.drawFloor(floorRects);
// --- Segment rects (with optional color from options.getSegmentColor) ---
const segmentRects: DrawRect[] = [];
const segmentsData: Record<number, { minX: number; maxX: number; minY: number; maxY: number }> = {};
if (image.pixels?.segments) {
for (const px of image.pixels.segments) {
const segnum = px >>> 21;
const pixelIndex = px & 0x1fffff;
const { x, y } = pixel(pixelIndex);
if (!segmentsData[segnum]) segmentsData[segnum] = { minX: x, maxX: x, minY: y, maxY: y };
else {
const s = segmentsData[segnum];
s.minX = Math.min(s.minX, x);
s.maxX = Math.max(s.maxX, x);
s.minY = Math.min(s.minY, y);
s.maxY = Math.max(s.maxY, y);
}
const fill = options.getSegmentColor ? options.getSegmentColor(segnum) : hexToRgbaString("#CCCCCC");
if (fill) segmentRects.push({ x, y, w: VISUAL_BLOCK_SIZE, h: VISUAL_BLOCK_SIZE, fill });
}
}
renderer.drawSegmentRects(segmentRects);
// Clean snapshot (backend only): after segments, before carpet
if (renderer.getCleanSnapshot) renderer.getCleanSnapshot();
// --- Carpet ---
const carpetPositions: { x: number; y: number }[] = [];
if (mapData.CARPET_MAP?.length) {
for (const px of mapData.CARPET_MAP) {
carpetPositions.push(pixel(px));
}
}
renderer.drawCarpet({ positions: carpetPositions });
// --- Paths ---
let mopPath = mapData.MOP_PATH;
if (!mopPath?.length && mapData.PATH?.points?.length) mopPath = new Array(mapData.PATH.points.length).fill(0);
const pathResult: PathResult =
mapData.PATH?.points && mopPath
? processPaths(
mapData.PATH.points,
mopPath,
(robotPoint) => robotToPx(robotPoint[0], robotPoint[1]),
scaleFactor,
null
)
: {
mainPath: [[]],
backwashPath: [[]],
pureCleanPath: [[]],
mopPath: [[]],
mainPathD: "",
backwashPathD: "",
pureCleanPathD: "",
mopPathD: "",
};
const lwMain = Math.max(1, VISUAL_BLOCK_SIZE / 2);
const lwBackwash = VISUAL_BLOCK_SIZE * 0.5;
renderer.drawPath({
segments: pathResult.mopPath,
stroke: "rgba(255, 255, 255, 1)",
lineWidth: 6.5 * VISUAL_BLOCK_SIZE,
opacity: 0.18,
pathLayer: "mop",
});
renderer.drawPath({
segments: pathResult.mainPath,
stroke: LEGACY_COLORS.path,
lineWidth: lwMain,
pathLayer: "main",
});
renderer.drawPath({
segments: pathResult.backwashPath,
stroke: "rgba(255, 255, 255, 1)",
lineWidth: lwBackwash,
dashed: true,
pathLayer: "backwash",
});
renderer.drawPath({
segments: pathResult.pureCleanPath,
stroke: "rgba(255, 255, 255, 1)",
lineWidth: lwBackwash,
pathLayer: "pure",
});
// --- Active zones ---
const activeZones: DrawZoneRectInput[] = [];
if (mapData.CURRENTLY_CLEANED_ZONES?.length) {
for (const coord of mapData.CURRENTLY_CLEANED_ZONES) {
const p1 = robotToPx(coord[0], coord[1]);
const p2 = robotToPx(coord[2], coord[3]);
const x = Math.min(p1.x, p2.x);
const y = Math.min(p1.y, p2.y);
const w = Math.abs(p2.x - p1.x);
const h = Math.abs(p2.y - p1.y);
activeZones.push({
x,
y,
w,
h,
fill: "rgba(46,139,87,0.1)",
stroke: "#2e8b57",
});
}
}
renderer.drawActiveZones(activeZones);
// --- Restricted zones + virtual walls ---
const restrictedZones: DrawZoneRectInput[] = [];
const toRect = (zone: number[]) => {
const xs = [zone[0], zone[2], zone[4], zone[6]];
const ys = [zone[1], zone[3], zone[5], zone[7]];
const p1 = robotToPx(Math.min(...xs), Math.max(...ys));
const p2 = robotToPx(Math.max(...xs), Math.min(...ys));
return { x: p1.x, y: p2.y, w: p2.x - p1.x, h: p1.y - p2.y };
};
if (mapData.FORBIDDEN_ZONES?.length) {
for (const z of mapData.FORBIDDEN_ZONES) {
const r = toRect(z);
restrictedZones.push({ ...r, fill: "rgba(255, 0, 0, 0.5)", stroke: "rgba(255, 0, 0, 1)" });
}
}
if (mapData.NO_MOP_ZONE?.length) {
for (const z of mapData.NO_MOP_ZONE) {
const r = toRect(z);
restrictedZones.push({ ...r, fill: "rgba(0, 0, 255, 0.5)", stroke: "rgba(0, 0, 255, 1)" });
}
}
if (mapData.CURTAIN?.length) {
for (const z of mapData.CURTAIN) {
const r = toRect(z);
restrictedZones.push({ ...r, fill: "rgba(250, 198, 182, 0.5)", stroke: "#fac6b6" });
}
}
if (mapData.MISS_ZONE?.length) {
for (const z of mapData.MISS_ZONE) {
const r = toRect(z);
restrictedZones.push({ ...r, fill: "rgba(250, 229, 158, 0.5)", stroke: "#fae59e" });
}
}
const virtualWalls: DrawVirtualWallInput[] = [];
if (mapData.VIRTUAL_WALLS?.length) {
for (const wall of mapData.VIRTUAL_WALLS) {
const p1 = robotToPx(wall[0], wall[1]);
const p2 = robotToPx(wall[2], wall[3]);
virtualWalls.push({
x1: p1.x,
y1: p1.y,
x2: p2.x,
y2: p2.y,
stroke: "rgba(255, 0, 0, 1)",
lineWidth: 1 * VISUAL_BLOCK_SIZE,
});
}
}
renderer.drawRestrictedZones(restrictedZones, virtualWalls);
// --- Predicted path ---
if (mapData.GOTO_PREDICTED_PATH?.points?.length) {
const points = mapData.GOTO_PREDICTED_PATH.points.map((c) => robotToPx(c[0], c[1]));
renderer.drawPredictedPath({
points,
stroke: "rgba(255, 255, 255, 1)",
lineWidth: (3 * VISUAL_BLOCK_SIZE) / 2,
dashArray: [3 * VISUAL_BLOCK_SIZE, 3 * VISUAL_BLOCK_SIZE],
});
} else {
renderer.drawPredictedPath({ points: [], stroke: "", lineWidth: 0, dashArray: [] });
}
// --- Obstacles ---
const obstacles: DrawObstacleInput[] = [];
if (mapData.OBSTACLES2?.length) {
for (const ob of mapData.OBSTACLES2) {
const p = robotToPx(ob[0], ob[1]);
obstacles.push({ x: p.x, y: p.y, typeOrSuffix: ob[2], obstacleData: ob });
}
}
const drawObs = renderer.drawObstacles(obstacles);
if (drawObs && typeof (drawObs as Promise<unknown>).then === "function") await (drawObs as Promise<void>);
// --- Robot ---
if (mapData.ROBOT_POSITION?.position) {
const [rx, ry] = mapData.ROBOT_POSITION.position;
const p = robotToPx(rx, ry);
renderer.drawRobot({
x: p.x,
y: p.y,
angle: mapData.ROBOT_POSITION.angle ?? 0,
});
}
// --- Charger ---
if (mapData.CHARGER_LOCATION?.position) {
const p = robotToPx(mapData.CHARGER_LOCATION.position[0], mapData.CHARGER_LOCATION.position[1]);
renderer.drawCharger({ x: p.x, y: p.y });
}
// --- Go-to pin ---
if (mapData.GOTO_TARGET?.[0] != null && mapData.GOTO_TARGET?.[1] != null) {
const p = robotToPx(mapData.GOTO_TARGET[0], mapData.GOTO_TARGET[1]);
renderer.drawGoToPin({ x: p.x, y: p.y });
}
// --- Room labels ---
const roomLabels: DrawRoomLabelInput[] =
(options.roomLabels?.length ?? 0) > 0
? options.roomLabels!
: (() => {
const out: DrawRoomLabelInput[] = [];
for (const [segnumStr, bounds] of Object.entries(segmentsData)) {
const segnum = parseInt(segnumStr, 10);
if (segnum === 0) continue;
const name = getRoomName(segnum, options);
if (!name) continue;
const centerX = bounds.minX + (bounds.maxX - bounds.minX) / 2 + VISUAL_BLOCK_SIZE / 2;
const centerY = bounds.minY + (bounds.maxY - bounds.minY) / 2 + VISUAL_BLOCK_SIZE / 2;
out.push({ segmentId: segnum, x: centerX, y: centerY, text: name });
}
return out;
})();
renderer.drawRoomLabels(roomLabels);
return { bounds };
}