7289b770e5
Self-host Leaflet locally, proxy map tiles through cockpit, set explicit map height, and invalidate size after init.
294 lines
9.7 KiB
JavaScript
294 lines
9.7 KiB
JavaScript
/**
|
|
* Export Intel map — Leaflet + clustering + rich popups
|
|
*/
|
|
window.ExportIntelMap = (function () {
|
|
var map = null;
|
|
var clusterLayer = null;
|
|
var onEntityClick = null;
|
|
var halalMode = false;
|
|
var markersById = {};
|
|
|
|
var HALAL_COLORS = {
|
|
hot: '#22c55e',
|
|
warm: '#eab308',
|
|
mild: '#f97316',
|
|
low: '#64748b',
|
|
};
|
|
|
|
var TYPE_CFG = {
|
|
distributor: { color: '#f59e0b', icon: '📦', label: 'Distributeur' },
|
|
wholesaler: { color: '#eab308', icon: '🏪', label: 'Groothandel' },
|
|
importer: { color: '#f97316', icon: '🚢', label: 'Importeur' },
|
|
logistics: { color: '#38bdf8', icon: '🚚', label: 'Logistiek' },
|
|
contract_caterer:{ color: '#a78bfa', icon: '🏢', label: 'Cateraar' },
|
|
restaurant: { color: '#34d399', icon: '🍽️', label: 'Restaurant' },
|
|
doner_shoarma: { color: '#4ade80', icon: '🥙', label: 'Döner / shoarma' },
|
|
butcher: { color: '#f87171', icon: '🥩', label: 'Slager' },
|
|
foodservice: { color: '#2dd4bf', icon: '🍴', label: 'Foodservice' },
|
|
};
|
|
|
|
function cfg(type) {
|
|
return TYPE_CFG[type] || { color: '#0ea5e9', icon: '📍', label: String(type || 'entity').replace(/_/g, ' ') };
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
function row(label, val, link) {
|
|
if (!val) return '';
|
|
var inner = link
|
|
? '<a href="' + escapeHtml(link) + '" target="_blank" rel="noopener">' + escapeHtml(val) + '</a>'
|
|
: escapeHtml(val);
|
|
return (
|
|
'<div class="ei-popup-row">' +
|
|
'<span class="ei-popup-k">' + escapeHtml(label) + '</span>' +
|
|
'<span class="ei-popup-v">' + inner + '</span>' +
|
|
'</div>'
|
|
);
|
|
}
|
|
|
|
function halalColor(p) {
|
|
var tier = p.halal_tier || 'low';
|
|
return HALAL_COLORS[tier] || HALAL_COLORS.low;
|
|
}
|
|
|
|
function popupHtml(p) {
|
|
var c = cfg(p.entity_type);
|
|
var id = p.id;
|
|
var web = p.website;
|
|
if (web && !/^https?:\/\//i.test(web)) web = 'https://' + web;
|
|
var halalRow = '';
|
|
if (p.halal_score != null) {
|
|
halalRow = row('Halal kans', p.halal_score + '/100' + (halalMode ? ' · ' + (p.halal_tier || '') : ''));
|
|
}
|
|
var contactBits = [];
|
|
if (p.email) contactBits.push('📧 ' + escapeHtml(p.email));
|
|
if (p.phone) contactBits.push('📞 ' + escapeHtml(p.phone));
|
|
var contactRow = contactBits.length
|
|
? '<div class="ei-popup-row"><span class="ei-popup-k">Contact</span><span class="ei-popup-v">' + contactBits.join('<br>') + '</span></div>'
|
|
: '';
|
|
return (
|
|
'<div class="ei-popup-card" data-entity-id="' + escapeHtml(id) + '">' +
|
|
'<div class="ei-popup-top">' +
|
|
'<span class="ei-popup-badge" style="--badge-color:' + (halalMode ? halalColor(p) : c.color) + '">' + c.icon + ' ' + escapeHtml(c.label) + '</span>' +
|
|
'<span class="ei-popup-flag">' + escapeHtml(p.country_iso2 || '') + '</span>' +
|
|
'</div>' +
|
|
'<h3 class="ei-popup-name">' + escapeHtml(p.name) + '</h3>' +
|
|
'<div class="ei-popup-grid">' +
|
|
halalRow +
|
|
contactRow +
|
|
row('Stad', p.city) +
|
|
row('Adres', p.address_line) +
|
|
row('E-mail', p.email, p.email ? 'mailto:' + p.email : null) +
|
|
row('Telefoon', p.phone, p.phone ? 'tel:' + p.phone : null) +
|
|
row('Website', p.website, web) +
|
|
row('Volume', p.volume_band) +
|
|
row('Contacten', p.contact_count > 0 ? p.contact_count + ' in registry' : null) +
|
|
row('Confidence', p.confidence != null ? p.confidence + '%' : null) +
|
|
row('Pipeline', p.pipeline_stage) +
|
|
'</div>' +
|
|
'<div class="ei-popup-actions">' +
|
|
'<button type="button" class="ei-popup-btn" data-entity-id="' + escapeHtml(id) + '">Volledig profiel →</button>' +
|
|
'</div>' +
|
|
'</div>'
|
|
);
|
|
}
|
|
|
|
function pinIcon(type, p) {
|
|
var c = cfg(type);
|
|
var color = halalMode && p && p.halal_score != null ? halalColor(p) : c.color;
|
|
var ring = halalMode && p && p.halal_tier === 'hot' ? ' ei-pin-hot' : '';
|
|
return L.divIcon({
|
|
className: 'ei-pin-wrap',
|
|
html:
|
|
'<div class="ei-pin' + ring + '" style="--pin-color:' + color + '">' +
|
|
'<span class="ei-pin-ring"></span>' +
|
|
'<span class="ei-pin-icon">' + c.icon + '</span>' +
|
|
'</div>',
|
|
iconSize: [30, 30],
|
|
iconAnchor: [15, 15],
|
|
popupAnchor: [0, -16],
|
|
});
|
|
}
|
|
|
|
function openEntityId(rawId) {
|
|
var id = Number(rawId);
|
|
if (!Number.isFinite(id)) return;
|
|
if (onEntityClick) {
|
|
onEntityClick(id);
|
|
return;
|
|
}
|
|
try {
|
|
document.dispatchEvent(new CustomEvent('export-intel:open-entity', { detail: { id: id } }));
|
|
} catch (e) { /* empty */ }
|
|
}
|
|
|
|
function wirePopup(container) {
|
|
if (!container) return;
|
|
var popupRoot = container.closest('.leaflet-popup') || container;
|
|
if (window.L && L.DomEvent) {
|
|
L.DomEvent.disableClickPropagation(popupRoot);
|
|
L.DomEvent.disableScrollPropagation(popupRoot);
|
|
}
|
|
var btn = container.querySelector('.ei-popup-btn');
|
|
var id = btn && btn.getAttribute('data-entity-id');
|
|
if (btn && id) {
|
|
var onBtn = function (e) {
|
|
if (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
openEntityId(id);
|
|
};
|
|
if (window.L && L.DomEvent) {
|
|
L.DomEvent.on(btn, 'click', onBtn);
|
|
L.DomEvent.on(btn, 'mousedown', L.DomEvent.stopPropagation);
|
|
} else {
|
|
btn.addEventListener('click', onBtn);
|
|
btn.addEventListener('mousedown', function (e) { e.stopPropagation(); });
|
|
}
|
|
}
|
|
container.addEventListener('click', function (e) {
|
|
if (e.target.closest('.ei-popup-btn') || e.target.closest('a')) return;
|
|
var card = e.target.closest('[data-entity-id]');
|
|
if (card) openEntityId(card.getAttribute('data-entity-id'));
|
|
});
|
|
}
|
|
|
|
function invalidateMapSize() {
|
|
if (!map) return;
|
|
map.invalidateSize(true);
|
|
setTimeout(function () { if (map) map.invalidateSize(true); }, 120);
|
|
setTimeout(function () { if (map) map.invalidateSize(true); }, 400);
|
|
}
|
|
|
|
function init(containerId) {
|
|
var el = document.getElementById(containerId);
|
|
if (!el || !window.L) return null;
|
|
if (map) {
|
|
map.remove();
|
|
map = null;
|
|
clusterLayer = null;
|
|
}
|
|
map = L.map(containerId, { zoomControl: true, attributionControl: true }).setView([20, 10], 2);
|
|
L.tileLayer('/api/map/tiles/{z}/{x}/{y}.png', {
|
|
attribution: '© OSM © CARTO',
|
|
maxZoom: 19,
|
|
}).addTo(map);
|
|
|
|
if (window.L.markerClusterGroup) {
|
|
clusterLayer = L.markerClusterGroup({
|
|
maxClusterRadius: 42,
|
|
spiderfyOnMaxZoom: true,
|
|
showCoverageOnHover: false,
|
|
zoomToBoundsOnClick: true,
|
|
iconCreateFunction: function (cluster) {
|
|
var n = cluster.getChildCount();
|
|
var size = n < 10 ? 'sm' : n < 50 ? 'md' : 'lg';
|
|
return L.divIcon({
|
|
html: '<div class="ei-cluster ei-cluster-' + size + '"><span>' + n + '</span></div>',
|
|
className: 'ei-cluster-wrap',
|
|
iconSize: L.point(44, 44),
|
|
});
|
|
},
|
|
});
|
|
map.addLayer(clusterLayer);
|
|
} else {
|
|
clusterLayer = L.layerGroup();
|
|
map.addLayer(clusterLayer);
|
|
}
|
|
|
|
map.on('popupopen', function (e) {
|
|
var root = e.popup && e.popup.getElement();
|
|
if (!root) return;
|
|
if (window.L && L.DomEvent) {
|
|
L.DomEvent.disableClickPropagation(root);
|
|
L.DomEvent.disableScrollPropagation(root);
|
|
}
|
|
wirePopup(root.querySelector('.leaflet-popup-content') || root);
|
|
});
|
|
|
|
invalidateMapSize();
|
|
return map;
|
|
}
|
|
|
|
function setFeatures(geojson) {
|
|
if (!map || !clusterLayer) return;
|
|
clusterLayer.clearLayers();
|
|
markersById = {};
|
|
var features = (geojson && geojson.features) || [];
|
|
var markers = [];
|
|
|
|
features.forEach(function (f) {
|
|
var coords = f.geometry && f.geometry.coordinates;
|
|
if (!coords) return;
|
|
var p = f.properties || {};
|
|
var marker = L.marker([coords[1], coords[0]], { icon: pinIcon(p.entity_type, p) });
|
|
marker._eiId = p.id;
|
|
if (p.id != null) markersById[p.id] = marker;
|
|
marker.bindPopup(popupHtml(p), { maxWidth: 320, minWidth: 260, className: 'ei-leaflet-popup' });
|
|
marker.on('dblclick', function () {
|
|
if (onEntityClick && p.id) onEntityClick(p.id);
|
|
});
|
|
markers.push(marker);
|
|
});
|
|
|
|
if (clusterLayer.addLayers) clusterLayer.addLayers(markers);
|
|
else markers.forEach(function (m) { clusterLayer.addLayer(m); });
|
|
|
|
invalidateMapSize();
|
|
|
|
if (features.length === 1) {
|
|
var c = features[0].geometry.coordinates;
|
|
map.setView([c[1], c[0]], 10);
|
|
} else if (markers.length > 1 && clusterLayer.getBounds) {
|
|
try {
|
|
map.fitBounds(clusterLayer.getBounds(), { padding: [40, 40], maxZoom: 12 });
|
|
} catch (e) { /* empty */ }
|
|
}
|
|
}
|
|
|
|
function setHalalMode(on) {
|
|
halalMode = !!on;
|
|
}
|
|
|
|
function setOnEntityClick(fn) {
|
|
onEntityClick = fn;
|
|
}
|
|
|
|
function flyTo(lat, lon, zoom) {
|
|
if (map && lat && lon) map.setView([lat, lon], zoom || 10);
|
|
}
|
|
|
|
function highlightEntity(id) {
|
|
var marker = markersById[id];
|
|
if (!marker || !map) return;
|
|
var latlng = marker.getLatLng();
|
|
map.setView(latlng, Math.max(map.getZoom(), 14));
|
|
marker.openPopup();
|
|
}
|
|
|
|
function closePopup() {
|
|
if (map) map.closePopup();
|
|
}
|
|
|
|
function destroy() {
|
|
if (map) {
|
|
map.remove();
|
|
map = null;
|
|
clusterLayer = null;
|
|
}
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
window.addEventListener('resize', function () { invalidateMapSize(); });
|
|
}
|
|
|
|
return { init, setFeatures, setOnEntityClick, setHalalMode, flyTo, closePopup, highlightEntity, destroy, TYPE_CFG: TYPE_CFG, cfg: cfg };
|
|
})();
|