diff --git a/package-lock.json b/package-lock.json
index 32d7c5e..dd9d011 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,15 +1,16 @@
{
"name": "spotmap",
- "version": "1.0.0",
+ "version": "1.0.0-rc.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "spotmap",
- "version": "1.0.0",
+ "version": "1.0.0-rc.2",
"license": "GPL-2.0-or-later",
"dependencies": {
"@fortawesome/fontawesome-free": "^5.15.3",
+ "@types/mustache": "^4.2.6",
"@wordpress/dataviews": "^14.0.0",
"beautifymarker": "^1.0.9",
"leaflet": "^1.9.4",
@@ -17,7 +18,8 @@
"leaflet-gpx": "^2.2.0",
"leaflet-textpath": "^1.3.0",
"leaflet-tilelayer-swiss": "^2.2.1",
- "leaflet.fullscreen": "^5.3.1"
+ "leaflet.fullscreen": "^5.3.1",
+ "mustache": "^4.2.0"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
@@ -8260,6 +8262,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/mustache": {
+ "version": "4.2.6",
+ "resolved": "https://registry.npmmirror.com/@types/mustache/-/mustache-4.2.6.tgz",
+ "integrity": "sha512-t+8/QWTAhOFlrF1IVZqKnMRJi84EgkIK5Kh0p2JV4OLywUvCwJPFxbJAl7XAow7DVIHsF+xW9f1MVzg0L6Szjw==",
+ "license": "MIT"
+ },
"node_modules/@types/mysql": {
"version": "2.15.26",
"resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.26.tgz",
@@ -21112,6 +21120,15 @@
"multicast-dns": "cli.js"
}
},
+ "node_modules/mustache": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmmirror.com/mustache/-/mustache-4.2.0.tgz",
+ "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
+ "license": "MIT",
+ "bin": {
+ "mustache": "bin/mustache"
+ }
+ },
"node_modules/mute-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/mute-stream/-/mute-stream-2.0.0.tgz",
diff --git a/package.json b/package.json
index 83a6350..713f799 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
],
"dependencies": {
"@fortawesome/fontawesome-free": "^5.15.3",
+ "@types/mustache": "^4.2.6",
"@wordpress/dataviews": "^14.0.0",
"beautifymarker": "^1.0.9",
"leaflet": "^1.9.4",
@@ -33,7 +34,8 @@
"leaflet-gpx": "^2.2.0",
"leaflet-textpath": "^1.3.0",
"leaflet-tilelayer-swiss": "^2.2.1",
- "leaflet.fullscreen": "^5.3.1"
+ "leaflet.fullscreen": "^5.3.1",
+ "mustache": "^4.2.0"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
diff --git a/src/map-engine/MarkerManager.ts b/src/map-engine/MarkerManager.ts
index a66bf9c..b930137 100644
--- a/src/map-engine/MarkerManager.ts
+++ b/src/map-engine/MarkerManager.ts
@@ -1,5 +1,7 @@
+import Mustache from 'mustache';
import type { SpotPoint, SpotmapLayers } from './types';
import { debug as debugLog } from './utils';
+import { POPUP_TEMPLATE, buildView } from './popup-templates';
import {
CIRCLE_DOT_ICON_SIZE,
CIRCLE_DOT_ICON_ANCHOR,
@@ -28,19 +30,22 @@ export class MarkerManager {
private readonly iconCache = new Map< string, L.Icon >();
private readonly abortController = new AbortController();
private readonly dbg: ( ...args: unknown[] ) => void;
+ private readonly feedCount: number;
constructor(
map: L.Map,
layers: SpotmapLayers,
layerManager: LayerManager,
canvasRenderer: L.Renderer,
- debugEnabled = false
+ debugEnabled = false,
+ feedCount = 1
) {
this.canvasRenderer = canvasRenderer;
this.map = map;
this.layers = layers;
this.layerManager = layerManager;
this.dbg = ( ...args ) => debugLog( debugEnabled, ...args );
+ this.feedCount = feedCount;
const { signal } = this.abortController;
document.addEventListener(
@@ -88,7 +93,7 @@ export class MarkerManager {
}
const iconShape = this.getIconShape( point );
- const popupHtml = MarkerManager.getPopupHtml( point );
+ const popupHtml = MarkerManager.getPopupHtml( point, this.feedCount );
const popupOptions: L.PopupOptions = {
autoPan: false,
maxWidth: 280,
@@ -359,65 +364,7 @@ export class MarkerManager {
this.markerById.clear();
}
- /**
- * Generate the popup HTML for a point.
- */
- private static popupImageHtml( src: string ): string {
- return `
`;
- }
-
- static getPopupHtml( entry: SpotPoint ): string {
- if ( entry.type === 'POST' ) {
- let html = '';
- if ( entry.image_url ) {
- html += MarkerManager.popupImageHtml( entry.image_url );
- }
- const title = entry.message ?? 'Post';
- if ( entry.url ) {
- html += `${ title }
`;
- } else {
- html += `${ title }
`;
- }
- if ( entry.excerpt ) {
- html += `${ entry.excerpt }
`;
- }
- html += `${ entry.date }`;
- return html;
- }
-
- let html = `${ entry.type }
`;
- html += `Time: ${ entry.time }
Date: ${ entry.date }
`;
-
- if (
- entry.local_timezone &&
- ! (
- entry.localdate === entry.date && entry.localtime === entry.time
- )
- ) {
- html += `Local Time: ${ entry.localtime }
Local Date: ${ entry.localdate }
`;
- }
-
- if ( entry.message && entry.type === 'MEDIA' ) {
- html += MarkerManager.popupImageHtml( entry.message );
- } else if ( entry.message ) {
- html += `${ entry.message }
`;
- }
-
- if ( entry.altitude > 0 ) {
- html += `Altitude: ${ Number( entry.altitude ) }m
`;
- }
-
- if ( entry.battery_status === 'LOW' ) {
- html += `Battery status is low!
`;
- }
-
- if ( entry.hiddenPoints ) {
- const { count, radius } = entry.hiddenPoints;
- const radiusNote =
- radius > 0 ? ` within a radius of ${ radius } meters` : '';
- html += `There are ${ count } hidden points${ radiusNote }
`;
- }
-
- return html;
+ static getPopupHtml( entry: SpotPoint, feedCount = 1 ): string {
+ return Mustache.render( POPUP_TEMPLATE, buildView( entry, feedCount ) );
}
}
diff --git a/src/map-engine/Spotmap.ts b/src/map-engine/Spotmap.ts
index 0da2291..e7b576a 100644
--- a/src/map-engine/Spotmap.ts
+++ b/src/map-engine/Spotmap.ts
@@ -226,7 +226,8 @@ export class Spotmap {
this.layers,
this.layerManager,
canvasRenderer,
- dbg
+ dbg,
+ this.options.feeds.length
);
this.lineManager = new LineManager(
this.layers,
diff --git a/src/map-engine/__tests__/MarkerManager.test.ts b/src/map-engine/__tests__/MarkerManager.test.ts
index 07041fc..8c80bf3 100644
--- a/src/map-engine/__tests__/MarkerManager.test.ts
+++ b/src/map-engine/__tests__/MarkerManager.test.ts
@@ -1,3 +1,11 @@
+import {
+ describe,
+ it,
+ expect,
+ jest,
+ beforeEach,
+ afterEach,
+} from '@jest/globals';
import { MarkerManager } from '../MarkerManager';
import type { SpotPoint } from '../types';
@@ -46,18 +54,7 @@ describe( 'MarkerManager.getPopupHtml', () => {
expect( html ).not.toContain( '
{
- const html = MarkerManager.getPopupHtml(
- makePoint( {
- type: 'MEDIA',
- message: 'https://example.com/photo.jpg',
- } )
- );
- expect( html ).toContain( '
{
+it( 'shows battery warning when status is LOW', () => {
const html = MarkerManager.getPopupHtml(
makePoint( { battery_status: 'LOW' } )
);
@@ -112,4 +109,72 @@ describe( 'MarkerManager.getPopupHtml', () => {
);
expect( html ).not.toContain( 'Local Time' );
} );
+
+ it( 'shows feed name inline when feedCount > 1', () => {
+ const html = MarkerManager.getPopupHtml(
+ makePoint( { type: 'OK', feed_name: 'Tracker A' } ),
+ 2
+ );
+ expect( html ).toContain( 'OK — Tracker A' );
+ } );
+
+ it( 'omits feed name when feedCount is 1', () => {
+ const html = MarkerManager.getPopupHtml(
+ makePoint( { type: 'OK', feed_name: 'Tracker A' } )
+ );
+ expect( html ).toContain( 'OK' );
+ expect( html ).not.toContain( 'Tracker A' );
+ } );
+} );
+
+describe( 'MarkerManager instance', () => {
+ beforeEach( () => {
+ const mockMarker = {
+ bindPopup: jest.fn().mockReturnThis(),
+ on: jest.fn().mockReturnThis(),
+ };
+ ( global as Record< string, unknown > ).L = {
+ BeautifyIcon: { icon: jest.fn().mockReturnValue( {} ) },
+ marker: jest.fn().mockReturnValue( mockMarker ),
+ };
+ ( global as Record< string, unknown > ).spotmapjsobj = { marker: {} };
+ } );
+
+ afterEach( () => {
+ delete ( global as Record< string, unknown > ).L;
+ delete ( global as Record< string, unknown > ).spotmapjsobj;
+ } );
+
+ it( 'passes feedCount through to getPopupHtml via addPoint', () => {
+ const mockFeed = {
+ points: [] as SpotPoint[],
+ markers: [] as unknown[],
+ featureGroup: { addLayer: jest.fn() },
+ };
+ // Use `as never` so TypeScript accepts plain objects as mock arguments.
+ const manager = new MarkerManager(
+ {} as never,
+ { feeds: { test: mockFeed } } as never,
+ { getFeedColor: jest.fn().mockReturnValue( 'blue' ) } as never,
+ {} as never,
+ false,
+ 2
+ );
+
+ const point = makePoint( { type: 'OK', feed_name: 'test' } );
+ manager.addPoint( point );
+
+ // The marker was created and added to the feed
+ expect( mockFeed.points ).toContain( point );
+ // getPopupHtml was called with feedCount=2, so feed name appears in popup
+ const markerMock = ( global as Record< string, unknown > ).L as {
+ marker: jest.Mock;
+ };
+ const bindPopupCall = (
+ markerMock.marker.mock.results[ 0 ].value as {
+ bindPopup: jest.Mock;
+ }
+ ).bindPopup.mock.calls[ 0 ][ 0 ] as string;
+ expect( bindPopupCall ).toContain( 'OK' );
+ } );
} );
diff --git a/src/map-engine/__tests__/popup-templates.test.ts b/src/map-engine/__tests__/popup-templates.test.ts
new file mode 100644
index 0000000..f72e40b
--- /dev/null
+++ b/src/map-engine/__tests__/popup-templates.test.ts
@@ -0,0 +1,130 @@
+import { describe, it, expect } from '@jest/globals';
+import { buildView } from '../popup-templates';
+import type { SpotPoint } from '../types';
+
+function makePoint( overrides: Partial< SpotPoint > = {} ): SpotPoint {
+ return {
+ id: 1,
+ feed_name: 'test',
+ latitude: 47.0,
+ longitude: 8.0,
+ altitude: 0,
+ type: 'OK',
+ unixtime: 1700000000,
+ time: '12:00 pm',
+ date: 'Jan 1, 2024',
+ ...overrides,
+ };
+}
+
+describe( 'buildView', () => {
+ describe( 'POST type', () => {
+ it( 'builds linkedTitle and url when URL is present', () => {
+ const view = buildView(
+ makePoint( {
+ type: 'POST',
+ message: 'My Post Title',
+ url: 'https://example.com/post',
+ date: 'Jan 1, 2024',
+ } )
+ );
+ expect( view.linkedTitle ).toBe( 'My Post Title' );
+ expect( view.url ).toBe( 'https://example.com/post' );
+ expect( view.plainTitle ).toBeUndefined();
+ expect( view.pointType ).toBeUndefined();
+ } );
+
+ it( 'builds plainTitle when no URL', () => {
+ const view = buildView(
+ makePoint( {
+ type: 'POST',
+ message: 'My Post Title',
+ date: 'Jan 1, 2024',
+ } )
+ );
+ expect( view.plainTitle ).toBe( 'My Post Title' );
+ expect( view.linkedTitle ).toBeUndefined();
+ } );
+
+ it( 'falls back to "Post" as title when message is absent', () => {
+ const view = buildView(
+ makePoint( { type: 'POST', date: 'Jan 1, 2024' } )
+ );
+ expect( view.plainTitle ).toBe( 'Post' );
+ } );
+
+ it( 'includes imageUrl when image_url is present', () => {
+ const view = buildView(
+ makePoint( {
+ type: 'POST',
+ image_url: 'https://example.com/img.jpg',
+ date: 'Jan 1, 2024',
+ } )
+ );
+ expect( view.imageUrl ).toBe( 'https://example.com/img.jpg' );
+ } );
+
+ it( 'includes excerpt when present', () => {
+ const view = buildView(
+ makePoint( {
+ type: 'POST',
+ excerpt: 'A short excerpt.',
+ date: 'Jan 1, 2024',
+ } )
+ );
+ expect( view.excerpt ).toBe( 'A short excerpt.' );
+ } );
+ } );
+
+ describe( 'non-POST type', () => {
+ it( 'includes feedName when feedCount > 1', () => {
+ const view = buildView(
+ makePoint( { feed_name: 'Tracker A' } ),
+ 2
+ );
+ expect( view.feedName ).toBe( 'Tracker A' );
+ } );
+
+ it( 'omits feedName when feedCount is 1', () => {
+ const view = buildView( makePoint( { feed_name: 'Tracker A' } ) );
+ expect( view.feedName ).toBeUndefined();
+ } );
+
+ it( 'sets showLocalTime when timezone differs from UTC', () => {
+ const view = buildView(
+ makePoint( {
+ local_timezone: 'Europe/Rome',
+ time: '10:00 am',
+ date: 'Jan 1, 2024',
+ localtime: '11:00 am',
+ localdate: 'Jan 1, 2024',
+ } )
+ );
+ expect( view.showLocalTime ).toBe( true );
+ } );
+
+ it( 'omits altitude when zero', () => {
+ const view = buildView( makePoint( { altitude: 0 } ) );
+ expect( view.altitude ).toBeUndefined();
+ } );
+
+ it( 'includes altitude when above zero', () => {
+ const view = buildView( makePoint( { altitude: 2500 } ) );
+ expect( view.altitude ).toBe( 2500 );
+ } );
+
+ it( 'wraps hiddenPoints radius in array when > 0', () => {
+ const view = buildView(
+ makePoint( { hiddenPoints: { count: 5, radius: 50 } } )
+ );
+ expect( view.hiddenPoints?.radius ).toEqual( [ 50 ] );
+ } );
+
+ it( 'passes empty radius array when radius is 0', () => {
+ const view = buildView(
+ makePoint( { hiddenPoints: { count: 3, radius: 0 } } )
+ );
+ expect( view.hiddenPoints?.radius ).toEqual( [] );
+ } );
+ } );
+} );
diff --git a/src/map-engine/index.ts b/src/map-engine/index.ts
index 51e6532..8174ba5 100644
--- a/src/map-engine/index.ts
+++ b/src/map-engine/index.ts
@@ -16,6 +16,7 @@ import 'leaflet-tilelayer-swiss';
// Font Awesome & custom styles
import '@fortawesome/fontawesome-free/css/all.min.css';
import '../css/custom.css';
+import './popup.css';
import { Spotmap } from './Spotmap';
diff --git a/src/map-engine/popup-templates.ts b/src/map-engine/popup-templates.ts
new file mode 100644
index 0000000..3c7253c
--- /dev/null
+++ b/src/map-engine/popup-templates.ts
@@ -0,0 +1,102 @@
+import type { SpotPoint } from './types';
+
+/**
+ * Single Mustache template for all popup types.
+ * Renders only the fields that are present in the view — no type branching here.
+ * The view object (built by buildView) is responsible for shaping data correctly.
+ */
+export const POPUP_TEMPLATE = `\
+{{#imageUrl}}
+{{/imageUrl}}\
+{{#linkedTitle}}{{linkedTitle}}
+{{/linkedTitle}}\
+{{#plainTitle}}{{plainTitle}}
+{{/plainTitle}}\
+{{#pointType}}{{pointType}}{{#feedName}} — {{feedName}}{{/feedName}}
+{{/pointType}}\
+{{#time}}Time: {{time}}
Date: {{date}}
+{{/time}}\
+{{^time}}{{#date}}{{/date}}
+{{/time}}\
+{{#showLocalTime}}Local Time: {{localtime}}
Local Date: {{localdate}}
+{{/showLocalTime}}\
+{{#excerpt}}
+{{/excerpt}}\
+{{#message}}{{message}}
+{{/message}}\
+{{#altitude}}Altitude: {{altitude}}m
+{{/altitude}}\
+{{#showBattery}}Battery status is low!
+{{/showBattery}}\
+{{#hiddenPoints}}There are {{count}} hidden points{{#radius}} within a radius of {{.}} meters{{/radius}}
+{{/hiddenPoints}}`;
+
+export interface PopupView {
+ // Image at the top (POST featured image)
+ imageUrl?: string;
+ // Title with link (POST with permalink)
+ linkedTitle?: string;
+ url?: string;
+ // Title without link (POST without permalink)
+ plainTitle?: string;
+ // Header label for SPOT points (the message type, e.g. "OK", "HELP")
+ pointType?: string;
+ // Feed name shown inline when the map has more than one feed
+ feedName?: string;
+ // Date/time — SPOT points show both, POST shows only date in small text
+ time?: string;
+ date: string;
+ showLocalTime?: boolean;
+ localtime?: string;
+ localdate?: string;
+ // Post excerpt
+ excerpt?: string;
+ // Optional text message
+ message?: string;
+ // Altitude in meters (omitted when zero)
+ altitude?: number;
+ showBattery?: boolean;
+ // Mustache renders count + optional radius section
+ hiddenPoints?: { count: number; radius: number[] } | null;
+}
+
+export function buildView( entry: SpotPoint, feedCount = 1 ): PopupView {
+ if ( entry.type === 'POST' ) {
+ return {
+ imageUrl: entry.image_url,
+ linkedTitle: entry.url ? ( entry.message ?? 'Post' ) : undefined,
+ url: entry.url,
+ plainTitle: entry.url ? undefined : ( entry.message ?? 'Post' ),
+ excerpt: entry.excerpt,
+ date: entry.date,
+ };
+ }
+
+ const showLocalTime =
+ !! entry.local_timezone &&
+ ! ( entry.localdate === entry.date && entry.localtime === entry.time );
+
+ return {
+ pointType: entry.type,
+ feedName: feedCount > 1 ? entry.feed_name : undefined,
+ time: entry.time,
+ date: entry.date,
+ showLocalTime,
+ localtime: entry.localtime,
+ localdate: entry.localdate,
+ message: entry.message,
+ altitude: entry.altitude > 0 ? entry.altitude : undefined,
+ showBattery: entry.battery_status === 'LOW',
+ hiddenPoints: entry.hiddenPoints
+ ? {
+ count: entry.hiddenPoints.count,
+ // Pass as one-element array so {{#radius}}{{.}}{{/radius}}
+ // renders the value, or empty array to skip the section.
+ radius:
+ entry.hiddenPoints.radius > 0
+ ? [ entry.hiddenPoints.radius ]
+ : [],
+ }
+ : null,
+ };
+}
diff --git a/src/map-engine/popup.css b/src/map-engine/popup.css
new file mode 100644
index 0000000..48d0f8a
--- /dev/null
+++ b/src/map-engine/popup.css
@@ -0,0 +1,16 @@
+.spotmap-popup-image {
+ display: block;
+ width: 100%;
+ max-height: 180px;
+ object-fit: cover;
+ margin-bottom: 4px;
+}
+
+.spotmap-popup-excerpt {
+ font-size: 0.9em;
+}
+
+.spotmap-popup-date {
+ font-size: 0.85em;
+ color: #888;
+}