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}}{{/date}} +{{/time}}\ +{{#showLocalTime}}Local Time: {{localtime}}
Local Date: {{localdate}}
+{{/showLocalTime}}\ +{{#excerpt}}{{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; +}