diff --git a/cockpit/app/routes/export_intel.py b/cockpit/app/routes/export_intel.py index 70b7a5a..4a5027b 100644 --- a/cockpit/app/routes/export_intel.py +++ b/cockpit/app/routes/export_intel.py @@ -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") diff --git a/cockpit/static/css/export-intel.css b/cockpit/static/css/export-intel.css index 1034c1c..135b860 100644 --- a/cockpit/static/css/export-intel.css +++ b/cockpit/static/css/export-intel.css @@ -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%; } diff --git a/cockpit/static/js/export-intel-map.js b/cockpit/static/js/export-intel-map.js index 1b17b73..613e76e 100644 --- a/cockpit/static/js/export-intel-map.js +++ b/cockpit/static/js/export-intel-map.js @@ -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; diff --git a/cockpit/static/js/export-intel.js b/cockpit/static/js/export-intel.js index 14aaafd..fa4b128 100644 --- a/cockpit/static/js/export-intel.js +++ b/cockpit/static/js/export-intel.js @@ -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) { diff --git a/cockpit/templates/export_intel.html b/cockpit/templates/export_intel.html index 6533fba..b0d28c3 100644 --- a/cockpit/templates/export_intel.html +++ b/cockpit/templates/export_intel.html @@ -4,11 +4,11 @@ - + - + {% endblock %} {% block content %}
Wereldoverzicht toont de belangrijkste ~2.500 locaties. Filter op regio of land voor meer detail.
+Klik rij of kaart-pin voor volledig profiel.
@@ -797,5 +798,5 @@