diff --git a/README.md b/README.md index e74caca..8fda25f 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ Then use map_scale and crop to make it fit. | vacuum_color | string | '--primary-text-color' | The color to use for the vacuum icon | map_scale | number | 1 | Scale the map by this value | icon_scale | number | 1 | Scale the icons (vacuum & dock) by this value +| full_width | boolean | false | Scale the map to fit the whole card | rotate | number | 0 | Value to rotate the map by (default is in deg, but a value like `2rad` is valid too) | left_padding | number | 0 | Value that moves the map `number` pixels from left to right | crop | Object | {top: 0, bottom: 0, left: 0, right: 0} | Crop the map @@ -138,6 +139,21 @@ Custom buttons can be added to this card when vacuum_entity is set. Each custom | icon | string | mdi:radiobox-blank | The icon that will represent the custom button | text | string | "" | Optional text to display next to the icon +## Development + +1. Run Rollup in watch mode + + ```sh + npm run dev + ``` + +2. Enable **Advanced Mode** in your Home Assistant profile + +3. Add the bundle as a Lovelace resource in Home Assistant (**Settings > Dashboards > ⋮ > Resources > + Add Resource**) + ``` + http://localhost:5000/valetudo-map-card.js + ``` + ## License Lovelace Valetudo Map Card is licensed under the MIT license. diff --git a/package-lock.json b/package-lock.json index cce05ba..35c5478 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "rollup-plugin-babel": "4.4.0", "rollup-plugin-commonjs": "10.1.0", "rollup-plugin-node-resolve": "5.2.0", + "rollup-plugin-serve": "1.1.1", "rollup-plugin-terser": "7.0.2", "rollup-plugin-typescript2": "0.33.0", "typescript": "4.8.2" @@ -2362,6 +2363,18 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2402,6 +2415,15 @@ "wrappy": "1" } }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "bin": { + "opener": "bin/opener-bin.js" + } + }, "node_modules/optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -2748,6 +2770,16 @@ "rollup": ">=1.11.0" } }, + "node_modules/rollup-plugin-serve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-serve/-/rollup-plugin-serve-1.1.1.tgz", + "integrity": "sha512-H0VarZRtFR0lfiiC9/P8jzCDvtFf1liOX4oSdIeeYqUCKrmFA7vNiQ0rg2D+TuoP7leaa/LBR8XBts5viF6lnw==", + "dev": true, + "dependencies": { + "mime": "^2", + "opener": "1" + } + }, "node_modules/rollup-plugin-terser": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz", @@ -4952,6 +4984,12 @@ "picomatch": "^2.3.1" } }, + "mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true + }, "minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4989,6 +5027,12 @@ "wrappy": "1" } }, + "opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true + }, "optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -5229,6 +5273,16 @@ "rollup-pluginutils": "^2.8.1" } }, + "rollup-plugin-serve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-serve/-/rollup-plugin-serve-1.1.1.tgz", + "integrity": "sha512-H0VarZRtFR0lfiiC9/P8jzCDvtFf1liOX4oSdIeeYqUCKrmFA7vNiQ0rg2D+TuoP7leaa/LBR8XBts5viF6lnw==", + "dev": true, + "requires": { + "mime": "^2", + "opener": "1" + } + }, "rollup-plugin-terser": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz", diff --git a/package.json b/package.json index 85a9d35..bce6495 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "lint": "eslint -c .automated.eslintrc.json .", "lint_fix": "eslint -c .automated.eslintrc.json . --fix", - "build": "rollup -c" + "build": "rollup -c", + "dev": "rollup -c -w" }, "repository": { "type": "git", @@ -34,6 +35,7 @@ "rollup-plugin-node-resolve": "5.2.0", "rollup-plugin-terser": "7.0.2", "rollup-plugin-typescript2": "0.33.0", + "rollup-plugin-serve": "1.1.1", "typescript": "4.8.2", "@rollup/plugin-json": "4.1.0", "babel-plugin-inline-json-import": "0.3.2", diff --git a/rollup.config.js b/rollup.config.js index 68da660..76d65de 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -4,6 +4,19 @@ import nodeResolve from "rollup-plugin-node-resolve"; import babel from "rollup-plugin-babel"; import { terser } from "rollup-plugin-terser"; import json from "@rollup/plugin-json"; +import serve from 'rollup-plugin-serve'; + +const dev = process.env.ROLLUP_WATCH; + +/** @type {import("rollup-plugin-serve").ServeOptions} */ +const serveOpts = { + contentBase: ['./dist'], + host: '0.0.0.0', + port: 5000, + headers: { + 'Access-Control-Allow-Origin': '*' + } +}; const plugins = [ nodeResolve({}), @@ -16,7 +29,8 @@ const plugins = [ ["inline-json-import", {}] ] }), - terser(), + dev && serve(serveOpts), + !dev && terser(), ]; export default [ diff --git a/src/valetudo-map-card.js b/src/valetudo-map-card.js index f198837..a0c88bc 100644 --- a/src/valetudo-map-card.js +++ b/src/valetudo-map-card.js @@ -217,109 +217,20 @@ class ValetudoMapCard extends HTMLElement { return this.getEntities(attributes, "no_mop_area"); } - drawMap(attributes, mapHeight, mapWidth, boundingBox) { - const pixelSize = attributes.pixelSize; - - const widthScale = pixelSize / this._config.map_scale; - const heightScale = pixelSize / this._config.map_scale; - - let objectLeftOffset = 0; - let objectTopOffset = 0; - let mapLeftOffset = 0; - let mapTopOffset = 0; - - mapLeftOffset = ((boundingBox.minX) - 1) * this._config.map_scale; - mapTopOffset = ((boundingBox.minY) - 1) * this._config.map_scale; + drawMapCanvas(attributes, mapCtx, { widthScale, heightScale, mapLeftOffset, mapTopOffset, objectLeftOffset, objectTopOffset }) { + const config = this._config; // Calculate colours const homeAssistant = document.getElementsByTagName("home-assistant")[0]; - const floorColor = this.calculateColor(homeAssistant, this._config.floor_color, "--valetudo-map-floor-color", "--secondary-background-color"); - const wallColor = this.calculateColor(homeAssistant, this._config.wall_color, "--valetudo-map-wall-color", "--accent-color"); - const currentlyCleanedZoneColor = this.calculateColor(homeAssistant, this._config.currently_cleaned_zone_color, "--valetudo-currently_cleaned_zone_color", "--secondary-text-color"); - const noGoAreaColor = this.calculateColor(homeAssistant, this._config.no_go_area_color, "--valetudo-no-go-area-color", "--accent-color"); - const noMopAreaColor = this.calculateColor(homeAssistant, this._config.no_mop_area_color, "--valetudo-no-mop-area-color", "--secondary-text-color"); - const virtualWallColor = this.calculateColor(homeAssistant, this._config.virtual_wall_color, "--valetudo-virtual-wall-color", "--accent-color"); - const pathColor = this.calculateColor(homeAssistant, this._config.path_color, "--valetudo-map-path-color", "--primary-text-color"); - const chargerColor = this.calculateColor(homeAssistant, this._config.dock_color, "green"); - const vacuumColor = this.calculateColor(homeAssistant, this._config.vacuum_color, "--primary-text-color"); - const gotoTargetColor = this.calculateColor(homeAssistant, this._config.goto_target_color, "blue"); + const floorColor = this.calculateColor(homeAssistant, config.floor_color, "--valetudo-map-floor-color", "--secondary-background-color"); + const wallColor = this.calculateColor(homeAssistant, config.wall_color, "--valetudo-map-wall-color", "--accent-color"); + const currentlyCleanedZoneColor = this.calculateColor(homeAssistant, config.currently_cleaned_zone_color, "--valetudo-currently_cleaned_zone_color", "--secondary-text-color"); + const noGoAreaColor = this.calculateColor(homeAssistant, config.no_go_area_color, "--valetudo-no-go-area-color", "--accent-color"); + const noMopAreaColor = this.calculateColor(homeAssistant, config.no_mop_area_color, "--valetudo-no-mop-area-color", "--secondary-text-color"); + const virtualWallColor = this.calculateColor(homeAssistant, config.virtual_wall_color, "--valetudo-virtual-wall-color", "--accent-color"); - // Create all objects - const containerContainer = document.createElement("div"); - containerContainer.id = "lovelaceValetudoCard"; - - const drawnMapContainer = document.createElement("div"); - const drawnMapCanvas = document.createElement("canvas"); - drawnMapCanvas.width = mapWidth * this._config.map_scale; - drawnMapCanvas.height = mapHeight * this._config.map_scale; - drawnMapContainer.style.zIndex = 1; - drawnMapContainer.appendChild(drawnMapCanvas); - - const chargerContainer = document.createElement("div"); - const chargerHTML = document.createElement("ha-icon"); - let chargerInfo = this.getChargerInfo(attributes); - if (this._config.show_dock && chargerInfo) { - chargerHTML.style.position = "absolute"; // Needed in Home Assistant 0.110.0 and up - chargerHTML.icon = this._config.dock_icon || "mdi:flash"; - chargerHTML.style.left = `${Math.floor(chargerInfo[0] / widthScale) - objectLeftOffset - mapLeftOffset - (12 * this._config.icon_scale)}px`; - chargerHTML.style.top = `${Math.floor(chargerInfo[1] / heightScale) - objectTopOffset - mapTopOffset - (12 * this._config.icon_scale)}px`; - chargerHTML.style.color = chargerColor; - chargerHTML.style.transform = `scale(${this._config.icon_scale}, ${this._config.icon_scale}) rotate(-${this._config.rotate})`; - } - chargerContainer.style.zIndex = 2; - chargerContainer.appendChild(chargerHTML); - - const pathContainer = document.createElement("div"); - const pathCanvas = document.createElement("canvas"); - pathCanvas.width = mapWidth * this._config.map_scale; - pathCanvas.height = mapHeight * this._config.map_scale; - pathContainer.style.zIndex = 3; - pathContainer.appendChild(pathCanvas); - - const vacuumContainer = document.createElement("div"); - const vacuumHTML = document.createElement("ha-icon"); - - let robotInfo = this.getRobotInfo(attributes); - if (!robotInfo) { - robotInfo = this.lastValidRobotInfo; - } - - if (this._config.show_vacuum && robotInfo) { - this.lastValidRobotInfo = robotInfo; - vacuumHTML.style.position = "absolute"; // Needed in Home Assistant 0.110.0 and up - vacuumHTML.icon = this._config.vacuum_icon || "mdi:robot-vacuum"; - vacuumHTML.style.color = vacuumColor; - vacuumHTML.style.left = `${Math.floor(robotInfo[0] / widthScale) - objectLeftOffset - mapLeftOffset - (12 * this._config.icon_scale)}px`; - vacuumHTML.style.top = `${Math.floor(robotInfo[1] / heightScale) - objectTopOffset - mapTopOffset - (12 * this._config.icon_scale)}px`; - vacuumHTML.style.transform = `scale(${this._config.icon_scale}, ${this._config.icon_scale})`; - } - vacuumContainer.style.zIndex = 4; - vacuumContainer.appendChild(vacuumHTML); - - const goToTargetContainer = document.createElement("div"); - const goToTargetHTML = document.createElement("ha-icon"); - let goToInfo = this.getGoToInfo(attributes); - if (this._config.show_goto_target && goToInfo) { - goToTargetHTML.style.position = "absolute"; // Needed in Home Assistant 0.110.0 and up - goToTargetHTML.icon = this._config.goto_target_icon || "mdi:pin"; - goToTargetHTML.style.left = `${Math.floor(goToInfo[0] / widthScale) - objectLeftOffset - mapLeftOffset - (12 * this._config.icon_scale)}px`; - goToTargetHTML.style.top = `${Math.floor(goToInfo[1] / heightScale) - objectTopOffset - mapTopOffset - (22 * this._config.icon_scale)}px`; - goToTargetHTML.style.color = gotoTargetColor; - goToTargetHTML.style.transform = `scale(${this._config.icon_scale}, ${this._config.icon_scale})`; - } - goToTargetContainer.style.zIndex = 5; - goToTargetContainer.appendChild(goToTargetHTML); - - // Put objects in container - containerContainer.appendChild(drawnMapContainer); - containerContainer.appendChild(chargerContainer); - containerContainer.appendChild(pathContainer); - containerContainer.appendChild(vacuumContainer); - containerContainer.appendChild(goToTargetContainer); - - const mapCtx = drawnMapCanvas.getContext("2d"); - if (this._config.show_floor) { - mapCtx.globalAlpha = this._config.floor_opacity; + if (config.show_floor) { + mapCtx.globalAlpha = config.floor_opacity; mapCtx.strokeStyle = floorColor; mapCtx.lineWidth = 1; @@ -328,12 +239,12 @@ class ValetudoMapCard extends HTMLElement { let floorPoints = this.getFloorPoints(attributes); if (floorPoints) { for (let i = 0; i < floorPoints.length; i+=2) { - let x = (floorPoints[i] * this._config.map_scale) - mapLeftOffset; - let y = (floorPoints[i + 1] * this._config.map_scale) - mapTopOffset; - if (this.isOutsideBounds(x, y, drawnMapCanvas, this._config)) { + let x = (floorPoints[i] * config.map_scale) - mapLeftOffset; + let y = (floorPoints[i + 1] * config.map_scale) - mapTopOffset; + if (this.isOutsideBounds(x, y, mapCtx.canvas, config)) { continue; } - mapCtx.fillRect(x, y, this._config.map_scale, this._config.map_scale); + mapCtx.fillRect(x, y, config.map_scale, config.map_scale); } } @@ -341,24 +252,24 @@ class ValetudoMapCard extends HTMLElement { } let segmentAreas = this.getSegments(attributes); - if (segmentAreas && this._config.show_segments) { + if (segmentAreas && config.show_segments) { const colorFinder = new FourColorTheoremSolver(segmentAreas, 6); - mapCtx.globalAlpha = this._config.segment_opacity; + mapCtx.globalAlpha = config.segment_opacity; for (let item of segmentAreas) { - mapCtx.strokeStyle = this._config.segment_colors[colorFinder.getColor(item.metaData.segmentId)]; + mapCtx.strokeStyle = config.segment_colors[colorFinder.getColor(item.metaData.segmentId)]; mapCtx.lineWidth = 1; - mapCtx.fillStyle = this._config.segment_colors[colorFinder.getColor(item.metaData.segmentId)]; + mapCtx.fillStyle = config.segment_colors[colorFinder.getColor(item.metaData.segmentId)]; mapCtx.beginPath(); let segmentPoints = item["pixels"]; if (segmentPoints) { for (let i = 0; i < segmentPoints.length; i+=2) { - let x = (segmentPoints[i] * this._config.map_scale) - mapLeftOffset; - let y = (segmentPoints[i + 1] * this._config.map_scale) - mapTopOffset; - if (this.isOutsideBounds(x, y, drawnMapCanvas, this._config)) { + let x = (segmentPoints[i] * config.map_scale) - mapLeftOffset; + let y = (segmentPoints[i + 1] * config.map_scale) - mapTopOffset; + if (this.isOutsideBounds(x, y, mapCtx.canvas, config)) { continue; } - mapCtx.fillRect(x, y, this._config.map_scale, this._config.map_scale); + mapCtx.fillRect(x, y, config.map_scale, config.map_scale); } } } @@ -366,8 +277,8 @@ class ValetudoMapCard extends HTMLElement { mapCtx.globalAlpha = 1; } - if (this._config.show_walls) { - mapCtx.globalAlpha = this._config.wall_opacity; + if (config.show_walls) { + mapCtx.globalAlpha = config.wall_opacity; mapCtx.strokeStyle = wallColor; mapCtx.lineWidth = 1; @@ -376,12 +287,12 @@ class ValetudoMapCard extends HTMLElement { let wallPoints = this.getWallPoints(attributes); if (wallPoints) { for (let i = 0; i < wallPoints.length; i+=2) { - let x = (wallPoints[i] * this._config.map_scale) - mapLeftOffset; - let y = (wallPoints[i + 1] * this._config.map_scale) - mapTopOffset; - if (this.isOutsideBounds(x, y, drawnMapCanvas, this._config)) { + let x = (wallPoints[i] * config.map_scale) - mapLeftOffset; + let y = (wallPoints[i + 1] * config.map_scale) - mapTopOffset; + if (this.isOutsideBounds(x, y, mapCtx.canvas, config)) { continue; } - mapCtx.fillRect(x, y, this._config.map_scale, this._config.map_scale); + mapCtx.fillRect(x, y, config.map_scale, config.map_scale); } } @@ -389,14 +300,14 @@ class ValetudoMapCard extends HTMLElement { } let activeZones = this.getActiveZones(attributes); - if (Array.isArray(activeZones) && activeZones.length > 0 && this._config.show_currently_cleaned_zones) { - mapCtx.globalAlpha = this._config.currently_cleaned_zone_opacity; + if (Array.isArray(activeZones) && activeZones.length > 0 && config.show_currently_cleaned_zones) { + mapCtx.globalAlpha = config.currently_cleaned_zone_opacity; mapCtx.strokeStyle = currentlyCleanedZoneColor; mapCtx.lineWidth = 2; mapCtx.fillStyle = currentlyCleanedZoneColor; for (let item of activeZones) { - mapCtx.globalAlpha = this._config.currently_cleaned_zone_opacity; + mapCtx.globalAlpha = config.currently_cleaned_zone_opacity; mapCtx.beginPath(); let points = item["points"]; for (let i = 0; i < points.length; i+=2) { @@ -407,14 +318,14 @@ class ValetudoMapCard extends HTMLElement { } else { mapCtx.lineTo(x, y); } - if (this.isOutsideBounds(x, y, drawnMapCanvas, this._config)) { + if (this.isOutsideBounds(x, y, mapCtx.canvas, config)) { // noinspection UnnecessaryContinueJS continue; } } mapCtx.fill(); - if (this._config.show_currently_cleaned_zones_border) { + if (config.show_currently_cleaned_zones_border) { mapCtx.closePath(); mapCtx.globalAlpha = 1.0; mapCtx.stroke(); @@ -424,12 +335,12 @@ class ValetudoMapCard extends HTMLElement { } let noGoAreas = this.getNoGoAreas(attributes); - if (noGoAreas && this._config.show_no_go_areas) { + if (noGoAreas && config.show_no_go_areas) { mapCtx.strokeStyle = noGoAreaColor; mapCtx.lineWidth = 2; mapCtx.fillStyle = noGoAreaColor; for (let item of noGoAreas) { - mapCtx.globalAlpha = this._config.no_go_area_opacity; + mapCtx.globalAlpha = config.no_go_area_opacity; mapCtx.beginPath(); let points = item["points"]; for (let i = 0; i < points.length; i+=2) { @@ -440,14 +351,14 @@ class ValetudoMapCard extends HTMLElement { } else { mapCtx.lineTo(x, y); } - if (this.isOutsideBounds(x, y, drawnMapCanvas, this._config)) { + if (this.isOutsideBounds(x, y, mapCtx.canvas, config)) { // noinspection UnnecessaryContinueJS continue; } } mapCtx.fill(); - if (this._config.show_no_go_area_border) { + if (config.show_no_go_area_border) { mapCtx.closePath(); mapCtx.globalAlpha = 1.0; mapCtx.stroke(); @@ -457,12 +368,12 @@ class ValetudoMapCard extends HTMLElement { } let noMopAreas = this.getNoMopAreas(attributes); - if (noMopAreas && this._config.show_no_mop_areas) { + if (noMopAreas && config.show_no_mop_areas) { mapCtx.strokeStyle = noMopAreaColor; mapCtx.lineWidth = 2; mapCtx.fillStyle = noMopAreaColor; for (let item of noMopAreas) { - mapCtx.globalAlpha = this._config.no_mop_area_opacity; + mapCtx.globalAlpha = config.no_mop_area_opacity; mapCtx.beginPath(); let points = item["points"]; for (let i = 0; i < points.length; i+=2) { @@ -473,14 +384,14 @@ class ValetudoMapCard extends HTMLElement { } else { mapCtx.lineTo(x, y); } - if (this.isOutsideBounds(x, y, drawnMapCanvas, this._config)) { + if (this.isOutsideBounds(x, y, mapCtx.canvas, config)) { // noinspection UnnecessaryContinueJS continue; } } mapCtx.fill(); - if (this._config.show_no_mop_area_border) { + if (config.show_no_mop_area_border) { mapCtx.closePath(); mapCtx.globalAlpha = 1.0; mapCtx.stroke(); @@ -490,21 +401,21 @@ class ValetudoMapCard extends HTMLElement { } let virtualWallPoints = this.getVirtualWallPoints(attributes); - if (virtualWallPoints && this._config.show_virtual_walls && this._config.virtual_wall_width > 0) { - mapCtx.globalAlpha = this._config.virtual_wall_opacity; + if (virtualWallPoints && config.show_virtual_walls && config.virtual_wall_width > 0) { + mapCtx.globalAlpha = config.virtual_wall_opacity; mapCtx.strokeStyle = virtualWallColor; - mapCtx.lineWidth = this._config.virtual_wall_width; + mapCtx.lineWidth = config.virtual_wall_width; mapCtx.beginPath(); for (let item of virtualWallPoints) { let fromX = Math.floor(item["points"][0] / widthScale) - objectLeftOffset - mapLeftOffset; let fromY = Math.floor(item["points"][1] / heightScale) - objectTopOffset - mapTopOffset; let toX = Math.floor(item["points"][2] / widthScale) - objectLeftOffset - mapLeftOffset; let toY = Math.floor(item["points"][3] / heightScale) - objectTopOffset - mapTopOffset; - if (this.isOutsideBounds(fromX, fromY, drawnMapCanvas, this._config)) { + if (this.isOutsideBounds(fromX, fromY, mapCtx.canvas, config)) { continue; } - if (this.isOutsideBounds(toX, toY, drawnMapCanvas, this._config)) { + if (this.isOutsideBounds(toX, toY, mapCtx.canvas, config)) { continue; } mapCtx.moveTo(fromX, fromY); @@ -514,14 +425,20 @@ class ValetudoMapCard extends HTMLElement { mapCtx.globalAlpha = 1; } + } - const pathCtx = pathCanvas.getContext("2d"); - pathCtx.globalAlpha = this._config.path_opacity; + drawPathCanvas(attributes, pathCtx, { widthScale, heightScale, mapLeftOffset, mapTopOffset, objectLeftOffset, objectTopOffset }) { + const config = this._config; + + const homeAssistant = document.getElementsByTagName("home-assistant")[0]; + const pathColor = this.calculateColor(homeAssistant, config.path_color, "--valetudo-map-path-color", "--primary-text-color"); + + pathCtx.globalAlpha = config.path_opacity; pathCtx.strokeStyle = pathColor; - pathCtx.lineWidth = this._config.path_width; + pathCtx.lineWidth = config.path_width; let pathPoints = this.getPathPoints(attributes); - if (Array.isArray(pathPoints) && pathPoints.length > 0 && (this._config.show_path && this._config.path_width > 0)) { + if (Array.isArray(pathPoints) && pathPoints.length > 0 && (config.show_path && config.path_width > 0)) { for (let item of pathPoints) { let x = 0; let y = 0; @@ -530,7 +447,7 @@ class ValetudoMapCard extends HTMLElement { for (let i = 0; i < item.points.length; i+=2) { x = Math.floor((item.points[i]) / widthScale) - objectLeftOffset - mapLeftOffset; y = Math.floor((item.points[i + 1]) / heightScale) - objectTopOffset - mapTopOffset; - if (this.isOutsideBounds(x, y, drawnMapCanvas, this._config)) { + if (this.isOutsideBounds(x, y, pathCtx.canvas, config)) { continue; } if (first) { @@ -543,14 +460,11 @@ class ValetudoMapCard extends HTMLElement { pathCtx.stroke(); } - // Update vacuum angle - vacuumHTML.style.transform = `scale(${this._config.icon_scale}, ${this._config.icon_scale}) rotate(${robotInfo[2]}deg)`; - pathCtx.globalAlpha = 1; } let predictedPathPoints = this.getPredictedPathPoints(attributes); - if (Array.isArray(predictedPathPoints) && predictedPathPoints.length > 0 && (this._config.show_predicted_path && this._config.path_width > 0)) { + if (Array.isArray(predictedPathPoints) && predictedPathPoints.length > 0 && (config.show_predicted_path && config.path_width > 0)) { pathCtx.setLineDash([5,3]); for (let item of predictedPathPoints) { let x = 0; @@ -560,7 +474,7 @@ class ValetudoMapCard extends HTMLElement { for (let i = 0; i < item.points.length; i+=2) { x = Math.floor((item.points[i]) / widthScale) - objectLeftOffset - mapLeftOffset; y = Math.floor((item.points[i + 1]) / heightScale) - objectTopOffset - mapTopOffset; - if (this.isOutsideBounds(x, y, drawnMapCanvas, this._config)) { + if (this.isOutsideBounds(x, y, pathCtx.canvas, config)) { continue; } if (first) { @@ -575,10 +489,185 @@ class ValetudoMapCard extends HTMLElement { pathCtx.globalAlpha = 1; } + } + + drawMap(attributes, mapHeight, mapWidth, boundingBox) { + const config = this._config; + const pixelSize = attributes.pixelSize; + + const widthScale = pixelSize / config.map_scale; + const heightScale = pixelSize / config.map_scale; + + let objectLeftOffset = 0; + let objectTopOffset = 0; + let mapLeftOffset = 0; + let mapTopOffset = 0; + + mapLeftOffset = ((boundingBox.minX) - 1) * config.map_scale; + mapTopOffset = ((boundingBox.minY) - 1) * config.map_scale; + + // Calculate colours + const homeAssistant = document.getElementsByTagName("home-assistant")[0]; + const chargerColor = this.calculateColor(homeAssistant, config.dock_color, "green"); + const vacuumColor = this.calculateColor(homeAssistant, config.vacuum_color, "--primary-text-color"); + const gotoTargetColor = this.calculateColor(homeAssistant, config.goto_target_color, "blue"); + + // Create all objects + const containerContainer = document.createElement("div"); + containerContainer.id = "lovelaceValetudoCard"; + + const drawnMapContainer = document.createElement("div"); + const drawnMapCanvas = document.createElement("canvas"); + drawnMapCanvas.width = mapWidth * config.map_scale; + drawnMapCanvas.height = mapHeight * config.map_scale; + drawnMapContainer.style.zIndex = 1; + drawnMapContainer.appendChild(drawnMapCanvas); + + const chargerContainer = document.createElement("div"); + const chargerHTML = document.createElement("ha-icon"); + let chargerInfo = this.getChargerInfo(attributes); + if (config.show_dock && chargerInfo) { + chargerHTML.style.position = "absolute"; // Needed in Home Assistant 0.110.0 and up + chargerHTML.icon = config.dock_icon || "mdi:flash"; + chargerHTML.style.left = `${Math.floor(chargerInfo[0] / widthScale) - objectLeftOffset - mapLeftOffset - (12 * config.icon_scale)}px`; + chargerHTML.style.top = `${Math.floor(chargerInfo[1] / heightScale) - objectTopOffset - mapTopOffset - (12 * config.icon_scale)}px`; + chargerHTML.style.color = chargerColor; + chargerHTML.style.transform = `scale(${config.icon_scale}, ${config.icon_scale}) rotate(-${config.rotate})`; + } + chargerContainer.style.zIndex = 2; + chargerContainer.appendChild(chargerHTML); + + const pathContainer = document.createElement("div"); + const pathCanvas = document.createElement("canvas"); + pathCanvas.width = mapWidth * config.map_scale; + pathCanvas.height = mapHeight * config.map_scale; + pathContainer.style.zIndex = 3; + pathContainer.appendChild(pathCanvas); + + const vacuumContainer = document.createElement("div"); + const vacuumHTML = document.createElement("ha-icon"); + + let robotInfo = this.getRobotInfo(attributes); + if (!robotInfo) { + robotInfo = this.lastValidRobotInfo; + } + + if (config.show_vacuum && robotInfo) { + this.lastValidRobotInfo = robotInfo; + vacuumHTML.style.position = "absolute"; // Needed in Home Assistant 0.110.0 and up + vacuumHTML.icon = config.vacuum_icon || "mdi:robot-vacuum"; + vacuumHTML.style.color = vacuumColor; + vacuumHTML.style.left = `${Math.floor(robotInfo[0] / widthScale) - objectLeftOffset - mapLeftOffset - (12 * config.icon_scale)}px`; + vacuumHTML.style.top = `${Math.floor(robotInfo[1] / heightScale) - objectTopOffset - mapTopOffset - (12 * config.icon_scale)}px`; + vacuumHTML.style.transform = `scale(${config.icon_scale}, ${config.icon_scale})`; + + let pathPoints = this.getPathPoints(attributes); + if (Array.isArray(pathPoints) && pathPoints.length > 0 && (config.show_path && config.path_width > 0)) { + // Update vacuum angle + vacuumHTML.style.transform = `scale(${config.icon_scale}, ${config.icon_scale}) rotate(${robotInfo[2]}deg)`; + } + } + vacuumContainer.style.zIndex = 4; + vacuumContainer.appendChild(vacuumHTML); + + const goToTargetContainer = document.createElement("div"); + const goToTargetHTML = document.createElement("ha-icon"); + let goToInfo = this.getGoToInfo(attributes); + if (config.show_goto_target && goToInfo) { + goToTargetHTML.style.position = "absolute"; // Needed in Home Assistant 0.110.0 and up + goToTargetHTML.icon = config.goto_target_icon || "mdi:pin"; + goToTargetHTML.style.left = `${Math.floor(goToInfo[0] / widthScale) - objectLeftOffset - mapLeftOffset - (12 * config.icon_scale)}px`; + goToTargetHTML.style.top = `${Math.floor(goToInfo[1] / heightScale) - objectTopOffset - mapTopOffset - (22 * config.icon_scale)}px`; + goToTargetHTML.style.color = gotoTargetColor; + goToTargetHTML.style.transform = `scale(${config.icon_scale}, ${config.icon_scale})`; + } + goToTargetContainer.style.zIndex = 5; + goToTargetContainer.appendChild(goToTargetHTML); + + // Put objects in container + containerContainer.appendChild(drawnMapContainer); + containerContainer.appendChild(chargerContainer); + containerContainer.appendChild(pathContainer); + containerContainer.appendChild(vacuumContainer); + containerContainer.appendChild(goToTargetContainer); + + const mapCtx = drawnMapCanvas.getContext("2d"); + const pathCtx = pathCanvas.getContext("2d"); + + const dimensions = { widthScale, heightScale, mapLeftOffset, mapTopOffset, objectLeftOffset, objectTopOffset }; + this.drawMapCanvas(attributes, mapCtx, dimensions); + this.drawPathCanvas(attributes, pathCtx, dimensions); // Put our newly generated map in there this.clearContainer(this.mapContainer); this.mapContainer.appendChild(containerContainer); + + if (config.full_width) { + containerContainer.style.width = 'auto'; + containerContainer.style.height = 'auto'; + containerContainer.style.aspectRatio = `${mapWidth} / ${mapHeight}`; + + drawnMapCanvas.style.width = "100%"; + pathCanvas.style.width = "100%"; + + const resizeObserver = new ResizeObserver((entries) => { + const entry = entries.length && entries[0]; + if (!entry) { + return; + } + const { width } = entry.contentRect; + + const ratio = width / mapWidth; + const iconScale = (ratio / 2) * config.icon_scale; + + if (config.show_dock && chargerInfo) { + const dockLeft = Math.floor(chargerInfo[0] / pixelSize) - dimensions.mapLeftOffset; + const dockTop = Math.floor(chargerInfo[1] / pixelSize) - dimensions.mapTopOffset; + chargerHTML.style.left = `${(dockLeft / mapWidth) * 100}%`; + chargerHTML.style.top = `${(dockTop / mapHeight) * 100}%`; + chargerHTML.style.transform = `translate(-12px, -12px) scale(${iconScale}, ${iconScale}) rotate(-${config.rotate})`; + } + + if (config.show_vacuum && robotInfo) { + const robotLeft = Math.floor(robotInfo[0] / pixelSize) - dimensions.mapLeftOffset; + const robotTop = Math.floor(robotInfo[1] / pixelSize) - dimensions.mapTopOffset; + vacuumHTML.style.left = `${(robotLeft / mapWidth) * 100}%`; + vacuumHTML.style.top = `${(robotTop / mapHeight) * 100}%`; + + vacuumHTML.style.transform = `translate(-12px, -12px) scale(${iconScale}, ${iconScale})`; + + const pathPoints = this.getPathPoints(attributes); + if (Array.isArray(pathPoints) && pathPoints.length > 0 && (config.show_path && config.path_width > 0)) { + // Update vacuum angle + vacuumHTML.style.transform += ` rotate(${robotInfo[2]}deg)`; + } + } + + if (config.show_goto_target && goToInfo) { + const targetLeft = Math.floor(goToInfo[0] / pixelSize) - dimensions.mapLeftOffset; + const targetTop = Math.floor(goToInfo[1] / pixelSize) - dimensions.mapTopOffset; + goToTargetHTML.style.left = `${(targetLeft / mapWidth) * 100}%`; + goToTargetHTML.style.top = `${(targetTop / mapWidth) * 100}%`; + goToTargetHTML.style.transform = `translate(-12px, -22px) scale(${iconScale}, ${iconScale})`; + } + + // Natural number to avoid antialiasing in the canvas + const scale = Math.max(Math.ceil(width / mapWidth), 1); + + drawnMapCanvas.width = scale * mapWidth; + drawnMapCanvas.height = scale * mapHeight; + pathCanvas.width = scale * mapWidth; + pathCanvas.height = scale * mapHeight; + + mapCtx.scale(scale, scale); + pathCtx.scale(scale, scale); + + this.drawMapCanvas(attributes, mapCtx, dimensions); + this.drawPathCanvas(attributes, pathCtx, dimensions); + }); + + resizeObserver.observe(drawnMapContainer); + } } clearContainer(container) {