From 0d131808d415fdabb22b4edccad88d09b9115a99 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Mon, 25 May 2026 07:53:33 -0700 Subject: [PATCH] =?UTF-8?q?fix(map):=20thinner=20always-on=20marker=20outl?= =?UTF-8?q?ine=20=E2=80=94=20was=20dominating=20at=20zoomed-out=20levels?= =?UTF-8?q?=20(#1347)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Operator feedback on #1334 PR #1334 (the #1293 marker a11y change) added a baked-in white outline at `stroke-width=2` to every node marker via `makeRoleMarkerSVG`. Operator reports it's too heavy and dominates the map at zoomed-out levels — every node reads as a "big white blob with a colour core", which actually drowns out the per-role shape silhouette at the exact zoom levels where the shape distinction matters most. ## Fix Drop the always-on stroke from **2 → 1** across all marker producers: | Producer | Before | After | |----------|--------|-------| | `public/roles.js` `makeRoleMarkerSVG` (circle / square / triangle / diamond / hexagon) | `stroke-width="2"` | `stroke-width="1"` | | `public/roles.js` `makeRoleMarkerSVG` (star branch) | `stroke-width="1.5"` | `stroke-width="1"` | | `public/live.js` `addNodeMarker` inline fallback SVG | `stroke-width="2"` | `stroke-width="1"` | | `public/map.js` `makeMarkerIcon` switch (all shapes) | `stroke-width="2"` / `"1.5"` | `stroke-width="1"` | | `_highlightRing` (pulse on selected/active) | `weight: 3 → 2` | **unchanged** | The highlight ring used by `pulseNodeMarker` is the one place where a heavy outline carries real signal (selected state), so it stays at weight 3 → 2. The always-on shape stroke is now just enough to keep silhouettes distinct on both Carto dark and light basemaps without dominating the surrounding terrain. ## Constraints preserved - Shape variation (#1293) — per-role shapes still rendered, helper untouched except for stroke width. - Colorblind palette — fills/colors unchanged, all via CSS variables / `ROLE_COLORS`. - Highlight ring still visible — pulse weight ≥ 2 retained and asserted. ## Tests New: `test-marker-outline-weight.js` (added to `test-all.sh` unit suite) - Asserts every `stroke-width` literal in `makeRoleMarkerSVG` is `<= 1`. - Asserts `live.js` inline fallback SVG `stroke-width <= 1`. - Asserts the `_highlightRing` (`ringHl.setStyle({ weight: N })`) keeps at least one `weight >= 2` so highlight stays visible. Red commit (`d17cfcc`) fails on assertion; green commit (`6cfe99b`) flips it. Existing `test-issue-1293-marker-shapes.js` still passes — the shape-variation and outline-ring highlight contracts are intact. --------- Co-authored-by: openclaw-bot --- public/live.js | 2 +- public/map.js | 12 +++--- public/roles.js | 12 +++--- test-all.sh | 1 + test-marker-outline-weight.js | 74 +++++++++++++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 13 deletions(-) create mode 100644 test-marker-outline-weight.js diff --git a/public/live.js b/public/live.js index a8249fbc..f3d6599f 100644 --- a/public/live.js +++ b/public/live.js @@ -2373,7 +2373,7 @@ ? window.makeRoleMarkerSVG(n.role, color, sizePx) : ''); + '" fill="' + color + '" stroke="#fff" stroke-width="1"/>'); const icon = L.divIcon({ html: svgHtml, diff --git a/public/map.js b/public/map.js index 54fb9504..751a2cfc 100644 --- a/public/map.js +++ b/public/map.js @@ -36,13 +36,13 @@ let path; switch (s.shape) { case 'diamond': - path = ``; + path = ``; break; case 'square': - path = ``; + path = ``; break; case 'triangle': - path = ``; + path = ``; break; case 'hexagon': { // #1293 — pointy-top hexagon for room servers @@ -53,7 +53,7 @@ hpts += (c + hr * Math.cos(ha)).toFixed(2) + ',' + (c + hr * Math.sin(ha)).toFixed(2) + ' '; } - path = ``; + path = ``; break; } case 'star': { @@ -66,11 +66,11 @@ pts += `${cx + outer * Math.cos(aOuter)},${cy + outer * Math.sin(aOuter)} `; pts += `${cx + inner * Math.cos(aInner)},${cy + inner * Math.sin(aInner)} `; } - path = ``; + path = ``; break; } default: // circle - path = ``; + path = ``; } // If this node is also an observer, add a small star overlay let obsOverlay = ''; diff --git a/public/roles.js b/public/roles.js index d328c62d..59d8bcb0 100644 --- a/public/roles.js +++ b/public/roles.js @@ -100,16 +100,16 @@ switch (shape) { case 'square': path = ''; + '" fill="' + fill + '" stroke="#fff" stroke-width="1"/>'; break; case 'triangle': path = ''; + ' 2,' + (size - 2) + '" fill="' + fill + '" stroke="#fff" stroke-width="1"/>'; break; case 'diamond': path = ''; + '" fill="' + fill + '" stroke="#fff" stroke-width="1"/>'; break; case 'hexagon': { // Pointy-top hexagon centred at (c,c), inscribed radius ≈ c-1.5 @@ -121,7 +121,7 @@ (c + r * Math.sin(a)).toFixed(2) + ' '; } path = ''; + '" stroke="#fff" stroke-width="1"/>'; break; } case 'star': { @@ -134,12 +134,12 @@ spts += (cx + inner * Math.cos(aI)) + ',' + (cy + inner * Math.sin(aI)) + ' '; } path = ''; + '" stroke="#fff" stroke-width="1"/>'; break; } default: // circle path = ''; + '" fill="' + fill + '" stroke="#fff" stroke-width="1"/>'; } return '= 2) so the highlight remains visible. + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +let passed = 0, failed = 0; +function assert(cond, msg) { + if (cond) { passed++; console.log(' ✓ ' + msg); } + else { failed++; console.error(' ✗ ' + msg); } +} + +const rolesSrc = fs.readFileSync(path.join(__dirname, 'public', 'roles.js'), 'utf8'); +const liveSrc = fs.readFileSync(path.join(__dirname, 'public', 'live.js'), 'utf8'); + +console.log('\n=== marker outline weight: always-on stroke is thin ==='); + +const helperMatch = rolesSrc.match(/window\.makeRoleMarkerSVG[\s\S]*?\n\s*\};/); +const helperBlock = helperMatch ? helperMatch[0] : ''; +assert(helperBlock.length > 0, 'makeRoleMarkerSVG block located'); + +// Every stroke-width literal inside the helper must be <= 1. +const widthRe = /stroke-width="([0-9.]+)"/g; +let m, widths = []; +while ((m = widthRe.exec(helperBlock)) !== null) { + widths.push(parseFloat(m[1])); +} +assert(widths.length > 0, 'helper contains stroke-width literals'); +const maxW = widths.reduce((a, b) => Math.max(a, b), 0); +assert(maxW <= 1, + 'makeRoleMarkerSVG max stroke-width <= 1 (got ' + maxW + ' across ' + + widths.length + ' shapes)'); + +// live.js inline fallback SVG must also be thin (it can render before +// roles.js loads in degraded scenarios). +const addNodeIdx = liveSrc.indexOf('function addNodeMarker'); +const addNodeBody = liveSrc.slice(addNodeIdx, addNodeIdx + 2500); +const fallbackMatch = addNodeBody.match(/stroke="#fff"\s+stroke-width="([0-9.]+)"/); +if (fallbackMatch) { + assert(parseFloat(fallbackMatch[1]) <= 1, + 'live.js inline fallback SVG stroke-width <= 1 (got ' + fallbackMatch[1] + ')'); +} + +console.log('\n=== highlight ring stays visible (weight >= 2) ==='); + +// The pulseNodeMarker / highlight ring uses ring.setStyle({ weight: N }). +// At least one such setStyle on _highlightRing must use weight >= 2 so +// the selected/highlighted node remains obviously highlighted. +const ringWeightRe = /ringHl\.setStyle\(\s*\{[^}]*weight:\s*([0-9.]+)/g; +let rm, ringWeights = []; +while ((rm = ringWeightRe.exec(liveSrc)) !== null) { + ringWeights.push(parseFloat(rm[1])); +} +assert(ringWeights.length >= 1, + 'highlight ring (_highlightRing) sets weight at least once'); +const maxRing = ringWeights.reduce((a, b) => Math.max(a, b), 0); +assert(maxRing >= 2, + 'highlight ring max weight >= 2 (got ' + maxRing + ') so highlight stays visible'); + +console.log('\n=== Summary ==='); +console.log(` Passed: ${passed}`); +console.log(` Failed: ${failed}`); +if (failed > 0) { console.error('\nmarker-outline-weight FAIL'); process.exit(1); } +console.log('\nmarker-outline-weight PASS');