Files
meshcore-analyzer/test-issue-1293-marker-shapes.js
T
Kpa-clawbot 0f7c03ccaf fix(#1293): role-aware marker shapes + outline-ring highlight (#1334)
Fixes #1293

## What

Marker shape now varies per role (WCAG 1.4.1 — colour is no longer the
only carrier of role identity), and the live map's selection/highlight
no longer stacks same-colour concentric markers.

| Role      | Shape    | Why |
|-----------|----------|-----|
| repeater  | circle   | default, most common |
| companion | square   | flat sides, easy to distinguish from circle |
| room      | hexagon  | tessellation hint = group |
| sensor    | triangle | "alert-like" silhouette |
| observer  | diamond  | network-infrastructure suggestion |

Existing role colours are preserved; the shape is the new differentiator
so red/green colourblind operators can still tell roles apart.

## How

- `public/roles.js`: new `window.ROLE_SHAPES` map (single source of
truth), `ROLE_STYLE.shape` synced, shared
`window.makeRoleMarkerSVG(role, color, size)` helper that emits
self-contained `<svg>` strings — including a new `hexagon` branch.
- `public/map.js`: `makeMarkerIcon` switch picks up the `hexagon` case.
- `public/live.js`: `addNodeMarker` now builds an `L.divIcon` via
`makeRoleMarkerSVG` (was a flat `L.circleMarker` — colour only). A
hidden stroke-only `_highlightRing` is allocated per marker; `pulseNode`
grows + fades that ring instead of recolouring the marker fill, so the
blue-on-blue concentric stacking the issue called out cannot occur.
`rescaleMarkers`, `pruneStaleNodes`, matrix mode toggling now drive the
divIcon via small DOM helpers.
- `public/live.js` role legend: emits SVG shape + colour swatch (was a
bare coloured dot).
- `public/live.css`: `.live-shape-swatch` wrapper for the SVG legend
swatches.

## TDD

Red commit: `7e5e2d95` — `test-issue-1293-marker-shapes.js` asserts the
shape map, helper, hexagon branches, divIcon switch in `addNodeMarker`,
SVG-based legend, and outline-ring highlight (no same-colour fill
overlay). Wired into `deploy.yml` JS unit tests.

Green commit: `fb33ca96`.

## Design check

Coblis simulator (deuteranopia / protanopia / tritanopia) — reviewer to
run on the staging build; shapes carry the signal independent of hue, so
all role categories should remain distinguishable. Existing colours are
retained per the issue's "keep colours, vary shape" guidance.

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
— all gates pass.

---------

Co-authored-by: corescope-bot <bot@corescope>
2026-05-23 20:54:12 -07:00

127 lines
5.7 KiB
JavaScript

/**
* #1293 — Marker shape variation per role + colorblind-safe palette.
*
* Acceptance:
* - ROLE_SHAPES map exposed by roles.js, with repeater=circle,
* companion=square, room=hexagon, sensor=triangle, observer=diamond.
* - ROLE_STYLE.shape values match ROLE_SHAPES (single source of truth).
* - A shared helper `window.makeRoleMarkerSVG(role, color, size)` exists
* and can produce a hexagon path for the room role (covers the
* previously-missing shape in map.js's switch).
* - public/live.js uses `L.divIcon` (shape-aware) for node markers,
* NOT the legacy `L.circleMarker` in `addNodeMarker`.
* - public/live.js legend renders SVG marker swatches (not flat dots) so
* colorblind users can distinguish shape, not only colour.
* - public/map.js switch handles `case 'hexagon'`.
* - Selected/highlighted state uses an outline RING (no same-colour
* filled overlay) — i.e. the highlight path sets fillOpacity:0
* (or 'transparent') and uses a stroke-based ring helper.
*
* Pure-string assertions; no DOM/browser required so this can land
* in the JS-unit-tests step of the CI workflow (fast red).
*/
'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');
const mapSrc = fs.readFileSync(path.join(__dirname, 'public', 'map.js'), 'utf8');
console.log('\n=== #1293: ROLE_SHAPES single source of truth ===');
// ROLE_SHAPES map declared on window
assert(/window\.ROLE_SHAPES\s*=\s*\{/.test(rolesSrc),
'roles.js declares window.ROLE_SHAPES map');
// Required role → shape pairings (line-order independent)
const shapeBlockMatch = rolesSrc.match(/window\.ROLE_SHAPES\s*=\s*\{([\s\S]*?)\};/);
const shapeBlock = shapeBlockMatch ? shapeBlockMatch[1] : '';
const expectedShapes = {
repeater: 'circle',
companion: 'square',
room: 'hexagon',
sensor: 'triangle',
observer: 'diamond',
};
for (const role of Object.keys(expectedShapes)) {
const re = new RegExp(role + '\\s*:\\s*[\'\"]' + expectedShapes[role] + '[\'\"]');
assert(re.test(shapeBlock), `ROLE_SHAPES.${role} === '${expectedShapes[role]}'`);
}
// ROLE_STYLE shape values match the new map
const styleBlockMatch = rolesSrc.match(/window\.ROLE_STYLE\s*=\s*\{([\s\S]*?)\};/);
const styleBlock = styleBlockMatch ? styleBlockMatch[1] : '';
for (const role of Object.keys(expectedShapes)) {
// crude per-line check
const lineRe = new RegExp(role + '\\s*:[^}]*shape:\\s*[\'\"]' + expectedShapes[role] + '[\'\"]');
assert(lineRe.test(styleBlock),
`ROLE_STYLE.${role}.shape === '${expectedShapes[role]}' (matches ROLE_SHAPES)`);
}
console.log('\n=== #1293: shared SVG helper covers hexagon ===');
assert(/window\.makeRoleMarkerSVG\s*=\s*function/.test(rolesSrc),
'roles.js exposes window.makeRoleMarkerSVG(role, color, size)');
// Helper string must include a hexagon branch (matches map.js switch)
const helperMatch = rolesSrc.match(/window\.makeRoleMarkerSVG[\s\S]*?\n\s*\};/);
const helperBlock = helperMatch ? helperMatch[0] : '';
assert(/case\s+['\"]hexagon['\"]/.test(helperBlock),
'helper handles case "hexagon" (room role)');
assert(/case\s+['\"]square['\"]/.test(helperBlock),
'helper handles case "square"');
assert(/case\s+['\"]triangle['\"]/.test(helperBlock),
'helper handles case "triangle"');
assert(/case\s+['\"]diamond['\"]/.test(helperBlock),
'helper handles case "diamond"');
console.log('\n=== #1293: map.js switch handles hexagon ===');
assert(/case\s+['\"]hexagon['\"]/.test(mapSrc),
'map.js makeMarkerIcon switch has a "hexagon" branch');
console.log('\n=== #1293: live.js node markers use shape-aware divIcons ===');
// Carve out addNodeMarker body (best-effort) and assert it uses divIcon.
const addNodeIdx = liveSrc.indexOf('function addNodeMarker');
assert(addNodeIdx > 0, 'live.js addNodeMarker function present');
const addNodeBody = liveSrc.slice(addNodeIdx, addNodeIdx + 2500);
assert(/L\.divIcon|window\.makeRoleMarkerSVG|makeRoleMarkerSVG\s*\(/.test(addNodeBody),
'addNodeMarker uses L.divIcon / makeRoleMarkerSVG (not legacy circleMarker)');
assert(!/L\.circleMarker\(\s*\[\s*n\.lat/.test(addNodeBody),
'addNodeMarker no longer creates L.circleMarker for the node itself');
console.log('\n=== #1293: live.js legend renders shape swatches ===');
// The role legend block (id="roleLegendList") must inject SVG, not a
// flat live-dot span only.
const legendIdx = liveSrc.indexOf("getElementById('roleLegendList')");
assert(legendIdx > 0, 'live.js renders roleLegendList');
const legendBody = liveSrc.slice(legendIdx, legendIdx + 1500);
assert(/<svg|makeRoleMarkerSVG/.test(legendBody),
'roleLegendList swatches include SVG shape (not bare colour dot)');
console.log('\n=== #1293: selected/highlight uses outline ring (no same-colour fill overlay) ===');
// New behaviour: marker highlight pulse must NOT recolor marker fill to
// the same packet colour stacked over a same-coloured base. The fix
// uses a stroke ring (fillOpacity 0 / 'transparent') for the overlay.
assert(/highlightNodeRing|RingHighlight|highlightRing/.test(liveSrc) ||
/fillOpacity:\s*0[,\s}]/.test(liveSrc.slice(liveSrc.indexOf('animatePulse') || 0,
(liveSrc.indexOf('animatePulse') || 0) + 1500)),
'highlight path uses a transparent-fill ring (no same-colour concentric fill)');
console.log('\n=== Summary ===');
console.log(` Passed: ${passed}`);
console.log(` Failed: ${failed}`);
if (failed > 0) { console.error('\n#1293 FAIL'); process.exit(1); }
console.log('\n#1293 PASS');