diff --git a/cmd/server/config.go b/cmd/server/config.go index 557f76b7..661ca4bc 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -48,6 +48,12 @@ type Config struct { TypeColors map[string]interface{} `json:"typeColors"` Home map[string]interface{} `json:"home"` + // #1488 — marker stroke (outline) settings. Operators dial color, width + // and opacity to soften the default white outline when hundreds of + // nodes feel overwhelming. Frontend reads these as CSS vars; see + // public/customize-v2.js applyCSS markerStroke block. + MarkerStroke map[string]interface{} `json:"markerStroke,omitempty"` + MapDefaults struct { Center []float64 `json:"center"` Zoom int `json:"zoom"` @@ -329,6 +335,8 @@ type ThemeFile struct { NodeColors map[string]interface{} `json:"nodeColors"` TypeColors map[string]interface{} `json:"typeColors"` Home map[string]interface{} `json:"home"` + // #1488 — marker stroke overlay from theme.json. + MarkerStroke map[string]interface{} `json:"markerStroke,omitempty"` } func LoadConfig(baseDirs ...string) (*Config, error) { diff --git a/cmd/server/routes.go b/cmd/server/routes.go index cfe6fc05..bdc801ec 100644 --- a/cmd/server/routes.go +++ b/cmd/server/routes.go @@ -523,13 +523,23 @@ func (s *Server) handleConfigTheme(w http.ResponseWriter, r *http.Request) { } home := mergeMap(defaultHome, s.cfg.Home, theme.Home) + // #1488 — marker stroke overlay. Defaults mirror the :root values in + // public/style.css so a fresh visitor with no config + no override + // still gets the same painted outline as the static CSS fallback. + markerStroke := mergeMap(map[string]interface{}{ + "color": "rgba(255,255,255,0.85)", + "width": 1, + "opacity": 1, + }, s.cfg.MarkerStroke, theme.MarkerStroke) + writeJSON(w, ThemeResponse{ - Branding: branding, - Theme: themeColors, - ThemeDark: themeDark, - NodeColors: nodeColors, - TypeColors: typeColors, - Home: home, + Branding: branding, + Theme: themeColors, + ThemeDark: themeDark, + NodeColors: nodeColors, + TypeColors: typeColors, + Home: home, + MarkerStroke: markerStroke, }) } diff --git a/cmd/server/types.go b/cmd/server/types.go index 3818f6f6..630e773b 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -979,6 +979,9 @@ type ThemeResponse struct { NodeColors map[string]interface{} `json:"nodeColors"` TypeColors map[string]interface{} `json:"typeColors"` Home interface{} `json:"home"` + // #1488 — marker stroke overlay so the frontend can apply server-side + // defaults before the operator's localStorage override loads. + MarkerStroke map[string]interface{} `json:"markerStroke,omitempty"` } type MapConfigResponse struct { diff --git a/config.example.json b/config.example.json index 1224c0b3..1a8ed3c8 100644 --- a/config.example.json +++ b/config.example.json @@ -47,6 +47,12 @@ "observer": "#8b5cf6", "_comment": "Marker/badge colors per node role. Used on map, nodes list, and live feed." }, + "markerStroke": { + "color": "rgba(255,255,255,0.85)", + "width": 1, + "opacity": 1, + "_comment": "#1488 — outline around each map marker (live + map pages). 'color' accepts any CSS color (hex, rgb, rgba); use rgba() or drop 'opacity' below ~0.5 to soften the outline when hundreds of nodes feel overwhelming. 'width' is the SVG stroke width (0 hides the outline entirely). Operators can override per-browser via the in-app Theme Customizer (Colors tab → Marker Stroke)." + }, "mapDarkTileProvider": "carto-dark", "_comment_mapDarkTileProvider": "Default dark-mode basemap provider. Allowed: 'carto-dark' (Carto dark_all — default), 'esri-darkgray-labels' (Esri Dark Gray Canvas + reference labels), 'voyager-inverted' (Carto Voyager with CSS invert filter), 'positron-inverted' (Carto Positron with CSS invert filter). Light mode is unaffected. Users can override per-browser via the in-app customizer (persisted to localStorage). #1420.", "home": { diff --git a/public/customize-v2.js b/public/customize-v2.js index 94d17032..5f16734d 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -33,8 +33,8 @@ 'meshcore-live-heatmap-opacity' ]; - var VALID_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps', 'heatmapOpacity', 'liveHeatmapOpacity', 'distanceUnit', 'favorites', 'myNodes']; - var OBJECT_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps']; + var VALID_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps', 'heatmapOpacity', 'liveHeatmapOpacity', 'distanceUnit', 'favorites', 'myNodes', 'markerStroke']; + var OBJECT_SECTIONS = ['branding', 'theme', 'themeDark', 'nodeColors', 'typeColors', 'home', 'timestamps', 'markerStroke']; var SCALAR_SECTIONS = ['heatmapOpacity', 'liveHeatmapOpacity']; var DISTANCE_UNIT_VALUES = ['km', 'mi', 'auto']; @@ -409,6 +409,32 @@ } else { console.warn('[customizer-v2] Invalid opacity value rejected:', key, delta[key]); } + } else if (key === 'markerStroke' && typeof delta[key] === 'object' && delta[key] !== null) { + // #1488 — markerStroke { color, width, opacity } + var msIn = delta[key]; + var msOut = {}; + if (typeof msIn.color === 'string' && isValidColor(msIn.color)) { + msOut.color = msIn.color; + } else if (msIn.color != null) { + console.warn('[customizer-v2] Invalid markerStroke.color rejected:', msIn.color); + } + var msW = typeof msIn.width === 'string' ? parseFloat(msIn.width) : msIn.width; + if (msIn.width != null) { + if (isFinite(msW) && msW >= 0 && msW <= 10) { + msOut.width = msW; + } else { + console.warn('[customizer-v2] Invalid markerStroke.width rejected:', msIn.width); + } + } + var msO = typeof msIn.opacity === 'string' ? parseFloat(msIn.opacity) : msIn.opacity; + if (msIn.opacity != null) { + if (isValidOpacity(msO)) { + msOut.opacity = msO; + } else { + console.warn('[customizer-v2] Invalid markerStroke.opacity rejected:', msIn.opacity); + } + } + if (Object.keys(msOut).length) clean[key] = msOut; } else if (key === 'timestamps' && typeof delta[key] === 'object' && delta[key] !== null) { var ts = {}; var tsrc = delta[key]; @@ -637,6 +663,22 @@ localStorage.setItem('meshcore-live-heatmap-opacity', effectiveConfig.liveHeatmapOpacity); } + // #1488 — marker stroke: drive CSS vars from effective config. SVG + // markers across map.js / live.js / roles.js all read these vars, so + // a single write here repaints every mounted marker without a reload. + var ms = effectiveConfig.markerStroke; + if (ms && typeof ms === 'object') { + if (typeof ms.color === 'string' && ms.color) { + root.setProperty('--mc-marker-stroke-color', ms.color); + } + if (ms.width != null && isFinite(ms.width)) { + root.setProperty('--mc-marker-stroke-width', String(ms.width)); + } + if (ms.opacity != null && isFinite(ms.opacity)) { + root.setProperty('--mc-marker-stroke-opacity', String(ms.opacity)); + } + } + // Distance unit → sync to localStorage for all pages if (typeof effectiveConfig.distanceUnit === 'string' && DISTANCE_UNIT_VALUES.indexOf(effectiveConfig.distanceUnit) >= 0) { localStorage.setItem('meshcore-distance-unit', effectiveConfig.distanceUnit); @@ -1262,6 +1304,15 @@ var liveHeatOpacity = typeof eff.liveHeatmapOpacity === 'number' ? eff.liveHeatmapOpacity : 0.3; var liveHeatPct = Math.round(liveHeatOpacity * 100); + // #1488 — marker stroke controls. Defaults match the :root values in + // style.css; the UI shows the effective merged value (server config → + // local override) so the operator sees what's actually painted. + var ms = eff.markerStroke || {}; + var msColor = typeof ms.color === 'string' && ms.color ? ms.color : '#ffffff'; + var msWidth = ms.width != null && isFinite(ms.width) ? Number(ms.width) : 1; + var msOpacity = ms.opacity != null && isFinite(ms.opacity) ? Number(ms.opacity) : 1; + var msOpacityPct = Math.round(msOpacity * 100); + return '
' + '

Node Role Colors

' + '

These are the canonical role colors used across the app. They inherit from your server config (or built-in defaults), and can be optionally remapped by a colorblind-safe preset below.

' + @@ -1279,6 +1330,22 @@ '
Heatmap overlay on the Live page (0–100%)
' + '' + '' + liveHeatPct + '%' + + '
' + + '

Marker Stroke (#1488)

' + + '

Outline around each map marker. Dial these down when hundreds of nodes make the default white border overwhelming. Changes are live on every mounted marker.

' + + '
' + + '
Marker outline color. Defaults to white for contrast on dark + light tiles.
' + + '' + + '' + + '' + esc(msColor) + '
' + + '
' + + '
Stroke thickness in SVG units (0–4). Set to 0 to remove the outline entirely.
' + + '' + + '' + msWidth + '
' + + '
' + + '
Stroke alpha (0–100%). Drop this to ~30% for a softer outline.
' + + '' + + '' + msOpacityPct + '%
' + ''; } @@ -1995,6 +2062,11 @@ // Mirror to logo brand vars so the wordmark recolors live too. if (key === 'accent') document.documentElement.style.setProperty('--logo-accent', inp.value); if (key === 'accentHover') document.documentElement.style.setProperty('--logo-accent-hi', inp.value); + // #1488 — marker stroke color also gets a live CSS-var write + // so the operator sees outlines repaint on drag. + if (section === 'markerStroke' && key === 'color') { + document.documentElement.style.setProperty('--mc-marker-stroke-color', inp.value); + } // Update hex display var hex = inp.parentElement.querySelector('.cust-hex'); if (hex) hex.textContent = inp.value; @@ -2074,6 +2146,40 @@ }); }); + // #1488 — Marker stroke width/opacity sliders. Color picker uses the + // generic data-cv2-field handler (markerStroke.color) above. + container.querySelectorAll('[data-cv2-marker-stroke]').forEach(function (inp) { + var which = inp.dataset.cv2MarkerStroke; // 'width' | 'opacity' + inp.addEventListener('input', function () { + var raw = parseFloat(inp.value); + if (!isFinite(raw)) return; + if (which === 'opacity') { + var lbl = document.getElementById('cv2MarkerStrokeO'); + if (lbl) lbl.textContent = Math.round(raw) + '%'; + // Optimistic CSS write so the markers repaint on drag. + document.documentElement.style.setProperty('--mc-marker-stroke-opacity', String(raw / 100)); + } else { + var lblW = document.getElementById('cv2MarkerStrokeW'); + if (lblW) lblW.textContent = String(raw); + document.documentElement.style.setProperty('--mc-marker-stroke-width', String(raw)); + } + }); + inp.addEventListener('change', function () { + var raw = parseFloat(inp.value); + if (!isFinite(raw)) return; + var eff = _getEffective(); + var current = JSON.parse(JSON.stringify(eff.markerStroke || {})); + if (which === 'opacity') current.opacity = raw / 100; + else current.width = raw; + // Persist the whole object so partial picks survive (color + + // width + opacity coexist in a single section). + var delta = JSON.parse(JSON.stringify(readOverrides())); + delta.markerStroke = Object.assign({}, delta.markerStroke || {}, current); + writeOverrides(delta); + _runPipeline(); + }); + }); + // Home page list editing container.querySelectorAll('[data-cv2-home]').forEach(function (inp) { inp.addEventListener('input', function () { diff --git a/public/live.js b/public/live.js index 4fc38d9e..4e2295e9 100644 --- a/public/live.js +++ b/public/live.js @@ -2422,7 +2422,7 @@ ? window.makeRoleMarkerSVG(n.role, null, sizePx) : ''); + '" fill="' + fillExpr + '" stroke="var(--mc-marker-stroke-color)" stroke-width="var(--mc-marker-stroke-width)" stroke-opacity="var(--mc-marker-stroke-opacity)"/>'); const icon = L.divIcon({ html: svgHtml, diff --git a/public/map.js b/public/map.js index fc500aea..b951bc33 100644 --- a/public/map.js +++ b/public/map.js @@ -62,18 +62,21 @@ // operator picks a per-role override. colorOverride (MultiByte // status tint) wins when provided. const fillColor = colorOverride || ('var(--mc-role-' + (role || 'companion') + ')'); + // #1488 — marker stroke through CSS vars so operators can dial + // colour / width / opacity via the customizer (Colors → Marker Stroke). + const strokeAttr = ' stroke="var(--mc-marker-stroke-color)" stroke-width="var(--mc-marker-stroke-width)" stroke-opacity="var(--mc-marker-stroke-opacity)"'; const size = s.radius * 2 + 4; const c = size / 2; 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 @@ -84,7 +87,7 @@ hpts += (c + hr * Math.cos(ha)).toFixed(2) + ',' + (c + hr * Math.sin(ha)).toFixed(2) + ' '; } - path = ``; + path = ``; break; } case 'star': { @@ -97,11 +100,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 = ''; @@ -116,7 +119,7 @@ starPts += `${scx + so * Math.cos(aO)},${scy + so * Math.sin(aO)} `; starPts += `${scx + si * Math.cos(aI)},${scy + si * Math.sin(aI)} `; } - obsOverlay = ``; + obsOverlay = ``; } const svg = `${path}${obsOverlay}`; return L.divIcon({ diff --git a/public/roles.js b/public/roles.js index ed58f7b9..5d033537 100644 --- a/public/roles.js +++ b/public/roles.js @@ -278,20 +278,23 @@ // that need a fixed tint (matrix mode, stale dim) keep passing // their explicit colour. var fill = color || ('var(--mc-role-' + (role || 'companion') + ')'); + // #1488 — stroke routed through CSS vars so operators can dial + // colour/width without code edits (customizer Colors → Marker Stroke). + var strokeAttr = ' stroke="var(--mc-marker-stroke-color)" stroke-width="var(--mc-marker-stroke-width)" stroke-opacity="var(--mc-marker-stroke-opacity)"'; var path; switch (shape) { case 'square': path = ''; + '" fill="' + fill + '"' + strokeAttr + '/>'; break; case 'triangle': path = ''; + ' 2,' + (size - 2) + '" fill="' + fill + '"' + strokeAttr + '/>'; break; case 'diamond': path = ''; + '" fill="' + fill + '"' + strokeAttr + '/>'; break; case 'hexagon': { // Pointy-top hexagon centred at (c,c), inscribed radius ≈ c-1.5 @@ -303,7 +306,7 @@ (c + r * Math.sin(a)).toFixed(2) + ' '; } path = ''; + '"' + strokeAttr + '/>'; break; } case 'star': { @@ -316,12 +319,12 @@ spts += (cx + inner * Math.cos(aI)) + ',' + (cy + inner * Math.sin(aI)) + ' '; } path = ''; + '"' + strokeAttr + '/>'; break; } default: // circle path = ''; + '" fill="' + fill + '"' + strokeAttr + '/>'; } return ' 0, 'makeRoleMarkerSVG block located'); + const bakedFff = (block.match(/stroke="#fff"/g) || []).length; + const bakedWhite = (block.match(/stroke="white"/g) || []).length; + assert(bakedFff === 0, + 'makeRoleMarkerSVG has no baked stroke="#fff" (got: ' + bakedFff + ')'); + assert(bakedWhite === 0, + 'makeRoleMarkerSVG has no baked stroke="white" (got: ' + bakedWhite + ')'); + assert(/var\(--mc-marker-stroke-color\)/.test(block), + 'makeRoleMarkerSVG emits stroke="var(--mc-marker-stroke-color)"'); + assert(/var\(--mc-marker-stroke-width\)/.test(block), + 'makeRoleMarkerSVG emits stroke-width="var(--mc-marker-stroke-width)"'); +} + +console.log('\n=== #1488 C: map.js makeMarkerIcon uses CSS-var stroke ==='); +{ + const fnIdx = mapSrc.indexOf('function makeMarkerIcon'); + assert(fnIdx >= 0, 'makeMarkerIcon function located'); + const block = mapSrc.slice(fnIdx, fnIdx + 3500); + const bakedFff = (block.match(/stroke="#fff"/g) || []).length; + assert(bakedFff === 0, + 'makeMarkerIcon has no baked stroke="#fff" in default path (got: ' + bakedFff + ')'); + assert(/var\(--mc-marker-stroke-color\)/.test(block), + 'makeMarkerIcon references var(--mc-marker-stroke-color)'); +} + +console.log('\n=== #1488 D: live.js addNodeMarker fallback uses CSS-var stroke ==='); +{ + const addIdx = liveSrc.indexOf('function addNodeMarker'); + assert(addIdx >= 0, 'addNodeMarker function located'); + const block = liveSrc.slice(addIdx, addIdx + 3500); + const bakedFff = (block.match(/stroke="#fff"/g) || []).length; + assert(bakedFff === 0, + 'addNodeMarker fallback has no baked stroke="#fff" (got: ' + bakedFff + ')'); + assert(/var\(--mc-marker-stroke-color\)/.test(block), + 'addNodeMarker fallback references var(--mc-marker-stroke-color)'); +} + +console.log('\n=== #1488 E: customize-v2.js exposes marker stroke controls ==='); +{ + assert(/markerStroke|marker-stroke/i.test(customizeSrc), + 'customize-v2.js mentions markerStroke section'); + assert(/--mc-marker-stroke-color/.test(customizeSrc), + 'customize-v2.js writes --mc-marker-stroke-color'); + assert(/--mc-marker-stroke-width/.test(customizeSrc), + 'customize-v2.js writes --mc-marker-stroke-width'); + assert(/--mc-marker-stroke-opacity/.test(customizeSrc), + 'customize-v2.js writes --mc-marker-stroke-opacity'); +} + +console.log('\n--- Summary ---'); +console.log(passed + ' passed, ' + failed + ' failed'); +process.exit(failed > 0 ? 1 : 0);