Skip to content

Commit f337acb

Browse files
authored
refactor(map-engine): Mustache popup templates, CSS classes, feed name in multi-feed popups (#46)
* refactor: bundle all vendor deps into spotmap-map.js, drop copy-deps Import Leaflet, all plugins, Font Awesome, and custom CSS directly in src/map-engine/index.ts so webpack packs everything into the single build/spotmap-map.js + build/spotmap-map.css output. This eliminates the copy-deps.js script and the public/ vendor subdirectories entirely. window.L is explicitly assigned after import so future Leaflet plugins loaded as separate <script> tags still find the global, matching the behaviour of the old enqueue-based approach. Fixes a class of issues where WordPress caching/optimisation plugins reorder or concatenate the previously separate Leaflet script tags and break the dependency chain. https://claude.ai/code/session_01AAsqtsybGk9VPJ4FmggxBC * fix global var * complete change * test(map-engine): cover POST branch in buildView and MarkerManager feedCount instance path
1 parent 68cb283 commit f337acb

9 files changed

Lines changed: 360 additions & 79 deletions

File tree

package-lock.json

Lines changed: 20 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,16 @@
2626
],
2727
"dependencies": {
2828
"@fortawesome/fontawesome-free": "^5.15.3",
29+
"@types/mustache": "^4.2.6",
2930
"@wordpress/dataviews": "^14.0.0",
3031
"beautifymarker": "^1.0.9",
3132
"leaflet": "^1.9.4",
3233
"leaflet-easybutton": "^2.4.0",
3334
"leaflet-gpx": "^2.2.0",
3435
"leaflet-textpath": "^1.3.0",
3536
"leaflet-tilelayer-swiss": "^2.2.1",
36-
"leaflet.fullscreen": "^5.3.1"
37+
"leaflet.fullscreen": "^5.3.1",
38+
"mustache": "^4.2.0"
3739
},
3840
"devDependencies": {
3941
"@jest/globals": "^29.7.0",

src/map-engine/MarkerManager.ts

Lines changed: 9 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import Mustache from 'mustache';
12
import type { SpotPoint, SpotmapLayers } from './types';
23
import { debug as debugLog } from './utils';
4+
import { POPUP_TEMPLATE, buildView } from './popup-templates';
35
import {
46
CIRCLE_DOT_ICON_SIZE,
57
CIRCLE_DOT_ICON_ANCHOR,
@@ -28,19 +30,22 @@ export class MarkerManager {
2830
private readonly iconCache = new Map< string, L.Icon >();
2931
private readonly abortController = new AbortController();
3032
private readonly dbg: ( ...args: unknown[] ) => void;
33+
private readonly feedCount: number;
3134

3235
constructor(
3336
map: L.Map,
3437
layers: SpotmapLayers,
3538
layerManager: LayerManager,
3639
canvasRenderer: L.Renderer,
37-
debugEnabled = false
40+
debugEnabled = false,
41+
feedCount = 1
3842
) {
3943
this.canvasRenderer = canvasRenderer;
4044
this.map = map;
4145
this.layers = layers;
4246
this.layerManager = layerManager;
4347
this.dbg = ( ...args ) => debugLog( debugEnabled, ...args );
48+
this.feedCount = feedCount;
4449

4550
const { signal } = this.abortController;
4651
document.addEventListener(
@@ -88,7 +93,7 @@ export class MarkerManager {
8893
}
8994

9095
const iconShape = this.getIconShape( point );
91-
const popupHtml = MarkerManager.getPopupHtml( point );
96+
const popupHtml = MarkerManager.getPopupHtml( point, this.feedCount );
9297
const popupOptions: L.PopupOptions = {
9398
autoPan: false,
9499
maxWidth: 280,
@@ -359,65 +364,7 @@ export class MarkerManager {
359364
this.markerById.clear();
360365
}
361366

362-
/**
363-
* Generate the popup HTML for a point.
364-
*/
365-
private static popupImageHtml( src: string ): string {
366-
return `<img src="${ src }" style="display:block;width:100%;max-height:180px;object-fit:cover;margin-bottom:4px;" loading="lazy" alt="" /><br>`;
367-
}
368-
369-
static getPopupHtml( entry: SpotPoint ): string {
370-
if ( entry.type === 'POST' ) {
371-
let html = '';
372-
if ( entry.image_url ) {
373-
html += MarkerManager.popupImageHtml( entry.image_url );
374-
}
375-
const title = entry.message ?? 'Post';
376-
if ( entry.url ) {
377-
html += `<b><a href="${ entry.url }" target="_blank" rel="noopener noreferrer">${ title }</a></b><br>`;
378-
} else {
379-
html += `<b>${ title }</b><br>`;
380-
}
381-
if ( entry.excerpt ) {
382-
html += `<span style="font-size:0.9em">${ entry.excerpt }</span><br>`;
383-
}
384-
html += `<span style="font-size:0.85em;color:#888">${ entry.date }</span>`;
385-
return html;
386-
}
387-
388-
let html = `<b>${ entry.type }</b><br>`;
389-
html += `Time: ${ entry.time }<br>Date: ${ entry.date }<br>`;
390-
391-
if (
392-
entry.local_timezone &&
393-
! (
394-
entry.localdate === entry.date && entry.localtime === entry.time
395-
)
396-
) {
397-
html += `Local Time: ${ entry.localtime }<br>Local Date: ${ entry.localdate }<br>`;
398-
}
399-
400-
if ( entry.message && entry.type === 'MEDIA' ) {
401-
html += MarkerManager.popupImageHtml( entry.message );
402-
} else if ( entry.message ) {
403-
html += `${ entry.message }<br>`;
404-
}
405-
406-
if ( entry.altitude > 0 ) {
407-
html += `Altitude: ${ Number( entry.altitude ) }m<br>`;
408-
}
409-
410-
if ( entry.battery_status === 'LOW' ) {
411-
html += `Battery status is low!<br>`;
412-
}
413-
414-
if ( entry.hiddenPoints ) {
415-
const { count, radius } = entry.hiddenPoints;
416-
const radiusNote =
417-
radius > 0 ? ` within a radius of ${ radius } meters` : '';
418-
html += `There are ${ count } hidden points${ radiusNote }<br>`;
419-
}
420-
421-
return html;
367+
static getPopupHtml( entry: SpotPoint, feedCount = 1 ): string {
368+
return Mustache.render( POPUP_TEMPLATE, buildView( entry, feedCount ) );
422369
}
423370
}

src/map-engine/Spotmap.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,8 @@ export class Spotmap {
226226
this.layers,
227227
this.layerManager,
228228
canvasRenderer,
229-
dbg
229+
dbg,
230+
this.options.feeds.length
230231
);
231232
this.lineManager = new LineManager(
232233
this.layers,

src/map-engine/__tests__/MarkerManager.test.ts

Lines changed: 77 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
import {
2+
describe,
3+
it,
4+
expect,
5+
jest,
6+
beforeEach,
7+
afterEach,
8+
} from '@jest/globals';
19
import { MarkerManager } from '../MarkerManager';
210
import type { SpotPoint } from '../types';
311

@@ -46,18 +54,7 @@ describe( 'MarkerManager.getPopupHtml', () => {
4654
expect( html ).not.toContain( '<img' );
4755
} );
4856

49-
it( 'shows image tag for MEDIA type', () => {
50-
const html = MarkerManager.getPopupHtml(
51-
makePoint( {
52-
type: 'MEDIA',
53-
message: 'https://example.com/photo.jpg',
54-
} )
55-
);
56-
expect( html ).toContain( '<img' );
57-
expect( html ).toContain( 'https://example.com/photo.jpg' );
58-
} );
59-
60-
it( 'shows battery warning when status is LOW', () => {
57+
it( 'shows battery warning when status is LOW', () => {
6158
const html = MarkerManager.getPopupHtml(
6259
makePoint( { battery_status: 'LOW' } )
6360
);
@@ -112,4 +109,72 @@ describe( 'MarkerManager.getPopupHtml', () => {
112109
);
113110
expect( html ).not.toContain( 'Local Time' );
114111
} );
112+
113+
it( 'shows feed name inline when feedCount > 1', () => {
114+
const html = MarkerManager.getPopupHtml(
115+
makePoint( { type: 'OK', feed_name: 'Tracker A' } ),
116+
2
117+
);
118+
expect( html ).toContain( 'OK — Tracker A' );
119+
} );
120+
121+
it( 'omits feed name when feedCount is 1', () => {
122+
const html = MarkerManager.getPopupHtml(
123+
makePoint( { type: 'OK', feed_name: 'Tracker A' } )
124+
);
125+
expect( html ).toContain( '<b>OK</b>' );
126+
expect( html ).not.toContain( 'Tracker A' );
127+
} );
128+
} );
129+
130+
describe( 'MarkerManager instance', () => {
131+
beforeEach( () => {
132+
const mockMarker = {
133+
bindPopup: jest.fn().mockReturnThis(),
134+
on: jest.fn().mockReturnThis(),
135+
};
136+
( global as Record< string, unknown > ).L = {
137+
BeautifyIcon: { icon: jest.fn().mockReturnValue( {} ) },
138+
marker: jest.fn().mockReturnValue( mockMarker ),
139+
};
140+
( global as Record< string, unknown > ).spotmapjsobj = { marker: {} };
141+
} );
142+
143+
afterEach( () => {
144+
delete ( global as Record< string, unknown > ).L;
145+
delete ( global as Record< string, unknown > ).spotmapjsobj;
146+
} );
147+
148+
it( 'passes feedCount through to getPopupHtml via addPoint', () => {
149+
const mockFeed = {
150+
points: [] as SpotPoint[],
151+
markers: [] as unknown[],
152+
featureGroup: { addLayer: jest.fn() },
153+
};
154+
// Use `as never` so TypeScript accepts plain objects as mock arguments.
155+
const manager = new MarkerManager(
156+
{} as never,
157+
{ feeds: { test: mockFeed } } as never,
158+
{ getFeedColor: jest.fn().mockReturnValue( 'blue' ) } as never,
159+
{} as never,
160+
false,
161+
2
162+
);
163+
164+
const point = makePoint( { type: 'OK', feed_name: 'test' } );
165+
manager.addPoint( point );
166+
167+
// The marker was created and added to the feed
168+
expect( mockFeed.points ).toContain( point );
169+
// getPopupHtml was called with feedCount=2, so feed name appears in popup
170+
const markerMock = ( global as Record< string, unknown > ).L as {
171+
marker: jest.Mock;
172+
};
173+
const bindPopupCall = (
174+
markerMock.marker.mock.results[ 0 ].value as {
175+
bindPopup: jest.Mock;
176+
}
177+
).bindPopup.mock.calls[ 0 ][ 0 ] as string;
178+
expect( bindPopupCall ).toContain( 'OK' );
179+
} );
115180
} );

0 commit comments

Comments
 (0)