uawdijnntqw1x1x1
IP : 216.73.217.6
Hostname : webm002.cluster111.gra.hosting.ovh.net
Kernel : Linux webm002.cluster111.gra.hosting.ovh.net 6.18.42-ovh-vps-grsec-zfs+ #1 SMP PREEMPT_DYNAMIC Wed Aug 5 15:59:48 CEST 2026 x86_64
Disable Function : _dyuweyrj4,_dyuweyrj4r,dl
OS : Linux
PATH:
/
home
/
atelierpru
/
provence
/
..
/
www
/
airvalue
/
av-minimap.js
/
/
// Minimap 3D AirValue — composant UNIQUE partagé par /adm (POC v0) et par la // section d'analyse de la landing. Style commun (ortho IGN + bâtiments // OpenFreeMap « pierre », lumière directionnelle), chorégraphie commune // (vol caméra zoom 16.8 / pitch 60, quartier à +1 s) et ORBITE 360° démarrée // DÈS LA FIN DU VOL caméra — indépendante de l'avancement des recherches. // Attribution repliée (ⓘ compact) : obligation ODbL respectée sans texte. // Nécessite maplibre-gl (vendored) chargé au préalable. (function () { 'use strict'; var IGN_ORTHO = 'https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0' + '&LAYER=ORTHOIMAGERY.ORTHOPHOTOS&STYLE=normal&TILEMATRIXSET=PM&FORMAT=image/jpeg' + '&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}'; function dlog() { try { var a = ['[minimap]'].concat([].slice.call(arguments)); console.info.apply(console, a); } catch (e) { /* ignore */ } } var OSM_HEIGHT_EXPR = ['case', ['has', 'render_height'], ['max', ['to-number', ['get', 'render_height'], 3], 3], 12]; function pointInRing(pt, ring) { var inside = false; for (var i = 0, j = ring.length - 1; i < ring.length; j = i++) { var xi = ring[i][0], yi = ring[i][1], xj = ring[j][0], yj = ring[j][1]; if (((yi > pt[1]) !== (yj > pt[1])) && (pt[0] < (xj - xi) * (pt[1] - yi) / (yj - yi) + xi)) inside = !inside; } return inside; } function pointInGeometry(pt, g) { if (!g) return false; if (g.type === 'Polygon') return pointInRing(pt, g.coordinates[0] || []); if (g.type === 'MultiPolygon') { return g.coordinates.some(function (poly) { return pointInRing(pt, poly[0] || []); }); } return false; } // Découpe un anneau par un anneau de parcelle (Sutherland–Hodgman). Les // empreintes à cheval sur la limite cadastrale produiraient des « trous » // sortant du polygone parcelle — invalides pour la tesselation (le trou est // alors ignoré et le rouge réapparaît sous le bâtiment). On ne garde que la // partie DANS la parcelle. Exact pour parcelle convexe (le cas courant), // approximation acceptable sinon. function clipRingToRing(ring, clipRing) { if (!ring || ring.length < 3 || !clipRing || clipRing.length < 3) return []; // Orientation du clip pour définir « intérieur ». var area = 0; for (var i = 0; i < clipRing.length - 1; i++) { area += clipRing[i][0] * clipRing[i + 1][1] - clipRing[i + 1][0] * clipRing[i][1]; } var sign = area >= 0 ? 1 : -1; var out = ring.slice(); if (out.length > 1 && out[0][0] === out[out.length - 1][0] && out[0][1] === out[out.length - 1][1]) out.pop(); for (var e = 0; e < clipRing.length - 1 && out.length; e++) { var a = clipRing[e], b = clipRing[e + 1]; var input = out; out = []; var sideOf = function (p) { return sign * ((b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0])); }; for (var j = 0; j < input.length; j++) { var p = input[j], q = input[(j + 1) % input.length]; var sp = sideOf(p), sq = sideOf(q); if (sp >= 0) out.push(p); if ((sp >= 0) !== (sq >= 0)) { var t = sp / (sp - sq); out.push([p[0] + t * (q[0] - p[0]), p[1] + t * (q[1] - p[1])]); } } } if (out.length < 3) return []; out.push(out[0]); return out; } // Centroïde (moyenne de sommets, suffisant pour cibler un masquage OSM). function geomCentroid(g) { try { var ring = g && g.type === 'Polygon' ? g.coordinates[0] : g && g.type === 'MultiPolygon' ? g.coordinates[0][0] : null; if (!ring || !ring.length) return null; var sx = 0, sy = 0; for (var i = 0; i < ring.length; i++) { sx += ring[i][0]; sy += ring[i][1]; } return [sx / ring.length, sy / ring.length]; } catch (e) { return null; } } function create(opts) { var maplibregl = opts.maplibregl || (typeof window !== 'undefined' ? window.maplibregl : null); if (!maplibregl) return null; var center = [opts.lon, opts.lat]; var st = { ready: false, queue: [], destroyed: false, orbitRaf: 0, orbitStopped: false, orbitWired: false, blueGeometry: null, blueHeight: null, built: false, volumesHidden: false, }; var map; try { map = new maplibregl.Map({ container: opts.container, style: { version: 8, sources: { ortho: { type: 'raster', tiles: [IGN_ORTHO], tileSize: 256, maxzoom: 19, attribution: '© IGN — Géoplateforme' }, osm: { type: 'vector', url: 'https://tiles.openfreemap.org/planet', attribution: '© OpenStreetMap' }, }, layers: [ { id: 'bg', type: 'background', paint: { 'background-color': '#1e293b' } }, { id: 'ortho', type: 'raster', source: 'ortho' }, ], }, center: center, zoom: opts.startZoom != null ? opts.startZoom : 15.4, pitch: 0, bearing: 0, attributionControl: false, }); } catch (e) { return null; } try { map.addControl(new maplibregl.AttributionControl({ compact: true })); } catch (e) { /* ignore */ } // MapLibre affiche l'attribution compacte OUVERTE au chargement : on la // replie derrière l'icône ⓘ (elle reste consultable au clic — ODbL ok). function foldAttribution() { try { var els = map.getContainer().querySelectorAll('.maplibregl-ctrl-attrib.maplibregl-compact-show'); for (var i = 0; i < els.length; i++) { els[i].classList.remove('maplibregl-compact-show'); els[i].removeAttribute('open'); } } catch (e) { /* ignore */ } } [0, 300, 1200].forEach(function (ms) { setTimeout(foldAttribution, ms); }); if (opts.navControl) { try { map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), 'bottom-right'); } catch (e) { /* ignore */ } } // Sécurité tuiles photoréalistes : dézoom BLOQUÉ (minZoom) et navigation // bornée (~±2 km) — impossible de faire streamer tout Paris. Appliqué // UNIQUEMENT quand le photoréaliste est actif (voir setG3d) : en mode // ortho/OSM la navigation reste libre. function setCameraLocks(on) { try { if (on) { map.setMinZoom(15); map.setMaxBounds([[center[0] - 0.02, center[1] - 0.013], [center[0] + 0.02, center[1] + 0.013]]); } else { map.setMinZoom(null); map.setMaxBounds(null); } } catch (e) { /* ignore */ } } try { window.__dfMap = map; } catch (e) { /* hook test */ } // Watchdog orbite : si elle devrait tourner (pas stoppée par l'utilisateur) // mais qu'aucune frame ne tourne, on relance et on le dit — le log nous // dira POURQUOI elle s'était arrêtée. st.orbitWatchdog = setInterval(function () { if (st.destroyed) { clearInterval(st.orbitWatchdog); return; } if (!st.orbitStopped && !st.orbitRaf && st.built) { try { console.warn('[minimap] orbite inactive alors que non stoppée — relance auto'); } catch (e) { /* ignore */ } api.startOrbit(); } }, 4000); // Battement de cœur diagnostic (10 s) : l'état complet en une ligne. st.hbLast = { bearing: 0, t: 0 }; st.heartbeat = setInterval(function () { if (st.destroyed) { clearInterval(st.heartbeat); return; } try { var brg = Math.round(map.getBearing()); var moved = brg !== st.hbLast.bearing; dlog('état : bearing=' + brg + (moved ? ' (tourne)' : ' (IMMOBILE)') + ' rafOrbite=' + (st.orbitRaf ? 'oui' : 'non') + ' stoppée=' + (st.orbitStopped ? 'oui' : 'non') + ' zoom=' + Math.round(map.getZoom() * 10) / 10 + ' g3d=' + (st.g3dOn ? (st.g3dLoaded ? 'chargé' : 'en attente') : 'off') + ' altOff=' + Math.round(st.altOff || 0) + 'm' + ' tuilesG3D=' + (st.g3dLayerMounted ? 'montées' : 'retirées')); st.hbLast.bearing = brg; } catch (e) { /* ignore */ } }, 10000); function markReady() { if (st.ready || st.destroyed) return; st.ready = true; if (opts.onReady) { try { opts.onReady(); } catch (e) { /* ignore */ } } st.queue.splice(0).forEach(function (fn) { try { fn(); } catch (e) { /* ignore */ } }); } map.on('styledata', markReady); map.on('load', function () { markReady(); setTimeout(function () { try { map.resize(); } catch (e) { /* ignore */ } }, 60); }); // Tuiles en échec (rate-limit/transitoire) = bâtiments absents par plaques // rectangulaires. On compte, on logue, et on retente la source osm (2 max). st.tileErrors = {}; map.on('error', function (ev) { try { var srcId = ev && ev.sourceId ? ev.sourceId : 'style'; st.tileErrors[srcId] = (st.tileErrors[srcId] || 0) + 1; window.__dfTileErrors = st.tileErrors; if (st.tileErrors[srcId] === 1 || st.tileErrors[srcId] % 10 === 0) { console.warn('[minimap] tuiles en échec — source ' + srcId + ' : ' + st.tileErrors[srcId]); } } catch (e) { /* ignore */ } }); st.srcReloads = {}; map.on('idle', function () { if (st.destroyed) return; ['osm', 'ortho'].forEach(function (src) { if (!st.tileErrors[src] || (st.srcReloads[src] || 0) >= 2) return; st.srcReloads[src] = (st.srcReloads[src] || 0) + 1; var n = st.tileErrors[src], attempt = st.srcReloads[src]; setTimeout(function () { if (st.destroyed) return; try { console.warn('[minimap] retentative tuiles ' + src + ' (' + n + ' échec(s), essai ' + attempt + '/2)'); st.tileErrors[src] = 0; map.getSource(src).reload(); } catch (e) { /* ignore */ } }, 1500 * attempt); }); }); function whenReady(fn) { if (st.ready) { try { fn(); } catch (e) { /* ignore */ } return; } st.queue.push(fn); } // Toutes les couches de volumes 3D (colorées + quartier) : masquables // d'un clic pour voir le sol (l'aplat rouge des espaces libres reste). var VOL_LAYERS = ['quartier-3d', 'context-3d', 'unified-3d', 'unified-outline', 'building-3d', 'building-outline', 'surelev-3d', 'mitoyens-3d', 'mitoyens-outline', 'candidates-hit', 'candidates-line']; function syncVolumes() { VOL_LAYERS.forEach(function (id) { if (!map.getLayer(id)) return; // RÈGLE : le quartier OSM n'est JAMAIS modifié par l'ANALYSE — // seuls le bouton 3D et le mode photoréaliste (doublon Google) // le masquent. // Quartier OSM : masqué si volumes off, si le photoréaliste le // remplace, ou HORS ZONE 3D (dézoom/éloignement → tout plat). var hidden = st.volumesHidden || (id === 'quartier-3d' && (st.unifiedActive || (st.g3dOn && st.g3dLoaded) || !viewInRange())); try { map.setLayoutProperty(id, 'visibility', hidden ? 'none' : 'visible'); } catch (e) { /* ignore */ } }); } // ---- Zone 3D limitée : la 3D (Google + quartier OSM) ne se rend que // dans le voisinage de l'adresse et à un zoom de quartier. Dézoom ou // éloignement → tout est PLAT et plus AUCUNE donnée 3D n'est chargée // (économie de tuiles Google/API et de rendu). var VIEW3D_MIN_ZOOM = 15, VIEW3D_MAX_DIST_M = 600; function viewInRange() { try { if (map.getZoom() < VIEW3D_MIN_ZOOM) return false; var c = map.getCenter(); var kx = 111320 * Math.cos(c.lat * Math.PI / 180); var dx = (c.lng - center[0]) * kx; var dy = (c.lat - center[1]) * 110540; return Math.sqrt(dx * dx + dy * dy) <= VIEW3D_MAX_DIST_M; } catch (e) { return true; } } // ---- Tuiles photoréalistes Google (Map Tiles API) via deck.gl ---------- // Actif seulement si opts.google3d = { key } ET deck.gl chargé. Rendu // interleaved dans le contexte MapLibre : nos volumes colorés gardent // leur profondeur au milieu des bâtiments photoréalistes. // Conversion lon/lat/alt (WGS84) → ECEF pour le filtre spatial des tuiles. var G3D_RADIUS_M = 150; var G3D_MAX_SPHERE_M = 500; var g3dCenterEcef = (function () { var DEG = Math.PI / 180; var a = 6378137, f = 1 / 298.257223563, e2 = 2 * f - f * f; var lat = opts.lat * DEG, lon = opts.lon * DEG; var sinLat = Math.sin(lat), cosLat = Math.cos(lat); var N = a / Math.sqrt(1 - e2 * sinLat * sinLat); var alt = (opts.google3d && typeof opts.google3d.groundAltM === 'number') ? opts.google3d.groundAltM : 80; return [(N + alt) * cosLat * Math.cos(lon), (N + alt) * cosLat * Math.sin(lon), (N * (1 - e2) + alt) * sinLat]; })(); st.g3dFilterCount = 0; function makeG3dLayer() { return new window.deck.Tile3DLayer({ id: 'g3d-tiles', data: 'https://tile.googleapis.com/v1/3dtiles/root.json?key=' + opts.google3d.key, pickable: true, loadOptions: { tileset: { maximumScreenSpaceError: 6, viewDistanceScale: 1, maximumTilesSelected: 80, maximumMemoryUsage: 512, onTraversalComplete: function (selectedTiles) { var kept = [], rejected = 0; for (var i = 0; i < selectedTiles.length; i++) { var tile = selectedTiles[i]; var bv = tile.header && tile.header.boundingVolume; if (!bv) { kept.push(tile); continue; } var sphere = bv.sphere; if (!sphere || sphere.length < 4) { kept.push(tile); continue; } var sr = sphere[3]; if (sr > G3D_MAX_SPHERE_M) { rejected++; continue; } var dx = sphere[0] - g3dCenterEcef[0]; var dy = sphere[1] - g3dCenterEcef[1]; var dz = sphere[2] - g3dCenterEcef[2]; var dist = Math.sqrt(dx * dx + dy * dy + dz * dz); if (dist - sr > G3D_RADIUS_M) { rejected++; continue; } kept.push(tile); } st.g3dFilterCount++; if (st.g3dFilterCount === 1 || st.g3dFilterCount % 30 === 0) { dlog('tuiles G3D filtre spatial : ' + kept.length + ' gardées, ' + rejected + ' rejetées (éval #' + st.g3dFilterCount + ')'); } return kept; }, } }, onTilesetLoad: function () { dlog('tileset Google chargé — bascule photoréaliste'); st.g3dLoaded = true; applyG3dBase(); applyAltOffset(); setTimeout(function () { measureAltitude(0); }, 1500); try { var entries = performance.getEntriesByType('resource'); var hits = 0, total = 0; for (var i = 0; i < entries.length; i++) { if (entries[i].name.indexOf('tile.googleapis.com') === -1) continue; total++; if (entries[i].transferSize === 0) hits++; } if (total) dlog('cache navigateur tuiles Google : ' + hits + '/' + total + ' hits (' + Math.round(100 * hits / total) + '%)'); } catch (e) { /* ignore */ } }, onTileError: function (t, e) { try { console.warn('[minimap] tuile photoréaliste en échec :', String(e).slice(0, 140)); } catch (x) { /* ignore */ } }, }); } // ---- Altitude du sol photoréaliste -------------------------------------- // Le mesh Google est à l'altitude RÉELLE (~35-70 m à Paris) alors que nos // volumes partent de z=0 : sans correction ils sont SOUS le sol Google. // On mesure l'altitude au centre (picking du mesh) puis on surélève tous // les volumes colorés de cette valeur. function measureAltitude(tries) { if (st.destroyed || !st.g3dOverlay || !st.g3dLoaded) return; try { var el = map.getContainer(); var w = el.clientWidth, h = el.clientHeight; // 5 points d'échantillonnage : centre + croix (le centre peut être // sur un toit ou un trou de mesh). var pts = [[0.5, 0.55], [0.4, 0.5], [0.6, 0.5], [0.5, 0.42], [0.5, 0.68]]; var zs = []; for (var i = 0; i < pts.length; i++) { var info = st.g3dOverlay.pickObject({ x: w * pts[i][0], y: h * pts[i][1], radius: 4 }); var z = info && info.coordinate && info.coordinate[2]; if (typeof z === 'number' && isFinite(z) && z > -100 && z < 1000) zs.push(z); } if (zs.length) { // Le sol = le plus bas des échantillons (les autres = toits). zs.sort(function (x, y) { return x - y; }); st.altOff = Math.max(0, zs[0]) + 0.8; st.altMeasured = true; applyAltOffset(); try { console.info('[minimap] sol photoréaliste mesuré ≈ ' + Math.round(zs[0]) + ' m (picking, ' + zs.length + ' points) — volumes surélevés'); } catch (e2) { /* ignore */ } return; } } catch (e) { /* ignore */ } if ((tries || 0) < 12) setTimeout(function () { measureAltitude((tries || 0) + 1); }, 1200); else { try { console.warn('[minimap] altitude sol photoréaliste NON mesurable par picking — fallback altimétrie IGN ' + (st.altOff ? 'actif (' + Math.round(st.altOff) + ' m)' : 'absent : volumes possiblement sous le mesh')); } catch (e3) { /* ignore */ } } } if (opts.google3d && typeof opts.google3d.groundAltM === 'number' && isFinite(opts.google3d.groundAltM)) { st.altOff = Math.max(0, opts.google3d.groundAltM) + 0.8; } function currentAltOff() { return (st.g3dOn && st.g3dLoaded && st.altOff) ? st.altOff : 0; } // Applique (ou retire) la surélévation à tous les volumes existants. function applyAltOffset() { var off = currentAltOff(); // En photoréaliste, les volumes sont légèrement agrandis (+12 % +2 m) // pour englober les toits du mesh Google (jamais en mode ortho/OSM). var lift = function (expr) { return off ? ['+', off + 2, ['*', 1.12, expr]] : expr; }; var liftNum = function (v) { return off ? off + 2 + 1.12 * v : v; }; try { if (map.getLayer('building-3d')) { map.setPaintProperty('building-3d', 'fill-extrusion-base', off); map.setPaintProperty('building-3d', 'fill-extrusion-height', lift(['get', 'height'])); } if (map.getLayer('surelev-3d') && st.surelevSpan) { map.setPaintProperty('surelev-3d', 'fill-extrusion-base', liftNum(st.surelevSpan[0])); map.setPaintProperty('surelev-3d', 'fill-extrusion-height', liftNum(st.surelevSpan[1])); } if (map.getLayer('unified-3d')) { map.setPaintProperty('unified-3d', 'fill-extrusion-base', off); map.setPaintProperty('unified-3d', 'fill-extrusion-height', lift(['get', 'height'])); } if (map.getLayer('context-3d')) { map.setPaintProperty('context-3d', 'fill-extrusion-base', off); map.setPaintProperty('context-3d', 'fill-extrusion-height', lift(['get', 'height'])); } if (map.getLayer('mitoyens-3d')) { map.setPaintProperty('mitoyens-3d', 'fill-extrusion-base', off); map.setPaintProperty('mitoyens-3d', 'fill-extrusion-height', lift(['get', 'height'])); } if (map.getLayer('candidates-hit')) { try { map.setPaintProperty('candidates-hit', 'fill-extrusion-base', off); map.setPaintProperty('candidates-hit', 'fill-extrusion-height', lift(['max', ['get', 'height'], 4])); } catch (e2) { /* mode aplat : pas d'extrusion */ } } // Rouge des espaces libres : à plat il serait sous le sol Google → // dalle fine surélevée quand un décalage est actif. if (map.getSource('freespace')) { var hasSlab = !!map.getLayer('freespace-slab'); if (off && !hasSlab) { map.addLayer({ id: 'freespace-slab', type: 'fill-extrusion', source: 'freespace', paint: { 'fill-extrusion-color': '#ef4444', 'fill-extrusion-base': off + 0.1, 'fill-extrusion-height': off + 0.7, 'fill-extrusion-opacity': 0.55, }, }); } else if (off && hasSlab) { map.setPaintProperty('freespace-slab', 'fill-extrusion-base', off + 0.1); map.setPaintProperty('freespace-slab', 'fill-extrusion-height', off + 0.7); } else if (!off && hasSlab) { map.removeLayer('freespace-slab'); } ['freespace-fill', 'freespace-line'].forEach(function (id) { if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', off ? 'none' : 'visible'); }); } } catch (e) { /* ignore */ } } // Hors zone : la couche est RETIRÉE de l'overlay → deck ne charge plus // rien (les .glb déjà vus restent en cache navigateur pour le retour). function g3dSyncLayers() { if (!st.g3dOverlay) return; var want = st.g3dOn && viewInRange(); if (want === st.g3dLayerMounted) return; try { st.g3dOverlay.setProps({ layers: want ? [makeG3dLayer()] : [] }); st.g3dLayerMounted = want; dlog(want ? 'tuiles photoréalistes MONTÉES (zone ok)' : 'tuiles photoréalistes RETIRÉES (hors zone ou G3D off)'); if (!want) st.g3dLoaded = false; } catch (e) { /* ignore */ } } function applyG3dBase() { // Le photoréaliste remplace ortho + bâtiments OSM (doublons) — mais // UNIQUEMENT une fois le tileset Google réellement chargé : si les // tuiles échouent (clé, réseau), la carte actuelle reste intacte. var hide = st.g3dOn && st.g3dLoaded && viewInRange(); // L'ortho IGN reste TOUJOURS visible : le mesh Google la recouvre là // où il est streamé, et au-delà le sol reste en photo aérienne PLATE // (la zone photoréaliste est bridée à quelques pâtés de maisons). if (map.getLayer('quartier-3d')) { try { map.setLayoutProperty('quartier-3d', 'visibility', hide ? 'none' : 'visible'); } catch (e) { /* ignore */ } } if (hide) syncVolumes(); } function setG3d(on) { if (!opts.google3d || !opts.google3d.key) return; if (typeof window === 'undefined' || !window.deck || !window.deck.MapboxOverlay) return; try { if (on && !st.g3dOverlay) { st.g3dOverlay = new window.deck.MapboxOverlay({ interleaved: true, layers: [] }); st.g3dLayerMounted = false; map.addControl(st.g3dOverlay); } else if (!on && st.g3dOverlay) { map.removeControl(st.g3dOverlay); st.g3dOverlay = null; st.g3dLayerMounted = false; st.g3dLoaded = false; } st.g3dOn = on && !!st.g3dOverlay; if (st.g3dOn && st.basemapMapbox && st.bmSetMapbox) { st.bmSetMapbox(false); } setCameraLocks(st.g3dOn); g3dSyncLayers(); applyG3dBase(); syncVolumes(); // Effet de bord CRITIQUE du toggle : les volumes colorés sont // surélevés (+altitude sol) et agrandis (+12 % +2 m) en mode // photoréaliste — re-application avec le nouvel état pour // retrouver les tailles/bases normales quand on repasse en ortho. applyAltOffset(); dlog('photoréaliste ' + (st.g3dOn ? 'ACTIVÉ' : 'DÉSACTIVÉ') + ' (verrous caméra ' + (st.g3dOn ? 'posés' : 'levés') + ')'); if (opts.google3d.onToggle) { try { opts.google3d.onToggle(st.g3dOn); } catch (e2) { /* ignore */ } } // Attribution Google obligatoire quand les tuiles sont affichées. if (!st.g3dAttrib) { st.g3dAttrib = document.createElement('div'); st.g3dAttrib.textContent = '© Google'; st.g3dAttrib.style.cssText = 'position:absolute;bottom:4px;left:6px;z-index:4;' + 'font:11px system-ui;color:#fff;text-shadow:0 0 3px rgba(0,0,0,.8);pointer-events:none;'; map.getContainer().appendChild(st.g3dAttrib); } st.g3dAttrib.style.display = st.g3dOn ? 'block' : 'none'; if (g3dBtn) { g3dBtn.style.background = st.g3dOn ? '#111827' : '#fff'; g3dBtn.style.color = st.g3dOn ? '#fff' : '#111'; } } catch (e) { /* deck indisponible : on reste sur ortho+OSM */ } } var g3dBtn = null; if (opts.google3d && opts.google3d.key) { g3dBtn = document.createElement('button'); g3dBtn.type = 'button'; g3dBtn.textContent = 'G3D'; g3dBtn.title = 'Basculer photoréaliste Google / ortho IGN'; g3dBtn.style.cssText = 'position:absolute;top:8px;left:52px;z-index:5;padding:6px 10px;' + 'border-radius:8px;border:1px solid rgba(0,0,0,.15);background:#fff;color:#111;' + 'font:600 12px system-ui;cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,.25);'; g3dBtn.addEventListener('click', function () { setG3d(!st.g3dOn); }); try { map.getContainer().appendChild(g3dBtn); } catch (e) { /* ignore */ } // DÉSACTIVÉ par défaut : le photoréaliste ne s'active que sur demande // (switch /adm ou bouton G3D), sauf si defaultOn est passé. if (opts.google3d.defaultOn) whenReady(function () { setG3d(true); }); } // Ré-évaluation de la zone 3D à chaque déplacement/zoom : montage ou // retrait de la couche Google + aplatissement du quartier hors zone. // Garde-fou perf : l'orbite déclenche moveend À CHAQUE FRAME — on ne // fait le travail que quand l'état entre/sort réellement de la zone. st.lastInRange = null; ['move', 'moveend', 'zoomend'].forEach(function (ev) { map.on(ev, function () { if (st.destroyed) return; var r = viewInRange(); if (r === st.lastInRange) return; st.lastInRange = r; g3dSyncLayers(); applyG3dBase(); syncVolumes(); }); }); var volBtn = document.createElement('button'); volBtn.type = 'button'; volBtn.textContent = '3D'; volBtn.title = 'Masquer / réafficher les volumes 3D'; volBtn.style.cssText = 'position:absolute;top:8px;left:8px;z-index:5;padding:6px 10px;' + 'border-radius:8px;border:1px solid rgba(0,0,0,.15);background:#fff;color:#111;' + 'font:600 12px system-ui;cursor:pointer;box-shadow:0 1px 4px rgba(0,0,0,.25);'; volBtn.addEventListener('click', function () { st.volumesHidden = !st.volumesHidden; volBtn.style.background = st.volumesHidden ? '#111827' : '#fff'; volBtn.style.color = st.volumesHidden ? '#fff' : '#111'; syncVolumes(); }); try { map.getContainer().appendChild(volBtn); } catch (e) { /* ignore */ } // ---- Légende couleurs -------------------------------------------------- var lgPanel = document.createElement('div'); lgPanel.style.cssText = 'position:absolute;top:8px;right:8px;z-index:5;' + 'background:rgba(255,255,255,.92);border-radius:8px;padding:6px 10px;' + 'border:1px solid rgba(0,0,0,.12);box-shadow:0 1px 4px rgba(0,0,0,.18);font:11px system-ui;line-height:1;'; var lgItems = [ ['#38bdf8', 'Immeuble cible'], ['#22c55e', 'Potentiel'], ['#8b5cf6', 'Mitoyens'], ['#ef4444', 'Parcelle'], ['#eab308', 'Candidats'], ['#b9ac92', 'Quartier'], ]; lgItems.forEach(function (item) { var row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;gap:5px;margin-bottom:3px;'; var swatch = document.createElement('span'); swatch.style.cssText = 'display:inline-block;width:12px;height:12px;border-radius:2px;flex-shrink:0;background:' + item[0] + ';'; var lbl = document.createElement('span'); lbl.textContent = item[1]; row.appendChild(swatch); row.appendChild(lbl); lgPanel.appendChild(row); }); lgPanel.lastChild.style.marginBottom = '0'; try { map.getContainer().appendChild(lgPanel); } catch (e) { /* ignore */ } // ---- Panneau checkboxes basemaps (Mapbox v3 / IGN 3D) ------------------ // Affiché seulement si opts.basemaps est passé (/adm uniquement). st.basemapMapbox = false; st.basemapIgn = false; st.basemapSrcsAdded = false; if (opts.basemaps) { var bmPanel = document.createElement('div'); bmPanel.style.cssText = 'position:absolute;top:' + (opts.google3d && opts.google3d.key ? '48' : '48') + 'px;left:8px;z-index:5;' + 'background:rgba(255,255,255,.92);border-radius:8px;padding:6px 10px;' + 'border:1px solid rgba(0,0,0,.12);box-shadow:0 1px 4px rgba(0,0,0,.18);font:12px system-ui;'; var mbToken = opts.basemaps.mapboxToken || ''; var mkCb = function (label, disabled, title) { var lbl = document.createElement('label'); lbl.style.cssText = 'display:flex;align-items:center;gap:5px;cursor:pointer;' + (disabled ? 'opacity:.45;cursor:not-allowed;' : ''); if (title) lbl.title = title; var cb = document.createElement('input'); cb.type = 'checkbox'; cb.disabled = disabled; cb.style.cssText = 'margin:0;'; var sp = document.createElement('span'); sp.textContent = label; lbl.appendChild(cb); lbl.appendChild(sp); return { lbl: lbl, cb: cb }; }; var cbMb = mkCb('Mapbox v3', !mbToken, !mbToken ? 'Token Mapbox manquant' : ''); var cbIgn = mkCb('IGN 3D', false, ''); bmPanel.appendChild(cbMb.lbl); bmPanel.appendChild(cbIgn.lbl); function ensureBasemapSources() { if (st.basemapSrcsAdded) return; st.basemapSrcsAdded = true; try { if (mbToken && !map.getSource('mapbox-sat')) { map.addSource('mapbox-sat', { type: 'raster', tiles: ['https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token=' + mbToken], tileSize: 512, maxzoom: 22, attribution: '© Mapbox © Maxar', }); map.addLayer({ id: 'mapbox-sat', type: 'raster', source: 'mapbox-sat', layout: { visibility: 'none' } }, 'quartier-3d'); } if (mbToken && !map.getSource('mapbox-dem')) { map.addSource('mapbox-dem', { type: 'raster-dem', tiles: ['https://api.mapbox.com/raster/v1/mapbox.mapbox-terrain-dem-v1/{z}/{x}/{y}.webp?access_token=' + mbToken], tileSize: 512, maxzoom: 14, encoding: 'mapbox', }); } if (!map.getSource('ign-hd')) { var ignHd = 'https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0' + '&LAYER=ORTHOIMAGERY.ORTHOPHOTOS.HR&STYLE=normal&TILEMATRIXSET=PM&FORMAT=image/jpeg' + '&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}'; map.addSource('ign-hd', { type: 'raster', tiles: [ignHd], tileSize: 256, maxzoom: 20, attribution: '© IGN — Géoplateforme (HR)' }); var beforeLayer = map.getLayer('quartier-3d') ? 'quartier-3d' : undefined; map.addLayer({ id: 'ign-hd', type: 'raster', source: 'ign-hd', layout: { visibility: 'none' } }, beforeLayer); } } catch (e) { dlog('basemap sources error:', e); } } function setBasemapMapbox(on) { st.basemapMapbox = on; cbMb.cb.checked = on; whenReady(function () { ensureBasemapSources(); if (map.getLayer('mapbox-sat')) map.setLayoutProperty('mapbox-sat', 'visibility', on ? 'visible' : 'none'); if (map.getLayer('ortho')) map.setLayoutProperty('ortho', 'visibility', on ? 'none' : 'visible'); try { if (on) map.setTerrain({ source: 'mapbox-dem', exaggeration: 1 }); else map.setTerrain(null); } catch (e) { /* ignore */ } if (on && st.g3dOn) { setG3d(false); } if (on && st.basemapIgn) { setBasemapIgn(false); } dlog('basemap Mapbox ' + (on ? 'ON (base ortho masquée)' : 'OFF (base ortho restaurée)')); if (opts.basemaps.onToggle) try { opts.basemaps.onToggle({ mapbox: st.basemapMapbox, ign: st.basemapIgn }); } catch (e) { /* ignore */ } }); } function setBasemapIgn(on) { st.basemapIgn = on; cbIgn.cb.checked = on; whenReady(function () { ensureBasemapSources(); if (map.getLayer('ign-hd')) map.setLayoutProperty('ign-hd', 'visibility', on ? 'visible' : 'none'); if (map.getLayer('ortho')) map.setLayoutProperty('ortho', 'visibility', on ? 'none' : 'visible'); if (on && st.basemapMapbox) { setBasemapMapbox(false); } dlog('basemap IGN 3D ' + (on ? 'ON (ortho HR z20, base ortho masquée)' : 'OFF (base ortho restaurée)')); if (opts.basemaps.onToggle) try { opts.basemaps.onToggle({ mapbox: st.basemapMapbox, ign: st.basemapIgn }); } catch (e) { /* ignore */ } }); } cbMb.cb.addEventListener('change', function () { setBasemapMapbox(cbMb.cb.checked); }); cbIgn.cb.addEventListener('change', function () { setBasemapIgn(cbIgn.cb.checked); }); st.bmSetMapbox = setBasemapMapbox; st.bmSetIgn = setBasemapIgn; try { map.getContainer().appendChild(bmPanel); } catch (e) { /* ignore */ } var ignNote = null; if (opts.basemaps.ignRelief === false || opts.basemaps.ignRelief == null) { ignNote = document.createElement('div'); ignNote.style.cssText = 'position:absolute;bottom:22px;left:6px;z-index:4;' + 'font:10px system-ui;color:#fff;text-shadow:0 0 3px rgba(0,0,0,.8);pointer-events:none;display:none;'; ignNote.textContent = 'Relief IGN : flux non compatible navigateur — ortho HD seule'; try { map.getContainer().appendChild(ignNote); } catch (e) { /* ignore */ } } // Show/hide IGN note if (ignNote) { var origSetIgn = setBasemapIgn; setBasemapIgn = function (on) { origSetIgn(on); ignNote.style.display = on ? 'block' : 'none'; }; } } function animateExtrusion(layerId, targetExpr, ms) { var t0 = performance.now(); function frame(now) { if (st.destroyed || !map.getLayer(layerId)) return; var t = Math.min(1, (now - t0) / ms); var k = 1 - Math.pow(1 - t, 3); try { map.setPaintProperty(layerId, 'fill-extrusion-height', ['*', k, targetExpr]); } catch (e) { return; } if (t < 1) requestAnimationFrame(frame); else { try { applyAltOffset(); } catch (e) { /* ignore */ } try { window.__dfGrown = (window.__dfGrown || []).concat(layerId); } catch (e) { /* hook */ } } } requestAnimationFrame(frame); } function addQuartier() { if (st.destroyed || map.getLayer('quartier-3d')) return; try { try { map.setLight({ anchor: 'viewport', color: '#fff7ec', intensity: 0.5, position: [1.2, 210, 32] }); } catch (e) { /* ignore */ } map.addLayer({ id: 'quartier-3d', type: 'fill-extrusion', source: 'osm', 'source-layer': 'building', minzoom: 14, paint: { 'fill-extrusion-color': [ 'interpolate', ['linear'], ['case', ['has', 'render_height'], ['max', ['to-number', ['get', 'render_height'], 3], 3], 12], 0, '#b9ac92', 8, '#cabfa4', 18, '#dad0ba', 40, '#e7dfce', ], 'fill-extrusion-height': 0, 'fill-extrusion-base': ['coalesce', ['to-number', ['get', 'render_min_height'], 0], 0], 'fill-extrusion-opacity': 0.96, 'fill-extrusion-vertical-gradient': true, }, }); animateExtrusion('quartier-3d', OSM_HEIGHT_EXPR, 1600); syncVolumes(); try { window.__dfBuildings3D = true; } catch (e) { /* hook */ } } catch (e) { /* ignore */ } } var api = { map: map, whenReady: whenReady, resize: function () { try { map.resize(); } catch (e) { /* ignore */ } }, // Vol caméra commun ; l'orbite démarre à la FIN du vol (durée + marge), // le quartier 3D apparaît à +1 s du début de l'animation. flyIn: function (delayMs) { var d = delayMs == null ? 250 : delayMs; setTimeout(function () { if (st.destroyed) return; try { map.resize(); map.flyTo({ center: center, zoom: 16.8, pitch: 60, bearing: 24, duration: 3200, essential: true }); } catch (e) { return; } setTimeout(function () { dlog('fin du vol caméra — démarrage orbite'); api.startOrbit(); }, 3200 + 150); }, d); setTimeout(function () { whenReady(addQuartier); }, d + 1000); }, addBlue: function (geometry, heightM) { whenReady(function () { try { st.blueGeometry = geometry; st.blueHeight = heightM; ['surelev-3d', 'building-3d', 'building-outline'].forEach(function (id) { if (map.getLayer(id)) map.removeLayer(id); }); if (map.getSource('building')) map.removeSource('building'); map.addSource('building', { type: 'geojson', data: { type: 'FeatureCollection', features: [ { type: 'Feature', properties: { height: heightM }, geometry: geometry }, ] } }); map.addLayer({ id: 'building-outline', type: 'line', source: 'building', paint: { 'line-color': '#38bdf8', 'line-width': 2.5, 'line-opacity': 0.95 }, }); map.addLayer({ id: 'building-3d', type: 'fill-extrusion', source: 'building', paint: { 'fill-extrusion-color': '#38bdf8', 'fill-extrusion-height': 0, 'fill-extrusion-base': 0, 'fill-extrusion-opacity': 0.9, 'fill-extrusion-vertical-gradient': true, }, }); animateExtrusion('building-3d', ['get', 'height'], 1600); syncVolumes(); applyAltOffset(); st.built = true; // AUCUN masquage OSM par id : une résidence entière peut // être UN seul objet multipolygone — le masquer efface ses // bâtiments partout. Le quartier reste intact, toujours. try { window.__dfBuildingAdded = true; window.__dfBlueTarget = heightM; } catch (e) { /* hook */ } } catch (e) { /* ignore */ } }); }, updateBlueHeight: function (geometry, h) { if (!st.built || !h) return; try { var src = map.getSource('building'); if (!src) return; src.setData({ type: 'FeatureCollection', features: [ { type: 'Feature', properties: { height: h }, geometry: geometry }, ] }); map.setPaintProperty('building-3d', 'fill-extrusion-height', ['get', 'height']); st.blueGeometry = geometry; st.blueHeight = h; try { window.__dfBlueTarget = h; } catch (e) { /* hook */ } } catch (e) { /* ignore */ } }, // Volume de surélévation posé sur le toit du volume bleu. Rendu // volontairement discret : ~moitié du potentiel (repère visuel). setSurelev: function (addH, feasible) { whenReady(function () { if (!st.blueGeometry || addH == null || addH <= 0) return; var off0 = currentAltOff(); var base = (st.blueHeight != null ? st.blueHeight : 12); var top = base + addH * 0.5; st.surelevSpan = [base, top]; base += off0; top += off0; try { if (map.getLayer('surelev-3d')) map.removeLayer('surelev-3d'); map.addLayer({ id: 'surelev-3d', type: 'fill-extrusion', source: 'building', paint: { 'fill-extrusion-color': feasible ? '#22c55e' : '#9ca3af', 'fill-extrusion-base': base, 'fill-extrusion-height': base, 'fill-extrusion-opacity': 0.85, 'fill-extrusion-vertical-gradient': true, }, }); var t0 = performance.now(); (function grow(now) { if (st.destroyed || !map.getLayer('surelev-3d')) return; var t = Math.min(1, (now - t0) / 1200); var k = 1 - Math.pow(1 - t, 3); try { map.setPaintProperty('surelev-3d', 'fill-extrusion-height', base + k * (top - base)); } catch (e) { return; } if (t < 1) requestAnimationFrame(grow); })(t0); try { window.__dfSurelev = { faisable: !!feasible, addH: addH }; } catch (e) { /* hook */ } } catch (e) { /* ignore */ } }); }, setMitoyens: function (features) { whenReady(function () { if (!features || !features.length) return; try { if (map.getLayer('mitoyens-3d')) map.removeLayer('mitoyens-3d'); if (map.getLayer('mitoyens-outline')) map.removeLayer('mitoyens-outline'); if (map.getSource('mitoyens')) map.removeSource('mitoyens'); var fc = { type: 'FeatureCollection', features: features.map(function (b) { var h = (b.height_m != null && b.height_m > 0) ? b.height_m : (b.floors_above_ground ? b.floors_above_ground * 3 : 12); return { type: 'Feature', properties: { height: h }, geometry: b.geometry }; }).filter(function (f) { return f.geometry; }) }; if (!fc.features.length) return; map.addSource('mitoyens', { type: 'geojson', data: fc }); map.addLayer({ id: 'mitoyens-outline', type: 'line', source: 'mitoyens', paint: { 'line-color': '#8b5cf6', 'line-width': 2, 'line-opacity': 0.95 }, }); map.addLayer({ id: 'mitoyens-3d', type: 'fill-extrusion', source: 'mitoyens', paint: { 'fill-extrusion-color': '#8b5cf6', 'fill-extrusion-height': 0, 'fill-extrusion-base': 0, 'fill-extrusion-opacity': 0.85, 'fill-extrusion-vertical-gradient': true, }, }); animateExtrusion('mitoyens-3d', ['get', 'height'], 1600); syncVolumes(); applyAltOffset(); } catch (e) { /* ignore */ } }); }, // UNIFIÉ BD TOPO : TOUS les bâtiments dans UNE SEULE couche avec // couleur par rôle (bleu=principal, violet=mitoyen, beige=contexte). // Remplace addBlue + setMitoyens + setContextBuildings après analyse. setAllBuildings: function (allFeatures, opts) { whenReady(function () { opts = opts || {}; if (!allFeatures || !allFeatures.length) return; try { dlog('setAllBuildings: ' + allFeatures.length + ' bâtiments, mainId=' + opts.mainId + ', mitoyenIds=' + (opts.mitoyenIds || []).length); // Supprimer les anciennes couches séparées ['building-3d', 'building-outline', 'mitoyens-3d', 'mitoyens-outline', 'context-3d'].forEach(function (id) { if (map.getLayer(id)) map.removeLayer(id); }); ['building', 'mitoyens', 'context-buildings'].forEach(function (id) { if (map.getSource(id)) map.removeSource(id); }); // Supprimer l'ancienne couche unifiée si elle existe ['unified-3d', 'unified-outline'].forEach(function (id) { if (map.getLayer(id)) map.removeLayer(id); }); if (map.getSource('unified-buildings')) map.removeSource('unified-buildings'); var mitoyenSet = {}; (opts.mitoyenIds || []).forEach(function (id) { mitoyenSet[id] = true; }); // Centroïde approx d'une géométrie (premier anneau). function bCentroid(geom) { if (!geom) return null; var ring = geom.type === 'Polygon' ? geom.coordinates[0] : geom.type === 'MultiPolygon' ? geom.coordinates[0][0] : null; if (!ring || !ring.length) return null; var sx = 0, sy = 0; for (var i = 0; i < ring.length; i++) { sx += ring[i][0]; sy += ring[i][1]; } return [sx / ring.length, sy / ring.length]; } // Pré-calculer centroïdes et hauteurs connues pour KNN. var withHeight = []; allFeatures.forEach(function (b) { var kh = (b.height_m > 0) ? b.height_m : (b.floors_above_ground > 0 ? b.floors_above_ground * 3 : 0); if (kh > 0) { var c = bCentroid(b.geometry); if (c) withHeight.push({ lon: c[0], lat: c[1], h: kh }); } }); // Médiane comme dernier recours si aucun voisin connu. var sortedH = withHeight.map(function (x) { return x.h; }).sort(function (a, b) { return a - b; }); var medianH = sortedH.length ? sortedH[Math.floor(sortedH.length / 2)] : 12; // KNN : moyenne des K plus proches bâtiments avec hauteur connue. function knnHeight(geom, k) { var c = bCentroid(geom); if (!c || !withHeight.length) return medianH; var dists = withHeight.map(function (wh) { var dx = (wh.lon - c[0]) * 111320; var dy = (wh.lat - c[1]) * 110540; return { h: wh.h, d: dx * dx + dy * dy }; }); dists.sort(function (a, b) { return a.d - b.d; }); var n = Math.min(k || 5, dists.length); var sum = 0; for (var i = 0; i < n; i++) sum += dists[i].h; return Math.round((sum / n) * 10) / 10; } dlog('setAllBuildings: ' + withHeight.length + '/' + allFeatures.length + ' avec hauteur connue, médiane=' + medianH + 'm'); var hCounts = { api: 0, floors: 0, knn: 0 }; var fc = { type: 'FeatureCollection', features: allFeatures.filter(function (b) { return b && b.geometry; }).map(function (b) { var isMain = opts.mainId && b.building_id === opts.mainId; var isMitoyen = !!mitoyenSet[b.building_id]; var color = isMain ? '#38bdf8' : isMitoyen ? '#8b5cf6' : '#c9b99a'; var h; if (isMain && opts.mainHeight > 0) { h = opts.mainHeight; hCounts.api++; } else if (b.height_m != null && b.height_m > 0) { h = b.height_m; hCounts.api++; } else if (b.floors_above_ground > 0) { h = b.floors_above_ground * 3; hCounts.floors++; } else { h = knnHeight(b.geometry, 5); hCounts.knn++; } return { type: 'Feature', properties: { height: h, color: color, building_id: b.building_id || '' }, geometry: b.geometry }; }) }; if (!fc.features.length) return; dlog('setAllBuildings hauteurs: ' + hCounts.api + ' BD TOPO, ' + hCounts.floors + ' étages×3, ' + hCounts.knn + ' KNN spatial'); // Stocker blueGeometry/blueHeight pour setSurelev if (opts.mainId) { var mainF = allFeatures.filter(function (b) { return b.building_id === opts.mainId; })[0]; if (mainF) { st.blueGeometry = mainF.geometry; st.blueHeight = opts.mainHeight || ((mainF.height_m > 0) ? mainF.height_m : (mainF.floors_above_ground ? mainF.floors_above_ground * 3 : 12)); // Source 'building' pour setSurelev (qui utilise cette source) if (map.getSource('building')) map.removeSource('building'); map.addSource('building', { type: 'geojson', data: { type: 'FeatureCollection', features: [ { type: 'Feature', properties: { height: st.blueHeight }, geometry: mainF.geometry }, ] } }); } } map.addSource('unified-buildings', { type: 'geojson', data: fc }); // Masquer quartier-3d : les bâtiments BD TOPO avec hauteurs // réelles remplacent les bâtiments OSM à 12m par défaut. if (map.getLayer('quartier-3d')) { try { map.setLayoutProperty('quartier-3d', 'visibility', 'none'); } catch (e) { /* ignore */ } } st.unifiedActive = true; map.addLayer({ id: 'unified-outline', type: 'line', source: 'unified-buildings', paint: { 'line-color': ['get', 'color'], 'line-width': 2, 'line-opacity': 0.95 }, }); map.addLayer({ id: 'unified-3d', type: 'fill-extrusion', source: 'unified-buildings', paint: { 'fill-extrusion-color': ['get', 'color'], 'fill-extrusion-height': 0, 'fill-extrusion-base': 0, 'fill-extrusion-opacity': 0.9, 'fill-extrusion-vertical-gradient': true, }, }); animateExtrusion('unified-3d', ['get', 'height'], 1600); syncVolumes(); applyAltOffset(); st.built = true; try { window.__dfBuildingAdded = true; window.__dfBlueTarget = st.blueHeight; } catch (e) { /* hook */ } dlog('setAllBuildings: couche unifiée créée avec ' + fc.features.length + ' features'); } catch (e) { dlog('setAllBuildings ERREUR: ' + e.message); } }); }, // BEIGE BD TOPO : bâtiments de contexte avec hauteurs réelles (remplace // les bâtiments OSM dans la zone couverte par BD TOPO). setContextBuildings: function (features) { whenReady(function () { if (!features || !features.length) return; try { if (map.getLayer('context-3d')) map.removeLayer('context-3d'); if (map.getSource('context-buildings')) map.removeSource('context-buildings'); var fc = { type: 'FeatureCollection', features: features.filter(function (b) { return b && b.geometry; }).map(function (b) { var h = (b.height_m != null && b.height_m > 0) ? b.height_m : (b.floors_above_ground ? b.floors_above_ground * 3 : 12); return { type: 'Feature', properties: { height: h }, geometry: b.geometry }; }) }; if (!fc.features.length) return; map.addSource('context-buildings', { type: 'geojson', data: fc }); var beforeLayer = map.getLayer('quartier-3d') ? 'quartier-3d' : undefined; map.addLayer({ id: 'context-3d', type: 'fill-extrusion', source: 'context-buildings', paint: { 'fill-extrusion-color': '#c9b99a', 'fill-extrusion-height': ['get', 'height'], 'fill-extrusion-base': 0, 'fill-extrusion-opacity': 0.9, 'fill-extrusion-vertical-gradient': true, }, }, beforeLayer); syncVolumes(); applyAltOffset(); } catch (e) { /* ignore */ } }); }, // ROUGE au sol : tout ce qui n'est pas bâti sur la parcelle (cours, // jardins, espaces libres). Rendu par parité : anneau parcelle + // anneaux des bâtiments en trous — aucune lib de géométrie requise. setFreeSpace: function (parcelGeometry, buildingGeoms) { whenReady(function () { try { ['freespace-fill', 'freespace-line'].forEach(function (id) { if (map.getLayer(id)) map.removeLayer(id); }); if (map.getSource('freespace')) map.removeSource('freespace'); if (!parcelGeometry) return; // Une parcelle peut être en PLUSIEURS parties (MultiPolygon, // ex. coupée par une voie) : chaque partie reçoit ses propres // trous, découpés à SA limite. var parts = parcelGeometry.type === 'Polygon' ? [parcelGeometry.coordinates] : parcelGeometry.type === 'MultiPolygon' ? parcelGeometry.coordinates : []; var batRings = []; (buildingGeoms || []).forEach(function (g) { if (!g) return; var outers = g.type === 'Polygon' ? [g.coordinates[0]] : g.type === 'MultiPolygon' ? g.coordinates.map(function (p) { return p[0]; }) : []; outers.forEach(function (r) { if (r && r.length) batRings.push(r); }); }); var multi = []; var pc = (typeof window !== 'undefined') ? window.polygonClipping : null; if (pc && pc.difference) { // Différence exacte (Martinez) : gère parcelles non // convexes, empreintes chevauchantes ou à cheval — le // découpage « maison » (Sutherland-Hodgman) produisait // des anneaux auto-intersectés (rouge en nœud papillon). try { var args = [parts].concat(batRings.map(function (r) { return [[r]]; })); multi = pc.difference.apply(pc, args) || []; } catch (e) { multi = []; } } if (!multi.length) { // Secours sans lib : découpage simple (exact si parcelle convexe). parts.forEach(function (part) { var outer = part[0]; if (!outer || outer.length < 4) return; var rings = [outer]; batRings.forEach(function (r) { var clipped = clipRingToRing(r, outer); if (clipped.length) rings.push(clipped); }); multi.push(rings); }); } if (!multi.length) return; map.addSource('freespace', { type: 'geojson', data: { type: 'Feature', properties: {}, geometry: { type: 'MultiPolygon', coordinates: multi }, } }); // IMPORTANT : insérer SOUS les couches 3D. MapLibre peint // les calques dans l'ordre — ajouté après, le rouge se // peindrait PAR-DESSUS murs et toits qui débordent en // parallaxe quand la caméra est inclinée. var beforeId = null; var layers = (map.getStyle() && map.getStyle().layers) || []; for (var li = 0; li < layers.length; li++) { if (layers[li].type === 'fill-extrusion') { beforeId = layers[li].id; break; } } map.addLayer({ id: 'freespace-fill', type: 'fill', source: 'freespace', paint: { 'fill-color': '#ef4444', 'fill-opacity': 0.4 }, }, beforeId || undefined); map.addLayer({ id: 'freespace-line', type: 'line', source: 'freespace', paint: { 'line-color': '#ef4444', 'line-width': 1.5, 'line-opacity': 0.8 }, }, beforeId || undefined); applyAltOffset(); } catch (e) { /* ignore */ } }); }, // Orbite douce (12°/s), stoppée à la première interaction utilisateur. startOrbit: function () { if (st.orbitRaf || st.destroyed || st.orbitStopped) { dlog('startOrbit ignoré :', st.orbitRaf ? 'déjà en cours' : st.destroyed ? 'carte détruite' : 'stoppée (interaction/picker)'); return; } dlog('orbite démarrée — bearing', Math.round(map.getBearing())); if (!st.orbitWired) { st.orbitWired = true; ['mousedown', 'touchstart', 'wheel', 'dragstart'].forEach(function (ev) { map.on(ev, function () { dlog('interaction utilisateur (' + ev + ') — orbite ' + (st.orbitStopped ? 'déjà stoppée' : 'stoppée')); st.orbitStopped = true; }); }); } var last = performance.now(); var bearing = map.getBearing(); var frames = 0, t0 = last, fpsLogged = false, lastProg = last; function step(now) { if (st.destroyed || st.orbitStopped) { dlog('orbite : sortie de la boucle de frames (' + (st.destroyed ? 'carte détruite' : 'flag stoppée') + ') après ' + frames + ' frame(s)'); st.orbitRaf = 0; return; } bearing = (bearing + (now - last) / 1000 * 12) % 360; last = now; frames++; if (frames === 1) dlog('orbite : 1re frame OK — la caméra tourne'); // Progression toutes les ~5 s : preuve de vie continue. if (now - lastProg > 5000) { lastProg = now; dlog('orbite : en cours, bearing=' + Math.round(bearing) + ', ' + frames + ' frames'); } // Diagnostic : si l'orbite tourne à moins de 8 ips, elle SEMBLE // figée — on le dit au lieu de laisser deviner. if (!fpsLogged && now - t0 > 3000) { fpsLogged = true; var fps = Math.round(frames / ((now - t0) / 1000)); if (fps < 8) { try { console.warn('[minimap] orbite dégradée : ' + fps + ' ips (charge GPU tuiles 3D)'); } catch (e2) { /* ignore */ } } } try { map.setBearing(bearing); } catch (e) { dlog('orbite TUÉE par exception setBearing :', e && e.message); st.orbitRaf = 0; return; } st.orbitRaf = requestAnimationFrame(step); } st.orbitRaf = requestAnimationFrame(step); try { window.__dfOrbit = true; } catch (e) { /* hook */ } }, stopOrbit: function () { if (st.orbitRaf) dlog('stopOrbit appelé (raf actif annulé)'); if (st.orbitRaf) cancelAnimationFrame(st.orbitRaf); st.orbitRaf = 0; st.orbitStopped = false; st.orbitWired = false; }, // Altitude ellipsoïdale du sol (m) fournie de l'extérieur (service // altimétrie IGN + géoïde) — ignorée si le picking a déjà mesuré. setG3dGroundAlt: function (altM) { if (typeof altM !== 'number' || !isFinite(altM) || st.altMeasured) return; st.altOff = Math.max(0, altM) + 0.8; applyAltOffset(); try { console.info('[minimap] sol photoréaliste (altimétrie IGN + géoïde) ≈ ' + Math.round(altM) + ' m — volumes surélevés'); } catch (e) { /* ignore */ } }, // Relance l'orbite après un picker (pickCandidates la stoppe pour que // la carte soit stable pendant le choix — sans resume, chaque analyse // avec re-sélection laissait l'orbite morte). resumeOrbit: function () { st.orbitStopped = false; dlog('resumeOrbit appelé — flag stoppée levé, relance'); api.startOrbit(); }, // Bascule photoréaliste pilotable de l'extérieur (switch /adm). setG3d: function (on) { setG3d(!!on); }, isG3dOn: function () { return !!st.g3dOn; }, // Sélection manuelle : candidats en JAUNE, ORANGE au survol, clic = choix. // items : [{ geometry, height? }] — extrude si height (bâtiments), // sinon aplat au sol (parcelles). onSelect(idx) appelé au clic. pickCandidates: function (items, opts) { opts = opts || {}; whenReady(function () { try { api.clearCandidates(); var fc = { type: 'FeatureCollection', features: [] }; for (var i = 0; i < items.length; i++) { if (!items[i] || !items[i].geometry) continue; fc.features.push({ type: 'Feature', id: i, properties: { idx: i, height: items[i].height || 0 }, geometry: items[i].geometry }); } if (!fc.features.length) return; map.addSource('candidates', { type: 'geojson', data: fc }); var colorExpr = ['case', ['boolean', ['feature-state', 'hover'], false], '#f97316', '#eab308']; if (opts.extrude) { map.addLayer({ id: 'candidates-hit', type: 'fill-extrusion', source: 'candidates', paint: { 'fill-extrusion-color': colorExpr, 'fill-extrusion-height': ['max', ['get', 'height'], 4], 'fill-extrusion-base': 0, // Opaque : sinon les volumes crème restent // visibles PAR TRANSPARENCE à l'intérieur. 'fill-extrusion-opacity': 1.0, }, }); // PAS de masquage des volumes crème (les ids de tuiles // OSM ne sont pas fiables : filtrer par id masquait des // immeubles ailleurs). À la place, le volume jaune est // ajusté pour DÉPASSER le plus haut bâtiment du // candidat (+2 m) : il l'englobe au lieu de le cacher. (function refine(tries) { if (st.destroyed || !map.getSource('candidates')) return; var changed = false; try { if (!map.getLayer('quartier-3d')) throw 0; var qLayers = ['quartier-3d']; fc.features.forEach(function (f) { var ring = f.geometry.type === 'Polygon' ? f.geometry.coordinates[0] : f.geometry.type === 'MultiPolygon' ? f.geometry.coordinates[0][0] : null; if (!ring || !ring.length) return; var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; ring.forEach(function (c) { var p = map.project(c); if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x; if (p.y < minY) minY = p.y; if (p.y > maxY) maxY = p.y; }); var maxH = 0; map.queryRenderedFeatures([[minX - 2, minY - 2], [maxX + 2, maxY + 2]], { layers: qLayers }).forEach(function (q) { // Un bâtiment OSM peut fusionner tout un îlot // (centroïde hors candidat) : il « touche » le // candidat dès qu'un sommet OU son centroïde // est dedans — sa hauteur compte alors. var touches = false; var c = geomCentroid(q.geometry); if (c && pointInGeometry(c, f.geometry)) touches = true; if (!touches && q.geometry) { var qr = q.geometry.type === 'Polygon' ? q.geometry.coordinates[0] : q.geometry.type === 'MultiPolygon' ? q.geometry.coordinates[0][0] : null; if (qr) { for (var k = 0; k < qr.length; k++) { if (pointInGeometry(qr[k], f.geometry)) { touches = true; break; } } } } // Sens inverse : la géométrie q sortie des tuiles est // DÉCOUPÉE — un îlot fusionné recouvrant le candidat // peut n'avoir aucun sommet dedans. On échantillonne // donc le candidat (sommets + milieux d'arêtes) dans q. if (!touches) { for (var m = 0; m < ring.length; m++) { if (pointInGeometry(ring[m], q.geometry)) { touches = true; break; } var n = ring[(m + 1) % ring.length]; if (pointInGeometry([(ring[m][0] + n[0]) / 2, (ring[m][1] + n[1]) / 2], q.geometry)) { touches = true; break; } } } if (!touches) return; var h = (q.properties && Number(q.properties.render_height)) || 12; if (h > maxH) maxH = h; }); if (maxH + 2 > f.properties.height) { f.properties.height = maxH + 2; changed = true; } }); if (changed) map.getSource('candidates').setData(fc); } catch (e) { /* tuiles pas prêtes */ } if (tries < 6) setTimeout(function () { refine(tries + 1); }, 800); })(0); } else { map.addLayer({ id: 'candidates-hit', type: 'fill', source: 'candidates', paint: { 'fill-color': colorExpr, 'fill-opacity': 0.45 }, }); } map.addLayer({ id: 'candidates-line', type: 'line', source: 'candidates', paint: { 'line-color': colorExpr, 'line-width': 2.5, 'line-opacity': 0.95 }, }); st.candHover = null; st.candMove = function (e) { var fs = map.queryRenderedFeatures(e.point, { layers: ['candidates-hit'] }); var id = fs.length ? fs[0].id : null; if (id !== st.candHover) { if (st.candHover != null) map.setFeatureState({ source: 'candidates', id: st.candHover }, { hover: false }); if (id != null) map.setFeatureState({ source: 'candidates', id: id }, { hover: true }); st.candHover = id; map.getCanvas().style.cursor = id != null ? 'pointer' : ''; } }; st.candClick = function (e) { var fs = map.queryRenderedFeatures(e.point, { layers: ['candidates-hit'] }); if (!fs.length) return; var idx = parseInt(fs[0].properties && fs[0].properties.idx, 10); dlog('candidat cliqué sur la carte (idx=' + idx + ') — reprise du pipeline + orbite'); api.clearCandidates(); // Le mousedown du clic vient de stopper l'orbite : on la // relance aussitôt — choisir un candidat n'est pas une // prise en main de la caméra. api.resumeOrbit(); if (opts.onSelect && !isNaN(idx)) opts.onSelect(idx); }; map.on('mousemove', st.candMove); map.on('click', st.candClick); syncVolumes(); // L'orbite CONTINUE pendant le choix (12°/s : assez lent // pour cliquer). Avant, elle était suspendue ici — résultat // vécu : « l'orbite ne tourne jamais », car toute adresse // ambiguë affiche un picker dès le vol caméra. dlog('picker affiché (' + fc.features.length + ' candidat(s)) — orbite maintenue'); } catch (e) { /* ignore */ } }); }, clearCandidates: function () { try { if (st.candMove) { map.off('mousemove', st.candMove); st.candMove = null; } if (st.candClick) { map.off('click', st.candClick); st.candClick = null; } ['candidates-hit', 'candidates-line'].forEach(function (id) { if (map.getLayer(id)) map.removeLayer(id); }); if (map.getSource('candidates')) map.removeSource('candidates'); map.getCanvas().style.cursor = ''; st.candHover = null; } catch (e) { /* ignore */ } }, // Retire tous les calques d'analyse (bleu, vert, violet, rouge, // candidats) en gardant carte, caméra et quartier : les re-runs // (changement de parcelle/bâtiment) ne rechargent AUCUNE tuile. resetOverlays: function () { api.clearCandidates(); ['surelev-3d', 'building-3d', 'building-outline', 'mitoyens-3d', 'mitoyens-outline', 'freespace-fill', 'freespace-line'].forEach(function (id) { try { if (map.getLayer(id)) map.removeLayer(id); } catch (e) { /* ignore */ } }); ['building', 'mitoyens', 'freespace'].forEach(function (id) { try { if (map.getSource(id)) map.removeSource(id); } catch (e) { /* ignore */ } }); st.blueGeometry = null; st.blueHeight = null; st.built = false; }, destroy: function () { st.destroyed = true; if (st.orbitWatchdog) clearInterval(st.orbitWatchdog); if (st.heartbeat) clearInterval(st.heartbeat); api.stopOrbit(); try { map.remove(); } catch (e) { /* ignore */ } }, }; return api; } window.AvMinimap = { create: create }; })();
/home/atelierpru/provence/../www/airvalue/av-minimap.js