Speed up Wereldexport map loading dramatically.

Limit world overview payload, optimize SQL joins, cache bundle and tiles, reuse map instance, and show loading state.
This commit is contained in:
Aissa
2026-07-19 18:42:47 +00:00
parent 7289b770e5
commit 6856d09cd1
6 changed files with 198 additions and 45 deletions
+40 -7
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import os
import time
from typing import Any, Optional
import httpx
@@ -15,6 +16,14 @@ BASE = Path(__file__).resolve().parent.parent.parent
templates = Jinja2Templates(directory=str(BASE / "templates"))
TOOLS = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/")
_TILE_CACHE: dict[str, tuple[float, bytes]] = {}
_TILE_CACHE_TTL = 86400.0
_TILE_CACHE_MAX = 900
_BUNDLE_CACHE: dict[str, tuple[float, bytes]] = {}
_BUNDLE_CACHE_TTL = 120.0
async def _proxy(method: str, path: str, **kwargs) -> Any:
url = f"{TOOLS}/export-intel{path}"
@@ -161,7 +170,12 @@ async def api_gov_sources(country: Optional[str] = None):
@router.get("/api/map/tiles/{z}/{x}/{y}.png")
async def map_tile_proxy(z: int, x: int, y: int) -> Response:
"""Proxy Carto dark basemap tiles via cockpit (no client internet needed)."""
"""Proxy Carto dark basemap tiles via cockpit (cached, no client internet needed)."""
key = f"{z}/{x}/{y}"
hit = _TILE_CACHE.get(key)
if hit and time.time() - hit[0] < _TILE_CACHE_TTL:
return Response(content=hit[1], media_type="image/png", headers={"Cache-Control": "public, max-age=604800"})
host = ("a", "b", "c", "d")[(x + y) % 4]
url = f"https://{host}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"
try:
@@ -169,11 +183,12 @@ async def map_tile_proxy(z: int, x: int, y: int) -> Response:
r = await client.get(url)
if r.status_code != 200:
return Response(status_code=502, content=b"")
return Response(
content=r.content,
media_type="image/png",
headers={"Cache-Control": "public, max-age=604800"},
)
body = r.content
if len(_TILE_CACHE) >= _TILE_CACHE_MAX:
oldest = min(_TILE_CACHE.items(), key=lambda item: item[1][0])[0]
_TILE_CACHE.pop(oldest, None)
_TILE_CACHE[key] = (time.time(), body)
return Response(content=body, media_type="image/png", headers={"Cache-Control": "public, max-age=604800"})
except Exception:
return Response(status_code=502, content=b"")
@@ -193,7 +208,25 @@ async def api_map_bundle(
"entity_types": entity_types, "q": q, "halal_min": halal_min,
"favorite_only": favorite_only, "crm_linked": crm_linked,
}.items() if v is not None and v is not False}
return await _proxy("GET", "/map/bundle", params=params)
import hashlib
import json
cache_key = hashlib.md5(json.dumps(params, sort_keys=True, default=str).encode()).hexdigest()
hit = _BUNDLE_CACHE.get(cache_key)
if hit and time.time() - hit[0] < _BUNDLE_CACHE_TTL:
return Response(content=hit[1], media_type="application/json")
url = f"{TOOLS}/export-intel/map/bundle"
async with httpx.AsyncClient(timeout=60.0) as client:
r = await client.get(url, params=params)
if r.status_code >= 400:
return {"error": r.text, "status": r.status_code}
body = r.content
if len(_BUNDLE_CACHE) > 32:
oldest = min(_BUNDLE_CACHE.items(), key=lambda item: item[1][0])[0]
_BUNDLE_CACHE.pop(oldest, None)
_BUNDLE_CACHE[cache_key] = (time.time(), body)
return Response(content=body, media_type="application/json")
@router.post("/api/export-intel/entities/favorites")
+17
View File
@@ -42,6 +42,23 @@
.ei-filter-clear { font-size: 0.72rem; opacity: 0.85; }
.ei-split { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; min-height: 420px; }
@media (max-width: 900px) { .ei-split { grid-template-columns: 1fr; } }
.ei-map-wrap { position: relative; }
.ei-map-loading {
position: absolute; inset: 0; z-index: 500;
display: flex; align-items: center; justify-content: center; gap: 0.65rem;
background: rgba(8, 12, 18, 0.72); color: #94a3b8; font-size: 0.85rem;
pointer-events: none;
}
.ei-map-spinner {
width: 18px; height: 18px; border-radius: 50%;
border: 2px solid rgba(56, 189, 248, 0.25);
border-top-color: #38bdf8;
animation: ei-spin 0.8s linear infinite;
}
@keyframes ei-spin { to { transform: rotate(360deg); } }
.ei-map-truncated { margin: 0 0 0.65rem; font-size: 0.75rem; color: #64748b; }
.ei-map-error { margin: 0 0 0.65rem; padding: 0.65rem 0.85rem; border-radius: 10px; border: 1px solid rgba(251,113,133,0.35); background: rgba(127,29,29,0.25); color: #fecdd3; font-size: 0.82rem; }
.ei-map-wrap { border-radius: 14px; overflow: hidden; border: 1px solid rgba(14,165,233,0.2); height: 420px; min-height: 420px; background: #0a0e14; }
.ei-map-wrap #ei-map { height: 420px; width: 100%; }
+4
View File
@@ -170,6 +170,10 @@ window.ExportIntelMap = (function () {
function init(containerId) {
var el = document.getElementById(containerId);
if (!el || !window.L) return null;
if (map && map.getContainer() === el) {
invalidateMapSize();
return map;
}
if (map) {
map.remove();
map = null;
+19 -9
View File
@@ -26,6 +26,8 @@ function exportIntelApp() {
mapHalalMode: false,
mapHalalMin: 55,
mapError: '',
mapLoading: false,
mapTruncated: false,
halalTopMarkets: [],
halalTopEntities: [],
listFocusId: null,
@@ -242,13 +244,7 @@ function exportIntelApp() {
var base = '/api/export-intel';
var fq = this.filterQs();
if (this.tab === 'map') {
var mapQs = this.filterQs();
var mt = this.mapTypesParam();
if (mt.entity_type) mapQs += '&entity_type=' + encodeURIComponent(mt.entity_type);
else if (mt.entity_types) mapQs += '&entity_types=' + encodeURIComponent(mt.entity_types);
var rm = await fetch(base + '/entities?limit=200' + mapQs).then((x) => x.json());
this.entities = rm.items || [];
this.resultCount = rm.total != null ? rm.total : this.entities.length;
return;
} else if (this.tab === 'distributors') {
var types = 'distributor,wholesaler,importer,logistics';
var et = this.entityTypeFilter ? '&entity_type=' + encodeURIComponent(this.entityTypeFilter) : '';
@@ -307,6 +303,8 @@ function exportIntelApp() {
async loadMap() {
this.clearListFocus();
this.mapLoading = true;
this.mapError = '';
var url = '/api/export-intel/map/bundle';
var qs = [];
if (this.country) qs.push('country=' + encodeURIComponent(this.country));
@@ -320,16 +318,27 @@ function exportIntelApp() {
if (this.filterCrmStatus === 'linked') qs.push('crm_linked=true');
if (this.filterCrmStatus === 'not_linked') qs.push('crm_linked=false');
if (qs.length) url += '?' + qs.join('&');
var bundle = await fetch(url).then((r) => r.json());
var bundle;
try {
bundle = await fetch(url).then((r) => r.json());
} catch (e) {
this.mapLoading = false;
this.mapError = 'Kaart laden mislukt. Probeer opnieuw.';
return;
}
this.halalTopMarkets = (bundle.meta && bundle.meta.top_markets) || [];
this.halalTopEntities = (bundle.meta && bundle.meta.top_halal_entities) || [];
this.entities = (bundle.meta && bundle.meta.sidebar_entities) || this.halalTopEntities || [];
this.resultCount = (bundle.meta && bundle.meta.entity_count) || this.entities.length;
this.mapTruncated = !!(bundle.meta && bundle.meta.truncated);
if (!window.L || !window.ExportIntelMap) {
this.mapError = 'Kaart kon niet laden. Vernieuw de pagina (Ctrl+F5).';
this.mapLoading = false;
return;
}
this.mapError = '';
if (window.ExportIntelMap) {
if (!document.getElementById('ei-map')) return;
if (!document.getElementById('ei-map')) { this.mapLoading = false; return; }
var self = this;
window.ExportIntelMap.init('ei-map');
window.ExportIntelMap.setOnEntityClick(function (id) { self.openEntity(id); });
@@ -340,6 +349,7 @@ function exportIntelApp() {
if (t && t.lat && t.lon) window.ExportIntelMap.flyTo(t.lat, t.lon, t.map_zoom || 6);
}
}
this.mapLoading = false;
},
setTab(id, el) {
+5 -4
View File
@@ -4,11 +4,11 @@
<link rel="stylesheet" href="/static/vendor/leaflet/leaflet.css?v=1" />
<link rel="stylesheet" href="/static/vendor/leaflet.markercluster/MarkerCluster.css?v=1" />
<link rel="stylesheet" href="/static/vendor/leaflet.markercluster/MarkerCluster.Default.css?v=1" />
<link rel="stylesheet" href="/static/css/export-intel.css?v=13" />
<link rel="stylesheet" href="/static/css/export-intel.css?v=14" />
<link rel="stylesheet" href="/static/css/export-intel-tabs.css?v=10" />
<script src="/static/vendor/leaflet/leaflet.js?v=1"></script>
<script src="/static/vendor/leaflet.markercluster/leaflet.markercluster.js?v=1"></script>
<script src="/static/js/export-intel-map.js?v=9"></script>
<script src="/static/js/export-intel-map.js?v=10"></script>
{% endblock %}
{% block content %}
<div class="ei-page" x-data="exportIntelApp()" x-init="init()">
@@ -237,7 +237,8 @@
</div>
<div class="ei-split">
<div class="ei-map-error" x-show="mapError" x-cloak x-text="mapError"></div>
<div class="ei-map-wrap"><div id="ei-map"></div></div>
<p class="ei-map-truncated" x-show="mapTruncated && !mapLoading" x-cloak>Wereldoverzicht toont de belangrijkste ~2.500 locaties. Filter op regio of land voor meer detail.</p>
<div class="ei-map-wrap"><div class="ei-map-loading" x-show="mapLoading" x-cloak><span class="ei-map-spinner"></span> Kaart laden…</div><div id="ei-map"></div></div>
<div class="ei-panel">
<h3 style="margin:0 0 0.5rem;font-size:0.9rem" x-text="mapHalalMode ? 'Top halal-kansen' : 'Entities op kaart'"></h3>
<p style="font-size:0.75rem;color:#64748b;margin:0 0 0.75rem" x-show="!mapHalalMode">Klik rij of kaart-pin voor volledig profiel.</p>
@@ -797,5 +798,5 @@
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/export-intel.js?v=12"></script>
<script src="/static/js/export-intel.js?v=13"></script>
{% endblock %}