mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat: update discovery map with dual-halo badge icons, update default basemap to OpenStreetMap, and improve marker label visibility
This commit is contained in:
parent
3d7387283d
commit
5567022348
17 changed files with 644 additions and 109 deletions
|
|
@ -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
|
||||
|
|
|
|||
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -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 = {
|
||||
|
|
|
|||
351
meshchatx/src/frontend/components/map/internal/markerStyles.js
Normal file
351
meshchatx/src/frontend/components/map/internal/markerStyles.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
190
tests/frontend/mapMarkerStyles.test.js
Normal file
190
tests/frontend/mapMarkerStyles.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue