Map render โ layer stack, room identity, and color resolution¶
The live map view (renderers/map.js โ renderMapRoomView, used by BOTH the sidebar panel
and the embedded vacuum-map-host card) is a stack of independently-authored layers that
grew up over several waves. They do NOT share a single fill mechanism, and they touch several
room-id spaces whose relationship is not fully settled โ see ยง2. This doc is the map โ read it
before touching room fills, overlays, or per-room theming, so you don't re-derive it from code
(it has bitten us).
1. The layer stack (bottom โ top = DOM order = paint order)¶
Inside .evcc-map-content-rotator, in order (later paints ON TOP; most rely on DOM order, not
z-index):
- Backdrop โ EITHER
<canvas class="evcc-map-image evcc-map-render-canvas">(the VA raster,vaActive) OR<img class="evcc-map-image">(the live camera map). Canvas XOR img, never both. The VA raster is the visible room fill and is where per-room color overrides land (bindings/map.jsโ_drawVaRender). Non-room pixels are transparent โ the themed container background reads as floor. - Furnished art โ
<img class="evcc-map-art">, rendered only when a furnished layout is active AND render mode โlive(_renderFurnishedArt). A static, to-scale, user-aligned image at full opacity, exactly over the backdrop. โ ๏ธ In furnished (blend/art) mode this covers the live map โ including room colors. That's by design: furnished replaces the live render. Room colors are a live-map feature; see the gotcha in ยง4. - Selection scrim โ
<canvas class="evcc-map-image evcc-map-selection-canvas">(_renderSelectionScrim+_bindSelectionScrim; it shares.evcc-map-imagepurely for LAYOUT โ width/height 100% +object-fit:containletterbox parity with the backdrop, and "positioned like the backdrop; just needs to be click-through" (styles/map.js:93-100,938-940)). A subtractive dark dim over UN-selected rooms; only present on a partial selection. It dims, it does not recolor. This scrim keys its selection set directly by managedroom.idagainst the raster's ownrid(see ยง2's disagreement note) โselected = new Set( rooms.filter(r => r.enabled).map(r => Number(r.id))), tested withif (selected.has(rid)) continue(bindings/map.js::_bindSelectionScrim). <svg class="evcc-map-svg">โ contains, in order:- floor-texture
<defs>(_buildFloorTextureDefs); - room polygons (
_renderMapSegmentPolygon) โfill: transparentunless selected, so they only ever show the color as a 0.25 selection tint, NOT the visible fill; - floor-texture polygons (
_renderFloorTexturePolygon) โ a per-room texture pattern that paints OVER the raster. Suppressed for a room that has a color override (the override is that room's fill), else it would cover the recolor and let it peek only at the edge; - device overlays (
_renderDeviceOverlaySvgโ current room / walls / path / robot / dock / โฆ). - Mascot โ
<div class="evcc-map-animal">. - Labels & chips โ room-name labels, area (mยฒ) chips, clean-order badges, hidden regions.
Key consequence: the room's visible fill is the VA raster canvas (layer 1). Anything opaque above it (furnished art, floor texture) will hide a raster recolor. The SVG room polygons do NOT provide the fill โ they're transparent except as a selection tint.
2. Room-id spaces โ RESOLVED 2026-08-06 (was a live disagreement)¶
| id | Where | What it is |
|---|---|---|
raster rid |
room_pixels byte >> 2 (rid_shift) in _drawVaRender |
the raster's own per-pixel room id |
managed room.id |
Number(attrs.room_id), from the device segments[].id |
the device's declared segment id (rooms/room_discovery.py:discover_rooms_for_vacuum, keyed by the adapter's room_id_key) |
room_names[rid] |
render payload {str(rid): name} (map_source.py) |
device's per-rid name |
CV segment_id |
"segment_N" (area-ranked) from the CV segmenter |
a separate id space |
The codebase used to disagree with itself about whether raster rid and managed room.id are
the same number. Verified on Alfred all three (rid / room.id / room_names key) coincided
(Kitchen=5, Office=9, Dining=8, Entryway=6) โ the one concrete dataset behind this doc. Two
different generations of code encoded two different beliefs about whether that's a guarantee:
- Treats them as the SAME number, no bridging (older, load-bearing feature code):
_bindSelectionScrimbuildsselected = new Set(rooms.filter(r => r.enabled).map(r => Number(r.id)))and tests it directly against the decoded rasterrid(bindings/map.js::_bindSelectionScrim) โ if they ever diverge for a device, the scrim dims the wrong rooms._renderRoomSelection's clean-order badges look uporder.get(Number(room.number)), whereorderis keyed byNumber(r.id)androom.numberis the rawridrooms_from_room_pixelsemits (map_source.py::rooms_from_room_pixels,"number": rid); the function's own docstring says "Keyed by device room number (== managed room id)" (renderers/map.js::_renderSelectionScrim).current_room_for_pixelreturns the raw rasterrid(map_source.py::current_room_for_pixel), andlearning/room_attribution_engines.py::PoseSampledocuments that return value as "the MANAGED room id" outright.- Treats them as POSSIBLY DIFFERENT spaces, and bridges defensively (newer, Phase-2 palette
code):
_drawVaRender's per-room override resolves a raster pixel'sridโrd.room_names[rid]โ our room by matching name (trimmed + lowercased) โroom.colorโ it deliberately does NOT key byroom.iddirectly. The comment at the point of the choice (bindings/map.js#CNFJPTKD) USED TO state this as an asserted, already-confirmed finding, not a hedge โ R2-BUG-5 rewrote that comment into a retraction, so the anchor now carries the retraction rather than the historical quote that follows: "Keying by room.id directly is WRONG โ the raster rid and our stored room.id are DIFFERENT id spaces on real devices (empirically verified), so a room.id key lands on no pixels (or, worse, another room's)." The name-bridge itself is atbindings/map.js#CNZZT0GC. - RESOLUTION (R2-BUG-5, 2026-08-06): the identity-assuming paths are the supported reading; the divergence claim has no dataset behind it. Two facts settle it:
- The raster
ridspace is Eufy-only.rooms_from_room_pixelsis the "Eufy storage backend" by its own docstring, andmap_source.py::zone_membershipstates that Roborock has no per-pixel raster (room_numberstaysNonethere). So "DIFFERENT id spaces on real devices" cannot be describing Roborock โ there is no Roborockridto differ. The only brand with a raster is the one brand where all three ids were observed to coincide. - The claim contradicts its own commit.
c4207b9, which introduced both the name-bridge and the "empirically verified" comment, describes its own doc changes as "map-render-layers.md (the layer stack, rid==room.id==room_names identity, โฆ)". Identity in the message, divergence in the code comment, same commit.
So this was never two findings in tension โ it was one verified observation against one unsourced assertion. No code changed. The three identity paths (scrim, clean-order badges, current-room attribution) are consistent with all available evidence, and rewriting working code to satisfy an unsourced comment is the exact failure 00a ยง9 warns about โ docs are part of the measurement apparatus, and a wrong one makes an auditor "fix" correct code.
The name-bridge is kept, relabelled defensive-not-required: it costs one lookup, it can
never miscolor (a name miss falls through to the palette), and it is the safe side if some
firmware we have never seen does diverge. What we cannot rule out is that the original author
saw divergence on an Eufy firmware there is no record of and wrote the commit body carelessly โ
hence keeping the bridge rather than deleting it. Keying a raster override by the CV
segment_id is WRONG regardless (different space, and it's a string โ NaN).
- CV segment_id โ room is indirect: state.roomIdForSegment(seg.segment_id) โ seg.room_id.
The SVG polygons + labels use this; the raster does not (it has no segments, just rid pixels).
3. Room-color resolution (the one cascade)¶
Single source of truth: src/cards/map-room-color.js. Cascade, resolved the same everywhere:
per-room override (
room.color) โธ theme token (--evcc-room-fill-N) โธ default palette
- SVG consumes it via
roomFillCss(idx, override)โ a concrete hex (override) orvar(--evcc-room-fill-N, default)(rides the live CSS cascade). Idx = render order. - Raster can't take CSS vars, so
_drawVaRenderresolves RGBs: the palette once per slot (roomFillRgb, onegetComputedStyleread), plus a per-rid override map (roomOverrideRgbviard.room_names, bridged by name โ see ยง2). An un-overridden pixel takes palette slot(rid โ 1) mod Nโ rid-derived, NOT render order (render order is the SVG path's index rule instead;bindings/map.js#CNYVDQ9S,ROOM_FILL_N= 12,cards/map-room-color.js::ROOM_FILL_PALETTE). AnoverrideSigin the_vaImageCachekey repaints on a recolor, likepaletteSigdoes for a theme change. - Floor texture is suppressed for an overridden room (see layer 4) so the override is the fill.
room.coloris a#rrggbbstring ornull, stored per-room (update_room_fields, modelsRoomConfig.color), surfaced on the room-switch entity โ_normalizeRoomโroom.color.
Themeless + no overrides โ the default palette โ byte-identical to the pre-feature render.
4. Gotcha: "my room color isn't showing"¶
Almost always a layer covering the raster, not a color/mapping bug:
- Furnished (blend/art) mode โ the
evcc-map-artimage (layer 2) sits over the live map. Room colors only show inliverender mode / a non-furnished layout. This is intended. - Floor texture โ if a textured floor covered an overridden room it'd peek only at the edge (~7% raster-vs-CV-polygon shape mismatch). Handled by the override-suppression in layer 4.
- To debug the stack, dump
canvas.parentElement.children(tag/class/opacity/rect) from_drawVaRender; to debug identity, dumprd.room_names+ the managed rooms + a rid histogram.
See also¶
map-state-source (VA render payload + room_names), docs/dev/11-mapping-system.md,
architecture-overview.md (the four-layer card + floor textures),
docs/dev/frontend/furnished-render.md, docs/dev/frontend/themeable-map-palette.md (the color feature design).