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:
@@ -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")
|
||||
|
||||
@@ -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%; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user