Your IP : 216.73.217.6


Current Path : /home/atelierpru/www/airvalue/
Upload File :
Current File : /home/atelierpru/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 };
})();