feat: update discovery map with dual-halo badge icons, update default basemap to OpenStreetMap, and improve marker label visibility

This commit is contained in:
Ivan 2026-07-22 18:10:31 -05:00
parent 3d7387283d
commit 5567022348
No known key found for this signature in database
17 changed files with 644 additions and 109 deletions

View file

@ -36,6 +36,8 @@ All notable changes to this project will be documented in this file.
### Changed
- Discovery map markers use dual-halo badge icons (peers, LXMF, discovered, tracking, stale) with banded cluster badges, zoom-gated labels, and cached styles for denser maps
- Map default basemap is OpenStreetMap (OSM). Style presets list OSM first and identity config wins over stale cached tile URLs
- UI opens sooner: HTTP binds first, Reticulum starts in the background
- Conversations load faster: slim list and thread queries via fields_meta and attachment flags
- Conversation list uses a per-peer summary table so refreshes no longer scan the full message history

Binary file not shown.

View file

@ -590,8 +590,8 @@
<div class="grid grid-cols-5 gap-1">
<button
v-for="style in [
{ id: 'openfreemap', label: 'OFM' },
{ id: 'osm', label: 'OSM' },
{ id: 'openfreemap', label: 'OFM' },
{ id: 'carto-dark', label: 'Dark' },
{ id: 'carto-voyager', label: 'Voy' },
{ id: 'carto-light', label: 'Lite' },
@ -1115,7 +1115,7 @@ import VectorSource from "ol/source/Vector";
import Feature from "ol/Feature";
import Point from "ol/geom/Point";
import { getMdiIconPath } from "../../js/mdiIconNames.js";
import { Style, Text, Fill, Stroke, Circle as CircleStyle, Icon } from "ol/style";
import { Style, Fill, Stroke, Circle as CircleStyle } from "ol/style";
import { shared as olIconCache } from "ol/style/IconImageCache";
import { fromLonLat, toLonLat } from "ol/proj";
import { defaults as defaultControls } from "ol/control";
@ -1147,13 +1147,28 @@ import {
dedupeDiscoveredMapNodes as dedupeDiscoveredMapNodesHelper,
dedupeTelemetryMarkersForMap as dedupeTelemetryMarkersForMapHelper,
} from "./internal/mapDedupe.js";
import {
BADGE_SCALE_HOVER,
BADGE_SCALE_NORMAL,
DEFAULT_DISCOVERED_FACE,
DEFAULT_PEER_FACE,
DEFAULT_PEER_GLYPH,
getCachedClusterStyle,
getCachedPeerBadgeStyle,
shouldShowMarkerLabel,
} from "./internal/markerStyles.js";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ContextMenuItem from "../contextmenu/ContextMenuItem.vue";
import ContextMenuPanel from "../contextmenu/ContextMenuPanel.vue";
import DOMPurify from "dompurify";
import ToastUtils from "../../js/ToastUtils";
import TileCache from "../../js/TileCache";
import { detectRasterTileProviderId, nextRasterTileProviderId, TILE_PROVIDER_URLS } from "../../js/mapTileProviders.js";
import {
detectRasterTileProviderId,
nextRasterTileProviderId,
TILE_PROVIDER_URLS,
DEFAULT_TILE_SERVER_URL,
} from "../../js/mapTileProviders.js";
import {
fetchTileBlobWithRetry,
fetchJsonWithRetry,
@ -1184,7 +1199,7 @@ import { styleFromMcxProperties } from "../../js/mapExchange/styleFromProperties
import { computeSegmentMetrics, buildBearingOverlayHtml, buildBearingLiveTooltipHtml } from "../../js/mapGeodesy.js";
const OPENFREEMAP_DEFAULT_STYLE = "https://tiles.openfreemap.org/styles/bright";
const DEFAULT_OSM_RASTER = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const DEFAULT_OSM_RASTER = DEFAULT_TILE_SERVER_URL;
const OFFLINE_MB_TILES_URL = "/api/v1/map/tiles/{z}/{x}/{y}.png";
const OFFLINE_TRANSPARENT_TILE_PNG =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
@ -1479,6 +1494,10 @@ export default {
if (!newVal || !oldVal || newVal.telemetry?.destination_hash !== oldVal.telemetry?.destination_hash) {
this.isMiniChatOpen = false;
}
// Re-evaluate zoom-gated labels when selection changes.
if (typeof this.markerLayer?.changed === "function") {
this.markerLayer.changed();
}
},
showSaveDrawingModal(val) {
if (val) {
@ -1527,7 +1546,8 @@ export default {
this.currentCenter = savedState.center || [0, 0];
this.currentZoom = savedState.zoom || 2;
if (savedState.offlineEnabled !== undefined) this.offlineEnabled = savedState.offlineEnabled;
if (savedState.tileServerUrl) this.tileServerUrl = savedState.tileServerUrl;
// Tile URL comes from identity config (defaults to OSM). Do not let a stale
// TileCache map-state override the configured basemap.
if (savedState.telemetry) this.telemetryList = savedState.telemetry;
// Temporarily store drawings to restore after map/source init
@ -1837,13 +1857,14 @@ export default {
if (type === "note" || geomType === "Point") {
const isNote = type === "note";
return this.createMarkerStyle({
iconColor: isNote ? "#f59e0b" : "#3b82f6",
bgColor: "#ffffff",
iconColor: DEFAULT_PEER_GLYPH,
bgColor: isNote ? "#d97706" : "#2563eb",
label: isNote && feature.get("note") ? "Note" : "",
isStale: false,
iconPath: isNote
? "M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"
: null,
showLabel: Boolean(isNote && feature.get("note")),
});
}
return new Style({
@ -1958,26 +1979,40 @@ export default {
if (feature.get("cluster")) {
const style = this.createClusterStyle(feature.get("clusterCount") || 0, isHovered);
style.setZIndex(isHovered ? 1000 : 200);
const z = isHovered ? 1000 : 200;
if (Array.isArray(style)) {
for (const s of style) s.setZIndex(z);
} else {
style.setZIndex(z);
}
return style;
}
const scale = isHovered ? 0.85 : 0.6;
const scale = isHovered ? BADGE_SCALE_HOVER : BADGE_SCALE_NORMAL;
const zIndex = isHovered ? 1000 : 100;
const zoom = this.map?.getView?.()?.getZoom?.();
const selectedHash = this.selectedMarker?.telemetry?.destination_hash;
const t = feature.get("telemetry");
const peer = feature.get("peer");
const disc = feature.get("discovered");
const isSelected = Boolean(
(t && selectedHash && t.destination_hash === selectedHash) ||
(disc && this.selectedMarker?.discovered && this.selectedMarker.discovered === disc)
);
const showLabel = shouldShowMarkerLabel({
zoom,
hovered: isHovered,
selected: isSelected,
});
let displayName = "";
let isStale = false;
let iconColor = "#2563eb";
let bgColor = "#ffffff";
let iconColor = DEFAULT_PEER_GLYPH;
let bgColor = DEFAULT_PEER_FACE;
let iconPath = null;
if (t) {
displayName = peer?.display_name || t.destination_hash.substring(0, 8);
// Calculate staleness
const now = Date.now();
const updatedAt = t.updated_at
? new Date(t.updated_at).getTime()
@ -1995,12 +2030,13 @@ export default {
}
} else if (disc) {
displayName = disc.name;
iconColor = "#10b981"; // emerald-500
bgColor = "#d1fae5"; // emerald-100
iconColor = DEFAULT_PEER_GLYPH;
bgColor = DEFAULT_DISCOVERED_FACE;
iconPath = this.getMdiPath(this.getDiscoveredIconName(disc));
} else if (feature === this.queryMarker) {
displayName = "Search Result";
iconColor = "#ef4444";
iconColor = DEFAULT_PEER_GLYPH;
bgColor = "#dc2626";
}
const style = this.createMarkerStyle({
@ -2011,6 +2047,7 @@ export default {
iconPath,
scale,
isTracking: t ? t.is_tracking : false,
showLabel,
});
style.setZIndex(zIndex);
return style;
@ -2148,11 +2185,12 @@ export default {
});
feature.setStyle(
this.createMarkerStyle({
iconColor: "#2563eb",
bgColor: "#bfdbfe",
iconColor: DEFAULT_PEER_GLYPH,
bgColor: "#2563eb",
label: String(label),
isStale: false,
iconPath: null,
showLabel: true,
})
);
this.queryMarker = feature;
@ -4928,25 +4966,10 @@ export default {
}
},
createClusterStyle(count, isHovered) {
const cacheKey = `cluster-${count}-${isHovered ? "h" : "n"}`;
if (this.styleCache[cacheKey]) return this.styleCache[cacheKey];
const radius = isHovered ? 20 : 18;
const style = new Style({
image: new CircleStyle({
radius,
fill: new Fill({ color: "rgba(37, 99, 235, 0.92)" }),
stroke: new Stroke({ color: "#ffffff", width: 2 }),
}),
text: new Text({
text: String(count),
font: "bold 13px sans-serif",
fill: new Fill({ color: "#ffffff" }),
stroke: new Stroke({ color: "rgba(0,0,0,0.35)", width: 1 }),
}),
return getCachedClusterStyle(this.styleCache, {
count,
hovered: Boolean(isHovered),
});
this.styleCache[cacheKey] = style;
return style;
},
buildClusterItems(feature) {
return buildClusterItemsHelper(feature);
@ -5068,67 +5091,26 @@ export default {
}
});
},
createMarkerStyle({ iconColor, bgColor, label, isStale, iconPath, scale = 0.6, isTracking = false }) {
const cacheKey = `${iconColor}-${bgColor}-${label}-${isStale}-${iconPath || "default"}-${scale}-${isTracking}`;
if (this.styleCache[cacheKey]) return this.styleCache[cacheKey];
const markerFill = isStale ? "#d1d5db" : bgColor;
const markerStroke = isStale ? "#9ca3af" : iconColor;
const path =
iconPath ||
"M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7Zm0 11a2 2 0 1 1 0-4 2 2 0 0 1 0 4Z";
const baseSize = isTracking ? 32 : 24;
const renderSize = baseSize * 2;
let svg = "";
if (isTracking) {
svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${renderSize}" height="${renderSize}" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="10" fill="none" stroke="#3b82f6" stroke-width="2">
<animate attributeName="r" from="10" to="15" dur="1.5s" repeatCount="indefinite" />
<animate attributeName="stroke-opacity" from="1" to="0" dur="1.5s" repeatCount="indefinite" />
</circle>
<circle cx="16" cy="16" r="10" fill="#3b82f6" fill-opacity="0.2">
<animate attributeName="r" from="8" to="12" dur="1.5s" repeatCount="indefinite" />
</circle>
<g transform="translate(4,4)">
<path d="${path}" fill="${markerFill}" stroke="${markerStroke}" stroke-width="1.5"/>
</g>
</svg>`;
} else {
svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${renderSize}" height="${renderSize}" viewBox="0 0 24 24"><path d="${path}" fill="${markerFill}" stroke="${markerStroke}" stroke-width="1.5"/></svg>`;
}
const svgBytes = new TextEncoder().encode(svg);
let svgBinary = "";
const chunkSize = 8192;
for (let i = 0; i < svgBytes.length; i += chunkSize) {
const sub = svgBytes.subarray(i, i + chunkSize);
svgBinary += String.fromCharCode(...sub);
}
const src = "data:image/svg+xml;base64," + btoa(svgBinary);
const displayHeight = renderSize * scale;
const labelOffset = -(displayHeight + 6);
const style = new Style({
image: new Icon({
src: src,
anchor: [0.5, 1],
scale: scale,
imgSize: [renderSize, renderSize],
}),
text: new Text({
text: label,
offsetY: labelOffset,
font: "bold 12px sans-serif",
fill: new Fill({ color: isStale ? "#6b7280" : "#111827" }),
stroke: new Stroke({ color: "#ffffff", width: 3 }),
}),
createMarkerStyle({
iconColor,
bgColor,
label,
isStale,
iconPath,
scale = BADGE_SCALE_NORMAL,
isTracking = false,
showLabel = true,
}) {
return getCachedPeerBadgeStyle(this.styleCache, {
iconColor,
bgColor,
label,
isStale,
iconPath,
scale,
isTracking,
showLabel,
});
this.styleCache[cacheKey] = style;
return style;
},
onMarkerClick(feature) {
this.selectedMarker = {

View file

@ -0,0 +1,351 @@
// SPDX-License-Identifier: 0BSD
/**
* Pure OpenLayers marker style builders for the discovery map.
*
* Badge SVGs use a dual halo (dark outer plus light inner rim) so the same asset
* stays readable on both dark and light basemaps without theme branching.
*/
import { Style, Text, Fill, Stroke, Circle as CircleStyle, Icon } from "ol/style";
/** Default indigo face for peers without an LXMF user icon. */
export const DEFAULT_PEER_FACE = "#3730a3";
/** Default white glyph on peer badges. */
export const DEFAULT_PEER_GLYPH = "#ffffff";
/** Emerald face for discovered interface markers. */
export const DEFAULT_DISCOVERED_FACE = "#059669";
/** Fallback MDI-style map pin path (24x24 viewBox). */
export const DEFAULT_MAP_PIN_PATH =
"M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7Zm0 11a2 2 0 1 1 0-4 2 2 0 0 1 0 4Z";
/** Show permanent name labels at or above this zoom. */
export const MARKER_LABEL_MIN_ZOOM = 12;
/** Normal badge Icon scale. */
export const BADGE_SCALE_NORMAL = 1;
/** Hovered badge Icon scale. */
export const BADGE_SCALE_HOVER = 1.12;
const STALE_FACE = "#64748b";
const STALE_GLYPH = "#e2e8f0";
/**
* Parse #rgb / #rrggbb into 0-1 linear-ish sRGB channels for luminance.
* @param {string} color
* @returns {{r:number,g:number,b:number}|null}
*/
function parseHexColor(color) {
if (typeof color !== "string") return null;
let hex = color.trim();
if (hex.startsWith("#")) hex = hex.slice(1);
if (hex.length === 3) {
hex = hex
.split("")
.map((c) => c + c)
.join("");
}
if (!/^[0-9a-fA-F]{6}$/.test(hex)) return null;
return {
r: parseInt(hex.slice(0, 2), 16) / 255,
g: parseInt(hex.slice(2, 4), 16) / 255,
b: parseInt(hex.slice(4, 6), 16) / 255,
};
}
/**
* Relative luminance (WCAG-ish) for a hex color.
* @param {string} color
* @returns {number} 0-1, or 0.5 when unparseable
*/
export function relativeLuminance(color) {
const rgb = parseHexColor(color);
if (!rgb) return 0.5;
const lin = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
return 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b);
}
/**
* Contrast ratio between two hex colors.
* @param {string} a
* @param {string} b
* @returns {number}
*/
export function contrastRatio(a, b) {
const L1 = relativeLuminance(a);
const L2 = relativeLuminance(b);
const lighter = Math.max(L1, L2);
const darker = Math.min(L1, L2);
return (lighter + 0.05) / (darker + 0.05);
}
/**
* Pick a readable glyph color for a badge face.
* Prefers the preferred color when contrast against bg is at least 3 to 1.
* @param {string} bg
* @param {string} [preferred]
* @returns {string}
*/
export function contrastGlyphColor(bg, preferred) {
const white = "#ffffff";
const ink = "#0f172a";
if (preferred && contrastRatio(preferred, bg) >= 3) {
return preferred;
}
return relativeLuminance(bg) > 0.45 ? ink : white;
}
/**
* Whether a marker name label should render.
* @param {{zoom?:number, hovered?:boolean, selected?:boolean}} opts
* @returns {boolean}
*/
export function shouldShowMarkerLabel({ zoom, hovered = false, selected = false } = {}) {
if (hovered || selected) return true;
return Number.isFinite(zoom) && zoom >= MARKER_LABEL_MIN_ZOOM;
}
/**
* Encode an SVG markup string as a data URL for ol/style/Icon.
* @param {string} svg
* @returns {string}
*/
export function encodeSvgDataUrl(svg) {
const bytes = new TextEncoder().encode(svg);
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return "data:image/svg+xml;base64," + btoa(binary);
}
/**
* Build dual-halo peer / discovered badge SVG markup.
* @param {{face:string, glyph:string, pathD?:string, stale?:boolean, tracking?:boolean}} opts
* @returns {string}
*/
export function buildPeerBadgeSvg({ face, glyph, pathD = DEFAULT_MAP_PIN_PATH, stale = false, tracking = false } = {}) {
const faceColor = stale ? STALE_FACE : face || DEFAULT_PEER_FACE;
const glyphColor = stale ? STALE_GLYPH : glyph || DEFAULT_PEER_GLYPH;
const opacity = stale ? "0.72" : "1";
const showPulse = tracking && !stale;
const size = showPulse ? 48 : 40;
const cx = size / 2;
const cy = size / 2;
const r = showPulse ? 12 : 11;
const pulse = showPulse
? `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#38bdf8" stroke-width="2">
<animate attributeName="r" from="${r}" to="${r + 8}" dur="1.5s" repeatCount="indefinite" />
<animate attributeName="stroke-opacity" from="0.9" to="0" dur="1.5s" repeatCount="indefinite" />
</circle>
<circle cx="${cx}" cy="${cy}" r="${r - 1}" fill="#38bdf8" fill-opacity="0.18">
<animate attributeName="r" from="${r - 2}" to="${r + 3}" dur="1.5s" repeatCount="indefinite" />
<animate attributeName="fill-opacity" from="0.22" to="0" dur="1.5s" repeatCount="indefinite" />
</circle>`
: "";
// Dual halo is dark outer rim plus light inner rim then face fill then glyph.
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
<g opacity="${opacity}">
<circle class="badge-shadow" cx="${cx + 0.6}" cy="${cy + 1.1}" r="${r + 1}" fill="rgba(15,23,42,0.28)"/>
${pulse}
<circle class="badge-rim-dark" cx="${cx}" cy="${cy}" r="${r}" fill="${faceColor}" stroke="rgba(15,23,42,0.78)" stroke-width="3"/>
<circle class="badge-rim-light" cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="rgba(255,255,255,0.92)" stroke-width="1.6"/>
<g transform="translate(${cx},${cy}) scale(0.52) translate(-12,-12)">
<path d="${pathD}" fill="${glyphColor}"/>
</g>
</g>
</svg>`;
}
/**
* Create an OpenLayers Style for a peer / discovered / note badge.
* @param {object} opts
* @param {string} [opts.face]
* @param {string} [opts.glyph]
* @param {string} [opts.iconColor] preferred glyph (legacy name from MapPage)
* @param {string} [opts.bgColor] face (legacy name from MapPage)
* @param {string} [opts.pathD]
* @param {string} [opts.iconPath] alias for pathD
* @param {string} [opts.label]
* @param {boolean} [opts.showLabel]
* @param {boolean} [opts.isStale]
* @param {boolean} [opts.isTracking]
* @param {number} [opts.scale]
* @returns {import("ol/style/Style").default}
*/
export function peerBadgeStyle({
face,
glyph,
iconColor,
bgColor,
pathD,
iconPath,
label = "",
showLabel = false,
isStale = false,
isTracking = false,
scale = BADGE_SCALE_NORMAL,
} = {}) {
const resolvedFace = face || bgColor || DEFAULT_PEER_FACE;
const preferredGlyph = glyph || iconColor || DEFAULT_PEER_GLYPH;
const resolvedGlyph = isStale ? STALE_GLYPH : contrastGlyphColor(resolvedFace, preferredGlyph);
const d = pathD || iconPath || DEFAULT_MAP_PIN_PATH;
const svg = buildPeerBadgeSvg({
face: resolvedFace,
glyph: resolvedGlyph,
pathD: d,
stale: isStale,
tracking: isTracking,
});
const src = encodeSvgDataUrl(svg);
const renderSize = isTracking && !isStale ? 48 : 40;
const displayHeight = renderSize * scale;
const labelOffset = -(displayHeight / 2 + 8);
const styleOpts = {
image: new Icon({
src,
anchor: [0.5, 0.5],
scale,
imgSize: [renderSize, renderSize],
}),
};
if (showLabel && label) {
styleOpts.text = new Text({
text: String(label),
offsetY: labelOffset,
font: "bold 12px sans-serif",
fill: new Fill({ color: isStale ? "#6b7280" : "#111827" }),
stroke: new Stroke({ color: "#ffffff", width: 3 }),
});
}
return new Style(styleOpts);
}
/**
* Cluster size / color band for a marker count.
* @param {number} count
* @returns {{bandId:string, radius:number, face:string}}
*/
export function clusterBand(count) {
const n = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0;
if (n < 10) {
return { bandId: "s", radius: 16, face: "#2563eb" };
}
if (n < 50) {
return { bandId: "m", radius: 20, face: "#7c3aed" };
}
return { bandId: "l", radius: 24, face: "#c2410c" };
}
/**
* Dual-halo cluster badge as layered OpenLayers styles (no SVG encode).
* @param {{count:number, hovered?:boolean}} opts
* @returns {import("ol/style/Style").default[]}
*/
export function clusterBadgeStyle({ count = 0, hovered = false } = {}) {
const band = clusterBand(count);
const radius = hovered ? band.radius + 2 : band.radius;
const label = String(count);
return [
new Style({
image: new CircleStyle({
radius: radius + 2.5,
fill: new Fill({ color: "rgba(15, 23, 42, 0.32)" }),
stroke: new Stroke({ color: "rgba(15, 23, 42, 0.55)", width: 2 }),
}),
zIndex: 0,
}),
new Style({
image: new CircleStyle({
radius,
fill: new Fill({ color: band.face }),
stroke: new Stroke({ color: "#ffffff", width: 2.5 }),
}),
text: new Text({
text: label,
font: "bold 13px sans-serif",
fill: new Fill({ color: "#ffffff" }),
stroke: new Stroke({ color: "rgba(15,23,42,0.45)", width: 2 }),
}),
zIndex: 1,
}),
];
}
/**
* Cache-aware cluster style helper.
* @param {Record<string, import("ol/style/Style").default[]>} cache
* @param {{count:number, hovered?:boolean}} opts
* @returns {import("ol/style/Style").default[]}
*/
export function getCachedClusterStyle(cache, { count = 0, hovered = false } = {}) {
const band = clusterBand(count);
const key = `cluster-v2-${band.bandId}-${count}-${hovered ? "h" : "n"}`;
if (cache[key]) return cache[key];
const style = clusterBadgeStyle({ count, hovered });
cache[key] = style;
return style;
}
/**
* Cache-aware peer badge style helper.
* @param {Record<string, import("ol/style/Style").default>} cache
* @param {object} opts
* @returns {import("ol/style/Style").default}
*/
export function getCachedPeerBadgeStyle(cache, opts = {}) {
const {
face,
glyph,
iconColor,
bgColor,
pathD,
iconPath,
label = "",
showLabel = false,
isStale = false,
isTracking = false,
scale = BADGE_SCALE_NORMAL,
} = opts;
const resolvedFace = face || bgColor || DEFAULT_PEER_FACE;
const preferredGlyph = glyph || iconColor || DEFAULT_PEER_GLYPH;
const resolvedGlyph = isStale ? STALE_GLYPH : contrastGlyphColor(resolvedFace, preferredGlyph);
const d = pathD || iconPath || DEFAULT_MAP_PIN_PATH;
const labelKey = showLabel && label ? String(label) : "";
const key = [
"peer-v2",
resolvedFace,
resolvedGlyph,
d,
labelKey,
showLabel ? "1" : "0",
isStale ? "1" : "0",
isTracking ? "1" : "0",
scale,
].join("|");
if (cache[key]) return cache[key];
const style = peerBadgeStyle({
face: resolvedFace,
glyph: resolvedGlyph,
pathD: d,
label,
showLabel,
isStale,
isTracking,
scale,
});
cache[key] = style;
return style;
}

View file

@ -1,10 +1,12 @@
/** Raster basemap providers tried in order when tiles fail to load. */
/** Canonical default basemap (OpenStreetMap raster). */
export const DEFAULT_TILE_SERVER_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
/** Raster basemap providers tried in order when tiles fail to load. */
export const RASTER_TILE_PROVIDER_ORDER = ["osm", "openfreemap"];
export const TILE_PROVIDER_URLS = {
osm: DEFAULT_TILE_SERVER_URL,
openfreemap: "https://tiles.openfreemap.org/styles/bright",
osm: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
"carto-dark": "https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
"carto-voyager": "https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png",
"carto-light": "https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",

View file

@ -1449,7 +1449,7 @@
"clear_cache": "Cache leeren",
"cache_cleared": "Kachel-Cache geleert",
"tile_server_url": "Kachel-Server-URL",
"tile_server_url_placeholder": "https://tiles.openfreemap.org/styles/bright",
"tile_server_url_placeholder": "https://tile.openstreetmap.org/{'{'}z{'}'}/{'{'}x{'}'}/{'{'}y{'}'}.png",
"tile_server_url_hint": "Verwenden Sie {z}, {x}, {y} für Zoom, Spalte, Zeile",
"tile_server_saved": "Kachel-Server-URL gespeichert",
"tile_server_openstreetmap": "OpenStreetMap",

View file

@ -1397,7 +1397,7 @@
"clear_cache": "Clear Cache",
"cache_cleared": "Tile cache cleared",
"tile_server_url": "Tile Server URL",
"tile_server_url_placeholder": "https://tiles.openfreemap.org/styles/bright",
"tile_server_url_placeholder": "https://tile.openstreetmap.org/{'{'}z{'}'}/{'{'}x{'}'}/{'{'}y{'}'}.png",
"tile_server_url_hint": "Use {z}, {x}, {y} for zoom, column, row",
"tile_server_saved": "Tile server URL saved",
"tile_server_openstreetmap": "OpenStreetMap",

View file

@ -1397,7 +1397,7 @@
"clear_cache": "Caché transparente",
"cache_cleared": "Caché de azul claro",
"tile_server_url": "URL del servidor",
"tile_server_url_placeholder": "https://tiles.openfreemap.org/styles/bright",
"tile_server_url_placeholder": "https://tile.openstreetmap.org/{'{'}z{'}'}/{'{'}x{'}'}/{'{'}y{'}'}.png",
"tile_server_url_hint": "Utilice{z},{x},{y}para zoom, columna, fila",
"tile_server_saved": "URL del servidor del azul",
"tile_server_openstreetmap": "OpenStreetMap",

View file

@ -1397,7 +1397,7 @@
"clear_cache": "Tyhjää välimuisti",
"cache_cleared": "Laattavälimuisti tyhjätty",
"tile_server_url": "Laattapalvelimen URL",
"tile_server_url_placeholder": "https://tiles.openfreemap.org/styles/bright",
"tile_server_url_placeholder": "https://tile.openstreetmap.org/{'{'}z{'}'}/{'{'}x{'}'}/{'{'}y{'}'}.png",
"tile_server_url_hint": "Käytä {z}, {x}, {y} lähennykselle, sarakkeelle, riville",
"tile_server_saved": "Laattapalvelimen URL tallennettu",
"tile_server_openstreetmap": "OpenStreetMap",

View file

@ -1397,7 +1397,7 @@
"clear_cache": "Effacer le cache",
"cache_cleared": "cache de carreaux effacé",
"tile_server_url": "URL du serveur Tile",
"tile_server_url_placeholder": "https://tiles.openfreemap.org/styles/bright",
"tile_server_url_placeholder": "https://tile.openstreetmap.org/{'{'}z{'}'}/{'{'}x{'}'}/{'{'}y{'}'}.png",
"tile_server_url_hint": "Utiliser{z},{x},{y}pour zoom, colonne, ligne",
"tile_server_saved": "URL du serveur Tile enregistrée",
"tile_server_openstreetmap": "OuvrirStreetMap",

View file

@ -1449,7 +1449,7 @@
"clear_cache": "Svuota Cache",
"cache_cleared": "Cache tile svuotata",
"tile_server_url": "URL Server Tile",
"tile_server_url_placeholder": "https://tiles.openfreemap.org/styles/bright",
"tile_server_url_placeholder": "https://tile.openstreetmap.org/{'{'}z{'}'}/{'{'}x{'}'}/{'{'}y{'}'}.png",
"tile_server_url_hint": "Usa {z}, {x}, {y} per zoom, colonna, riga",
"tile_server_saved": "URL server tile salvato",
"tile_server_openstreetmap": "OpenStreetMap",

View file

@ -1397,7 +1397,7 @@
"clear_cache": "Cache wissen",
"cache_cleared": "Tegel cache gewist",
"tile_server_url": "Tegelserver-URL",
"tile_server_url_placeholder": "https://tiles.openfreemap.org/styles/bright",
"tile_server_url_placeholder": "https://tile.openstreetmap.org/{'{'}z{'}'}/{'{'}x{'}'}/{'{'}y{'}'}.png",
"tile_server_url_hint": "{z},{x},{y}gebruiken voor zoom, kolom, rij",
"tile_server_saved": "Tegelserver-URL opgeslagen",
"tile_server_openstreetmap": "OpenStreetMap",

View file

@ -1449,7 +1449,7 @@
"clear_cache": "Очистить кэш",
"cache_cleared": "Кэш тайлов очищен",
"tile_server_url": "URL сервера тайлов",
"tile_server_url_placeholder": "https://tiles.openfreemap.org/styles/bright",
"tile_server_url_placeholder": "https://tile.openstreetmap.org/{'{'}z{'}'}/{'{'}x{'}'}/{'{'}y{'}'}.png",
"tile_server_url_hint": "Используйте {z}, {x}, {y} для масштаба, столбца, строки",
"tile_server_saved": "URL сервера тайлов сохранен",
"tile_server_openstreetmap": "OpenStreetMap",

View file

@ -1397,7 +1397,7 @@
"clear_cache": "清除缓存",
"cache_cleared": "瓦片缓存已清除",
"tile_server_url": "瓦片服务器 URL",
"tile_server_url_placeholder": "https://tiles.openfreemap.org/styles/bright",
"tile_server_url_placeholder": "https://tile.openstreetmap.org/{'{'}z{'}'}/{'{'}x{'}'}/{'{'}y{'}'}.png",
"tile_server_url_hint": "使用 {z}、{x}、{y} 表示缩放、列、行",
"tile_server_saved": "瓦片服务器 URL 已保存",
"tile_server_openstreetmap": "OpenStreetMap",

View file

@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Vite HMR (http://127.0.0.1:5173) + MeshChat backend (HTTPS on MESHCHAT_PORT).
# Vite HMR (http://127.0.0.1:5173) plus MeshChat backend (HTTPS on MESHCHAT_PORT).
# Vite proxies /api and /ws to https://127.0.0.1:$MESHCHAT_PORT.
set -euo pipefail
@ -51,7 +51,7 @@ while (( SECONDS < deadline )); do
if ss -ltn "( sport = :${MESHCHAT_PORT} )" 2>/dev/null | grep -q LISTEN; then
saw_listen=1
fi
# Use /api/v1/status: it is exempt from auth and the "still starting" 503 gate
# Use /api/v1/status. It is exempt from auth and the still-starting 503 gate
# so the Vite process can start while deferred network finishes.
if curl -fsSk --max-time 2 "$BACKEND_URL" >/dev/null 2>&1; then
echo "[dev] Backend ready."

View file

@ -0,0 +1,190 @@
// SPDX-License-Identifier: 0BSD
import { describe, it, expect } from "vitest";
import {
BADGE_SCALE_HOVER,
BADGE_SCALE_NORMAL,
DEFAULT_MAP_PIN_PATH,
DEFAULT_PEER_FACE,
MARKER_LABEL_MIN_ZOOM,
buildPeerBadgeSvg,
clusterBadgeStyle,
clusterBand,
contrastGlyphColor,
contrastRatio,
encodeSvgDataUrl,
getCachedClusterStyle,
getCachedPeerBadgeStyle,
peerBadgeStyle,
relativeLuminance,
shouldShowMarkerLabel,
} from "@/components/map/internal/markerStyles.js";
describe("relativeLuminance / contrastGlyphColor", () => {
it("rates white brighter than black", () => {
expect(relativeLuminance("#ffffff")).toBeGreaterThan(relativeLuminance("#000000"));
});
it("keeps a preferred glyph when contrast is strong", () => {
expect(contrastGlyphColor("#3730a3", "#ffffff")).toBe("#ffffff");
expect(contrastRatio("#ffffff", "#3730a3")).toBeGreaterThanOrEqual(3);
});
it("forces a readable glyph when preferred contrast is weak", () => {
// Light yellow on light yellow face would fail contrast.
const glyph = contrastGlyphColor("#fef9c3", "#fef08a");
expect(contrastRatio(glyph, "#fef9c3")).toBeGreaterThanOrEqual(3);
expect(glyph).toBe("#0f172a");
});
it("picks white on dark faces without a preferred color", () => {
expect(contrastGlyphColor("#0f172a")).toBe("#ffffff");
});
});
describe("buildPeerBadgeSvg", () => {
it("includes dual halo rims and a shadow", () => {
const svg = buildPeerBadgeSvg({
face: DEFAULT_PEER_FACE,
glyph: "#ffffff",
pathD: DEFAULT_MAP_PIN_PATH,
});
expect(svg).toContain('class="badge-rim-dark"');
expect(svg).toContain('class="badge-rim-light"');
expect(svg).toContain('class="badge-shadow"');
expect(svg).toContain(DEFAULT_MAP_PIN_PATH);
expect(svg).not.toContain("<animate");
});
it("mutes stale badges and skips pulse", () => {
const svg = buildPeerBadgeSvg({
face: "#ff0000",
glyph: "#00ff00",
stale: true,
tracking: true,
});
expect(svg).toContain('opacity="0.72"');
expect(svg).toContain("#64748b");
expect(svg).toContain("#e2e8f0");
expect(svg).not.toContain("<animate");
});
it("adds pulse animation only when tracking and not stale", () => {
const svg = buildPeerBadgeSvg({
face: DEFAULT_PEER_FACE,
glyph: "#ffffff",
tracking: true,
stale: false,
});
expect(svg).toContain("<animate");
expect(svg).toContain('stroke="#38bdf8"');
});
});
describe("encodeSvgDataUrl", () => {
it("round-trips a tiny SVG through base64", () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><circle r="1"/></svg>';
const url = encodeSvgDataUrl(svg);
expect(url.startsWith("data:image/svg+xml;base64,")).toBe(true);
const b64 = url.slice("data:image/svg+xml;base64,".length);
expect(atob(b64)).toBe(svg);
});
});
describe("clusterBand", () => {
it("returns size bands with monotonic radius", () => {
const small = clusterBand(2);
const mid = clusterBand(10);
const large = clusterBand(50);
expect(small.bandId).toBe("s");
expect(mid.bandId).toBe("m");
expect(large.bandId).toBe("l");
expect(small.radius).toBeLessThan(mid.radius);
expect(mid.radius).toBeLessThan(large.radius);
expect(small.face).not.toBe(large.face);
});
it("treats boundaries correctly", () => {
expect(clusterBand(9).bandId).toBe("s");
expect(clusterBand(10).bandId).toBe("m");
expect(clusterBand(49).bandId).toBe("m");
expect(clusterBand(50).bandId).toBe("l");
});
});
describe("clusterBadgeStyle / getCachedClusterStyle", () => {
it("returns layered styles with count text", () => {
const styles = clusterBadgeStyle({ count: 12, hovered: false });
expect(Array.isArray(styles)).toBe(true);
expect(styles).toHaveLength(2);
expect(styles[1].getText().getText()).toBe("12");
});
it("returns the same cached object for identical inputs", () => {
const cache = {};
const a = getCachedClusterStyle(cache, { count: 7, hovered: false });
const b = getCachedClusterStyle(cache, { count: 7, hovered: false });
const c = getCachedClusterStyle(cache, { count: 7, hovered: true });
expect(a).toBe(b);
expect(a).not.toBe(c);
});
});
describe("peerBadgeStyle / getCachedPeerBadgeStyle", () => {
it("builds a center-anchored Icon style", () => {
const style = peerBadgeStyle({
face: DEFAULT_PEER_FACE,
glyph: "#ffffff",
label: "Alice",
showLabel: true,
scale: BADGE_SCALE_NORMAL,
});
const image = style.getImage();
expect(image.getScale()).toBe(BADGE_SCALE_NORMAL);
expect(image.getSrc().startsWith("data:image/svg+xml;base64,")).toBe(true);
expect(image.anchorXUnits_).toBe("fraction");
expect(image.anchorYUnits_).toBe("fraction");
expect(image.anchor_).toEqual([0.5, 0.5]);
expect(style.getText().getText()).toBe("Alice");
});
it("omits text when showLabel is false", () => {
const style = peerBadgeStyle({
face: DEFAULT_PEER_FACE,
label: "Hidden",
showLabel: false,
});
expect(style.getText()).toBeNull();
});
it("caches peer styles by discrete key", () => {
const cache = {};
const a = getCachedPeerBadgeStyle(cache, {
bgColor: DEFAULT_PEER_FACE,
iconColor: "#ffffff",
scale: BADGE_SCALE_HOVER,
showLabel: false,
});
const b = getCachedPeerBadgeStyle(cache, {
bgColor: DEFAULT_PEER_FACE,
iconColor: "#ffffff",
scale: BADGE_SCALE_HOVER,
showLabel: false,
});
expect(a).toBe(b);
});
});
describe("shouldShowMarkerLabel", () => {
it("shows labels at or above the min zoom", () => {
expect(shouldShowMarkerLabel({ zoom: MARKER_LABEL_MIN_ZOOM })).toBe(true);
expect(shouldShowMarkerLabel({ zoom: MARKER_LABEL_MIN_ZOOM + 1 })).toBe(true);
expect(shouldShowMarkerLabel({ zoom: MARKER_LABEL_MIN_ZOOM - 1 })).toBe(false);
});
it("shows labels when hovered or selected even at low zoom", () => {
expect(shouldShowMarkerLabel({ zoom: 3, hovered: true })).toBe(true);
expect(shouldShowMarkerLabel({ zoom: 3, selected: true })).toBe(true);
expect(shouldShowMarkerLabel({ zoom: 3 })).toBe(false);
});
});

View file

@ -1,11 +1,19 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_TILE_SERVER_URL,
detectRasterTileProviderId,
nextRasterTileProviderId,
RASTER_TILE_PROVIDER_ORDER,
TILE_PROVIDER_URLS,
} from "../../meshchatx/src/frontend/js/mapTileProviders.js";
describe("mapTileProviders", () => {
it("defaults to OSM raster URL", () => {
expect(DEFAULT_TILE_SERVER_URL).toBe("https://tile.openstreetmap.org/{z}/{x}/{y}.png");
expect(TILE_PROVIDER_URLS.osm).toBe(DEFAULT_TILE_SERVER_URL);
expect(RASTER_TILE_PROVIDER_ORDER[0]).toBe("osm");
});
it("detects provider from URL", () => {
expect(detectRasterTileProviderId("https://tiles.openfreemap.org/styles/bright")).toBe("openfreemap");
expect(detectRasterTileProviderId("https://tile.openstreetmap.org/1/1/1.png")).toBe("osm");