@memberjunction/geo-maps 0.0.1 → 5.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,10 +1,18 @@
1
1
  {
2
2
  "name": "@memberjunction/geo-maps",
3
- "version": "0.0.1",
4
- "description": "OIDC trusted publishing setup package for @memberjunction/geo-maps",
5
- "keywords": [
6
- "oidc",
7
- "trusted-publishing",
8
- "setup"
9
- ]
3
+ "version": "5.30.0",
4
+ "description": "MemberJunction: Framework-agnostic Leaflet map engine for point, heatmap, and choropleth rendering",
5
+ "main": "src/map-core.js",
6
+ "types": "types/index.d.ts",
7
+ "files": [
8
+ "src",
9
+ "types"
10
+ ],
11
+ "author": "MemberJunction.com",
12
+ "license": "ISC",
13
+ "sideEffects": false,
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/MemberJunction/MJ"
17
+ }
10
18
  }
@@ -0,0 +1,923 @@
1
+ /**
2
+ * @file map-core.js — Framework-agnostic Leaflet map engine for MemberJunction.
3
+ *
4
+ * Provides point markers, heatmap, and choropleth rendering with optional
5
+ * GeoDataEngine integration for coordinate-based region resolution.
6
+ *
7
+ * Consumed by:
8
+ * - Angular ng-map-view (via npm import)
9
+ * - React simple-map.js (via @file: embedding in component spec)
10
+ *
11
+ * @requires L (Leaflet 1.9.4) — must be available as a global
12
+ */
13
+ var MapCore = (function () {
14
+ 'use strict';
15
+
16
+ // ================================================================
17
+ // Constants
18
+ // ================================================================
19
+
20
+ var DEFAULT_COLORS = [
21
+ '#3498db', '#2ecc71', '#e74c3c', '#f39c12', '#9b59b6',
22
+ '#1abc9c', '#e67e22', '#2980b9', '#27ae60', '#c0392b',
23
+ '#16a085', '#d35400', '#8e44ad', '#2c3e50', '#f1c40f'
24
+ ];
25
+
26
+ var HEATMAP_FILL_COLOR = '#e74c3c';
27
+ var HEATMAP_STROKE_COLOR = '#c0392b';
28
+ var UNMATCHED_COLOR = '#95a5a6';
29
+
30
+ // ================================================================
31
+ // Helpers
32
+ // ================================================================
33
+
34
+ /** Escape HTML for safe popup content. */
35
+ function escapeHtml(text) {
36
+ var div = document.createElement('div');
37
+ div.textContent = String(text);
38
+ return div.innerHTML;
39
+ }
40
+
41
+ /** Read a field from a record, supporting BaseEntity .Get() and plain objects. */
42
+ function getField(record, fieldName) {
43
+ if (record && typeof record.Get === 'function') {
44
+ return record.Get(fieldName);
45
+ }
46
+ return record ? record[fieldName] : undefined;
47
+ }
48
+
49
+ /** Get a numeric field value, returning null if not a valid number. */
50
+ function getNumericField(record, fieldName) {
51
+ var val = getField(record, fieldName);
52
+ if (val == null) return null;
53
+ var num = Number(val);
54
+ return isNaN(num) ? null : num;
55
+ }
56
+
57
+ function defaultGetRecordId(record) {
58
+ var id = getField(record, 'ID') || getField(record, 'id') || '';
59
+ return String(id);
60
+ }
61
+
62
+ function defaultGetRecordName(record) {
63
+ var name = getField(record, 'Name') || getField(record, 'name') || 'Record';
64
+ return String(name);
65
+ }
66
+
67
+ // ================================================================
68
+ // Spatial Clustering
69
+ // ================================================================
70
+
71
+ /**
72
+ * Group nearby points within a lat/lng radius into clusters.
73
+ * Returns clusters with center coordinates and member records.
74
+ * Pure math — no Leaflet dependency.
75
+ *
76
+ * @param {Array<{lat: number, lng: number, record: Object}>} items
77
+ * @param {number} radiusDegrees
78
+ * @returns {Array<{centerLat: number, centerLng: number, records: Object[]}>}
79
+ */
80
+ function spatialCluster(items, radiusDegrees) {
81
+ var assigned = {};
82
+ var clusters = [];
83
+
84
+ for (var i = 0; i < items.length; i++) {
85
+ if (assigned[i]) continue;
86
+
87
+ var seed = items[i];
88
+ var members = [seed.record];
89
+ var sumLat = seed.lat;
90
+ var sumLng = seed.lng;
91
+ assigned[i] = true;
92
+
93
+ for (var j = i + 1; j < items.length; j++) {
94
+ if (assigned[j]) continue;
95
+ var candidate = items[j];
96
+ if (Math.abs(candidate.lat - seed.lat) <= radiusDegrees &&
97
+ Math.abs(candidate.lng - seed.lng) <= radiusDegrees) {
98
+ members.push(candidate.record);
99
+ sumLat += candidate.lat;
100
+ sumLng += candidate.lng;
101
+ assigned[j] = true;
102
+ }
103
+ }
104
+
105
+ clusters.push({
106
+ centerLat: sumLat / members.length,
107
+ centerLng: sumLng / members.length,
108
+ records: members
109
+ });
110
+ }
111
+
112
+ return clusters;
113
+ }
114
+
115
+ // ================================================================
116
+ // Point-in-Polygon (ray-casting)
117
+ // ================================================================
118
+
119
+ /**
120
+ * Ray-casting point-in-polygon test.
121
+ * @param {number} lat
122
+ * @param {number} lng
123
+ * @param {Array<[number, number]>} ring - GeoJSON ring ([lng, lat] pairs)
124
+ * @returns {boolean}
125
+ */
126
+ function pointInPolygon(lat, lng, ring) {
127
+ var inside = false;
128
+ for (var i = 0, j = ring.length - 1; i < ring.length; j = i++) {
129
+ // GeoJSON coordinates are [longitude, latitude]
130
+ var xi = ring[i][0], yi = ring[i][1];
131
+ var xj = ring[j][0], yj = ring[j][1];
132
+ var intersect = ((yi > lat) !== (yj > lat)) &&
133
+ (lng < (xj - xi) * (lat - yi) / (yj - yi) + xi);
134
+ if (intersect) inside = !inside;
135
+ }
136
+ return inside;
137
+ }
138
+
139
+ // ================================================================
140
+ // Country Name Matching (text-field fallback for choropleth)
141
+ // ================================================================
142
+
143
+ /**
144
+ * Match a free-text country name to reference data via Name, ISO2, or CommonAliases.
145
+ * @param {Object[]} countries - Array of country records with Name, ISO2, CommonAliases
146
+ * @param {string} searchName
147
+ * @returns {Object|null}
148
+ */
149
+ function findCountryMatch(countries, searchName) {
150
+ var normalized = searchName.trim().toLowerCase();
151
+
152
+ for (var i = 0; i < countries.length; i++) {
153
+ if (String(countries[i].Name || '').toLowerCase() === normalized) return countries[i];
154
+ }
155
+
156
+ for (var i = 0; i < countries.length; i++) {
157
+ if (String(countries[i].ISO2 || '').toLowerCase() === normalized) return countries[i];
158
+ }
159
+
160
+ for (var i = 0; i < countries.length; i++) {
161
+ var aliases = countries[i].CommonAliases;
162
+ if (!aliases) continue;
163
+ try {
164
+ var arr = JSON.parse(String(aliases));
165
+ for (var j = 0; j < arr.length; j++) {
166
+ if (arr[j].toLowerCase() === normalized) return countries[i];
167
+ }
168
+ } catch (e) { /* ignore parse errors */ }
169
+ }
170
+
171
+ return null;
172
+ }
173
+
174
+ // ================================================================
175
+ // Popup HTML Construction
176
+ // ================================================================
177
+
178
+ /**
179
+ * Build clickable popup HTML for a cluster of records.
180
+ * Shows first N records as links, "and X more..." overflow.
181
+ */
182
+ function buildClusterPopup(records, title, config) {
183
+ var maxShow = config.maxPopupRecords || 5;
184
+ var shown = records.slice(0, maxShow);
185
+ var remaining = records.length - maxShow;
186
+ var getId = config.getRecordId || defaultGetRecordId;
187
+ var getName = config.getRecordName || defaultGetRecordName;
188
+
189
+ var html = '<div style="font-size:12px;min-width:160px;">' +
190
+ '<b>' + escapeHtml(title) + '</b>' +
191
+ '<hr style="margin:4px 0;border-color:#e5e7eb;">';
192
+
193
+ for (var i = 0; i < shown.length; i++) {
194
+ var name = getName(shown[i]);
195
+ var recordId = getId(shown[i]);
196
+ html += '<div style="padding:2px 0;cursor:pointer;color:#2563eb;" ' +
197
+ 'class="mj-map-popup-record" data-record-id="' + escapeHtml(recordId) + '">' +
198
+ escapeHtml(name) + '</div>';
199
+ }
200
+
201
+ if (remaining > 0) {
202
+ html += '<div style="padding:4px 0 0;color:#6b7280;font-style:italic;">' +
203
+ 'and ' + remaining + ' more...</div>';
204
+ }
205
+
206
+ html += '</div>';
207
+ return html;
208
+ }
209
+
210
+ /** Build popup for a single record (point mode). */
211
+ function buildSinglePopup(record, config) {
212
+ var getId = config.getRecordId || defaultGetRecordId;
213
+ var getName = config.getRecordName || defaultGetRecordName;
214
+ var name = getName(record);
215
+ var recordId = getId(record);
216
+ return '<div style="font-size:12px;min-width:120px;">' +
217
+ '<div style="padding:2px 0;cursor:pointer;color:#2563eb;" ' +
218
+ 'class="mj-map-popup-record" data-record-id="' + escapeHtml(recordId) + '">' +
219
+ '<b>' + escapeHtml(name) + '</b></div></div>';
220
+ }
221
+
222
+ // ================================================================
223
+ // Popup Click Handler
224
+ // ================================================================
225
+
226
+ function setupPopupClickHandler(map, config) {
227
+ map.on('popupopen', function () {
228
+ setTimeout(function () {
229
+ var links = document.querySelectorAll('.mj-map-popup-record');
230
+ links.forEach(function (link) {
231
+ link.addEventListener('click', function (e) {
232
+ var recordId = e.currentTarget.getAttribute('data-record-id') || '';
233
+ if (config.onPopupRecordClick) {
234
+ config.onPopupRecordClick(recordId);
235
+ }
236
+ });
237
+ });
238
+ }, 50);
239
+ });
240
+ }
241
+
242
+ // ================================================================
243
+ // Bounds Fitting
244
+ // ================================================================
245
+
246
+ function fitBounds(map, bounds, maxZoom) {
247
+ if (bounds.length > 0) {
248
+ var boundsObj = L.latLngBounds(bounds);
249
+ map.fitBounds(boundsObj, { padding: [30, 30], maxZoom: maxZoom || 14 });
250
+ }
251
+ }
252
+
253
+ // ================================================================
254
+ // Rendering: GeoJSON Boundary Region
255
+ // ================================================================
256
+
257
+ /**
258
+ * Render a single GeoJSON boundary region with shading.
259
+ * Returns true if rendered successfully, false if fallback needed.
260
+ */
261
+ function renderBoundaryRegion(layer, regionName, boundaryGeoJSON, records, color, groupBy, config) {
262
+ if (!boundaryGeoJSON) return false;
263
+
264
+ try {
265
+ var geojson = typeof boundaryGeoJSON === 'string'
266
+ ? JSON.parse(boundaryGeoJSON)
267
+ : boundaryGeoJSON;
268
+
269
+ var geoLayer = L.geoJSON(geojson, {
270
+ style: {
271
+ fillColor: color,
272
+ fillOpacity: 0.35,
273
+ color: color,
274
+ weight: 2,
275
+ opacity: 0.8
276
+ }
277
+ });
278
+
279
+ geoLayer.bindPopup(buildClusterPopup(records, regionName + ' (' + records.length + ')', config));
280
+
281
+ if (config.onRegionClick) {
282
+ (function (name, recs, gb) {
283
+ geoLayer.on('click', function () {
284
+ config.onRegionClick({
285
+ regionName: name,
286
+ groupBy: gb,
287
+ recordCount: recs.length,
288
+ records: recs
289
+ });
290
+ });
291
+ })(regionName, records, groupBy);
292
+ }
293
+
294
+ layer.addLayer(geoLayer);
295
+ return true;
296
+ } catch (e) {
297
+ console.warn('[MapCore] GeoJSON render failed for "' + regionName + '"', e);
298
+ return false;
299
+ }
300
+ }
301
+
302
+ // ================================================================
303
+ // Rendering: Circle Fallback
304
+ // ================================================================
305
+
306
+ /** Render a colored circle marker as fallback for regions without boundary data. */
307
+ function renderCircleFallback(layer, regionName, records, color, config) {
308
+ var latField = config.latitudeField || '__mj_Latitude';
309
+ var lngField = config.longitudeField || '__mj_Longitude';
310
+ var sumLat = 0, sumLng = 0, count = 0;
311
+
312
+ for (var i = 0; i < records.length; i++) {
313
+ var lat = getNumericField(records[i], latField);
314
+ var lng = getNumericField(records[i], lngField);
315
+ if (lat && lng) { sumLat += lat; sumLng += lng; count++; }
316
+ }
317
+
318
+ if (count > 0) {
319
+ var radius = Math.min(15 + records.length * 5, 50);
320
+ var circle = L.circleMarker([sumLat / count, sumLng / count], {
321
+ radius: radius,
322
+ fillColor: color,
323
+ fillOpacity: 0.45,
324
+ color: color,
325
+ weight: 2,
326
+ opacity: 0.85
327
+ });
328
+ circle.bindPopup(buildClusterPopup(records, regionName + ' (' + records.length + ')', config));
329
+ layer.addLayer(circle);
330
+ }
331
+ }
332
+
333
+ // ================================================================
334
+ // Rendering: Point Markers
335
+ // ================================================================
336
+
337
+ function renderPointMarkers(map, layer, records, config) {
338
+ var latField = config.latitudeField || '__mj_Latitude';
339
+ var lngField = config.longitudeField || '__mj_Longitude';
340
+ var bounds = [];
341
+ var useCluster = (config.clusterMarkers !== false) && (typeof L.markerClusterGroup === 'function');
342
+ var clusterGroup = useCluster ? L.markerClusterGroup({
343
+ maxClusterRadius: 50,
344
+ spiderfyOnMaxZoom: true,
345
+ showCoverageOnHover: false
346
+ }) : null;
347
+
348
+ for (var i = 0; i < records.length; i++) {
349
+ var record = records[i];
350
+ var lat = getNumericField(record, latField);
351
+ var lng = getNumericField(record, lngField);
352
+ if (lat == null || lng == null || isNaN(lat) || isNaN(lng)) continue;
353
+
354
+ var latLng = L.latLng(lat, lng);
355
+ bounds.push(latLng);
356
+
357
+ var marker = L.marker(latLng);
358
+ marker.bindPopup(buildSinglePopup(record, config));
359
+
360
+ if (config.onMarkerClick) {
361
+ (function (rec, la, ln) {
362
+ marker.on('click', function () {
363
+ var getId = config.getRecordId || defaultGetRecordId;
364
+ config.onMarkerClick({
365
+ recordId: getId(rec),
366
+ lat: la,
367
+ lng: ln,
368
+ record: rec
369
+ });
370
+ });
371
+ })(record, lat, lng);
372
+ }
373
+
374
+ if (clusterGroup) {
375
+ clusterGroup.addLayer(marker);
376
+ } else {
377
+ layer.addLayer(marker);
378
+ }
379
+ }
380
+
381
+ if (clusterGroup) {
382
+ layer.addLayer(clusterGroup);
383
+ }
384
+
385
+ fitBounds(map, bounds, config.maxZoom);
386
+ return bounds.length;
387
+ }
388
+
389
+ // ================================================================
390
+ // Rendering: Heatmap (density circles via spatial clustering)
391
+ // ================================================================
392
+
393
+ function renderHeatmap(map, layer, records, config) {
394
+ var latField = config.latitudeField || '__mj_Latitude';
395
+ var lngField = config.longitudeField || '__mj_Longitude';
396
+ var bounds = [];
397
+ var recordsWithCoords = [];
398
+
399
+ for (var i = 0; i < records.length; i++) {
400
+ var record = records[i];
401
+ var lat = getNumericField(record, latField);
402
+ var lng = getNumericField(record, lngField);
403
+ if (lat == null || lng == null || isNaN(lat) || isNaN(lng)) continue;
404
+ bounds.push(L.latLng(lat, lng));
405
+ recordsWithCoords.push({ lat: lat, lng: lng, record: record });
406
+ }
407
+
408
+ var clusters = spatialCluster(recordsWithCoords, config.clusterRadius || 2.0);
409
+
410
+ for (var c = 0; c < clusters.length; c++) {
411
+ var cluster = clusters[c];
412
+ var radius = Math.min(12 + cluster.records.length * 5, 40);
413
+ var opacity = Math.min(0.3 + cluster.records.length * 0.08, 0.85);
414
+
415
+ var circle = L.circleMarker([cluster.centerLat, cluster.centerLng], {
416
+ radius: radius,
417
+ fillColor: HEATMAP_FILL_COLOR,
418
+ fillOpacity: opacity,
419
+ color: HEATMAP_STROKE_COLOR,
420
+ weight: 1,
421
+ opacity: 0.7
422
+ });
423
+
424
+ var title = cluster.records.length + ' record' + (cluster.records.length !== 1 ? 's' : '');
425
+ circle.bindPopup(buildClusterPopup(cluster.records, title, config));
426
+
427
+ if (config.onRegionClick) {
428
+ (function (cl) {
429
+ circle.on('click', function () {
430
+ config.onRegionClick({
431
+ regionName: 'Cluster (' + cl.records.length + ' records)',
432
+ groupBy: 'cluster',
433
+ recordCount: cl.records.length,
434
+ records: cl.records
435
+ });
436
+ });
437
+ })(cluster);
438
+ }
439
+
440
+ layer.addLayer(circle);
441
+ }
442
+
443
+ fitBounds(map, bounds, config.maxZoom);
444
+ return bounds.length;
445
+ }
446
+
447
+ // ================================================================
448
+ // Rendering: Boundary (one polygon per record)
449
+ // ================================================================
450
+
451
+ /**
452
+ * Render one GeoJSON polygon per record using a boundary field.
453
+ * Each record is expected to carry its own GeoJSON in config.boundaryField.
454
+ * Records without boundary data fall back to a centroid marker if lat/lng exist.
455
+ */
456
+ var BOUNDARY_MAX_POLYGONS = 200;
457
+
458
+ function renderBoundary(map, layer, records, config) {
459
+ // Too many polygons causes stack overflow and browser freeze.
460
+ // Fall back to point markers when the dataset is too large.
461
+ if (records.length > BOUNDARY_MAX_POLYGONS) {
462
+ console.warn('[MapCore] Boundary mode: ' + records.length + ' records exceeds ' + BOUNDARY_MAX_POLYGONS + ' polygon limit, falling back to point markers');
463
+ return renderPointMarkers(map, layer, records, config);
464
+ }
465
+
466
+ var boundaryField = config.boundaryField || 'BoundaryGeoJSON';
467
+ var latField = config.latitudeField || '__mj_Latitude';
468
+ var lngField = config.longitudeField || '__mj_Longitude';
469
+ var colors = config.colors || DEFAULT_COLORS;
470
+ var getId = config.getRecordId || defaultGetRecordId;
471
+ var getName = config.getRecordName || defaultGetRecordName;
472
+ var bounds = [];
473
+
474
+ for (var i = 0; i < records.length; i++) {
475
+ var record = records[i];
476
+ var color = colors[i % colors.length];
477
+ var name = getName(record);
478
+ var boundaryRaw = getField(record, boundaryField);
479
+
480
+ if (boundaryRaw) {
481
+ try {
482
+ var geojson = typeof boundaryRaw === 'string'
483
+ ? JSON.parse(boundaryRaw) : boundaryRaw;
484
+
485
+ // IIFE to capture color and record per iteration
486
+ (function (rec, recName, recColor, recId) {
487
+ var baseStyle = {
488
+ fillColor: recColor,
489
+ fillOpacity: 0.5,
490
+ color: recColor,
491
+ weight: 2,
492
+ opacity: 0.8
493
+ };
494
+
495
+ var geoLayer = L.geoJSON(geojson, {
496
+ style: baseStyle,
497
+ onEachFeature: function (feature, featureLayer) {
498
+ featureLayer.on({
499
+ mouseover: function (e) {
500
+ e.target.setStyle({ fillOpacity: 0.8, weight: 3 });
501
+ if (e.target.bringToFront) e.target.bringToFront();
502
+ },
503
+ mouseout: function (e) {
504
+ e.target.setStyle(baseStyle);
505
+ }
506
+ });
507
+ }
508
+ });
509
+
510
+ geoLayer.bindTooltip(escapeHtml(recName), { sticky: true, direction: 'auto' });
511
+ geoLayer.bindPopup(buildSinglePopup(rec, config));
512
+
513
+ // Fire onMarkerClick for per-record clicks
514
+ geoLayer.on('click', function () {
515
+ if (config.onMarkerClick) {
516
+ config.onMarkerClick({
517
+ recordId: recId,
518
+ lat: 0,
519
+ lng: 0,
520
+ record: rec
521
+ });
522
+ }
523
+ // Also fire onRegionClick so parent components can use either event
524
+ if (config.onRegionClick) {
525
+ config.onRegionClick({
526
+ regionName: recName,
527
+ groupBy: 'boundary',
528
+ recordCount: 1,
529
+ records: [rec]
530
+ });
531
+ }
532
+ });
533
+
534
+ layer.addLayer(geoLayer);
535
+
536
+ // Collect bounds from the rendered layer
537
+ var layerBounds = geoLayer.getBounds();
538
+ if (layerBounds && layerBounds.isValid()) {
539
+ bounds.push(layerBounds.getSouthWest());
540
+ bounds.push(layerBounds.getNorthEast());
541
+ }
542
+ })(record, name, color, getId(record));
543
+ } catch (e) {
544
+ console.warn('[MapCore] Boundary GeoJSON parse failed for "' + name + '"', e);
545
+ }
546
+ } else {
547
+ // Fallback to centroid marker if lat/lng available
548
+ var lat = getNumericField(record, latField);
549
+ var lng = getNumericField(record, lngField);
550
+ if (lat != null && lng != null) {
551
+ var marker = L.circleMarker([lat, lng], {
552
+ radius: 8, fillColor: color, fillOpacity: 0.6,
553
+ color: color, weight: 2, opacity: 0.8
554
+ });
555
+ marker.bindTooltip(escapeHtml(name), { sticky: true });
556
+ marker.bindPopup(buildSinglePopup(record, config));
557
+ if (config.onMarkerClick) {
558
+ (function (rec, la, ln) {
559
+ marker.on('click', function () {
560
+ config.onMarkerClick({ recordId: getId(rec), lat: la, lng: ln, record: rec });
561
+ });
562
+ })(record, lat, lng);
563
+ }
564
+ layer.addLayer(marker);
565
+ bounds.push(L.latLng(lat, lng));
566
+ }
567
+ }
568
+ }
569
+
570
+ fitBounds(map, bounds, config.maxZoom);
571
+ return bounds.length > 0 ? records.length : 0;
572
+ }
573
+
574
+ // ================================================================
575
+ // Rendering: Choropleth — coordinate-based (with GeoResolver)
576
+ // ================================================================
577
+
578
+ function renderChoroplethWithGeoResolver(map, layer, records, config) {
579
+ var latField = config.latitudeField || '__mj_Latitude';
580
+ var lngField = config.longitudeField || '__mj_Longitude';
581
+ var geo = config.geoResolver;
582
+ var colors = config.colors || DEFAULT_COLORS;
583
+ var bounds = [];
584
+
585
+ // Group records by resolved country/state
586
+ var recordsByCountryId = {};
587
+ var countryInfoById = {};
588
+ var recordsByStateId = {};
589
+ var stateInfoById = {};
590
+ var unmatchedRecords = [];
591
+
592
+ for (var i = 0; i < records.length; i++) {
593
+ var record = records[i];
594
+ var lat = getNumericField(record, latField);
595
+ var lng = getNumericField(record, lngField);
596
+ if (lat == null || lng == null || isNaN(lat) || isNaN(lng)) continue;
597
+ bounds.push(L.latLng(lat, lng));
598
+
599
+ var resolution = geo.ResolvePointToLocation(lat, lng);
600
+
601
+ if (resolution.Country) {
602
+ var countryId = String(resolution.Country.ID).toLowerCase();
603
+ if (!recordsByCountryId[countryId]) {
604
+ recordsByCountryId[countryId] = [];
605
+ countryInfoById[countryId] = resolution.Country;
606
+ }
607
+ recordsByCountryId[countryId].push(record);
608
+
609
+ if (resolution.State) {
610
+ var stateId = String(resolution.State.ID).toLowerCase();
611
+ if (!recordsByStateId[stateId]) {
612
+ recordsByStateId[stateId] = [];
613
+ stateInfoById[stateId] = resolution.State;
614
+ }
615
+ recordsByStateId[stateId].push(record);
616
+ }
617
+ } else {
618
+ unmatchedRecords.push(record);
619
+ }
620
+ }
621
+
622
+ // Auto-detect grouping: single country → state-level, multiple → country-level
623
+ var countryIds = Object.keys(recordsByCountryId);
624
+ var stateIds = Object.keys(recordsByStateId);
625
+ var colorIdx = 0;
626
+
627
+ if (countryIds.length <= 1 && stateIds.length > 1) {
628
+ // State-level rendering
629
+ for (var s = 0; s < stateIds.length; s++) {
630
+ var sid = stateIds[s];
631
+ var stateInfo = stateInfoById[sid];
632
+ var stateRecords = recordsByStateId[sid];
633
+ var color = colors[colorIdx % colors.length];
634
+ var rendered = renderBoundaryRegion(
635
+ layer, stateInfo.Name, stateInfo.BoundaryGeoJSON,
636
+ stateRecords, color, 'state_province', config
637
+ );
638
+ if (!rendered) {
639
+ renderCircleFallback(layer, stateInfo.Name, stateRecords, color, config);
640
+ }
641
+ colorIdx++;
642
+ }
643
+ } else {
644
+ // Country-level rendering
645
+ for (var ci = 0; ci < countryIds.length; ci++) {
646
+ var cid = countryIds[ci];
647
+ var countryInfo = countryInfoById[cid];
648
+ var countryRecords = recordsByCountryId[cid];
649
+ var color = colors[colorIdx % colors.length];
650
+ var rendered = renderBoundaryRegion(
651
+ layer, countryInfo.Name, countryInfo.BoundaryGeoJSON,
652
+ countryRecords, color, 'country', config
653
+ );
654
+ if (!rendered) {
655
+ renderCircleFallback(layer, countryInfo.Name, countryRecords, color, config);
656
+ }
657
+ colorIdx++;
658
+ }
659
+ }
660
+
661
+ if (unmatchedRecords.length > 0) {
662
+ renderCircleFallback(layer, 'Unmatched', unmatchedRecords, UNMATCHED_COLOR, config);
663
+ }
664
+
665
+ fitBounds(map, bounds, config.maxZoom);
666
+ return bounds.length;
667
+ }
668
+
669
+ // ================================================================
670
+ // Rendering: Choropleth — text-field fallback (without GeoResolver)
671
+ // ================================================================
672
+
673
+ function renderChoroplethWithTextFallback(map, layer, records, config, countryCache) {
674
+ var latField = config.latitudeField || '__mj_Latitude';
675
+ var lngField = config.longitudeField || '__mj_Longitude';
676
+ var countryField = config.countryField || 'Country';
677
+ var colors = config.colors || DEFAULT_COLORS;
678
+ var bounds = [];
679
+ var recordsByCountry = {};
680
+
681
+ for (var i = 0; i < records.length; i++) {
682
+ var record = records[i];
683
+ var lat = getNumericField(record, latField);
684
+ var lng = getNumericField(record, lngField);
685
+ if (lat == null || lng == null || isNaN(lat) || isNaN(lng)) continue;
686
+ bounds.push(L.latLng(lat, lng));
687
+
688
+ var country = String(getField(record, countryField) || 'Unknown');
689
+ if (!recordsByCountry[country]) recordsByCountry[country] = [];
690
+ recordsByCountry[country].push(record);
691
+ }
692
+
693
+ var countryNames = Object.keys(recordsByCountry);
694
+ var colorIdx = 0;
695
+
696
+ for (var ci = 0; ci < countryNames.length; ci++) {
697
+ var countryName = countryNames[ci];
698
+ var countryRecords = recordsByCountry[countryName];
699
+ var color = colors[colorIdx % colors.length];
700
+ var rendered = false;
701
+
702
+ if (countryCache) {
703
+ var countryData = findCountryMatch(countryCache, countryName);
704
+ if (countryData && countryData.BoundaryGeoJSON) {
705
+ rendered = renderBoundaryRegion(
706
+ layer, countryName, countryData.BoundaryGeoJSON,
707
+ countryRecords, color, 'country', config
708
+ );
709
+ }
710
+ }
711
+
712
+ if (!rendered) {
713
+ // Circle fallback at centroid of records
714
+ renderCircleFallback(layer, countryName, countryRecords, color, config);
715
+ }
716
+
717
+ colorIdx++;
718
+ }
719
+
720
+ fitBounds(map, bounds, config.maxZoom);
721
+ return bounds.length;
722
+ }
723
+
724
+ // ================================================================
725
+ // MapEngine Factory
726
+ // ================================================================
727
+
728
+ /**
729
+ * Create a new map engine attached to a DOM container.
730
+ *
731
+ * @param {Object} config
732
+ * @param {HTMLDivElement} config.container - DOM element to render into
733
+ * @param {Object} [config.center] - Initial center {lat, lng}
734
+ * @param {number} [config.zoom] - Initial zoom level
735
+ * @param {string} [config.latitudeField] - Latitude field name (default '__mj_Latitude')
736
+ * @param {string} [config.longitudeField] - Longitude field name (default '__mj_Longitude')
737
+ * @param {Object} [config.geoResolver] - GeoDataEngine-compatible resolver with ResolvePointToLocation()
738
+ * @param {string} [config.countryField] - Country field name for text-field fallback (default 'Country')
739
+ * @param {Function} [config.loadCountryData] - Async function returning country reference data
740
+ * @param {Function} [config.getRecordId] - Returns composite PK string for a record
741
+ * @param {Function} [config.getRecordName] - Returns display name for a record
742
+ * @param {Function} [config.onMarkerClick] - Callback when a point marker is clicked
743
+ * @param {Function} [config.onRegionClick] - Callback when a choropleth region or heatmap cluster is clicked
744
+ * @param {Function} [config.onPopupRecordClick] - Callback when a record link in a popup is clicked
745
+ * @param {Function} [config.onMoveEnd] - Callback when the map is panned/zoomed
746
+ * @param {Function} [config.onRenderComplete] - Callback after rendering finishes
747
+ * @param {boolean} [config.clusterMarkers] - Enable marker clustering (default true)
748
+ * @param {number} [config.clusterRadius] - Spatial clustering radius in degrees (default 2.0)
749
+ * @param {number} [config.maxPopupRecords] - Max records shown in popups (default 5)
750
+ * @param {string[]} [config.colors] - Color palette for choropleth regions
751
+ * @param {number} [config.maxZoom] - Max zoom for fitBounds (default 14)
752
+ * @returns {Object} MapEngine instance
753
+ */
754
+ function createEngine(config) {
755
+ if (!config || !config.container) {
756
+ throw new Error('[MapCore] config.container is required');
757
+ }
758
+
759
+ var _config = config;
760
+ var _records = [];
761
+ var _mode = 'point';
762
+ var _markerCount = 0;
763
+ var _countryCache = null;
764
+ var _map = null;
765
+ var _markerLayer = null;
766
+
767
+ // Initialize the Leaflet map
768
+ var defaultCenter = config.center || { lat: 20, lng: 0 };
769
+ var defaultZoom = config.zoom || 2;
770
+
771
+ _map = L.map(config.container, {
772
+ center: [defaultCenter.lat, defaultCenter.lng],
773
+ zoom: defaultZoom,
774
+ zoomControl: true,
775
+ attributionControl: false
776
+ });
777
+
778
+ // Compact attribution — required by OSM terms
779
+ L.control.attribution({ prefix: false, position: 'bottomright' }).addTo(_map);
780
+
781
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
782
+ attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>',
783
+ maxZoom: 18
784
+ }).addTo(_map);
785
+
786
+ _markerLayer = L.layerGroup().addTo(_map);
787
+
788
+ // Set up popup click handler for record drill-through
789
+ setupPopupClickHandler(_map, _config);
790
+
791
+ // Set up moveend handler for display state persistence
792
+ if (config.onMoveEnd) {
793
+ _map.on('moveend', function () {
794
+ if (!_map) return;
795
+ var center = _map.getCenter();
796
+ config.onMoveEnd({
797
+ zoom: _map.getZoom(),
798
+ centerLat: center.lat,
799
+ centerLng: center.lng
800
+ });
801
+ });
802
+ }
803
+
804
+ // ---- Render dispatcher ----
805
+
806
+ function doRender(records, mode) {
807
+ if (!_map || !_markerLayer) return;
808
+ _markerLayer.clearLayers();
809
+ _records = records || [];
810
+ if (mode) _mode = mode;
811
+
812
+ switch (_mode) {
813
+ case 'heatmap':
814
+ _markerCount = renderHeatmap(_map, _markerLayer, _records, _config);
815
+ break;
816
+ case 'choropleth':
817
+ renderChoroplethDispatch();
818
+ break;
819
+ case 'boundary':
820
+ _markerCount = renderBoundary(_map, _markerLayer, _records, _config);
821
+ break;
822
+ case 'point':
823
+ default:
824
+ _markerCount = renderPointMarkers(_map, _markerLayer, _records, _config);
825
+ break;
826
+ }
827
+
828
+ notifyRenderComplete();
829
+ }
830
+
831
+ function renderChoroplethDispatch() {
832
+ if (_config.geoResolver) {
833
+ _markerCount = renderChoroplethWithGeoResolver(_map, _markerLayer, _records, _config);
834
+ } else if (_config.loadCountryData) {
835
+ if (_countryCache) {
836
+ _markerCount = renderChoroplethWithTextFallback(_map, _markerLayer, _records, _config, _countryCache);
837
+ } else {
838
+ _config.loadCountryData().then(function (countries) {
839
+ _countryCache = countries;
840
+ _markerCount = renderChoroplethWithTextFallback(_map, _markerLayer, _records, _config, _countryCache);
841
+ notifyRenderComplete();
842
+ }).catch(function () {
843
+ _markerCount = renderChoroplethWithTextFallback(_map, _markerLayer, _records, _config, null);
844
+ notifyRenderComplete();
845
+ });
846
+ return; // async — notifyRenderComplete called in .then/.catch
847
+ }
848
+ } else {
849
+ _markerCount = renderChoroplethWithTextFallback(_map, _markerLayer, _records, _config, null);
850
+ }
851
+ }
852
+
853
+ function notifyRenderComplete() {
854
+ if (_config.onRenderComplete && _map) {
855
+ var b = _map.getBounds();
856
+ _config.onRenderComplete({
857
+ mode: _mode,
858
+ markerCount: _markerCount,
859
+ bounds: {
860
+ north: b.getNorth(),
861
+ south: b.getSouth(),
862
+ east: b.getEast(),
863
+ west: b.getWest()
864
+ }
865
+ });
866
+ }
867
+ }
868
+
869
+ // ---- Public MapEngine API ----
870
+
871
+ return {
872
+ /** Render records in the given mode. Stores records/mode for re-renders. */
873
+ render: doRender,
874
+
875
+ /** Switch render mode and re-render with stored records. */
876
+ setRenderMode: function (mode) {
877
+ _mode = mode;
878
+ doRender(_records, _mode);
879
+ },
880
+
881
+ /** Fix tile rendering after visibility/size change. */
882
+ invalidateSize: function () {
883
+ if (_map) _map.invalidateSize();
884
+ },
885
+
886
+ /** Clean up Leaflet instance and layers. */
887
+ destroy: function () {
888
+ if (_map) {
889
+ _map.remove();
890
+ _map = null;
891
+ _markerLayer = null;
892
+ }
893
+ },
894
+
895
+ /** Get current render statistics. */
896
+ getStats: function () {
897
+ return { markerCount: _markerCount };
898
+ },
899
+
900
+ /** Access the underlying Leaflet map instance (escape hatch). */
901
+ getMap: function () {
902
+ return _map;
903
+ }
904
+ };
905
+ }
906
+
907
+ // ================================================================
908
+ // Public API
909
+ // ================================================================
910
+
911
+ return {
912
+ createEngine: createEngine,
913
+ spatialCluster: spatialCluster,
914
+ pointInPolygon: pointInPolygon,
915
+ findCountryMatch: findCountryMatch,
916
+ VERSION: '1.0.0'
917
+ };
918
+ })();
919
+
920
+ // Support CommonJS module export for npm package consumption
921
+ if (typeof module !== 'undefined' && module.exports) {
922
+ module.exports = MapCore;
923
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Framework-agnostic Leaflet map engine for MemberJunction.
3
+ *
4
+ * Provides point markers, heatmap, and choropleth rendering with optional
5
+ * GeoDataEngine integration for coordinate-based region resolution.
6
+ */
7
+
8
+ /** Configuration for creating a MapEngine instance. */
9
+ export interface MapConfig {
10
+ /** DOM element to render into. */
11
+ container: HTMLDivElement;
12
+ /** Initial center coordinates. Defaults to {lat: 20, lng: 0}. */
13
+ center?: { lat: number; lng: number };
14
+ /** Initial zoom level. Defaults to 2. */
15
+ zoom?: number;
16
+ /** Latitude field name. Defaults to '__mj_Latitude'. */
17
+ latitudeField?: string;
18
+ /** Longitude field name. Defaults to '__mj_Longitude'. */
19
+ longitudeField?: string;
20
+ /** GeoDataEngine-compatible resolver for coordinate-based choropleth. */
21
+ geoResolver?: GeoResolver;
22
+ /** Country field name for text-field fallback choropleth. Defaults to 'Country'. */
23
+ countryField?: string;
24
+ /** Async function returning country reference data (for text-field fallback). */
25
+ loadCountryData?: () => Promise<CountryData[]>;
26
+ /** Returns a composite primary key string for a record. */
27
+ getRecordId?: (record: Record<string, unknown>) => string;
28
+ /** Returns a display name for a record. */
29
+ getRecordName?: (record: Record<string, unknown>) => string;
30
+ /** Callback when a point marker is clicked. */
31
+ onMarkerClick?: (event: MarkerClickEvent) => void;
32
+ /** Callback when a choropleth region or heatmap cluster is clicked. */
33
+ onRegionClick?: (event: RegionClickEvent) => void;
34
+ /** Callback when a record link in a popup is clicked. */
35
+ onPopupRecordClick?: (recordId: string) => void;
36
+ /** Callback when the map is panned or zoomed. */
37
+ onMoveEnd?: (state: MoveEndEvent) => void;
38
+ /** Callback after rendering finishes. */
39
+ onRenderComplete?: (stats: RenderCompleteEvent) => void;
40
+ /** Enable marker clustering in point mode. Defaults to true. */
41
+ clusterMarkers?: boolean;
42
+ /** Spatial clustering radius in degrees for heatmap mode. Defaults to 2.0. */
43
+ clusterRadius?: number;
44
+ /** Max records shown as links in popups. Defaults to 5. */
45
+ maxPopupRecords?: number;
46
+ /** Color palette for choropleth regions. */
47
+ colors?: string[];
48
+ /** Max zoom level for fitBounds. Defaults to 14. */
49
+ maxZoom?: number;
50
+ /** Field name containing GeoJSON boundary data on each record (for boundary mode). Defaults to 'BoundaryGeoJSON'. */
51
+ boundaryField?: string;
52
+ }
53
+
54
+ /** GeoDataEngine-compatible resolver interface. */
55
+ export interface GeoResolver {
56
+ ResolvePointToLocation(lat: number, lng: number): GeoPointResolution;
57
+ }
58
+
59
+ /** Result of resolving a coordinate to geographic regions. */
60
+ export interface GeoPointResolution {
61
+ Country?: { ID: string; Name: string; BoundaryGeoJSON?: string | null } | undefined;
62
+ State?: { ID: string; Name: string; BoundaryGeoJSON?: string | null } | undefined;
63
+ }
64
+
65
+ /** Country reference data for text-field fallback choropleth. */
66
+ export interface CountryData {
67
+ Name: string;
68
+ ISO2?: string;
69
+ BoundaryGeoJSON?: string | null;
70
+ CommonAliases?: string | null;
71
+ }
72
+
73
+ /** Emitted when a point marker is clicked. */
74
+ export interface MarkerClickEvent {
75
+ recordId: string;
76
+ lat: number;
77
+ lng: number;
78
+ record: Record<string, unknown>;
79
+ }
80
+
81
+ /** Emitted when a choropleth region or heatmap cluster is clicked. */
82
+ export interface RegionClickEvent {
83
+ regionName: string;
84
+ groupBy: string;
85
+ recordCount: number;
86
+ records: Record<string, unknown>[];
87
+ }
88
+
89
+ /** Emitted when the map is panned or zoomed. */
90
+ export interface MoveEndEvent {
91
+ zoom: number;
92
+ centerLat: number;
93
+ centerLng: number;
94
+ }
95
+
96
+ /** Emitted after rendering completes. */
97
+ export interface RenderCompleteEvent {
98
+ mode: string;
99
+ markerCount: number;
100
+ bounds: { north: number; south: number; east: number; west: number };
101
+ }
102
+
103
+ /** Spatial cluster result. */
104
+ export interface SpatialClusterResult {
105
+ centerLat: number;
106
+ centerLng: number;
107
+ records: Record<string, unknown>[];
108
+ }
109
+
110
+ /** Map engine instance returned by createEngine(). */
111
+ export interface MapEngine {
112
+ /** Render records in the given mode. Stores records/mode for re-renders. */
113
+ render(records: Record<string, unknown>[], mode?: string): void;
114
+ /** Switch render mode and re-render with stored records. */
115
+ setRenderMode(mode: string): void;
116
+ /** Fix tile rendering after visibility/size change. */
117
+ invalidateSize(): void;
118
+ /** Clean up Leaflet instance and layers. */
119
+ destroy(): void;
120
+ /** Get current render statistics. */
121
+ getStats(): { markerCount: number };
122
+ /** Access the underlying Leaflet map instance (escape hatch). */
123
+ getMap(): unknown;
124
+ }
125
+
126
+ /** Create a new map engine attached to a DOM container. */
127
+ export function createEngine(config: MapConfig): MapEngine;
128
+
129
+ /** Group nearby points within a lat/lng radius into clusters. */
130
+ export function spatialCluster(
131
+ items: Array<{ lat: number; lng: number; record: Record<string, unknown> }>,
132
+ radiusDegrees: number
133
+ ): SpatialClusterResult[];
134
+
135
+ /** Ray-casting point-in-polygon test. Ring uses GeoJSON [lng, lat] pairs. */
136
+ export function pointInPolygon(lat: number, lng: number, ring: Array<[number, number]>): boolean;
137
+
138
+ /** Match a free-text country name to reference data via Name, ISO2, or CommonAliases. */
139
+ export function findCountryMatch(countries: CountryData[], searchName: string): CountryData | null;
140
+
141
+ /** Library version. */
142
+ export const VERSION: string;
package/README.md DELETED
@@ -1,45 +0,0 @@
1
- # @memberjunction/geo-maps
2
-
3
- ## ⚠️ IMPORTANT NOTICE ⚠️
4
-
5
- **This package is created solely for the purpose of setting up OIDC (OpenID Connect) trusted publishing with npm.**
6
-
7
- This is **NOT** a functional package and contains **NO** code or functionality beyond the OIDC setup configuration.
8
-
9
- ## Purpose
10
-
11
- This package exists to:
12
- 1. Configure OIDC trusted publishing for the package name `@memberjunction/geo-maps`
13
- 2. Enable secure, token-less publishing from CI/CD workflows
14
- 3. Establish provenance for packages published under this name
15
-
16
- ## What is OIDC Trusted Publishing?
17
-
18
- OIDC trusted publishing allows package maintainers to publish packages directly from their CI/CD workflows without needing to manage npm access tokens. Instead, it uses OpenID Connect to establish trust between the CI/CD provider (like GitHub Actions) and npm.
19
-
20
- ## Setup Instructions
21
-
22
- To properly configure OIDC trusted publishing for this package:
23
-
24
- 1. Go to [npmjs.com](https://www.npmjs.com/) and navigate to your package settings
25
- 2. Configure the trusted publisher (e.g., GitHub Actions)
26
- 3. Specify the repository and workflow that should be allowed to publish
27
- 4. Use the configured workflow to publish your actual package
28
-
29
- ## DO NOT USE THIS PACKAGE
30
-
31
- This package is a placeholder for OIDC configuration only. It:
32
- - Contains no executable code
33
- - Provides no functionality
34
- - Should not be installed as a dependency
35
- - Exists only for administrative purposes
36
-
37
- ## More Information
38
-
39
- For more details about npm's trusted publishing feature, see:
40
- - [npm Trusted Publishing Documentation](https://docs.npmjs.com/generating-provenance-statements)
41
- - [GitHub Actions OIDC Documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
42
-
43
- ---
44
-
45
- **Maintained for OIDC setup purposes only**