@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 +15 -7
- package/src/map-core.js +923 -0
- package/types/index.d.ts +142 -0
- package/README.md +0 -45
package/package.json
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@memberjunction/geo-maps",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
"
|
|
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
|
}
|
package/src/map-core.js
ADDED
|
@@ -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: '© <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
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -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**
|