Skip to content

Commit cff76e9

Browse files
claudeHuggeK
andcommitted
fix(web): found buildings draw on the map and a click picks one
MapLibre rejected both footprint layers without throwing: the case condition needed a typed boolean, and the theme's oklch() tokens are unparseable to its colour parser, so themeColor now bakes the resolved value to sRGB bytes through a 1x1 canvas. A find also fits the camera to the nearby candidates (city zoom leaves a footprint smaller than a pixel), retries the draw if the style is still loading, and a click on a footprint selects that building instead of dragging the site pin - and the saved coordinates - to wherever you clicked. Verified in headless Edge against the live Geotorget service: 39 footprints rendered, map-click selection enables the derive button, pin coordinates unchanged. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
1 parent 344d0c1 commit cff76e9

4 files changed

Lines changed: 166 additions & 11 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"ftw": patch
3+
---
4+
5+
Found buildings now actually appear on the map, and clicking one selects it.
6+
Three defects hid them: MapLibre silently rejected both footprint layers
7+
because the styling used a value-typed `["get", "selected"]` where a typed
8+
boolean is required, and — once typed — because the theme's `oklch()` colour
9+
tokens reached MapLibre's parser unconverted, so the resolved colours are now
10+
baked to sRGB through a canvas probe. Finally the camera never moved: the map
11+
sat at city zoom, where a footprint is smaller than a pixel. A find now zooms
12+
to the nearby candidates, and clicking a footprint selects that building
13+
without also dragging the site pin (and its saved coordinates) to wherever
14+
you clicked.

web/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -994,7 +994,7 @@ <h3>Price bars (top of the chart)</h3>
994994
<script src="/settings/tabs/devices.js?v=catcards2"></script>
995995
<script src="/settings/tabs/loadpoints.js?v=lp7"></script>
996996
<script src="/settings/tabs/price.js?v=zones1"></script>
997-
<script src="/settings/tabs/weather.js?v=demo5"></script>
997+
<script src="/settings/tabs/weather.js?v=demo6"></script>
998998
<script src="/settings/tabs/batteries.js?v=tabsplit1"></script>
999999
<script src="/settings/tabs/planner.js?v=inputs1"></script>
10001000
<script src="/settings/tabs/ha.js?v=tabsplit2"></script>

web/settings/tabs/weather.js

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,13 @@
334334
setCoord(ll.lat, ll.lng);
335335
});
336336
map.on("click", function (e) {
337+
// A click on a building footprint selects that building (the layer's own
338+
// handler). It must not also drag the site pin there and silently
339+
// rewrite the saved coordinates.
340+
if (map.getLayer("roof-buildings-fill") &&
341+
map.queryRenderedFeatures(e.point, { layers: ["roof-buildings-fill"] }).length) {
342+
return;
343+
}
337344
marker.setLngLat(e.lngLat);
338345
setCoord(e.lngLat.lat, e.lngLat.lng);
339346
});
@@ -556,7 +563,10 @@
556563
roofSay("Found " + roofState.features.length +
557564
" building(s). Pick yours on the map or in the list.");
558565
drawBuildings();
566+
fitToBuildings(d.latitude, d.longitude);
559567
renderBuildingList(ctx);
568+
var mapEl = document.getElementById("weather-map");
569+
if (mapEl && mapEl.scrollIntoView) mapEl.scrollIntoView({ block: "nearest" });
560570
})
561571
.catch(function (e) { roofSay(ctx.escHtml(String(e && e.message || e)), "bad"); });
562572
}
@@ -574,9 +584,12 @@
574584

575585
// MapLibre paints with concrete colours and cannot read var(), the same
576586
// problem the canvas charts have. Resolve the theme tokens through a hidden
577-
// probe that inherits :root, exactly as app.js's cssColor does. Resolved once
578-
// per layer creation, so a theme toggle mid-pick keeps the old hue until the
579-
// tab is reopened — the footprints stay legible either way.
587+
// probe that inherits :root, exactly as app.js's cssColor does. The theme
588+
// authors its tokens in oklch(), which getComputedStyle passes through
589+
// verbatim and MapLibre's parser rejects — so bake the resolved colour to
590+
// sRGB bytes through a 1x1 canvas, whose getImageData is sRGB by contract.
591+
// Resolved once per layer creation, so a theme toggle mid-pick keeps the old
592+
// hue until the tab is reopened — the footprints stay legible either way.
580593
var _probe = null;
581594
function themeColor(name, fallback) {
582595
if (!_probe) {
@@ -585,12 +598,35 @@
585598
document.body.appendChild(_probe);
586599
}
587600
_probe.style.color = "var(" + name + ", " + fallback + ")";
588-
return getComputedStyle(_probe).color || fallback;
601+
var resolved = getComputedStyle(_probe).color || fallback;
602+
try {
603+
var ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
604+
ctx.fillStyle = fallback; // an unparseable resolved value leaves this
605+
ctx.fillStyle = resolved;
606+
ctx.fillRect(0, 0, 1, 1);
607+
var px = ctx.getImageData(0, 0, 1, 1).data;
608+
return "rgb(" + px[0] + "," + px[1] + "," + px[2] + ")";
609+
} catch (e) {
610+
return fallback;
611+
}
612+
}
613+
614+
// A "case" condition must be a typed boolean: a bare ["get", ...] is value-
615+
// typed, and MapLibre rejects the whole layer through its error event without
616+
// throwing — the picker then looks enabled while the map stays empty.
617+
function whenSelected(then, otherwise) {
618+
return ["case", ["boolean", ["get", "selected"], false], then, otherwise];
589619
}
590620

591621
function drawBuildings() {
592622
var map = window._weatherMap;
593-
if (!map || !map.isStyleLoaded || !map.isStyleLoaded()) return;
623+
if (!map) return;
624+
if (!map.isStyleLoaded || !map.isStyleLoaded()) {
625+
// A find can win the race against the style. Idempotent, so a stacked
626+
// retry only costs a setData with identical data.
627+
map.once("load", drawBuildings);
628+
return;
629+
}
594630
var data = featureCollection();
595631
var src = map.getSource("roof-buildings");
596632
if (src) { src.setData(data); return; }
@@ -600,15 +636,15 @@
600636
map.addLayer({
601637
id: "roof-buildings-fill", type: "fill", source: "roof-buildings",
602638
paint: {
603-
"fill-color": ["case", ["get", "selected"], picked, candidate],
604-
"fill-opacity": ["case", ["get", "selected"], 0.55, 0.25],
639+
"fill-color": whenSelected(picked, candidate),
640+
"fill-opacity": whenSelected(0.55, 0.25),
605641
},
606642
});
607643
map.addLayer({
608644
id: "roof-buildings-line", type: "line", source: "roof-buildings",
609645
paint: {
610-
"line-color": ["case", ["get", "selected"], picked, candidate],
611-
"line-width": ["case", ["get", "selected"], 2.5, 1],
646+
"line-color": whenSelected(picked, candidate),
647+
"line-width": whenSelected(2.5, 1),
612648
},
613649
});
614650
map.on("click", "roof-buildings-fill", function (e) {
@@ -624,6 +660,42 @@
624660
});
625661
}
626662

663+
// [[west, south], [east, north]] around the buildings someone would actually
664+
// pick — the nearby ones the list also shows — plus the site pin. Fitting
665+
// the whole search radius leaves every footprint a few pixels wide.
666+
function buildingsBounds(features, siteLat, siteLon) {
667+
var west = null, south = null, east = null, north = null;
668+
function extend(lon, lat) {
669+
if (typeof lon !== "number" || typeof lat !== "number") return;
670+
if (west === null || lon < west) west = lon;
671+
if (east === null || lon > east) east = lon;
672+
if (south === null || lat < south) south = lat;
673+
if (north === null || lat > north) north = lat;
674+
}
675+
extend(siteLon, siteLat);
676+
var near = features.filter(function (f) {
677+
return ((f.properties || {}).distance_m || 0) <= 150;
678+
});
679+
if (near.length < 3) near = features;
680+
near.forEach(function (f) {
681+
var rings = (f.geometry && f.geometry.coordinates) || [];
682+
(rings[0] || []).forEach(function (pt) { extend(pt[0], pt[1]); });
683+
});
684+
if (west === null) return null;
685+
return [[west, south], [east, north]];
686+
}
687+
688+
// The picker opens at city zoom, where a footprint is smaller than a pixel.
689+
// Zoom to the search results once per find; selection redraws leave the
690+
// camera where the operator put it.
691+
function fitToBuildings(siteLat, siteLon) {
692+
var map = window._weatherMap;
693+
if (!map || !roofState.features.length) return;
694+
var bounds = buildingsBounds(roofState.features, siteLat, siteLon);
695+
if (!bounds) return;
696+
map.fitBounds(bounds, { padding: 48, maxZoom: 17.5, duration: 600 });
697+
}
698+
627699
function selectBuilding(id) {
628700
roofState.selectedId = id;
629701
drawBuildings();
@@ -823,5 +895,9 @@
823895
},
824896
};
825897

826-
S.tabs.weather._pure = { arraysSummary: arraysSummary };
898+
S.tabs.weather._pure = {
899+
arraysSummary: arraysSummary,
900+
whenSelected: whenSelected,
901+
buildingsBounds: buildingsBounds,
902+
};
827903
})();

web/settings/tabs/weather.test.mjs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,68 @@ describe("weather household path", () => {
8282
assert.doesNotMatch(source, /observed.*pv_rated_w|peak.*pv_rated_w/i);
8383
});
8484
});
85+
86+
describe("roof buildings on the map", () => {
87+
const { whenSelected, buildingsBounds } = tab._pure;
88+
89+
function footprint(distanceM, ring) {
90+
return {
91+
type: "Feature",
92+
geometry: { type: "Polygon", coordinates: [ring] },
93+
properties: { distance_m: distanceM },
94+
};
95+
}
96+
97+
it("types the selected flag as a boolean, or MapLibre rejects the layer", () => {
98+
// A bare ["get", ...] case condition is value-typed; MapLibre then drops
99+
// the whole layer through its error event without throwing, and the map
100+
// silently stays empty.
101+
assert.deepEqual(whenSelected("#picked", "#other"), [
102+
"case", ["boolean", ["get", "selected"], false], "#picked", "#other",
103+
]);
104+
});
105+
106+
it("zooms to the near buildings plus the site, not the whole radius", () => {
107+
const bounds = buildingsBounds([
108+
footprint(40, [[18.06, 59.32], [18.07, 59.33], [18.06, 59.33], [18.06, 59.32]]),
109+
footprint(90, [[18.065, 59.325], [18.066, 59.326], [18.065, 59.326], [18.065, 59.325]]),
110+
footprint(120, [[18.068, 59.328], [18.069, 59.329], [18.068, 59.329], [18.068, 59.328]]),
111+
footprint(800, [[18.20, 59.40], [18.21, 59.41], [18.20, 59.41], [18.20, 59.40]]),
112+
], 59.335, 18.05);
113+
assert.deepEqual(bounds, [[18.05, 59.32], [18.07, 59.335]]);
114+
});
115+
116+
it("falls back to every footprint when almost nothing is near", () => {
117+
const bounds = buildingsBounds([
118+
footprint(800, [[18.20, 59.40], [18.21, 59.41], [18.20, 59.41], [18.20, 59.40]]),
119+
footprint(900, [[18.30, 59.42], [18.31, 59.43], [18.30, 59.43], [18.30, 59.42]]),
120+
], 59.33, 18.07);
121+
assert.deepEqual(bounds, [[18.07, 59.33], [18.31, 59.43]]);
122+
});
123+
124+
it("survives features with no usable geometry", () => {
125+
assert.equal(buildingsBounds([], undefined, undefined), null);
126+
assert.deepEqual(
127+
buildingsBounds([{ type: "Feature", properties: { distance_m: 5 } },
128+
{ type: "Feature", properties: { distance_m: 6 } },
129+
{ type: "Feature", properties: { distance_m: 7 } }], 59.33, 18.07),
130+
[[18.07, 59.33], [18.07, 59.33]],
131+
);
132+
});
133+
134+
it("bakes theme colours to sRGB — MapLibre cannot parse oklch()", () => {
135+
assert.match(source, /getImageData\(0, 0, 1, 1\)/);
136+
assert.match(source, /"rgb\(" \+ px\[0\]/);
137+
});
138+
139+
it("keeps a building click from dragging the site pin", () => {
140+
assert.match(
141+
source,
142+
/queryRenderedFeatures\(e\.point, \{ layers: \["roof-buildings-fill"\] \}\)/,
143+
);
144+
});
145+
146+
it("retries the draw once the style has loaded", () => {
147+
assert.match(source, /map\.once\("load", drawBuildings\)/);
148+
});
149+
});

0 commit comments

Comments
 (0)