| Current Path : /home/atelierpru/www/airvalue/ |
| Current File : /home/atelierpru/www/airvalue/adm-flow.js |
'use strict';
document.addEventListener('DOMContentLoaded', function () {
var BUILD = '20260804c';
var PROXY_KEY = 'Fefef430430@';
var PROXY = '/adm-api/proxy.php';
var GEOCODE_URL = 'https://api-adresse.data.gouv.fr/search/';
var selected = null;
var allResults = {};
var $stepContainer = null;
var $address = document.getElementById('df-address');
var $dropdown = document.getElementById('df-dropdown');
var $runBtn = document.getElementById('df-run');
var $info = document.getElementById('df-info');
var $steps = document.getElementById('df-steps');
var $globalStatus = document.getElementById('df-status');
var $log = document.getElementById('df-log');
var $logToggle = document.getElementById('df-log-toggle');
var $nocache = document.getElementById('df-nocache');
var $recent = document.getElementById('df-recent');
if (!$address) return;
// Garde-fou de version : compare le build du JS (ce fichier) à celui attendu
// par le HTML. Les builds sont des dates → comparaison lexicale fiable.
// JS < HTML : vieux JS en cache navigateur → Ctrl+Shift+R.
// JS > HTML : vieux HTML servi (cache serveur/CDN ou déploiement en cours) —
// le rafraîchissement navigateur n'y peut rien, c'est côté hébergeur.
function buildBadge(el) {
if (!el) return;
el.textContent = 'Build JS: ' + BUILD;
var expected = (typeof window !== 'undefined' && window.__DF_EXPECTED_BUILD) || null;
if (!expected || expected === BUILD) return;
if (BUILD < expected) {
el.textContent = 'Build JS: ' + BUILD + ' — JS PÉRIMÉ (attendu ' + expected + ') : Ctrl+Shift+R';
} else {
el.textContent = 'Build JS: ' + BUILD + ' — page HTML périmée (build ' + expected +
') : cache serveur/CDN ou déploiement en cours, réessayer dans ~1 min';
}
el.style.color = '#ef4444';
el.style.fontWeight = '700';
}
buildBadge(document.getElementById('df-build'));
buildBadge(document.getElementById('mvp-build'));
function updateLogToggle() {
if (!$logToggle || !$log) return;
var n = $log.children.length;
$logToggle.hidden = n === 0;
$logToggle.textContent = ($log.classList.contains('open') ? 'Masquer les logs' : 'Afficher les logs') + ' (' + n + ')';
}
if ($logToggle && $log) {
$logToggle.addEventListener('click', function () {
$log.classList.toggle('open');
updateLogToggle();
if ($log.classList.contains('open')) $log.scrollTop = $log.scrollHeight;
});
}
function logMsg(text, level) {
if (!$log) return;
var now = new Date();
var ts = [now.getHours(), now.getMinutes(), now.getSeconds()]
.map(function (n) { return n < 10 ? '0' + n : '' + n; }).join(':');
var colors = { info: '#3b82f6', ok: '#22c55e', error: '#ef4444', warn: '#f59e0b', run: '#a855f7' };
var c = colors[level] || colors.info;
var line = document.createElement('div');
line.className = 'df-log-line';
line.innerHTML = '<span class="df-log-ts">' + ts + '</span> <span style="color:' + c + '">●</span> ' + text;
$log.appendChild(line);
$log.scrollTop = $log.scrollHeight;
updateLogToggle();
}
function clearLog() {
if ($log) $log.innerHTML = '';
updateLogToggle();
}
function timedFetch(url, opts, timeoutMs) {
var controller = new AbortController();
var timer = setTimeout(function () { controller.abort(); }, timeoutMs || 20000);
opts = opts || {};
opts.signal = controller.signal;
return fetch(url, opts).finally(function () { clearTimeout(timer); });
}
var debounceTimer = null;
var activeIndex = -1;
var currentFeatures = [];
// Lignes non vides du champ unique (1 = analyse détaillée, >1 = batch).
function addressLines() {
return $address.value.split('\n').map(function (s) { return s.trim(); }).filter(Boolean);
}
$address.addEventListener('input', function () {
var lines = addressLines();
selected = null;
clearTimeout(debounceTimer);
// Plusieurs lignes = mode batch : pas d'autocomplétion, bouton actif.
if (lines.length > 1) {
closeDropdown();
$runBtn.disabled = false;
updateRunBtnLabel();
$info.textContent = Math.min(lines.length, 100) + ' adresse(s) — mode batch (les étapes détaillées ne seront pas affichées).';
return;
}
var q = $address.value.trim();
$runBtn.disabled = q.length < 3;
updateRunBtnLabel();
if (q.length < 3) {
$info.textContent = 'Saisir au moins 3 caractères pour rechercher.';
closeDropdown();
return;
}
$info.textContent = 'Recherche en cours…';
debounceTimer = setTimeout(function () { geocodeSearch(q); }, 300);
});
$address.addEventListener('keydown', function (e) {
if (!$dropdown.classList.contains('open')) return;
var items = $dropdown.querySelectorAll('.df-dd-item');
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = Math.min(activeIndex + 1, items.length - 1);
highlightItem(items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = Math.max(activeIndex - 1, 0);
highlightItem(items);
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && currentFeatures[activeIndex]) {
selectFeature(currentFeatures[activeIndex]);
}
} else if (e.key === 'Escape') {
closeDropdown();
}
});
document.addEventListener('click', function (e) {
if (!e.target.closest('#df-search-wrap')) closeDropdown();
});
function highlightItem(items) {
items.forEach(function (el, i) { el.classList.toggle('active', i === activeIndex); });
if (items[activeIndex]) items[activeIndex].scrollIntoView({ block: 'nearest' });
}
function closeDropdown() {
$dropdown.classList.remove('open');
$dropdown.innerHTML = '';
activeIndex = -1;
}
async function geocodeSearch(query) {
var url = GEOCODE_URL + '?q=' + encodeURIComponent(query) + '&limit=10';
logMsg('GET <code>' + esc(url) + '</code>', 'info');
$info.textContent = 'Recherche en cours…';
try {
var t0 = performance.now();
var res = await timedFetch(url, {}, 15000);
var dur = Math.round(performance.now() - t0);
if (!res.ok) {
var msg = 'Geocodage HTTP ' + res.status + ' (' + dur + 'ms)';
$info.textContent = msg;
logMsg(msg, 'error');
return;
}
var json = await res.json();
currentFeatures = json.features || [];
if (currentFeatures.length === 0) {
$info.textContent = '0 résultat pour "' + query + '" (' + dur + 'ms)';
logMsg('0 résultat pour "' + esc(query) + '" (' + dur + 'ms)', 'warn');
} else {
$info.textContent = currentFeatures.length + ' résultat(s) — ' + dur + 'ms — cliquer pour sélectionner';
logMsg(currentFeatures.length + ' résultat(s) en ' + dur + 'ms', 'ok');
}
renderDropdown(currentFeatures);
} catch (err) {
var errMsg = err.name === 'AbortError' ? 'Timeout (15s) — api-adresse.data.gouv.fr ne répond pas' : err.message;
$info.textContent = 'Erreur geocodage : ' + errMsg;
logMsg('ERREUR geocodage : ' + esc(errMsg), 'error');
closeDropdown();
}
}
// Ids BAN des adresses en cache local valide (TTL + build), avec leur date de
// mise en cache. Lecture du seul début de chaque entrée localStorage (savedAt
// et build sont sérialisés en premier) pour éviter de parser des Mo de JSON à
// chaque frappe.
function cachedAddressIds() {
var ids = {};
var now = Date.now();
try {
for (var i = 0; i < localStorage.length; i++) {
var k = localStorage.key(i);
if (!k || k.indexOf(LOCAL_CACHE_PREFIX) !== 0) continue;
var head = (localStorage.getItem(k) || '').slice(0, 200);
var mAt = head.match(/"savedAt":(\d+)/);
var mBuild = head.match(/"build":"([^"]*)"/);
var savedAt = mAt ? parseInt(mAt[1], 10) : 0;
if (savedAt && (now - savedAt) < LOCAL_CACHE_TTL_MS && mBuild && mBuild[1] === BUILD) {
ids[k.slice(LOCAL_CACHE_PREFIX.length)] = savedAt;
}
}
} catch (e) { /* localStorage indisponible */ }
return ids;
}
function renderDropdown(features) {
if (!features.length) { closeDropdown(); return; }
var cached = cachedAddressIds();
var cachedAt = function (f) {
var id = f.properties && f.properties.id;
return id && cached[id] ? cached[id] : 0;
};
// 2 = Paris, 1 = Île-de-France, 0 = reste, d'après le département INSEE.
var IDF_DEPTS = { 75: 1, 77: 1, 78: 1, 91: 1, 92: 1, 93: 1, 94: 1, 95: 1 };
var geoScore = function (f) {
var dept = String((f.properties && f.properties.citycode) || '').slice(0, 2);
if (dept === '75') return 2;
return IDF_DEPTS[dept] ? 1 : 0;
};
// Priorité : adresses en cache, puis Paris, puis IDF ; ordre BAN sinon.
var decorated = features.map(function (f, i) { return { f: f, i: i }; });
decorated.sort(function (a, b) {
var ca = cachedAt(a.f) ? 1 : 0, cb = cachedAt(b.f) ? 1 : 0;
return cb - ca || geoScore(b.f) - geoScore(a.f) || a.i - b.i;
});
features.length = 0;
decorated.forEach(function (d) { features.push(d.f); });
var nbCached = features.filter(function (f) { return cachedAt(f) > 0; }).length;
if (nbCached) {
$info.textContent = features.length + ' résultat(s) — dont ' + nbCached + ' déjà en cache — cliquer pour sélectionner';
}
$dropdown.innerHTML = features.map(function (f, i) {
var p = f.properties || {};
var label = esc(p.label || '');
var at = cachedAt(f);
var badge = at
? '<span class="df-dd-badge">en cache · ' + esc(new Date(at).toLocaleDateString('fr-FR')) + '</span>'
: '';
var ctx = esc(p.context || ([p.postcode, p.city].filter(Boolean).join(' ')));
return '<div class="df-dd-item" data-idx="' + i + '">' +
label + badge + '<span class="df-dd-sub">' + ctx + ' — INSEE ' + esc(p.citycode || '?') + '</span></div>';
}).join('');
$dropdown.classList.add('open');
activeIndex = -1;
$dropdown.querySelectorAll('.df-dd-item').forEach(function (el) {
el.addEventListener('click', function () {
selectFeature(currentFeatures[parseInt(el.dataset.idx, 10)]);
});
});
}
function selectFeature(feature) {
var p = feature.properties || {};
var coords = feature.geometry.coordinates;
selected = {
lat: coords[1], lon: coords[0],
insee: p.citycode || '', commune: p.city || '', label: p.label || '',
_feature: feature,
};
$address.value = selected.label;
closeDropdown();
$runBtn.disabled = false;
var fromCache = p.id && cachedAddressIds()[p.id];
$info.innerHTML =
'<strong style="color:#22c55e">✓</strong> ' +
'<code class="text-xs">' + esc(selected.label) + '</code> — ' +
'lat=<code class="text-xs">' + selected.lat + '</code> lon=<code class="text-xs">' + selected.lon + '</code> ' +
'insee=<code class="text-xs">' + esc(selected.insee) + '</code>' +
(fromCache
? ' — <span style="color:#15803d;font-weight:600">⚡ adresse déjà en cache (' + esc(new Date(fromCache).toLocaleDateString('fr-FR')) + ') : résultats instantanés, aucun appel API</span>'
: '');
updateRunBtnLabel();
logMsg('Adresse sélectionnée : <strong>' + esc(selected.label) + '</strong> (INSEE ' + esc(selected.insee) + ')', 'ok');
}
var STEPS = [
{ num: 3, label: 'Identification parcelle',
build: function (ctx) { return { action: 'parcels', params: { lat: ctx.lat, lon: ctx.lon } }; },
extract: function (data, ctx) { var p = firstParcel(data); if (p) { ctx.parcelId = p.id || p.parcelId || p.idu || ''; ctx.parcelSurface = p.surface != null ? p.surface : (p.contenance != null ? p.contenance : null); } },
summary: function (r, ctx) { return [kv('parcelId', ctx.parcelId || '—'), ctx.parcelSurface != null ? kv('surface', ctx.parcelSurface + ' m²') : '']; } },
{ num: 4, label: 'Détail parcelle',
build: function (ctx) { return { action: 'parcel_detail', params: { parcelId: ctx.parcelId } }; }, requires: 'parcelId',
extract: function (data, ctx) { ctx.neighbors = (data && Array.isArray(data.neighbors)) ? data.neighbors.filter(Boolean) : []; },
summary: function (r, ctx) { return [countItems(r.data) + ' élément(s) — ' + byteSize(r) + ' bytes', (ctx.neighbors && ctx.neighbors.length) ? kv('parcelles mitoyennes', ctx.neighbors.length) : '']; } },
{ num: 5, label: 'Coordonnées parcelle',
build: function (ctx) { return { action: 'parcel_coords', params: { parcelId: ctx.parcelId } }; }, requires: 'parcelId',
summary: function (r) { return [byteSize(r) + ' bytes de géométrie']; } },
{ num: 6, label: 'Zones urbaines commune',
build: function (ctx) { return { action: 'urban_zones', params: { insee: ctx.insee } }; },
summary: function (r) { return [countItems(r.data) + ' zone(s)']; } },
{ num: 7, label: 'Unité foncière complète',
build: function (ctx) { return { action: 'parcel_units', params: { parcelId: ctx.parcelId } }; }, requires: 'parcelId',
extract: function (data, ctx) { var z = extractUrbanZone(data); if (z) { ctx.zoneId = z.id || ''; ctx.zoneName = z.name || ''; ctx.zoneLibelong = z.libelong || ''; ctx.zoneType = z.type || 'PLU'; } },
summary: function (r, ctx) { return [kv('zoneId', ctx.zoneId || '—'), kv('zoneName', ctx.zoneName || '—'), kv('libelong', ctx.zoneLibelong || '—')]; } },
{ num: 8, label: 'Règlement PLU Airtable',
build: function (ctx) { return { action: 'airtable_plu', method: 'POST', params: { zoneId: ctx.zoneId }, body: { urbanZoneParams: { id: ctx.zoneId, name: ctx.zoneName, libelong: ctx.zoneLibelong, type: ctx.zoneType || 'PLU' } } }; }, requires: 'zoneId',
summary: function (r) { return [countItems(r.data) + ' enregistrement(s) PLU']; } },
{ num: 9, label: 'Droits à construire',
build: function (ctx) { return { action: 'building_rights', params: { zoneId: ctx.zoneId } }; }, requires: 'zoneId',
summary: function (r) { return [countItems(r.data) + ' règle(s)']; } },
{ num: 10, label: 'PSC commune',
build: function (ctx) { return { action: 'town_psc', params: { insee: ctx.insee } }; },
summary: function (r) { return [byteSize(r) + ' bytes']; } },
{ num: 11, label: 'Type doc urbanisme',
build: function (ctx) { return { action: 'docurba', params: { insee: ctx.insee } }; },
summary: function (r) { return [describeShort(r.data)]; } },
{ num: 12, label: 'Info PLU GPU',
build: function (ctx) { return { action: 'gpu_plu_info', params: { insee: ctx.insee } }; },
summary: function (r) { return [describeShort(r.data)]; } },
{ num: 13, label: 'PSMV parcelle',
build: function (ctx) { return { action: 'psmv', params: { parcelId: ctx.parcelId } }; }, requires: 'parcelId',
summary: function (r) { return [countItems(r.data) + ' PSMV']; } },
{ num: 14, label: 'District / arrondissement',
build: function (ctx) { return { action: 'districts', params: { insee: ctx.insee } }; },
summary: function (r) { return [describeShort(r.data)]; } },
{ num: 15, label: 'Stats DVF commune',
build: function (ctx) { return { action: 'dvf_stats', params: { insee: ctx.insee } }; },
summary: function (r) { return [byteSize(r) + ' bytes de stats']; } },
{ num: 16, label: 'Séries DVF commune',
build: function (ctx) { return { action: 'dvf_series', params: { insee: ctx.insee } }; },
summary: function (r) { return [countItems(r.data) + ' point(s) de série']; } },
{ num: 17, label: 'Ventes DVF parcelle',
build: function (ctx) { return { action: 'dvf_parcels', params: { parcelId: ctx.parcelId } }; }, requires: 'parcelId',
summary: function (r) { return [countItems(r.data) + ' vente(s)']; } },
{ num: 18, label: 'Loyers de référence',
build: function (ctx) { return { action: 'loyers', params: { insee: ctx.insee } }; },
summary: function (r) { return [byteSize(r) + ' bytes']; } },
{ num: 19, label: 'ADU totaux commune',
build: function (ctx) { return { action: 'adu_totals', params: { insee: ctx.insee } }; },
summary: function (r) { return [describeShort(r.data)]; } },
{ num: 20, label: 'ADU parcelle',
build: function (ctx) { return { action: 'adu_parcels', params: { parcelId: ctx.parcelId } }; }, requires: 'parcelId',
summary: function (r) { return [countItems(r.data) + ' enregistrement(s)']; } },
{ num: 21, label: 'ADU commune GeoJSON',
build: function (ctx) { return { action: 'adu_insee', params: { insee: ctx.insee } }; },
summary: function (r) { return [byteSize(r) + ' bytes GeoJSON']; } },
{ num: 22, label: 'Plans prévention risques',
build: function (ctx) { return { action: 'prevention_plans', params: { parcelId: ctx.parcelId } }; }, requires: 'parcelId',
summary: function (r) { return [countItems(r.data) + ' plan(s)']; } },
{ num: 23, label: 'Géorisques',
build: function (ctx) { return { action: 'georisques', params: { lon: ctx.lon, lat: ctx.lat, insee: ctx.insee } }; },
summary: function (r) { return [byteSize(r) + ' bytes de risques']; } },
{ num: 24, label: 'Analytics (tracking)',
build: function (ctx) { return { action: 'analytics', method: 'POST', body: { user: ctx.userId || '', input: ctx.label || '', codeInsee: ctx.insee || '', nomCommune: ctx.commune || '' } }; },
summary: function () { return ['événement envoyé']; } },
{ num: 25, label: 'Search count',
build: function () { return { action: 'search_count', method: 'POST', body: null }; },
summary: function (r) { return [describeShort(r.data)]; } },
];
// ---- Cache local par adresse ------------------------------------------------
// Un flow complet réussi est stocké dans localStorage, indexé par l'id BAN de
// l'adresse. Une adresse déjà cherchée est restaurée instantanément sans aucun
// appel API (ni analytics ni search_count — pas de quota iudo consommé).
// Invalidé après 7 jours, à chaque changement de build, ou via la case
// « Ignorer le cache ». Le proxy PHP a en plus son propre cache serveur.
var LOCAL_CACHE_PREFIX = 'dfcache:v1:';
var LOCAL_CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
function cacheKeyFor(sel) {
var banId = (sel._feature && sel._feature.properties && sel._feature.properties.id) || sel.label;
return LOCAL_CACHE_PREFIX + banId;
}
function loadCache(sel) {
try {
var raw = localStorage.getItem(cacheKeyFor(sel));
if (!raw) return null;
var entry = JSON.parse(raw);
var stale = !entry || !entry.results || entry.build !== BUILD
|| (Date.now() - (entry.savedAt || 0)) > LOCAL_CACHE_TTL_MS;
if (stale) {
localStorage.removeItem(cacheKeyFor(sel));
return null;
}
return entry;
} catch (e) { return null; }
}
function evictOldestCache() {
var oldestKey = null, oldestAt = Infinity;
for (var i = 0; i < localStorage.length; i++) {
var k = localStorage.key(i);
if (!k || k.indexOf(LOCAL_CACHE_PREFIX) !== 0) continue;
var at = 0;
try { at = (JSON.parse(localStorage.getItem(k)) || {}).savedAt || 0; } catch (e) {}
if (at < oldestAt) { oldestAt = at; oldestKey = k; }
}
if (oldestKey) { localStorage.removeItem(oldestKey); return true; }
return false;
}
function saveCache(sel, results) {
var entry;
try {
entry = JSON.stringify({ savedAt: Date.now(), build: BUILD, label: sel.label, results: results });
} catch (e) { return false; }
for (var attempt = 0; attempt < 5; attempt++) {
try { localStorage.setItem(cacheKeyFor(sel), entry); return true; }
catch (e) { if (!evictOldestCache()) return false; }
}
return false;
}
// Libellé du bouton : « Afficher (cache) » quand l'adresse sélectionnée a un
// flow en cache local et que « Ignorer le cache » n'est pas coché.
function updateRunBtnLabel() {
if (!$runBtn) return;
var lines = $address ? addressLines() : [];
if (lines.length > 1) {
$runBtn.textContent = 'Lancer le batch (' + Math.min(lines.length, 100) + ')';
return;
}
var live = $nocache && $nocache.checked;
var banId = selected && selected._feature && selected._feature.properties && selected._feature.properties.id;
var cachedTs = !live && banId ? cachedAddressIds()[banId] : 0;
$runBtn.textContent = cachedTs ? 'Afficher (cache)' : 'Lancer';
}
if ($nocache) $nocache.addEventListener('change', updateRunBtnLabel);
// ---- Dernières recherches (historique serveur, partagé) ---------------------
// La box liste les 5 dernières adresses analysées depuis l'historique SERVEUR
// (partagé entre postes, persistant, indépendant du build front). Fallback sur
// l'historique local si le serveur est injoignable. Un clic rejoue le flow :
// instantané si l'adresse est en cache local, sinon relancé (le cache serveur
// accélère alors chaque step).
var recentOpen = false;
var recentItems = [];
// Historique local (fallback) — lu par tranches d'en-tête du cache localStorage.
function recentLocalEntries() {
var list = [];
var now = Date.now();
try {
for (var i = 0; i < localStorage.length; i++) {
var k = localStorage.key(i);
if (!k || k.indexOf(LOCAL_CACHE_PREFIX) !== 0) continue;
var head = (localStorage.getItem(k) || '').slice(0, 400);
var mAt = head.match(/"savedAt":(\d+)/);
var mBuild = head.match(/"build":"([^"]*)"/);
var mLabel = head.match(/"label":"((?:[^"\\]|\\.)*)"/);
var savedAt = mAt ? parseInt(mAt[1], 10) : 0;
if (!savedAt || (now - savedAt) >= LOCAL_CACHE_TTL_MS || !mBuild || mBuild[1] !== BUILD) continue;
var label = mLabel ? mLabel[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\') : k.slice(LOCAL_CACHE_PREFIX.length);
list.push({ key: k, banId: k.slice(LOCAL_CACHE_PREFIX.length), savedAt: savedAt, label: label, source: 'local' });
}
} catch (e) { /* localStorage indisponible */ }
list.sort(function (a, b) { return b.savedAt - a.savedAt; });
return list;
}
function fetchServerHistory() {
return timedFetch(PROXY + '?action=history', { headers: { 'X-Admin-Key': PROXY_KEY } }, 8000)
.then(function (res) { return res.json(); })
.then(function (j) { return (j && j.ok && Array.isArray(j.entries)) ? j.entries : null; })
.catch(function () { return null; });
}
// Envoie l'adresse analysée à l'historique serveur (partagé). Best-effort.
function postHistory(sel) {
if (!sel || !sel._feature) return;
var p = sel._feature.properties || {};
var body = JSON.stringify({
banId: p.id || sel.label, label: sel.label, insee: sel.insee, feature: sel._feature,
});
timedFetch(PROXY + '?action=history_add', {
method: 'POST', headers: { 'X-Admin-Key': PROXY_KEY, 'Content-Type': 'application/json' }, body: body,
}, 8000).then(function () { renderRecent(); }).catch(function () { /* best-effort */ });
}
// Date + heure d'une recherche. savedAt est en ms epoch ; on tolère les
// anciennes entrées mal formées (secondes, chaîne, valeur invalide).
function formatHistDate(v) {
var n = typeof v === 'string' ? parseInt(v, 10) : v;
if (!n || isNaN(n)) return '';
if (n < 1e12) n *= 1000; // savedAt en secondes → ms
var d = new Date(n);
if (isNaN(d.getTime())) return '';
// Garde-fou : d'anciennes entrées avaient un timestamp corrompu (overflow
// 32 bits → dates ~1904). On n'affiche que des dates plausibles.
var y = d.getFullYear();
if (y < 2020 || y > 2100) return '';
return d.toLocaleDateString('fr-FR') + ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
}
function renderRecent() {
if (!$recent) return;
fetchServerHistory().then(function (server) {
var entries;
if (server) {
entries = server.slice(0, 5).map(function (e) {
return { label: e.label || e.banId, savedAt: e.savedAt, feature: e.feature, banId: e.banId, source: 'server' };
});
} else {
entries = recentLocalEntries().slice(0, 5);
}
recentItems = entries;
if (!entries.length) { $recent.hidden = true; $recent.innerHTML = ''; return; }
$recent.hidden = false;
$recent.className = recentOpen ? 'open' : '';
$recent.innerHTML =
'<div class="df-recent-head">' +
'<span class="df-caret">▶</span>' +
'<span>Dernières recherches (' + entries.length + ')' + (server ? '' : ' · local') + '</span></div>' +
'<div class="df-recent-list">' +
entries.map(function (e, i) {
return '<button type="button" class="df-recent-item" data-idx="' + i + '">' +
'<span class="df-recent-icon">⚡</span>' +
'<span class="df-recent-label">' + esc(e.label) + '</span>' +
'<span class="df-recent-date">' + esc(formatHistDate(e.savedAt)) + '</span>' +
'</button>';
}).join('') + '</div>';
$recent.querySelector('.df-recent-head').addEventListener('click', function () {
recentOpen = !recentOpen;
$recent.className = recentOpen ? 'open' : '';
});
$recent.querySelectorAll('.df-recent-item').forEach(function (el) {
el.addEventListener('click', function () { openRecent(recentItems[parseInt(el.getAttribute('data-idx'), 10)]); });
});
});
}
function openRecent(entry) {
if (!entry) return;
if (entry.source === 'local' && entry.key) { openFromCache(entry.key); return; }
var feat = entry.feature;
if (!feat || !feat.geometry) {
logMsg('Entrée d\'historique sans géodonnées — saisir l\'adresse pour relancer', 'warn');
return;
}
var p = feat.properties || {};
var coords = feat.geometry.coordinates || [];
selected = {
lat: coords[1], lon: coords[0],
insee: p.citycode || entry.insee || '', commune: p.city || '', label: p.label || entry.label || '',
_feature: feat,
};
$address.value = selected.label;
closeDropdown();
$runBtn.disabled = false;
updateRunBtnLabel();
var local = loadCache(selected);
if (local) replayFromCache(local); else runFlow();
if ($steps && $steps.scrollIntoView) $steps.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// Reconstruit `selected` depuis une entrée de cache LOCAL et rejoue (fallback).
function openFromCache(key) {
var raw, entry;
try { raw = localStorage.getItem(key); entry = raw ? JSON.parse(raw) : null; } catch (e) { entry = null; }
var feat = entry && entry.results && entry.results.step_1_geocode && entry.results.step_1_geocode.data;
if (!feat || !feat.geometry) {
logMsg('Entrée de cache illisible — relancez une recherche', 'error');
try { localStorage.removeItem(key); } catch (x) {}
renderRecent();
return;
}
var p = feat.properties || {};
var coords = feat.geometry.coordinates || [];
selected = {
lat: coords[1], lon: coords[0],
insee: p.citycode || '', commune: p.city || '', label: p.label || '',
_feature: feat,
};
$address.value = selected.label;
closeDropdown();
$runBtn.disabled = false;
updateRunBtnLabel();
replayFromCache(entry);
if ($steps && $steps.scrollIntoView) $steps.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// Force un run en direct (ignore les caches local ET serveur) pour ce run
// uniquement, sans toucher à la case « Ignorer le cache ».
var forceLiveOnce = false;
var LOGIN_STEP = {
num: 2, label: 'Authentification API', action: 'login',
extract: function (data, ctx) {
if (data && typeof data === 'object') {
ctx.userId = data.userId || data._id || data.id || (data.user && data.user._id) || '';
ctx.userStatus = data.status || data.subscriptionStatus || (data.user && data.user.status) || '';
}
},
summary: function (r, ctx) { return [kv('userId', ctx.userId || '—'), kv('status', ctx.userStatus || '—')]; },
};
function replayStep(def, resp, ctx) {
var spec = null;
try { spec = def.build ? def.build(ctx) : { action: def.action, params: {} }; } catch (e) {}
var url = spec ? buildDisplayUrl(spec.action, spec.params || {}, spec.method || 'GET') : '';
var box = renderStep(def.num, def.label, url);
if (!resp) {
setDot(box, 'error');
setMeta(box, 'absent du cache');
box.classList.add('open');
box.querySelector('.df-step-body').insertAdjacentHTML('beforeend',
'<div class="df-step-error"><strong>Absent du cache</strong></div>');
return false;
}
if (resp.ok) {
setDot(box, 'ok');
setMeta(box, 'HTTP ' + resp.status + ' · cache local');
if (def.extract) { try { def.extract(resp.data, ctx); } catch (e) { /* ignore */ } }
var lines = def.summary ? def.summary(resp, ctx).filter(Boolean) : [];
renderSummary(box, resp.status, 0, lines);
} else {
setDot(box, 'error');
setMeta(box, resp.status ? 'HTTP ' + resp.status : 'erreur');
box.classList.add('open');
renderError(box, resp, 0);
}
renderRawToggle(box, resp);
return !!resp.ok;
}
function replayFromCache(entry) {
$steps.innerHTML = '';
$stepContainer = $steps;
currentGroup = null;
clearLog();
Object.keys(allResults).forEach(function (k) { delete allResults[k]; });
Object.assign(allResults, entry.results);
var savedDate = new Date(entry.savedAt).toLocaleString('fr-FR');
setGlobalStatus('Restauré du cache (' + savedDate + ')', 'ok');
logMsg('<strong>Adresse déjà analysée le ' + esc(savedDate) + '</strong> — restauration depuis le cache local, aucun appel API. '
+ 'Cocher « Ignorer le cache » pour rejouer le flow.', 'ok');
var banner = document.createElement('div');
banner.className = 'df-cache-banner';
banner.innerHTML =
'<div>⚡ <strong>Résultats issus du cache local</strong> — adresse analysée le ' + esc(savedDate) +
'. Aucun appel API n\'a été effectué.</div>' +
'<button type="button">Relancer en direct</button>';
banner.querySelector('button').addEventListener('click', function () {
forceLiveOnce = true;
runFlow();
});
$steps.appendChild(banner);
var ctx = { lat: selected.lat, lon: selected.lon, insee: selected.insee, commune: selected.commune, label: selected.label };
minimapBuilt = false;
renderMinimap();
renderLegend();
renderGeocodeStep();
setChantierStatus('Restauré du cache', true);
replayStep(LOGIN_STEP, allResults.step_2_login, ctx);
STEPS.forEach(function (def) {
if (def.num === GROUP_FROM) startStepGroup();
var prefix = 'step_' + def.num + '_';
var key = Object.keys(allResults).find(function (k) { return k.indexOf(prefix) === 0; });
groupNote(replayStep(def, key ? allResults[key] : null, ctx));
});
endStepGroup();
renderMitoyensReplay();
updateMinimapBuilding();
renderPocSteps();
renderOutputStep();
renderSurelevationStep();
renderSurelevationReportStep();
updateRunBtnLabel();
postHistory(selected);
}
// Reconstruit la box mitoyens depuis les données en cache (sans re-fetch).
function renderMitoyensReplay() {
var step = allResults.step_25b_mitoyens;
var mitoyens = (step && Array.isArray(step.data)) ? step.data : [];
var box = renderStep('25b', 'Immeubles mitoyens', mitoyens.length + ' parcelle(s) voisine(s)');
setDot(box, 'ok');
setMeta(box, mitoyens.length + ' mitoyen(s) · cache local');
var withH = mitoyens.filter(function (m) { return m.hauteur_m != null; });
var lines = mitoyens.length
? mitoyens.map(function (m) {
return kv(m.parcelId, m.hauteur_m != null ? m.hauteur_m + ' m · ' + (m.niveaux != null ? m.niveaux + ' niv.' : '?') : (m.error || 'sans bâti'));
})
: ['Aucune parcelle mitoyenne'];
if (withH.length > 1) lines.push(kv('moyenne hauteurs', avgHeight(withH) + ' m'));
renderSummary(box, 200, 0, lines);
}
// ---- Sources nationales (switches de l'onglet API) --------------------------
// Quand un switch est actif, l'étape POC v0 correspondante appelle notre API
// nationale (action mvp_*) à la place d'iudo. Défaut : tout OFF = iudo.
var nationalSources = {};
function nationalActive() {
for (var k in nationalSources) { if (nationalSources[k]) return true; }
return false;
}
function fetchSources() {
return timedFetch(PROXY + '?action=sources_get', { headers: { 'X-Admin-Key': PROXY_KEY } }, 5000)
.then(function (res) { return res.json(); })
.then(function (j) {
if (j && j.ok && Array.isArray(j.sources)) {
nationalSources = {};
j.sources.forEach(function (s) { nationalSources[s.name] = !!s.enabled; });
}
return nationalSources;
})
.catch(function () { return nationalSources; });
}
// Remplacements iudo -> national, par action iudo. Chaque swap fournit son
// build (lat/lon au lieu des ids iudo) et son extract adapté au schéma national.
var NATIONAL_SWAPS = {
parcels: {
flag: 'parcelle_cadastre',
build: function (ctx) { return { action: 'mvp_parcelle', params: { lat: ctx.lat, lon: ctx.lon } }; },
extract: function (data, ctx) {
var f = data && Array.isArray(data.features) && data.features[0];
if (!f || !f.properties) return;
var p = f.properties;
ctx.parcelId = p.idu || [p.code_dep, p.code_com, p.com_abs || '000', p.section, p.numero].join('');
ctx.parcelSurface = p.contenance != null ? p.contenance : null;
},
},
urban_zones: {
flag: 'zone_plu',
build: function (ctx) { return { action: 'mvp_zone_urba', params: { lat: ctx.lat, lon: ctx.lon } }; },
extract: null,
},
psmv: {
flag: 'servitudes',
build: function (ctx) { return { action: 'mvp_servitudes', params: { lat: ctx.lat, lon: ctx.lon } }; },
extract: null,
},
};
// Applique le swap éventuel à une étape : renvoie { spec, extract, label, swapped }.
function applyNationalSwap(def, spec, ctx) {
var swap = NATIONAL_SWAPS[spec.action];
if (!swap || !nationalSources[swap.flag]) {
return { spec: spec, extract: def.extract, summary: def.summary, label: def.label, swapped: false };
}
return { spec: swap.build(ctx), extract: swap.extract, summary: null, label: def.label + ' · API nationale', swapped: true };
}
// Point d'entrée unique : 1 adresse → flow détaillé, plusieurs → batch.
async function runFlowOrBatch() {
var lines = addressLines();
if (!lines.length) return;
if (lines.length > 1) {
$steps.innerHTML = '';
clearLog();
setGlobalStatus('Batch en cours…', 'running');
await runBatch();
setGlobalStatus('Batch terminé', 'ok');
return;
}
// Nettoie un éventuel batch précédent avant une analyse simple.
batchRows = [];
renderBatchTable();
if ($batchProgress) $batchProgress.textContent = '';
if ($batchCompile) $batchCompile.hidden = true;
if (!selected) {
// Adresse tapée sans passer par les suggestions : géocodage direct.
$info.textContent = 'Géocodage de l\'adresse…';
$runBtn.disabled = true;
var feat = await geocodeOne(lines[0]);
$runBtn.disabled = false;
if (!feat || !feat.geometry) {
$info.textContent = 'Adresse introuvable — précisez la saisie ou choisissez une suggestion.';
return;
}
var p = feat.properties || {};
selected = {
lat: feat.geometry.coordinates[1], lon: feat.geometry.coordinates[0],
insee: p.citycode || '', commune: p.city || '', label: p.label || lines[0], _feature: feat,
};
$info.textContent = '✓ ' + selected.label;
}
runFlow();
}
$runBtn.addEventListener('click', runFlowOrBatch);
renderRecent();
fetchSources();
async function runFlow() {
if (!selected) return;
$runBtn.disabled = true;
$stepContainer = $steps;
currentGroup = null;
// Sources nationales à jour AVANT la décision de cache : un flow mixte
// iudo/national ne doit pas être servi ni enregistré depuis le cache local.
await fetchSources();
var skipCache = forceLiveOnce || ($nocache && $nocache.checked) || nationalActive();
if (!skipCache) {
var cachedEntry = loadCache(selected);
if (cachedEntry) {
replayFromCache(cachedEntry);
$runBtn.disabled = false;
return;
}
}
$steps.innerHTML = '';
clearLog();
Object.keys(allResults).forEach(function (k) { delete allResults[k]; });
setGlobalStatus('En cours…', 'running');
var ctx = { lat: selected.lat, lon: selected.lon, insee: selected.insee, commune: selected.commune, label: selected.label };
var hadError = false;
logMsg('<strong>Lancement du flow pour</strong> ' + esc(selected.label), 'run');
minimapBuilt = false;
renderMinimap();
renderLegend();
renderGeocodeStep();
setChantierStatus('Géocodage de l\'adresse…');
logMsg('Test connexion proxy (ping)… [JS build ' + BUILD + ']', 'info');
try {
var pingRes = await timedFetch(PROXY + '?action=ping', { headers: { 'X-Admin-Key': PROXY_KEY } }, 10000);
var pingText = await pingRes.text();
logMsg('Ping HTTP ' + pingRes.status + ' — <code>' + esc(pingText.slice(0, 500)) + '</code>', pingRes.ok ? 'ok' : 'error');
if (pingRes.ok) {
try { var pj = JSON.parse(pingText); if (pj.build) logMsg('PHP build: ' + esc(pj.build) + ' — PHP ' + esc(pj.php), 'ok'); } catch(e) {}
}
} catch (pingErr) {
logMsg('Ping ERREUR: ' + esc(pingErr.message), 'error');
}
logMsg('STEP 2 — Authentification API…', 'info');
setChantierStatus(LOGIN_STEP.label + '…');
await sleep(STEP_MIN_MS);
var loginResp = await runStep({
num: LOGIN_STEP.num, label: LOGIN_STEP.label, action: LOGIN_STEP.action,
extract: LOGIN_STEP.extract, summary: LOGIN_STEP.summary,
}, ctx);
if (!loginResp || !loginResp.ok) hadError = true;
for (var i = 0; i < STEPS.length; i++) {
var def = STEPS[i];
if (def.num === GROUP_FROM) startStepGroup();
if (def.requires && !ctx[def.requires]) {
logMsg('STEP ' + def.num + ' — ' + esc(def.label) + ' : <span style="color:#ef4444">IGNORÉ</span> (prérequis manquant : ' + esc(def.requires) + ')', 'warn');
renderSkippedStep(def, 'Prérequis manquant : ' + def.requires);
allResults['step_' + def.num] = { skipped: true, reason: def.requires };
groupNote(false);
hadError = true;
continue;
}
logMsg('STEP ' + def.num + ' — ' + esc(def.label) + '…', 'info');
setChantierStatus(def.label + '…');
await sleep(STEP_MIN_MS);
var swapped = applyNationalSwap(def, def.build(ctx), ctx);
var spec = swapped.spec;
var resp = await runStep({
num: def.num, label: swapped.label, action: spec.action,
method: spec.method || 'GET', params: spec.params || {},
body: spec.body !== undefined ? spec.body : null,
extract: swapped.extract, summary: swapped.summary,
}, ctx);
groupNote(resp && resp.ok);
if (!resp || !resp.ok) hadError = true;
// Dès que la géométrie de la parcelle est disponible (step 3), on
// affiche le bloc bleu sans attendre la fin du flow.
updateMinimapBuilding();
}
endStepGroup();
setChantierStatus('Immeubles mitoyens…');
await sleep(STEP_MIN_MS);
await collectMitoyens(ctx);
updateMinimapBuilding();
renderPocSteps();
renderOutputStep();
renderSurelevationStep();
renderSurelevationReportStep();
setChantierStatus(hadError ? 'Terminé avec erreurs' : 'Analyse terminée', true);
setGlobalStatus(hadError ? 'Terminé avec erreurs' : 'Terminé', hadError ? 'error' : 'ok');
logMsg('<strong>' + (hadError ? 'Terminé avec erreurs' : 'Terminé avec succès') + '</strong> — ' + Object.keys(allResults).length + ' étape(s)', hadError ? 'error' : 'ok');
if (!hadError && nationalActive()) {
logMsg('Sources nationales actives — résultat non mis en cache local (mix iudo/national)', 'info');
postHistory(selected);
} else if (!hadError) {
if (saveCache(selected, allResults)) {
logMsg('Flow mis en cache local (7 jours) — prochaine recherche de cette adresse instantanée', 'info');
} else {
logMsg('Cache local indisponible (quota navigateur) — le cache serveur reste actif', 'warn');
}
postHistory(selected);
}
forceLiveOnce = false;
updateRunBtnLabel();
$runBtn.disabled = false;
}
// ---- Immeubles mitoyens -----------------------------------------------------
// Les parcelles voisines viennent de parcel_detail (ctx.neighbors). On récupère
// l'unité foncière de chacune pour en extraire la hauteur du bâti, nécessaire à
// la moyenne demandée pour le calcul de surélévation.
function extractBuilding(unitData, parcelId) {
var unit = Array.isArray(unitData) && unitData.length ? unitData[0] : (unitData || {});
var b = unit.buildings || {};
var addr = unit.address || {};
return {
parcelId: parcelId,
adresse: addr.streetNbr ? ((addr.streetNbr + ' ' + (addr.streetName || '')).trim()) : (addr.streetName || null),
hauteur_m: b.high != null ? b.high : null,
niveaux: b.floor != null ? b.floor : null,
logements: b.housing != null ? b.housing : null,
usages: b.usages || null,
anneeConstruction: b.date || null,
surface_m2: b.surface != null ? Math.round(b.surface * 10) / 10 : null,
};
}
async function fetchNeighborUnit(parcelId) {
var proxyUrl = PROXY + '?action=parcel_units&parcelId=' + encodeURIComponent(parcelId);
if (forceLiveOnce || ($nocache && $nocache.checked)) proxyUrl += '&nocache=1';
try {
var res = await timedFetch(proxyUrl, { headers: { 'X-Admin-Key': PROXY_KEY } }, 30000);
var resp = JSON.parse(await res.text());
return (resp && resp.ok) ? extractBuilding(resp.data, parcelId) : { parcelId: parcelId, error: resp && resp.error ? resp.error : 'échec' };
} catch (e) {
return { parcelId: parcelId, error: e.message };
}
}
async function collectMitoyens(ctx) {
var neighbors = (ctx && Array.isArray(ctx.neighbors)) ? ctx.neighbors : [];
var box = renderStep('25b', 'Immeubles mitoyens', neighbors.length + ' parcelle(s) voisine(s)');
setDot(box, neighbors.length ? 'running' : 'ok');
var mitoyens = [];
for (var i = 0; i < neighbors.length; i++) {
logMsg('Mitoyen ' + (i + 1) + '/' + neighbors.length + ' — ' + esc(neighbors[i]) + '…', 'info');
mitoyens.push(await fetchNeighborUnit(neighbors[i]));
}
allResults.step_25b_mitoyens = { ok: true, action: 'mitoyens', data: mitoyens };
setDot(box, 'ok');
var withH = mitoyens.filter(function (m) { return m.hauteur_m != null; });
setMeta(box, mitoyens.length + ' mitoyen(s)');
var lines = mitoyens.length
? mitoyens.map(function (m) {
return kv(m.parcelId, m.hauteur_m != null ? m.hauteur_m + ' m · ' + (m.niveaux != null ? m.niveaux + ' niv.' : '?') : (m.error || 'sans bâti'));
})
: ['Aucune parcelle mitoyenne renvoyée par l\'API'];
if (withH.length > 1) lines.push(kv('moyenne hauteurs', avgHeight(withH) + ' m'));
renderSummary(box, 200, 0, lines);
logMsg('Mitoyens collectés : ' + withH.length + '/' + mitoyens.length + ' avec hauteur', 'ok');
}
function avgHeight(list) {
var hs = list.map(function (m) { return m.hauteur_m; }).filter(function (h) { return typeof h === 'number'; });
if (!hs.length) return null;
return Math.round((hs.reduce(function (a, b) { return a + b; }, 0) / hs.length) * 10) / 10;
}
async function runStep(cfg, ctx) {
var num = cfg.num, label = cfg.label, action = cfg.action;
var method = cfg.method || 'GET', params = cfg.params || {}, body = cfg.body;
var extract = cfg.extract, summary = cfg.summary;
var qs = new URLSearchParams(Object.assign({ action: action }, params)).toString();
var proxyUrl = PROXY + '?' + qs;
if (forceLiveOnce || ($nocache && $nocache.checked)) proxyUrl += '&nocache=1';
var displayUrl = buildDisplayUrl(action, params, method);
var box = renderStep(num, label, displayUrl);
setDot(box, 'running');
var t0 = performance.now();
var resp = null;
try {
var fetchOpts = { method: method, headers: { 'X-Admin-Key': PROXY_KEY } };
if (method === 'POST') {
fetchOpts.headers['Content-Type'] = 'application/json';
fetchOpts.body = body != null ? JSON.stringify(body) : '';
}
var res = await timedFetch(proxyUrl, fetchOpts, 30000);
var text = await res.text();
try { resp = JSON.parse(text); } catch (e) {
resp = { ok: false, status: res.status, action: action, error: 'Réponse proxy non-JSON', raw: text };
}
} catch (err) {
var errMsg = err.name === 'AbortError' ? 'Timeout (30s)' : err.message;
resp = { ok: false, status: 0, action: action, error: 'Erreur réseau : ' + errMsg, raw: '' };
}
var dur = Math.round(performance.now() - t0);
var durMs = resp && resp.duration_ms != null ? resp.duration_ms : dur;
allResults['step_' + num + '_' + action] = resp;
if (resp && resp.ok) {
setDot(box, 'ok');
setMeta(box, 'HTTP ' + resp.status + ' · ' + durMs + 'ms' + (resp.cached ? ' · cache' : ''));
if (extract) { try { extract(resp.data, ctx); } catch (e) { /* ignore */ } }
var lines = summary ? summary(resp, ctx).filter(Boolean) : [];
if (resp.cached) {
lines.push('<span class="df-k">cache serveur</span><span class="df-v">' + esc(resp.cached_at || 'oui') + '</span>');
}
renderSummary(box, resp.status, durMs, lines);
logMsg('STEP ' + num + ' OK — HTTP ' + resp.status + ' — ' + durMs + 'ms' + (resp.cached ? ' — cache serveur' : ''), 'ok');
} else {
setDot(box, 'error');
setMeta(box, resp && resp.status ? 'HTTP ' + resp.status : 'erreur');
box.classList.add('open');
renderError(box, resp, durMs);
logMsg('STEP ' + num + ' ERREUR — ' + esc(resp ? resp.error || 'HTTP ' + resp.status : 'inconnu') + ' — ' + durMs + 'ms', 'error');
}
renderRawToggle(box, resp);
return resp;
}
// ---- Annotations migration POC ----------------------------------------------
// todo : pastille TODO bleue + note — étape à adapter (migration open data,
// remplacement du compte iudo, maj IGN…).
// dashed : bordure pointillée — étape servie par iudo hors process métier V2
// (à trancher garder/supprimer) ou purement technique iudo.
// open : API publique de remplacement (pastille verte « API open » + quota,
// vérifié le 16/07/2026). quota : conditions exactes.
var STEP_ANNOTATIONS = {
'1': { todo: 'Migrer le géocodage vers la Géoplateforme IGN (api-adresse.data.gouv.fr déprécié)',
open: 'API Adresse BAN / Géoplateforme', quota: 'Gratuit, sans clé · 50 req/s par IP' },
'2': { dashed: true, todo: 'Compte iudo perso + mot de passe en dur — compte dédié, puis suppression après migration open data' },
'3': { todo: 'Remplacer par API Carto module Cadastre',
open: 'API Carto Cadastre (IGN)', quota: 'Gratuit, sans clé · 1000 objets/réponse (500 communes), pagination _start' },
'4': { todo: 'Remplacer par API Carto Cadastre (fournit aussi les parcelles voisines)',
open: 'API Carto Cadastre (IGN)', quota: 'Gratuit, sans clé · 1000 objets/réponse, pagination _start' },
'5': { todo: 'Remplacer par API Carto Cadastre (géométrie)',
open: 'API Carto Cadastre (IGN)', quota: 'Gratuit, sans clé · géométrie en Lambert 93 (reprojeter vers WGS84)' },
'6': { todo: 'Remplacer par API Carto module GPU (zonage)',
open: 'API Carto GPU (IGN)', quota: 'Gratuit, sans clé · 1000 objets/réponse' },
'7': { todo: 'Remplacer par BD TOPO IGN + RNB — conversion hauteur faîtage → égout à spécifier ; propriétaires : pas d\'équivalent open data',
open: 'BD TOPO — WFS Géoplateforme + RNB', quota: 'Gratuit, sans clé · 30 req/s par IP · max 2 couches/URL (depuis 15/06/2026)' },
'8': { todo: 'Règlement structuré : PAS d\'API open équivalente (les couches plub_* ne donnent que le graphique). À reconstruire via plub_* + PDF GPU + parsing.' },
'9': { dashed: true, todo: 'Endpoint iudo (renvoie null sur nos tests) — vérifier ou supprimer' },
'10': { todo: 'Remplacer par les couches plub_gpu_ps / pl / pp',
open: 'Paris Data (Opendatasoft)', quota: 'Gratuit · quota anonyme non publié, clé API pour le relever' },
'11': { todo: 'Remplacer par API Carto GPU',
open: 'API Carto GPU (IGN)', quota: 'Gratuit, sans clé · 1000 objets/réponse' },
'12': { todo: 'Remplacer par le GPU en direct (téléchargement du PDF)',
open: 'Géoportail de l\'Urbanisme', quota: 'Gratuit, sans clé · téléchargement direct du règlement' },
'13': { todo: 'Remplacer par le GPU (secteurs patrimoniaux)',
open: 'API Carto GPU (IGN)', quota: 'Gratuit, sans clé · 1000 objets/réponse' },
'14': { dashed: true, todo: 'Hors process métier V2 — si conservé : geo.api.gouv.fr',
open: 'geo.api.gouv.fr', quota: 'Gratuit, sans clé' },
'15': { todo: 'Remplacer par DVF+ Cerema / fichiers DGFiP',
open: 'API Données foncières (DVF+ Cerema)', quota: 'Gratuit, Licence Ouverte 2.0' },
'16': { todo: 'Remplacer par DVF+ Cerema / fichiers DGFiP',
open: 'API Données foncières (DVF+ Cerema)', quota: 'Gratuit, Licence Ouverte 2.0' },
'17': { todo: 'Remplacer par DVF+ Cerema — le calcul exige ventes de la parcelle ET des mitoyens',
open: 'API Données foncières (DVF+ Cerema)', quota: 'Gratuit, Licence Ouverte 2.0' },
'18': { dashed: true, todo: 'Hors process métier V2 — si conservé : Paris Data « encadrement des loyers »',
open: 'Paris Data (Opendatasoft)', quota: 'Gratuit · maj annuelle au 1er juillet' },
'19': { dashed: true, todo: 'Hors process métier V2 — si conservé : Sitadel',
open: 'Sitadel (open data)', quota: 'Gratuit · granularité parcelle à valider' },
'20': { dashed: true, todo: 'Hors process métier V2 — si conservé : Sitadel',
open: 'Sitadel (open data)', quota: 'Gratuit · granularité parcelle à valider' },
'21': { dashed: true, todo: 'Hors process métier V2 — si conservé : Sitadel',
open: 'Sitadel (open data)', quota: 'Gratuit · granularité parcelle à valider' },
'22': { todo: 'Remplacer par API Géorisques',
open: 'API Géorisques v1', quota: 'Gratuit, sans jeton (v1) · v2 = jeton Cerbère (1 an)' },
'23': { todo: 'Remplacer par API Géorisques',
open: 'API Géorisques v1', quota: 'Gratuit, sans jeton (v1) · v2 = jeton Cerbère (1 an)' },
'24': { dashed: true, todo: 'Tracking iudo — à supprimer après migration' },
'25': { dashed: true, todo: 'Compteur de quota iudo — à supprimer après migration' },
'25b': { todo: 'Basculera sur API Carto + BD TOPO (comme les steps 3 à 7)',
open: 'API Carto Cadastre + BD TOPO', quota: 'Gratuit, sans clé · voir steps 3 et 7' },
};
function annotateStep(box, num) {
var ann = STEP_ANNOTATIONS[String(num)];
if (!ann) return;
if (ann.dashed) box.classList.add('df-dashed');
var meta = box.querySelector('.df-step-meta');
var body = box.querySelector('.df-step-body');
if (ann.open && meta) {
meta.insertAdjacentHTML('beforebegin',
'<span class="df-open" title="' + esc(ann.open + ' — ' + ann.quota) + '">API open</span>');
}
if (ann.todo && meta) {
meta.insertAdjacentHTML('beforebegin',
'<span class="df-todo" title="' + esc(ann.todo) + '">TODO</span>');
}
if (body) {
var note = '';
if (ann.todo) note += '<div class="df-todo-note">🔧 ' + esc(ann.todo) + '</div>';
if (ann.open) note += '<div class="df-open-note">🌐 <strong>' + esc(ann.open) + '</strong> — ' + esc(ann.quota) + '</div>';
if (note) body.insertAdjacentHTML('afterbegin', note);
}
}
// Étapes POC à construire (iudo ne les a pas) — purement front : rendu grisé,
// aucun appel backend. Sert de checklist visuelle dans le flow.
var POC_STEPS = [
{ id: 'P1', label: 'Couches PLUb filets & hauteurs', desc: 'plub_filet / plub_hauteur / plub_hmc (Paris Data, gratuit) — table couleur → hauteur à re-sourcer (Jo + Théo)' },
{ id: 'P2', label: 'Largeur de voie (prospect)', desc: 'Aucune API directe identifiée — filaire de voies Paris Data + calcul géométrique. Point dur.' },
{ id: 'P3', label: 'Conversion faîtage IGN → égout', desc: 'Règle métier à spécifier — IGN mesure le faîtage, le gabarit PLU raisonne à l\'égout' },
{ id: 'P4', label: 'Calcul hauteur autorisée', desc: 'Gabarit-enveloppe : filet coloré, ou largeur de voie (< 12 m refus, sinon H = P + 25 %P ≤ 28 m) + couronnement' },
{ id: 'P5', label: 'Conditionnalité PLUb + dent creuse', desc: 'Filtres : création logement, débitumisation, biosourcés, > RE2020, souvent 1 niveau ; comparaison aux 2 voisins concomitants' },
{ id: 'P6', label: 'Filtre patrimonial amont', desc: 'Fiche synthétique immeuble PLU, flag bâtiment d\'angle, sortie « vérification à faire en mairie »' },
{ id: 'P7', label: 'Moteur économique', desc: 'Hauteur disponible (seuil 2,80 m), niveaux = floor(h/3), surface × abattement −20 %, prix DVF (immeuble + mitoyens) × 1,20, CA TTC' },
{ id: 'P8', label: 'Bilan promoteur', desc: 'CA TTC − 20 % TVA − 50 % coûts − 15 % marge APDS = 15 % acquisition copro (10 % si < 200 m² et foncier < 8 k€/m²)' },
{ id: 'P9', label: 'Score de fiabilité A / B / C / D', desc: 'A = PLU lu + hauteur fiable + parcelle claire … D = zone protégée / donnée manquante' },
{ id: 'P10', label: 'Statut GO / À VÉRIFIER / NO-GO', desc: 'Filtre patrimonial intégré, restitution en fourchettes non juridique — wording à arbitrer' },
];
function renderPocSteps() {
var box = document.createElement('div');
box.className = 'df-step df-group df-poc open';
box.innerHTML =
'<div class="df-step-head">' +
'<span class="df-caret">▶</span>' +
'<div><span class="df-step-title">STEPS POC — À CONSTRUIRE (' + POC_STEPS.length + ')</span>' +
'<span class="df-step-url">Étapes du process métier absentes de iudo — front uniquement, aucun appel</span></div>' +
'<span class="df-step-meta">0 / ' + POC_STEPS.length + '</span>' +
'<span class="df-dot pending"></span>' +
'</div>' +
'<div class="df-step-body"></div>';
box.querySelector('.df-step-head').addEventListener('click', function () {
box.classList.toggle('open');
});
$steps.appendChild(box);
var container = box.querySelector('.df-step-body');
POC_STEPS.forEach(function (p) {
var el = document.createElement('div');
el.className = 'df-step df-poc-step';
el.innerHTML =
'<div class="df-step-head">' +
'<span class="df-caret">▶</span>' +
'<div><span class="df-step-title">STEP ' + esc(p.id) + ' — ' + esc(p.label) + '</span></div>' +
'<span class="df-step-meta">à construire</span>' +
'<span class="df-dot pending"></span>' +
'</div>' +
'<div class="df-step-body"><div class="df-step-summary"><span class="df-line">' + esc(p.desc) + '</span></div></div>';
el.querySelector('.df-step-head').addEventListener('click', function () {
el.classList.toggle('open');
});
container.appendChild(el);
});
}
// ---- Minimap 3D + loader chantier -------------------------------------------
// Carte 3D (MapLibre + ortho IGN) qui zoome dynamiquement sur l'immeuble, avec
// une grue animée façon Kayak et une box de statut affichant chaque étape en
// cours (remplace la précédente) pour valoriser le temps de traitement.
// Le rendu carte (style, vol caméra, quartier 3D, volumes, orbite) est délégué
// au composant partagé /av-minimap.js (window.AvMinimap) — le MÊME que la
// landing. Ici ne restent que le métier : identification WFS de l'immeuble,
// fallback parcelle, fiche d'audit et barre chantier.
var avMap = null; // instance AvMinimap ({ map, whenReady, flyIn, addBlue, ... })
var CRANE_SVG =
'<svg class="df-crane-svg" viewBox="0 0 64 64" width="46" height="46" aria-hidden="true">' +
'<g stroke="#fbbf24" stroke-width="2.4" fill="none" stroke-linecap="round">' +
'<line x1="14" y1="58" x2="14" y2="10"/>' + // mât
'<line x1="8" y1="58" x2="20" y2="58"/>' + // base
'<line x1="14" y1="12" x2="52" y2="12"/>' + // flèche
'<line x1="14" y1="12" x2="4" y2="20"/>' + // contre-flèche
'<line x1="14" y1="20" x2="24" y2="12"/>' + // hauban
'</g>' +
'<g class="df-crane-hook">' +
'<line x1="44" y1="12" x2="44" y2="30" stroke="#f59e0b" stroke-width="1.6"/>' +
'<rect x="40" y="30" width="8" height="6" rx="1" fill="#22c55e"/>' +
'</g>' +
'<g transform="translate(11,44)">' + // ouvrier
'<circle cx="3" cy="0" r="2.4" fill="#fbbf24"/>' +
'<line x1="3" y1="2.4" x2="3" y2="9" stroke="#fbbf24" stroke-width="2"/>' +
'<g class="df-crane-arm"><line x1="3" y1="4" x2="8" y2="4" stroke="#fbbf24" stroke-width="2" stroke-linecap="round"/></g>' +
'</g>' +
'</svg>';
function parcelInfoFromResults() {
function poly(g) { return g && g.type === 'Polygon' && Array.isArray(g.coordinates) ? g : null; }
var geometry = null, height = null;
var u = allResults.step_7_parcel_units && allResults.step_7_parcel_units.ok
? (Array.isArray(allResults.step_7_parcel_units.data) ? allResults.step_7_parcel_units.data[0] : null) : null;
if (u) {
if (u.parcels && u.parcels[0]) geometry = poly(u.parcels[0].geometry);
if (u.buildings && u.buildings.high != null) height = u.buildings.high;
}
if (!geometry) {
var p3 = allResults.step_3_parcels && allResults.step_3_parcels.data;
if (p3) geometry = poly(p3.geometry) || (Array.isArray(p3) && p3[0] ? poly(p3[0].geometry) : null);
}
if (!geometry) {
var p4 = allResults.step_4_parcel_detail && allResults.step_4_parcel_detail.data;
if (p4 && p4.parcels && p4.parcels[0]) geometry = poly(p4.parcels[0].geometry);
}
return geometry ? { geometry: geometry, height: (height != null ? height : 12) } : null;
}
// Exécute fn dès que le style de la carte est prêt (délégué au composant).
function whenMapReady(fn) {
if (avMap) avMap.whenReady(fn);
}
function setMapStatus(text, isError) {
var el = document.getElementById('df-map-status');
if (!el) return;
el.textContent = text || '';
el.classList.toggle('df-minimap-foot-err', !!isError);
}
var minimapStartAt = 0;
var animStartAt = 0; // début du vol caméra — ancre de la chorégraphie
// Chorégraphie métier, relative au début du vol caméra (le quartier 3D et
// l'orbite fin-de-vol sont gérés par le composant).
var BLEU_AT_MS = 2000; // notre immeuble en bleu : +2 s
// Exécute fn à `offsetMs` après le début de l'animation (tout de suite si déjà
// passé), une fois la carte prête.
function scheduleAtAnim(offsetMs, fn) {
var delay = Math.max(0, animStartAt + offsetMs - performance.now());
setTimeout(function () { whenMapReady(fn); }, delay);
}
function renderMinimap() {
minimapStartAt = performance.now();
minimapBuilt = false;
blueFromWfs = false;
wfsState = 'pending';
parcelFallbackInfo = null;
blueGeometry = null;
blueHeight = null;
if (avMap) { avMap.destroy(); avMap = null; }
var box = document.createElement('div');
box.className = 'df-minimap';
box.innerHTML =
'<div class="df-minimap-head">' +
'<span class="df-minimap-title">🏙 ' + esc(selected.label || 'Immeuble') + '</span>' +
'<span class="df-minimap-sub">lat ' + selected.lat + ' · lon ' + selected.lon + '</span>' +
'</div>' +
'<div class="df-minimap-canvas" id="df-map"></div>' +
'<div class="df-minimap-foot" id="df-map-status"></div>' +
'<div class="df-minimap-msg" id="df-map-msg">Chargement de la vue 3D…</div>' +
// Fiche d'audit gratuite intégrée à la carte (droite en desktop, bas en mobile)
'<div class="df-audit-card" id="df-audit-card">' +
'<span class="df-audit-badge">Audit gratuit</span>' +
'<div class="df-audit-title">' + esc(selected.label || 'Votre immeuble') + '</div>' +
'<div class="df-audit-rows" id="df-audit-rows">' +
'<div class="df-audit-row df-audit-wait">Analyse en cours…</div>' +
'</div>' +
'<button class="df-audit-cta" type="button">Recevoir l\'audit complet</button>' +
'</div>';
$steps.appendChild(box);
renderChantier();
if (typeof maplibregl === 'undefined' || !window.AvMinimap) {
var msg = document.getElementById('df-map-msg');
if (msg) msg.textContent = 'Vue 3D indisponible (librairie carto non chargée).';
return;
}
// Composant partagé admin/landing : style, vol caméra, quartier 3D et
// orbite 360° (démarrée à la FIN du vol, indépendante des recherches).
animStartAt = performance.now() + 250;
avMap = window.AvMinimap.create({
container: 'df-map',
lon: selected.lon, lat: selected.lat,
startZoom: 15.5, navControl: true,
onReady: function () {
var m = document.getElementById('df-map-msg');
if (m) m.style.display = 'none';
},
});
if (!avMap) {
var m = document.getElementById('df-map-msg');
if (m) m.textContent = 'Vue 3D indisponible (initialisation carte impossible).';
return;
}
avMap.flyIn(250);
// Le fetch WFS part immédiatement ; seul l'affichage du bleu est calé (+2 s).
fetchOurBuilding();
}
// Remplit la fiche d'audit gratuite (sur la carte) à partir du rapport de
// surélévation, une fois le flow terminé.
function updateAuditCard() {
var rowsEl = document.getElementById('df-audit-rows');
if (!rowsEl) return;
var rep;
try { rep = buildSurelevationReport(allResults); } catch (e) { return; }
var rows = [];
var z = rep.zonePlu || {};
if (z.name || z.libelong) rows.push(['Zone PLU', z.name || z.libelong]);
if (rep.parcelle && rep.parcelle.surface_m2 != null) rows.push(['Parcelle', rep.parcelle.surface_m2 + ' m²']);
var b = rep.batimentExistant || {};
if (b.hauteur_m != null) rows.push(['Hauteur actuelle', b.hauteur_m + ' m']);
if (b.niveaux != null) rows.push(['Niveaux', b.niveaux]);
var nbRegles = (rep.reglesSurelevation && rep.reglesSurelevation.length) || 0;
rows.push(['Règles de surélévation', nbRegles + ' repérée(s)']);
rowsEl.innerHTML = rows.length
? rows.map(function (r) { return '<div class="df-audit-row"><span>' + esc(r[0]) + '</span><strong>' + esc(String(r[1])) + '</strong></div>'; }).join('')
: '<div class="df-audit-row df-audit-wait">Données indisponibles</div>';
}
// NOTRE immeuble en GeoJSON via le WFS BD TOPO (BDTOPO_V3:batiment, attribut
// « hauteur » documenté). Deux variantes de requête essayées dans l'ordre —
// les serveurs WFS diffèrent sur l'ordre des axes / le CRS accepté.
function wfsBatimentsUrls(lon, lat) {
var dLon = 0.0026, dLat = 0.0017; // ≈ ±190 m
var base = 'https://data.geopf.fr/wfs/ows?SERVICE=WFS&VERSION=2.0.0&REQUEST=GetFeature'
+ '&TYPENAME=BDTOPO_V3:batiment&TYPENAMES=BDTOPO_V3:batiment'
+ '&OUTPUTFORMAT=application/json&COUNT=400';
return [
base + '&SRSNAME=EPSG:4326&BBOX=' + [lat - dLat, lon - dLon, lat + dLat, lon + dLon, 'EPSG:4326'].join(','),
base + '&SRSNAME=CRS:84&BBOX=' + [lon - dLon, lat - dLat, lon + dLon, lat + dLat, 'CRS:84'].join(','),
];
}
function fetchWfsBatiments(lon, lat) {
var urls = wfsBatimentsUrls(lon, lat);
function attempt(i) {
return fetch(urls[i])
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (fc) {
if (fc && Array.isArray(fc.features) && fc.features.length) return fc;
throw new Error('0 bâtiment renvoyé');
})
.catch(function (e) {
if (i + 1 < urls.length) return attempt(i + 1);
throw e;
});
}
return attempt(0);
}
// Certains serveurs WFS renvoient les coordonnées en (lat, lon) selon le CRS
// demandé : on détecte et on remet en (lon, lat) attendu par GeoJSON/MapLibre.
function normalizeAxes(fc, lon0, lat0) {
var first = null;
(function find(c) {
if (first) return;
if (Array.isArray(c) && typeof c[0] === 'number') { first = c; return; }
if (Array.isArray(c)) c.forEach(find);
})((fc.features || []).map(function (f) { return f.geometry && f.geometry.coordinates; }));
if (!first) return fc;
var normal = Math.abs(first[0] - lon0) + Math.abs(first[1] - lat0);
var swapped = Math.abs(first[0] - lat0) + Math.abs(first[1] - lon0);
if (swapped < normal) {
(function swap(c) {
if (Array.isArray(c) && typeof c[0] === 'number') { var t = c[0]; c[0] = c[1]; c[1] = t; return; }
if (Array.isArray(c)) c.forEach(swap);
})(fc.features.map(function (f) { return f.geometry && f.geometry.coordinates; }));
}
return fc;
}
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;
}
var minimapBuilt = false;
var blueFromWfs = false;
var blueGeometry = null; // empreinte du bâtiment (réutilisée pour la surélévation)
var blueHeight = null; // hauteur du bâtiment existant (m)
// État de l'identification WFS : le fallback parcelle ne doit s'appliquer que
// si le WFS a échoué — pas gagner la course pendant qu'il répond.
var wfsState = 'pending'; // 'found' | 'none' | 'error'
var parcelFallbackInfo = null;
// Notre immeuble en bleu (rendu délégué au composant, garde-fous métier ici :
// un seul bloc, priorité WFS sur le fallback parcelle).
function addBlueBlock(geometry, heightValue, fromWfs) {
if (!avMap || minimapBuilt) return;
minimapBuilt = true;
blueFromWfs = !!fromWfs;
blueGeometry = geometry;
blueHeight = heightValue;
avMap.addBlue(geometry, heightValue);
}
// Si le bloc vient du fallback parcelle (hauteur par défaut), on affine sa
// hauteur quand le bâti réel arrive (step 7), sans le reconstruire.
function updateBlueHeight(geometry, h) {
if (!avMap || !minimapBuilt || blueFromWfs || !h) return;
blueGeometry = geometry;
blueHeight = h;
avMap.updateBlueHeight(geometry, h);
}
function applyParcelFallback() {
if (!parcelFallbackInfo) return;
var info = parcelFallbackInfo;
scheduleAtAnim(BLEU_AT_MS, function () { addBlueBlock(info.geometry, info.height, false); });
}
// Volume de surélévation : hauteur ajoutée + faisabilité (couleur).
function surelevationSpec(rep) {
var existant = rep.batimentExistant && rep.batimentExistant.hauteur_m;
var regs = (rep.hauteursReglementaires && rep.hauteursReglementaires.valeursRepereesM) || [];
var maxReg = 0;
regs.forEach(function (h) { if (typeof h === 'number' && h > maxReg) maxReg = h; });
var delta = null;
if (maxReg && existant != null && maxReg > existant) delta = maxReg - existant;
else if (rep.calcul && typeof rep.calcul.ecartMoyenneMitoyens_m === 'number' && rep.calcul.ecartMoyenneMitoyens_m > 0) {
delta = rep.calcul.ecartMoyenneMitoyens_m;
}
var c = rep.contraintes || {};
var bloque = c.psmv === true
|| (c.monumentsHistoriquesProximite || 0) > 0
|| (c.plansPreventionRisques || 0) > 0
|| (rep.parcelle && rep.parcelle.protegee === true);
// Plafond réglementaire connu mais déjà atteint/dépassé → pas de marge.
var sansMarge = maxReg > 0 && existant != null && maxReg <= existant;
var faisable = !bloque && !sansMarge && (delta == null || delta > 0);
// Hauteur du volume : le potentiel si connu, sinon ~1 niveau ; bornée à l'affichage.
var addH = (delta && delta > 0) ? delta : 3;
addH = Math.max(2.5, Math.min(addH, 12));
return { addH: addH, faisable: faisable, delta: delta };
}
// Petit volume de surélévation posé sur le toit du volume bleu : vert si
// faisable, gris si contrainte patrimoniale/réglementaire bloquante.
function addSurelevationBlock(tries) {
tries = tries || 0;
if (!avMap) return;
if ((!minimapBuilt || !blueGeometry) && tries < 12) {
setTimeout(function () { addSurelevationBlock(tries + 1); }, 500);
return;
}
if (!blueGeometry) return;
var rep;
try { rep = buildSurelevationReport(allResults); } catch (e) { return; }
var spec = surelevationSpec(rep);
avMap.setSurelev(spec.addH, spec.faisable);
updateAuditFeasibility(spec);
}
// Ligne « faisabilité » dans la fiche d'audit (pastille verte / grise).
function updateAuditFeasibility(spec) {
var card = document.getElementById('df-audit-card');
if (!card) return;
var el = card.querySelector('.df-audit-feasible');
if (!el) {
el = document.createElement('div');
el.className = 'df-audit-feasible';
var rows = document.getElementById('df-audit-rows');
if (rows && rows.parentNode) rows.parentNode.insertBefore(el, rows.nextSibling);
else card.appendChild(el);
}
el.innerHTML = spec.faisable
? '<span class="df-audit-dot df-audit-dot-ok"></span> Surélévation a priori faisable'
: '<span class="df-audit-dot df-audit-dot-no"></span> Surélévation contrainte';
}
// Identification de NOTRE immeuble : le polygone WFS qui contient le point de
// l'adresse, affiché en bleu à la seconde 1 de l'animation (pas avant, pour
// laisser le zoom s'installer ; pas après, dès que la donnée est là).
function fetchOurBuilding() {
setMapStatus('Bâtiments 3D : identification de l\'immeuble…');
fetchWfsBatiments(selected.lon, selected.lat)
.then(function (fc) {
if (!avMap) return;
normalizeAxes(fc, selected.lon, selected.lat);
var pt = [selected.lon, selected.lat];
var main = null;
fc.features.some(function (f) {
if (pointInGeometry(pt, f.geometry)) { main = f; return true; }
return false;
});
if (!main) {
wfsState = 'none';
setMapStatus('Immeuble non trouvé sous le point BD TOPO — parcelle en fallback (step 3)');
logMsg('Minimap : aucun bâtiment BD TOPO sous le point (' + fc.features.length + ' aux alentours) — fallback parcelle', 'warn');
applyParcelFallback();
return;
}
wfsState = 'found';
var h = main.properties && main.properties.hauteur != null ? Number(main.properties.hauteur) : NaN;
var height = isNaN(h) || h <= 0 ? 15 : h;
setMapStatus('Immeuble identifié — hauteur BD TOPO : ' + (isNaN(h) ? 'inconnue' : h + ' m'));
logMsg('Minimap : immeuble identifié via WFS BD TOPO (' + height + ' m)', 'ok');
// Apparition calée à +2 s du début de l'animation.
scheduleAtAnim(BLEU_AT_MS, function () { addBlueBlock(main.geometry, height, true); });
})
.catch(function (err) {
wfsState = 'error';
setMapStatus('WFS BD TOPO indisponible (' + ((err && err.message) || 'erreur') + ') — parcelle en fallback', true);
logMsg('Minimap : WFS BD TOPO indisponible : ' + esc((err && err.message) || 'erreur'), 'warn');
applyParcelFallback();
});
}
// Fallback + contour de parcelle quand la géométrie arrive du flow (step 3+).
function updateMinimapBuilding() {
if (!avMap) return;
var info = parcelInfoFromResults();
if (!info) return;
whenMapReady(function () {
// Contour cyan de la parcelle (complète le bloc bleu du bâtiment).
try {
var m = avMap.map;
if (!m.getSource('parcelle')) {
m.addSource('parcelle', { type: 'geojson', data: { type: 'Feature', properties: {}, geometry: info.geometry } });
m.addLayer({
id: 'parcelle-line', type: 'line', source: 'parcelle',
paint: { 'line-color': '#22d3ee', 'line-width': 2, 'line-dasharray': [2, 1.5], 'line-opacity': 0.9 },
});
}
} catch (e) { /* ignore */ }
// Priorité au WFS : la parcelle ne devient bloc bleu que si le WFS a
// échoué ; s'il est encore en cours, on mémorise pour son .catch.
parcelFallbackInfo = info;
if (wfsState === 'error' || wfsState === 'none') {
scheduleAtAnim(BLEU_AT_MS, function () { addBlueBlock(info.geometry, info.height, false); });
}
// Bloc parcelle déjà posé avec la hauteur par défaut : affine quand le
// bâti réel (step 7) est connu.
updateBlueHeight(info.geometry, info.height);
});
}
// Durée minimale d'affichage de chaque étape dans la box de statut (≥ 3 s),
// pour bien voir chaque étape défiler. Surchargée par window.__DF_STEP_MS
// (tests) pour ne pas ralentir l'automatisation.
var STEP_MIN_MS = (typeof window !== 'undefined' && window.__DF_STEP_MS != null) ? window.__DF_STEP_MS : 3000;
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
// Barre chantier : mascotte AirValue + grue animée + box de statut (une étape
// à la fois).
function renderChantier() {
var el = document.createElement('div');
el.className = 'df-chantier';
el.innerHTML =
'<img class="df-mascotte" src="/mascotte.png" alt="" aria-hidden="true" />' +
'<span class="df-crane">' + CRANE_SVG + '</span>' +
'<span class="df-chantier-status" id="df-chantier-status">Initialisation…</span>';
$steps.appendChild(el);
}
function setChantierStatus(text, done) {
var el = document.getElementById('df-chantier-status');
if (!el) return;
var label = String(text).replace(/[.…\s]+$/, '');
if (done) {
el.textContent = text;
} else {
// Libellé + points animés « . .. ... » qui défilent rapidement.
el.innerHTML = esc(label) + '<span class="df-dots" aria-hidden="true"></span>';
}
var bar = el.parentNode;
if (bar) {
bar.classList.toggle('df-chantier-done', !!done);
var crane = bar.querySelector('.df-crane');
if (crane) crane.classList.toggle('df-crane-done', !!done);
}
}
function renderLegend() {
var el = document.createElement('div');
el.className = 'df-legend';
el.innerHTML =
'<span><span class="df-todo">TODO</span> à adapter (migration open data)</span>' +
'<span><span class="df-open">API open</span> API publique gratuite dispo (survol = quota)</span>' +
'<span><span class="df-legend-dashed"></span> iudo hors process métier — à trancher</span>' +
'<span><span class="df-legend-poc"></span> à construire (iudo ne l\'a pas)</span>';
$steps.appendChild(el);
}
// ---- Groupe repliable des steps 7 → 25 --------------------------------------
// Les steps de collecte détaillée sont rangés dans une box unique, repliée par
// défaut ; elle s'ouvre seule si un step en erreur s'y trouve.
var GROUP_FROM = 7, GROUP_TO = 25;
var currentGroup = null;
function startStepGroup() {
var box = document.createElement('div');
box.className = 'df-step df-group';
box.innerHTML =
'<div class="df-step-head">' +
'<span class="df-caret">▶</span>' +
'<div><span class="df-step-title">STEPS ' + GROUP_FROM + ' → ' + GROUP_TO + ' — Collecte de données détaillées</span>' +
'<span class="df-step-url">' + (GROUP_TO - GROUP_FROM + 1) + ' appels API — déplier pour le détail</span></div>' +
'<span class="df-step-meta"></span>' +
'<span class="df-dot running"></span>' +
'</div>' +
'<div class="df-step-body"></div>';
box.querySelector('.df-step-head').addEventListener('click', function () {
box.classList.toggle('open');
});
$steps.appendChild(box);
currentGroup = { box: box, ok: 0, err: 0 };
$stepContainer = box.querySelector('.df-step-body');
}
function groupNote(success) {
if (!currentGroup) return;
if (success) currentGroup.ok++; else currentGroup.err++;
setMeta(currentGroup.box, currentGroup.ok + ' OK' + (currentGroup.err ? ' · ' + currentGroup.err + ' erreur(s)' : ''));
if (currentGroup.err) {
setDot(currentGroup.box, 'error');
currentGroup.box.classList.add('open');
}
}
function endStepGroup() {
if (currentGroup) setDot(currentGroup.box, currentGroup.err ? 'error' : 'ok');
currentGroup = null;
$stepContainer = $steps;
}
function renderStep(num, label, url) {
var box = document.createElement('div');
box.className = 'df-step';
box.innerHTML =
'<div class="df-step-head">' +
'<span class="df-caret">▶</span>' +
'<div>' +
'<span class="df-step-title">STEP ' + num + ' — ' + esc(label) + '</span>' +
'<span class="df-step-url">' + esc(url) + '</span>' +
'</div>' +
'<span class="df-step-meta"></span>' +
'<span class="df-dot pending"></span>' +
'</div>' +
'<div class="df-step-body"></div>';
box.querySelector('.df-step-head').addEventListener('click', function () {
box.classList.toggle('open');
});
annotateStep(box, num);
($stepContainer || $steps).appendChild(box);
return box;
}
function setDot(box, state) { box.querySelector('.df-dot').className = 'df-dot ' + state; }
function setMeta(box, text) {
var meta = box.querySelector('.df-step-meta');
if (meta) meta.textContent = text;
}
function renderSummary(box, status, durMs, lines) {
var body = box.querySelector('.df-step-body');
var rest = lines.map(function (l) { return '<span class="df-line">' + l + '</span>'; }).join('');
if (rest) body.insertAdjacentHTML('beforeend', '<div class="df-step-summary">' + rest + '</div>');
}
function renderError(box, resp, durMs) {
var body = box.querySelector('.df-step-body');
var status = resp ? resp.status : 0;
var errMsg = resp && resp.error ? resp.error : 'Erreur inconnue';
var raw = resp && (resp.raw || resp.data)
? (typeof resp.raw === 'string' && resp.raw ? resp.raw : JSON.stringify(resp.data || resp.raw, null, 2))
: '';
body.insertAdjacentHTML('beforeend',
'<div class="df-step-summary"><span class="df-line"><span class="df-v">' + status + '</span> — ' + durMs + 'ms</span></div>' +
'<div class="df-step-error"><strong>HTTP ' + status + '</strong> — ' + esc(errMsg) +
(raw ? '<pre>' + esc(raw) + '</pre>' : '') + '</div>');
}
function renderRawToggle(box, resp) {
var body = box.querySelector('.df-step-body');
var id = 'dfraw_' + Math.random().toString(36).slice(2);
body.insertAdjacentHTML('beforeend',
'<div class="df-json-actions">' +
'<button class="df-toggle-raw" data-mode="compact">JSON compact</button>' +
'<button class="df-toggle-raw" data-mode="raw">JSON brut</button>' +
'</div>' +
'<pre class="df-raw-json" id="' + id + '"></pre>');
var pre = body.querySelector('.df-raw-json');
var currentMode = null;
body.querySelectorAll('.df-json-actions .df-toggle-raw').forEach(function (btn) {
btn.addEventListener('click', function () {
var mode = btn.getAttribute('data-mode');
if (currentMode === mode) {
pre.classList.remove('open');
currentMode = null;
return;
}
pre.textContent = mode === 'compact'
? JSON.stringify(compactStepResp(resp), null, 2)
: JSON.stringify(resp, null, 2);
pre.classList.add('open');
currentMode = mode;
});
});
}
function renderSkippedStep(def, reason) {
var spec = safeBuild(def);
var url = spec ? buildDisplayUrl(spec.action, spec.params || {}, spec.method || 'GET') : '';
var box = renderStep(def.num, def.label, url);
setDot(box, 'error');
setMeta(box, 'ignoré');
box.classList.add('open');
box.querySelector('.df-step-body').insertAdjacentHTML('beforeend',
'<div class="df-step-error"><strong>Ignoré</strong> — ' + esc(reason) + '</div>');
}
function safeBuild(def) { try { return def.build({}); } catch (e) { return null; } }
function renderGeocodeStep() {
var url = GEOCODE_URL + '?q=' + encodeURIComponent(selected.label);
var box = renderStep(1, 'Géocodage IGN (BAN)', url);
setDot(box, 'ok');
setMeta(box, 'HTTP 200');
allResults['step_1_geocode'] = { ok: true, status: 200, action: 'geocode', data: selected._feature };
renderSummary(box, 200, 0, [
'Adresse : <span class="df-v">' + esc(selected.label) + '</span>',
kv('lat', selected.lat) + ' ' + kv('lon', selected.lon) + ' ' + kv('insee', selected.insee),
kv('commune', selected.commune || '—'),
]);
renderRawToggle(box, allResults['step_1_geocode']);
logMsg('STEP 1 — Géocodage OK : ' + esc(selected.label), 'ok');
}
// ---- Compaction du JSON final ---------------------------------------------
// Le JSON agrégé brut dépasse facilement 400 Ko (règlement PLU complet, listes
// d'URLs PDF, milliers d'ids PSC). La version compacte garde toutes les infos
// liées à l'adresse (parcelle, zones, servitudes, propriétaires, DVF, risques…)
// et résume le reste. Le JSON brut reste disponible via « Télécharger complet ».
function truncList(arr, max) {
if (!Array.isArray(arr) || arr.length <= max) return arr;
return { total: arr.length, premiers: arr.slice(0, max) };
}
function excerpt(text, max) {
if (typeof text !== 'string') return null;
var t = text.replace(/\s+/g, ' ').trim();
if (!t) return null;
return t.length > max ? t.slice(0, max) + '…' : t;
}
function compactGeocode(feature) {
if (!feature || typeof feature !== 'object') return feature;
var coords = (feature.geometry && feature.geometry.coordinates) || [];
return Object.assign({ lat: coords[1], lon: coords[0] }, feature.properties || {});
}
function compactLogin(data) {
if (!data || typeof data !== 'object') return data;
return {
userId: data._id || data.id || null,
status: data.status || null,
subscription: (data.subscription && data.subscription.name) || null,
};
}
function compactParcelUnits(data) {
if (!Array.isArray(data)) return data;
return data.map(function (unit) {
if (!unit || typeof unit !== 'object') return unit;
var copy = Object.assign({}, unit);
if (copy.gpu && typeof copy.gpu === 'object' && !Array.isArray(copy.gpu)) {
var gpu = {};
Object.keys(copy.gpu).forEach(function (k) { gpu[k] = truncList(copy.gpu[k], 3); });
copy.gpu = gpu;
}
return copy;
});
}
function compactAirtablePlu(payload) {
if (!payload || typeof payload !== 'object') return payload;
var inner = payload.data && typeof payload.data === 'object' ? payload.data : {};
var blocks = inner.blocks && typeof inner.blocks === 'object' ? inner.blocks : {};
var out = { meta: inner.meta || null };
var keyData = blocks.KEY_DATA && blocks.KEY_DATA.value;
if (keyData && typeof keyData === 'object') {
var dc = {};
Object.keys(keyData).forEach(function (k) {
var entry = keyData[k] || {};
dc[entry.key || k] = entry.value != null ? entry.value : null;
});
out.donneesCles = dc;
}
var resume = {};
Object.keys(blocks).forEach(function (bk) {
if (bk === 'KEY_DATA') return;
var block = blocks[bk] || {};
var sections = block.value && typeof block.value === 'object' ? block.value : {};
var compactSections = {};
Object.keys(sections).forEach(function (sk) {
var section = sections[sk] || {};
var rubs = section.value && typeof section.value === 'object' ? section.value : {};
var compactRubs = {};
Object.keys(rubs).forEach(function (rk) {
var rub = rubs[rk] || {};
var c = {};
if (typeof rub.tag === 'string' && rub.tag) c.tag = rub.tag;
else if (rub.tag && typeof rub.tag === 'object') c.tags = rub.tag;
if (typeof rub.resum === 'string' && rub.resum) c.resum = rub.resum;
if (!c.tag && !c.tags && !c.resum) {
var ex = excerpt(rub.textSource, 300);
if (ex) c.extrait = ex;
}
if (Object.keys(c).length) compactRubs[rk] = c;
});
compactSections[section.key || sk] = compactRubs;
});
resume[block.key || bk] = compactSections;
});
out.reglementResume = resume;
return out;
}
function compactTownPsc(data) {
if (!data || typeof data !== 'object' || Array.isArray(data)) return data;
// psc_pct / psc_lin / psc_surf ne contiennent que des ids internes d'objets
// graphiques (protections ponctuelles / linéaires / surfaciques) : on ne
// garde que leur nombre.
var out = {};
Object.keys(data).forEach(function (k) {
out[k] = Array.isArray(data[k]) ? data[k].length : data[k];
});
return out;
}
function compactGpuPluInfo(data) {
if (!data || typeof data !== 'object') return data;
var copy = Object.assign({}, data);
copy.regulationGraphicPdfUrls = truncList(copy.regulationGraphicPdfUrls, 3);
return copy;
}
function compactAduInsee(data) {
if (!data || typeof data !== 'object' || !Array.isArray(data.features)) return data;
var copy = Object.assign({}, data);
copy.features = truncList(copy.features, 3);
return copy;
}
var COMPACTORS = {
geocode: compactGeocode,
login: compactLogin,
parcel_units: compactParcelUnits,
airtable_plu: compactAirtablePlu,
town_psc: compactTownPsc,
gpu_plu_info: compactGpuPluInfo,
adu_insee: compactAduInsee,
};
function compactStepResp(resp, action) {
if (!resp || typeof resp !== 'object') return resp;
if (resp.skipped) return { skipped: true, reason: resp.reason };
if (!resp.ok) return { ok: false, status: resp.status, error: resp.error || null };
var data = resp.data != null ? resp.data : (resp.raw ? excerpt(resp.raw, 500) : null);
var fn = COMPACTORS[action || resp.action];
try { return fn ? fn(data) : data; } catch (e) { return data; }
}
function buildCompactOutput(results) {
var out = {};
Object.keys(results).forEach(function (key) {
out[key] = compactStepResp(results[key], key.replace(/^step_\d+_/, ''));
});
return out;
}
// ---- STEP 27 : analyse surélévation -----------------------------------------
// Parse le JSON complet et en extrait un rapport ciblé surélévation :
// parcelle + bâtiment existant, règles de hauteur, tous les passages du
// règlement PLU mentionnant la surélévation, contraintes patrimoniales et
// points de vigilance.
var SURELEV_RE = /sur[ée]l[eé]v/i;
function findStep(results, action) {
var suffix = '_' + action;
var key = Object.keys(results).find(function (k) { return k.slice(-suffix.length) === suffix; });
var resp = key ? results[key] : null;
return resp && resp.ok ? resp.data : null;
}
function collectMentions(text, source, store) {
if (typeof text !== 'string' || !SURELEV_RE.test(text)) return;
var lines = text.split('\n');
var consumed = {};
for (var i = 0; i < lines.length; i++) {
if (consumed[i] || !SURELEV_RE.test(lines[i])) continue;
var indent = lines[i].match(/^\s*/)[0].length;
var chunk = [lines[i].trim()];
consumed[i] = true;
for (var j = i + 1; j < lines.length; j++) {
if (!lines[j].trim()) break;
if (lines[j].match(/^\s*/)[0].length <= indent) break;
chunk.push(lines[j].trim());
consumed[j] = true;
}
var texte = chunk.join('\n');
var existing = store.find(function (m) { return m.texte === texte; });
if (existing) {
if (existing.sources.indexOf(source) === -1) existing.sources.push(source);
} else {
store.push({ sources: [source], texte: texte });
}
}
}
function walkBlocks(blocks, visit) {
if (!blocks || typeof blocks !== 'object') return;
Object.keys(blocks).forEach(function (bk) {
if (bk === 'KEY_DATA') return;
var block = blocks[bk] || {};
var sections = block.value && typeof block.value === 'object' ? block.value : {};
Object.keys(sections).forEach(function (sk) {
var section = sections[sk] || {};
var rubs = section.value && typeof section.value === 'object' ? section.value : {};
Object.keys(rubs).forEach(function (rk) {
var rub = rubs[rk];
if (rub && typeof rub === 'object') {
visit(block.key || bk, section.key || sk, rk, rub);
}
});
});
});
}
function buildSurelevationReport(results) {
var geocode = findStep(results, 'geocode');
var units = findStep(results, 'parcel_units');
var plu = findStep(results, 'airtable_plu');
var psmv = findStep(results, 'psmv');
var prevention = findStep(results, 'prevention_plans');
var unit = Array.isArray(units) && units.length ? units[0] : (units || {});
var parcel = unit.parcels && unit.parcels[0] ? unit.parcels[0] : {};
var buildings = unit.buildings || {};
var zone = unit.urban_zones && unit.urban_zones[0] ? unit.urban_zones[0] : null;
var geoProps = geocode && geocode.properties ? geocode.properties : {};
var geoCoords = (geocode && geocode.geometry && geocode.geometry.coordinates) || [];
var pluInner = plu && plu.data && typeof plu.data === 'object' ? plu.data : {};
var blocks = pluInner.blocks && typeof pluInner.blocks === 'object' ? pluInner.blocks : {};
var psmvConcerne = false;
if (Array.isArray(psmv)) psmvConcerne = psmv.length > 0;
else if (psmv && typeof psmv === 'object') {
psmvConcerne = psmv.concerned === true
|| (Array.isArray(psmv.zones) && psmv.zones.length > 0)
|| (Array.isArray(psmv.documents) && psmv.documents.length > 0);
}
var nbPlansPrevention = 0;
if (Array.isArray(prevention)) nbPlansPrevention = prevention.length;
else if (prevention && Array.isArray(prevention.items)) nbPlansPrevention = prevention.items.length;
var report = {
adresse: {
label: geoProps.label || null,
insee: geoProps.citycode || (unit.address && unit.address.inseeCode) || null,
commune: geoProps.city || null,
lat: geoCoords[1] != null ? geoCoords[1] : null,
lon: geoCoords[0] != null ? geoCoords[0] : null,
},
parcelle: {
id: parcel.id || (unit.address && unit.address.parcelle_id) || null,
section: parcel.section || null,
numero: parcel.number || null,
surface_m2: parcel.surface != null ? Math.round(parcel.surface * 10) / 10 : null,
surfaceFiscale_m2: parcel.surface_fisc != null ? parcel.surface_fisc : null,
protegee: parcel.protected != null ? parcel.protected : null,
},
batimentExistant: {
hauteur_m: buildings.high != null ? buildings.high : null,
niveaux: buildings.floor != null ? buildings.floor : null,
logements: buildings.housing != null ? buildings.housing : null,
usages: buildings.usages || null,
anneeConstruction: buildings.date || null,
dpe: buildings.dpe || null,
constructionLegere: buildings.has_light_construction != null ? buildings.has_light_construction : null,
},
zonePlu: Object.assign({}, zone || {}, pluInner.meta && typeof pluInner.meta === 'object' ? {
specificite: pluInner.meta.specificity || null,
approbation: pluInner.meta.approbationDate || null,
lienReglement: pluInner.meta.link || null,
} : {}),
reglesCles: null,
hauteurs: { dispositionsGenerales: [], casParticuliers: null },
reglesSurelevation: [],
contraintes: {
prescriptions: [],
servitudes: [],
psmv: psmvConcerne,
monumentsHistoriquesProximite: (unit.nearby && Array.isArray(unit.nearby.historic_monument)) ? unit.nearby.historic_monument.length : 0,
plansPreventionRisques: nbPlansPrevention,
preemption: unit.preemption || null,
},
pointsDeVigilance: [],
};
var keyData = blocks.KEY_DATA && blocks.KEY_DATA.value;
if (keyData && typeof keyData === 'object') {
var dc = {};
Object.keys(keyData).forEach(function (k) {
var entry = keyData[k] || {};
dc[entry.key || k] = entry.value != null ? entry.value : null;
});
report.reglesCles = dc;
}
walkBlocks(blocks, function (blockKey, sectionKey, rubKey, rub) {
var isHauteurs = /hauteur/i.test(blockKey);
if (isHauteurs && rubKey === 'dispo-gen') {
if (typeof rub.textSource === 'string' && rub.textSource.trim()) {
report.hauteurs.dispositionsGenerales.push(rub.textSource.trim());
}
}
if (isHauteurs && rubKey === 'dispo-parti' && typeof rub.resum === 'string' && !report.hauteurs.casParticuliers) {
report.hauteurs.casParticuliers = rub.resum;
}
var ts = rub.textSource;
var source = blockKey + ' > ' + sectionKey;
if (typeof ts === 'string') {
collectMentions(ts, source, report.reglesSurelevation);
} else if (ts && typeof ts === 'object') {
Object.keys(ts).forEach(function (tag) {
collectMentions(ts[tag], source + ' ' + tag, report.reglesSurelevation);
});
}
});
(unit.prescriptions || []).forEach(function (p) {
report.contraintes.prescriptions.push({ type: p.type || null, libelle: p.libelle || null });
});
(unit.servitudes || []).forEach(function (s) {
report.contraintes.servitudes.push({ type: s.type || null, libelle: truncList(s.libelle, 3) });
});
var vigilance = report.pointsDeVigilance;
function flag(msg) { if (vigilance.indexOf(msg) === -1) vigilance.push(msg); }
report.contraintes.prescriptions.forEach(function (p) {
var txt = (p.type || '') + ' ' + (Array.isArray(p.libelle) ? p.libelle.join(' ') : (p.libelle || ''));
if (/prot[ée]g[ée]/i.test(txt)) flag('Bâtiment ou parcelle protégé(e) au PLU — surélévation fortement contrainte, étude patrimoniale à prévoir');
if (/hauteur/i.test(txt)) flag('Prescription de hauteur sur la parcelle (' + (p.type || '') + ') — vérifier le plafond applicable avant tout projet');
});
report.contraintes.servitudes.forEach(function (s) {
if (/monuments historiques|abords/i.test(s.type || '')) flag('Périmètre des abords de monuments historiques — avis de l\'Architecte des Bâtiments de France requis');
if (/sites inscrits et class[ée]s/i.test(s.type || '')) flag('Site inscrit ou classé — autorisation spéciale nécessaire');
});
if (report.contraintes.psmv) flag('Parcelle en Plan de Sauvegarde et de Mise en Valeur (PSMV) — règlement spécifique prioritaire sur le PLU');
if (report.contraintes.plansPreventionRisques > 0) flag('Plan(s) de prévention des risques applicable(s) — contraintes constructives possibles');
if (parcel.protected === true) flag('Parcelle marquée protégée dans le cadastre iudo');
// --- Immeubles mitoyens + calcul de surélévation --------------------------
var mitoyensStep = results.step_25b_mitoyens;
var mitoyens = (mitoyensStep && Array.isArray(mitoyensStep.data)) ? mitoyensStep.data : [];
var mitoyensH = mitoyens.map(function (m) { return m.hauteur_m; })
.filter(function (h) { return typeof h === 'number'; });
var moyenneMitoyens = mitoyensH.length
? Math.round((mitoyensH.reduce(function (a, b) { return a + b; }, 0) / mitoyensH.length) * 10) / 10
: null;
report.mitoyens = {
parcelles: mitoyens,
nbAvecHauteur: mitoyensH.length,
moyenneHauteur_m: moyenneMitoyens,
};
var existant = report.batimentExistant.hauteur_m;
report.hauteursReglementaires = {
note: 'Zone ' + (report.zonePlu.name || '?') + ' : la hauteur maximale résulte de règles cumulatives (gabarits-enveloppes, fuseaux, HMC). Valeur à retenir par l\'équipe parmi les hauteurs repérées ci-dessous et le règlement.',
valeursRepereesM: extractRegHeights(blocks),
};
report.calcul = {
hauteurExistante_m: existant,
moyenneMitoyens_m: moyenneMitoyens,
// Résultat 1 (potentiel brut) = hauteur réglementaire max − existant.
// La hauteur réglementaire n'étant pas un scalaire unique en zone UG,
// on fournit la formule et l'écart existant / moyenne mitoyens.
formuleResultat1: 'Potentiel = Hauteur réglementaire max (à retenir) − ' + (existant != null ? existant + ' m (existant)' : 'hauteur existante'),
formuleFinale: 'Résultat final = pondération du Résultat 1 avec la moyenne des immeubles mitoyens',
ecartMoyenneMitoyens_m: (moyenneMitoyens != null && existant != null)
? Math.round((moyenneMitoyens - existant) * 10) / 10
: null,
};
if (moyenneMitoyens != null && existant != null) {
if (moyenneMitoyens > existant) {
flag('Immeubles mitoyens plus hauts en moyenne (' + moyenneMitoyens + ' m vs ' + existant + ' m existant) — marge d\'harmonisation possible vers le haut (+' + report.calcul.ecartMoyenneMitoyens_m + ' m)');
} else if (moyenneMitoyens < existant) {
flag('Immeubles mitoyens plus bas en moyenne (' + moyenneMitoyens + ' m vs ' + existant + ' m existant) — surélévation susceptible de dépasser le bâti voisin, insertion urbaine à justifier');
}
} else if (!mitoyens.length) {
flag('Aucun immeuble mitoyen exploitable — moyenne d\'harmonisation non calculable');
}
if (!vigilance.length) flag('Aucun signal patrimonial ou réglementaire bloquant détecté dans les données collectées');
return report;
}
// Hauteurs en mètres citées dans les règles de hauteur (phrases mentionnant une
// hauteur maximale / plafond / gabarit / HMC), dédupliquées et triées.
function extractRegHeights(blocks) {
var hauteurBlock = null;
Object.keys(blocks || {}).forEach(function (bk) {
if (/hauteur/i.test((blocks[bk] && blocks[bk].key) || bk)) hauteurBlock = blocks[bk];
});
if (!hauteurBlock) return [];
var vals = {};
var texts = [];
(function walk(v) {
if (typeof v === 'string') { texts.push(v); return; }
if (Array.isArray(v)) { v.forEach(walk); return; }
if (v && typeof v === 'object') { Object.keys(v).forEach(function (k) { walk(v[k]); }); }
})(hauteurBlock.value);
texts.forEach(function (txt) {
txt.split(/(?<=[.;:])\s+|\n/).forEach(function (sentence) {
if (!/hauteur|gabarit|plafond|HMC|limite/i.test(sentence)) return;
var m, re = /(\d{1,2}(?:[.,]\d)?)\s*m(?:ètre|etre)?s?\b/g;
while ((m = re.exec(sentence)) !== null) {
var n = parseFloat(m[1].replace(',', '.'));
if (n >= 6 && n <= 90) vals[n] = true;
}
});
});
return Object.keys(vals).map(Number).sort(function (a, b) { return a - b; });
}
function renderSurelevationStep() {
var report;
try { report = buildSurelevationReport(allResults); }
catch (e) {
logMsg('STEP 27 ERREUR — analyse surélévation : ' + esc(e.message), 'error');
return;
}
var json = JSON.stringify(report, null, 2);
logMsg('STEP 27 — Analyse surélévation : ' + report.reglesSurelevation.length + ' règle(s) trouvée(s), '
+ report.pointsDeVigilance.length + ' point(s) de vigilance — ' + json.length + ' bytes', 'ok');
var box = document.createElement('div');
box.className = 'df-step df-output open';
box.innerHTML =
'<div class="df-step-head">' +
'<span class="df-caret">▶</span>' +
'<div><span class="df-step-title">STEP 27 — ANALYSE SURÉLÉVATION</span>' +
'<span class="df-step-url">Extraction des infos et règles utiles à la surélévation</span></div>' +
'<span class="df-step-meta">' + Math.round(json.length / 1024) + ' Ko</span>' +
'<span class="df-dot ok"></span>' +
'</div>' +
'<div class="df-step-body">' +
'<div class="df-output-actions">' +
'<button id="df-show-sur" class="px-4 py-2 text-xs rounded-xl bg-gray-200 text-gray-700 hover:bg-gray-300">Afficher le JSON</button>' +
'<button id="df-copy-sur" class="btn-cta text-xs px-4 py-2">Copier JSON</button>' +
'<button id="df-dl-sur" class="px-4 py-2 text-xs rounded-xl bg-gray-600 text-white hover:bg-gray-700">Télécharger</button>' +
'</div>' +
'<pre class="df-raw-json" id="df-surelevation">' + esc(json) + '</pre>' +
'</div>';
box.querySelector('.df-step-head').addEventListener('click', function () {
box.classList.toggle('open');
});
$steps.appendChild(box);
document.getElementById('df-show-sur').addEventListener('click', function () {
var open = document.getElementById('df-surelevation').classList.toggle('open');
this.textContent = open ? 'Masquer le JSON' : 'Afficher le JSON';
});
document.getElementById('df-copy-sur').addEventListener('click', function () {
navigator.clipboard.writeText(json).then(function () {
var b = document.getElementById('df-copy-sur');
b.textContent = 'Copié !';
setTimeout(function () { b.textContent = 'Copier JSON'; }, 1500);
});
});
document.getElementById('df-dl-sur').addEventListener('click', function () {
var blob = new Blob([json], { type: 'application/json' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'dataflow_' + (selected.insee || 'output') + '_surelevation.json';
a.click();
URL.revokeObjectURL(a.href);
});
}
// ---- STEP 28 : rapport surélévation lisible (sans JSON) ---------------------
// Rapport destiné à l'équipe métier : mise en forme HTML, sections claires,
// calcul « hauteur réglementaire − existant » et moyenne des mitoyens en avant.
// Aucun JSON affiché ; l'équipe lit, trie et décide.
function fmtM(v) { return v == null ? '<span class="df-rep-na">non renseigné</span>' : String(v).replace('.', ',') + ' m'; }
function fmtVal(v) { return (v == null || v === '') ? '<span class="df-rep-na">non renseigné</span>' : esc(String(v)); }
function repTable(rows) {
return '<table class="df-rep-table"><tbody>' + rows.map(function (r) {
return '<tr><th>' + esc(r[0]) + '</th><td>' + (r[2] ? r[1] : fmtVal(r[1])) + '</td></tr>';
}).join('') + '</tbody></table>';
}
function repSection(title, inner) {
return '<section class="df-rep-section"><h4>' + esc(title) + '</h4>' + inner + '</section>';
}
function buildSurelevationHtml(rep) {
var b = rep.batimentExistant, m = rep.mitoyens, c = rep.calcul;
var html = '';
// En-tête adresse
html += '<div class="df-rep-addr">' +
'<div class="df-rep-addr-main">' + fmtVal(rep.adresse.label) + '</div>' +
'<div class="df-rep-addr-sub">Parcelle ' + fmtVal(rep.parcelle.id) +
' · INSEE ' + fmtVal(rep.adresse.insee) +
' · Zone PLU ' + fmtVal(rep.zonePlu.name) + (rep.zonePlu.libelong ? ' (' + esc(rep.zonePlu.libelong) + ')' : '') + '</div></div>';
// A — Calcul de surélévation (headline)
var calcRows =
'<div class="df-rep-calc">' +
'<div class="df-rep-calc-item"><span class="df-rep-calc-lbl">Hauteur existante</span><span class="df-rep-calc-val">' + fmtM(c.hauteurExistante_m) + '</span><span class="df-rep-calc-sub">' + (b.niveaux != null ? b.niveaux + ' niveaux' : '') + '</span></div>' +
'<div class="df-rep-calc-op">+ ?</div>' +
'<div class="df-rep-calc-item"><span class="df-rep-calc-lbl">Hauteur réglementaire max</span><span class="df-rep-calc-val df-rep-todo">à retenir</span><span class="df-rep-calc-sub">voir section D</span></div>' +
'<div class="df-rep-calc-op">=</div>' +
'<div class="df-rep-calc-item"><span class="df-rep-calc-lbl">Potentiel (Résultat 1)</span><span class="df-rep-calc-val">Régl. − ' + fmtM(c.hauteurExistante_m) + '</span><span class="df-rep-calc-sub">réglementaire − existant</span></div>' +
'</div>' +
'<p class="df-rep-formula"><strong>Résultat 1 :</strong> ' + esc(c.formuleResultat1) + '</p>' +
'<p class="df-rep-formula"><strong>Résultat final :</strong> ' + esc(c.formuleFinale) + '</p>' +
'<p class="df-rep-formula"><strong>Moyenne hauteur des immeubles mitoyens :</strong> ' + fmtM(c.moyenneMitoyens_m) +
(c.ecartMoyenneMitoyens_m != null ? ' · écart avec l\'existant : ' + (c.ecartMoyenneMitoyens_m > 0 ? '+' : '') + String(c.ecartMoyenneMitoyens_m).replace('.', ',') + ' m' : '') + '</p>';
html += repSection('A. Calcul de surélévation', calcRows);
// B — Bâtiment existant
html += repSection('B. Bâtiment existant', repTable([
['Hauteur', fmtM(b.hauteur_m), true],
['Niveaux', b.niveaux, false],
['Logements', b.logements, false],
['Usages', b.usages, false],
['Année de construction', b.anneeConstruction, false],
['DPE', b.dpe, false],
['Surface parcelle', rep.parcelle.surface_m2 != null ? fmtM(rep.parcelle.surface_m2).replace(' m', ' m²') : null, true],
]));
// C — Immeubles mitoyens
var mitInner;
if (m.parcelles.length) {
mitInner = '<table class="df-rep-table df-rep-grid"><thead><tr>' +
'<th>Parcelle</th><th>Adresse</th><th>Hauteur</th><th>Niveaux</th><th>Usages</th><th>Année</th></tr></thead><tbody>' +
m.parcelles.map(function (n) {
if (n.error) return '<tr><td>' + fmtVal(n.parcelId) + '</td><td colspan="5" class="df-rep-na">' + esc(n.error) + '</td></tr>';
return '<tr><td>' + fmtVal(n.parcelId) + '</td><td>' + fmtVal(n.adresse) + '</td><td>' + fmtM(n.hauteur_m) +
'</td><td>' + fmtVal(n.niveaux) + '</td><td>' + fmtVal(n.usages) + '</td><td>' + fmtVal(n.anneeConstruction) + '</td></tr>';
}).join('') +
'</tbody></table>' +
'<p class="df-rep-note">Moyenne des hauteurs mitoyennes : <strong>' + fmtM(m.moyenneHauteur_m) + '</strong> (' + m.nbAvecHauteur + ' immeuble(s) avec hauteur sur ' + m.parcelles.length + ')</p>';
} else {
mitInner = '<p class="df-rep-na">Aucune parcelle mitoyenne renvoyée par l\'API.</p>';
}
html += repSection('C. Immeubles mitoyens', mitInner);
// D — Hauteurs réglementaires
var hr = rep.hauteursReglementaires || {};
var dInner = '<p class="df-rep-note">' + esc(hr.note || '') + '</p>';
if (hr.valeursRepereesM && hr.valeursRepereesM.length) {
dInner += '<p>Hauteurs repérées dans les règles de hauteur : ' +
hr.valeursRepereesM.map(function (v) { return '<span class="df-rep-chip">' + String(v).replace('.', ',') + ' m</span>'; }).join(' ') + '</p>';
}
if (rep.hauteurs.dispositionsGenerales.length) {
dInner += '<div class="df-rep-rules">' + rep.hauteurs.dispositionsGenerales.map(function (t) {
return '<p>' + esc(t) + '</p>';
}).join('') + '</div>';
}
if (rep.reglesCles) {
var kc = Object.keys(rep.reglesCles).map(function (k) { return [k, rep.reglesCles[k], false]; });
dInner += '<h5 class="df-rep-h5">Données clés du règlement</h5>' + repTable(kc);
}
html += repSection('D. Hauteurs réglementaires (PLU)', dInner);
// E — Règles spécifiques surélévation
var eInner = rep.reglesSurelevation.length
? '<ul class="df-rep-list">' + rep.reglesSurelevation.map(function (r) {
return '<li><span class="df-rep-src">' + esc(r.sources.join(', ')) + '</span>' + esc(r.texte) + '</li>';
}).join('') + '</ul>'
: '<p class="df-rep-na">Aucune règle mentionnant explicitement la surélévation.</p>';
html += repSection('E. Règles spécifiques à la surélévation (' + rep.reglesSurelevation.length + ')', eInner);
// F — Contraintes & vigilance
var ct = rep.contraintes;
var fInner = '<ul class="df-rep-vigilance">' + rep.pointsDeVigilance.map(function (v) {
return '<li>' + esc(v) + '</li>';
}).join('') + '</ul>';
fInner += repTable([
['Prescriptions PLU', ct.prescriptions.length, false],
['Servitudes', ct.servitudes.length, false],
['PSMV', ct.psmv ? 'Oui' : 'Non', false],
['Monuments historiques à proximité', ct.monumentsHistoriquesProximite, false],
['Plans de prévention des risques', ct.plansPreventionRisques, false],
]);
if (ct.prescriptions.length) {
fInner += '<h5 class="df-rep-h5">Prescriptions</h5><ul class="df-rep-list">' +
ct.prescriptions.map(function (p) {
return '<li><span class="df-rep-src">' + fmtVal(p.type) + '</span>' + esc(Array.isArray(p.libelle) ? p.libelle.join(' · ') : (p.libelle || '')) + '</li>';
}).join('') + '</ul>';
}
html += repSection('F. Contraintes & points de vigilance', fInner);
return html;
}
function reportToText(rep) {
var b = rep.batimentExistant, m = rep.mitoyens, c = rep.calcul, L = [];
L.push('RAPPORT SURÉLÉVATION — ' + (rep.adresse.label || ''));
L.push('Parcelle ' + (rep.parcelle.id || '') + ' · Zone PLU ' + (rep.zonePlu.name || '') + '\n');
L.push('A. CALCUL DE SURÉLÉVATION');
L.push(' Hauteur existante : ' + (c.hauteurExistante_m != null ? c.hauteurExistante_m + ' m' : '—') + (b.niveaux != null ? ' (' + b.niveaux + ' niveaux)' : ''));
L.push(' ' + c.formuleResultat1);
L.push(' ' + c.formuleFinale);
L.push(' Moyenne hauteur mitoyens : ' + (c.moyenneMitoyens_m != null ? c.moyenneMitoyens_m + ' m' : '—') + (c.ecartMoyenneMitoyens_m != null ? ' (écart existant : ' + (c.ecartMoyenneMitoyens_m > 0 ? '+' : '') + c.ecartMoyenneMitoyens_m + ' m)' : '') + '\n');
L.push('B. BÂTIMENT EXISTANT');
L.push(' Hauteur ' + (b.hauteur_m != null ? b.hauteur_m + ' m' : '—') + ' · ' + (b.niveaux != null ? b.niveaux + ' niveaux' : '—') + ' · ' + (b.logements != null ? b.logements + ' logements' : '—') + ' · ' + (b.usages || '—') + ' · ' + (b.anneeConstruction || '—') + ' · DPE ' + (b.dpe || '—') + '\n');
L.push('C. IMMEUBLES MITOYENS');
if (m.parcelles.length) {
m.parcelles.forEach(function (n) {
L.push(' ' + n.parcelId + ' : ' + (n.error ? n.error : ((n.hauteur_m != null ? n.hauteur_m + ' m' : '—') + ' · ' + (n.niveaux != null ? n.niveaux + ' niv.' : '—') + ' · ' + (n.usages || '—') + ' · ' + (n.anneeConstruction || '—'))));
});
L.push(' → Moyenne : ' + (m.moyenneHauteur_m != null ? m.moyenneHauteur_m + ' m' : '—') + ' (' + m.nbAvecHauteur + '/' + m.parcelles.length + ')');
} else { L.push(' Aucune parcelle mitoyenne.'); }
L.push('');
L.push('D. HAUTEURS RÉGLEMENTAIRES');
L.push(' ' + (rep.hauteursReglementaires.note || ''));
if (rep.hauteursReglementaires.valeursRepereesM.length) L.push(' Valeurs repérées : ' + rep.hauteursReglementaires.valeursRepereesM.map(function (v) { return v + ' m'; }).join(', '));
L.push('');
L.push('E. RÈGLES SURÉLÉVATION (' + rep.reglesSurelevation.length + ')');
rep.reglesSurelevation.forEach(function (r) { L.push(' [' + r.sources.join(', ') + '] ' + r.texte.replace(/\n/g, ' ')); });
L.push('');
L.push('F. CONTRAINTES & VIGILANCE');
rep.pointsDeVigilance.forEach(function (v) { L.push(' - ' + v); });
return L.join('\n');
}
function renderSurelevationReportStep() {
var rep;
try { rep = buildSurelevationReport(allResults); }
catch (e) {
logMsg('STEP 28 ERREUR — rapport surélévation : ' + esc(e.message), 'error');
return;
}
logMsg('STEP 28 — Rapport surélévation métier généré', 'ok');
var box = document.createElement('div');
box.className = 'df-step df-output df-report-step open';
box.innerHTML =
'<div class="df-step-head">' +
'<span class="df-caret">▶</span>' +
'<div><span class="df-step-title">STEP 28 — RAPPORT SURÉLÉVATION (métier)</span>' +
'<span class="df-step-url">Synthèse lisible pour tri et décision — sans JSON</span></div>' +
'<span class="df-todo" title="Brancher le calcul de hauteur autorisée (filets plub_filet / largeur de voie) puis le moteur économique">TODO</span>' +
'<span class="df-dot ok"></span>' +
'</div>' +
'<div class="df-step-body">' +
'<div class="df-output-actions">' +
'<button id="df-copy-rep" class="btn-cta text-xs px-4 py-2">Copier le rapport (texte)</button>' +
'<button id="df-print-rep" class="px-4 py-2 text-xs rounded-xl bg-gray-600 text-white hover:bg-gray-700">Imprimer / PDF</button>' +
'</div>' +
'<div class="df-report" id="df-report">' + buildSurelevationHtml(rep) + '</div>' +
'</div>';
box.querySelector('.df-step-head').addEventListener('click', function () {
box.classList.toggle('open');
});
$steps.appendChild(box);
document.getElementById('df-copy-rep').addEventListener('click', function () {
navigator.clipboard.writeText(reportToText(rep)).then(function () {
var bt = document.getElementById('df-copy-rep');
bt.textContent = 'Copié !';
setTimeout(function () { bt.textContent = 'Copier le rapport (texte)'; }, 1500);
});
});
document.getElementById('df-print-rep').addEventListener('click', function () {
var w = window.open('', '_blank');
if (!w) return;
w.document.write('<html><head><title>Rapport surélévation — ' + esc(rep.adresse.label || '') + '</title>' +
'<style>body{font-family:system-ui,sans-serif;margin:32px;color:#111;line-height:1.5}' +
'h4{margin:22px 0 8px;font-size:16px;border-bottom:2px solid #16a34a;padding-bottom:4px}' +
'h5{margin:14px 0 6px;font-size:13px}table{border-collapse:collapse;width:100%;margin:6px 0}' +
'th,td{border:1px solid #ddd;padding:6px 10px;text-align:left;font-size:13px;vertical-align:top}' +
'th{background:#f3f4f6;width:220px}.df-rep-grid th{width:auto}ul{margin:6px 0;padding-left:20px}' +
'li{margin:4px 0;font-size:13px}.df-rep-chip{display:inline-block;background:#dcfce7;padding:2px 8px;border-radius:10px;margin:2px;font-size:12px}' +
'.df-rep-src{color:#6b7280;font-size:11px;display:block}.df-rep-na{color:#9ca3af}' +
'.df-rep-calc{display:flex;gap:10px;flex-wrap:wrap;margin:10px 0}.df-rep-calc-item{border:1px solid #ddd;border-radius:8px;padding:8px 12px}' +
'.df-rep-addr-main{font-size:18px;font-weight:700}.df-rep-formula{margin:4px 0;font-size:13px}</style></head><body>' +
'<div class="df-rep-addr"><div class="df-rep-addr-main">' + esc(rep.adresse.label || '') + '</div></div>' +
buildSurelevationHtml(rep) + '</body></html>');
w.document.close();
w.focus();
w.print();
});
}
function renderOutputStep() {
var box = document.createElement('div');
box.className = 'df-step df-output open';
var fullJson = JSON.stringify(allResults, null, 2);
var compactJson = JSON.stringify(buildCompactOutput(allResults), null, 2);
logMsg('Output final : ' + compactJson.length + ' bytes (compact) — ' + fullJson.length + ' bytes (complet)', 'info');
box.innerHTML =
'<div class="df-step-head">' +
'<span class="df-caret">▶</span>' +
'<div><span class="df-step-title">STEP 26 — OUTPUT FINAL</span>' +
'<span class="df-step-url">Synthèse compacte — le JSON complet reste téléchargeable</span></div>' +
'<span class="df-step-meta">' + Math.round(compactJson.length / 1024) + ' Ko</span>' +
'<span class="df-dot ok"></span>' +
'</div>' +
'<div class="df-step-body">' +
'<div class="df-output-actions">' +
'<button id="df-show" class="px-4 py-2 text-xs rounded-xl bg-gray-200 text-gray-700 hover:bg-gray-300">Afficher le JSON</button>' +
'<button id="df-copy" class="btn-cta text-xs px-4 py-2">Copier JSON</button>' +
'<button id="df-dl" class="px-4 py-2 text-xs rounded-xl bg-gray-600 text-white hover:bg-gray-700">Télécharger</button>' +
'<button id="df-dl-full" class="px-4 py-2 text-xs rounded-xl bg-gray-400 text-white hover:bg-gray-500">Télécharger complet</button>' +
'</div>' +
'<pre class="df-raw-json" id="df-final">' + esc(compactJson) + '</pre>' +
'</div>';
box.querySelector('.df-step-head').addEventListener('click', function () {
box.classList.toggle('open');
});
$steps.appendChild(box);
document.getElementById('df-show').addEventListener('click', function () {
var open = document.getElementById('df-final').classList.toggle('open');
this.textContent = open ? 'Masquer le JSON' : 'Afficher le JSON';
});
function download(json, suffix) {
var blob = new Blob([json], { type: 'application/json' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'dataflow_' + (selected.insee || 'output') + suffix + '.json';
a.click();
URL.revokeObjectURL(a.href);
}
document.getElementById('df-copy').addEventListener('click', function () {
navigator.clipboard.writeText(compactJson).then(function () {
var b = document.getElementById('df-copy');
b.textContent = 'Copié !';
setTimeout(function () { b.textContent = 'Copier JSON'; }, 1500);
});
});
document.getElementById('df-dl').addEventListener('click', function () { download(compactJson, ''); });
document.getElementById('df-dl-full').addEventListener('click', function () { download(fullJson, '_complet'); });
updateAuditCard(); // remplit la fiche d'audit gratuite sur la carte
addSurelevationBlock(0); // pose le volume de surélévation (vert/gris) sur le toit
}
function setGlobalStatus(text, state) {
$globalStatus.textContent = text;
var colors = { running: '#f59e0b', ok: '#22c55e', error: '#ef4444' };
$globalStatus.style.color = colors[state] || '';
}
function buildDisplayUrl(action, params, method) {
var map = {
login: function () { return 'POST /auth/log-in'; },
parcels: function (p) { return 'GET /parcels?lat=' + p.lat + '&lon=' + p.lon; },
parcel_detail: function (p) { return 'GET /parcels/' + p.parcelId; },
parcel_coords: function (p) { return 'GET /parcels/' + p.parcelId + '/coordinates'; },
urban_zones: function (p) { return 'GET /urban_zones/zones/' + p.insee; },
parcel_units: function (p) { return 'GET /parcels/' + p.parcelId + '/units?no_adu='; },
airtable_plu: function (p) { return 'POST /airtable/plu/' + p.zoneId; },
building_rights: function (p) { return 'GET /airtable/plu/' + p.zoneId + '/building-rights'; },
town_psc: function (p) { return 'GET /town/psc/' + p.insee; },
docurba: function (p) { return 'GET /docurba/urban-zone-type/' + p.insee; },
gpu_plu_info: function (p) { return 'GET /geoportail-urbanisme/plu-info-insee/' + p.insee; },
psmv: function (p) { return 'GET /psmv/parcels/' + p.parcelId; },
districts: function (p) { return 'GET /districts/insee/' + p.insee; },
dvf_stats: function (p) { return 'GET /dvf/sales-stats/' + p.insee + '?startYear=2020&endYear=2025'; },
dvf_series: function (p) { return 'GET /dvf/sales-stats-series/' + p.insee + '?startYear=2020&endYear=2025'; },
dvf_parcels: function (p) { return 'GET /dvf/sales/parcels/' + p.parcelId; },
loyers: function (p) { return 'GET /loyers/2025/' + p.insee; },
adu_totals: function (p) { return 'GET /adu/totals/' + p.insee; },
adu_parcels: function (p) { return 'GET /adu/parcels/' + p.parcelId; },
adu_insee: function (p) { return 'GET /adu/' + p.insee; },
prevention_plans: function (p) { return 'GET /sup-surf/prevention-plans/parcels/' + p.parcelId; },
georisques: function (p) { return 'GET /georisques/address-risks?lon=' + p.lon + '&lat=' + p.lat + '&codeInsee=' + p.insee; },
analytics: function () { return 'POST /analytics/add'; },
search_count: function () { return 'POST /user/search-count'; },
};
var fn = map[action];
return fn ? fn(params || {}) : method + ' ' + action;
}
function kv(k, v) {
return '<span class="df-k">' + esc(k) + '=</span><span class="df-v">' + esc(String(v)) + '</span>';
}
function firstParcel(data) {
if (Array.isArray(data)) return data[0] || null;
if (data && Array.isArray(data.parcels)) return data.parcels[0] || null;
if (data && Array.isArray(data.data)) return data.data[0] || null;
if (data && typeof data === 'object') return data;
return null;
}
function extractUrbanZone(data) {
var arr = Array.isArray(data) ? data : (data && data.data) || [];
var first = Array.isArray(arr) ? arr[0] : arr;
if (!first) return null;
var zones = first.urban_zones || first.urbanZones || [];
return Array.isArray(zones) && zones.length ? zones[0] : null;
}
function countItems(data) {
if (data == null) return 0;
if (Array.isArray(data)) return data.length;
if (typeof data === 'object') return Object.keys(data).length;
return 1;
}
function byteSize(resp) {
try {
var src = resp.data != null ? resp.data : (resp.raw || '');
return new Blob([typeof src === 'string' ? src : JSON.stringify(src)]).size;
} catch (e) { return 0; }
}
function describeShort(data) {
if (data == null) return 'réponse vide';
if (Array.isArray(data)) return data.length + ' élément(s)';
if (typeof data === 'object') {
var keys = Object.keys(data).slice(0, 4).join(', ');
return keys ? 'clés: ' + esc(keys) : 'objet vide';
}
return esc(String(data));
}
function esc(s) {
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
}
// ============================================================================
// BATCH — test de masse (onglet Batch)
// ============================================================================
// Rejoue le flow headless (sans rendu d'étapes ni minimap) sur N adresses,
// puis liste les outputs dans un tableau paginé avec statut et téléchargements.
var $batchInput = document.getElementById('df-address'); // champ unique partagé
var $batchRun = document.getElementById('df-run'); // bouton commun
var $batchCancel = document.getElementById('batch-cancel');
var $batchCompile = document.getElementById('batch-compile');
var $batchProgress = document.getElementById('batch-progress');
var $batchTable = document.getElementById('batch-table');
var $batchPager = document.getElementById('batch-pager');
var BATCH_MAX = 100;
var BATCH_CONCURRENCY = 3;
var BATCH_PAGE_SIZE = 10;
var batchRows = []; // [{ label, insee, feature, allResults, steps, status }]
var batchPage = 1;
var batchCancelled = false;
// Appel proxy headless (pas de rendu). Renvoie la réponse JSON parsée.
async function callProxy(action, method, params, body) {
method = method || 'GET';
var qs = new URLSearchParams(Object.assign({ action: action }, params || {})).toString();
var proxyUrl = PROXY + '?' + qs;
if (forceLiveOnce || ($nocache && $nocache.checked)) proxyUrl += '&nocache=1';
try {
var fetchOpts = { method: method, headers: { 'X-Admin-Key': PROXY_KEY } };
if (method === 'POST') {
fetchOpts.headers['Content-Type'] = 'application/json';
fetchOpts.body = body != null ? JSON.stringify(body) : '';
}
var res = await timedFetch(proxyUrl, fetchOpts, 30000);
var text = await res.text();
try { return JSON.parse(text); }
catch (e) { return { ok: false, status: res.status, action: action, error: 'Réponse proxy non-JSON', raw: text }; }
} catch (err) {
var msg = err.name === 'AbortError' ? 'Timeout (30s)' : err.message;
return { ok: false, status: 0, action: action, error: 'Erreur réseau : ' + msg, raw: '' };
}
}
// Géocode une adresse texte via la BAN. Renvoie la 1re feature ou null.
async function geocodeOne(query) {
try {
var res = await timedFetch(GEOCODE_URL + '?q=' + encodeURIComponent(query) + '&limit=1', {}, 15000);
if (!res.ok) return null;
var json = await res.json();
return (json.features && json.features[0]) || null;
} catch (e) { return null; }
}
// Rejoue le flow complet sur une adresse, sans DOM. Renvoie { allResults, steps }.
async function runAddressHeadless(sel) {
var allResults = {};
var steps = [];
allResults.step_1_geocode = { ok: true, status: 200, action: 'geocode', data: sel._feature };
var ctx = { lat: sel.lat, lon: sel.lon, insee: sel.insee, commune: sel.commune, label: sel.label };
var login = await callProxy('login', 'GET', {}, null);
allResults.step_2_login = login;
if (login && login.ok && LOGIN_STEP.extract) { try { LOGIN_STEP.extract(login.data, ctx); } catch (e) { /* ignore */ } }
steps.push({ num: 2, label: LOGIN_STEP.label, ok: !!(login && login.ok), error: login && !login.ok ? (login.error || 'HTTP ' + login.status) : null });
for (var i = 0; i < STEPS.length; i++) {
if (batchCancelled) break;
var def = STEPS[i];
if (def.requires && !ctx[def.requires]) {
allResults['step_' + def.num] = { skipped: true, reason: def.requires };
steps.push({ num: def.num, label: def.label, skipped: true, reason: def.requires });
continue;
}
var swappedB = applyNationalSwap(def, def.build(ctx), ctx);
var spec = swappedB.spec;
var resp = await callProxy(spec.action, spec.method || 'GET', spec.params || {}, spec.body !== undefined ? spec.body : null);
allResults['step_' + def.num + '_' + spec.action] = resp;
if (resp && resp.ok && swappedB.extract) { try { swappedB.extract(resp.data, ctx); } catch (e) { /* ignore */ } }
steps.push({ num: def.num, label: def.label, ok: !!(resp && resp.ok), error: resp && !resp.ok ? (resp.error || 'HTTP ' + resp.status) : null });
}
// Immeubles mitoyens (headless, réutilise fetchNeighborUnit).
var neighbors = Array.isArray(ctx.neighbors) ? ctx.neighbors : [];
var mitoyens = [];
for (var n = 0; n < neighbors.length && !batchCancelled; n++) {
mitoyens.push(await fetchNeighborUnit(neighbors[n]));
}
allResults.step_25b_mitoyens = { ok: true, action: 'mitoyens', data: mitoyens };
return { allResults: allResults, steps: steps };
}
// Statut : rouge si étape critique KO, orange si erreurs/ignorés, vert sinon.
// Copie inline de src/lib/batch.mjs (testé) — garder les deux synchronisées.
function batchStatusOf(allResults, steps) {
var errors = steps.filter(function (s) { return s.error; }).map(function (s) { return 'STEP ' + s.num + ' ' + s.label + ' : ' + s.error; });
var skips = steps.filter(function (s) { return s.skipped; }).map(function (s) { return 'STEP ' + s.num + ' ' + s.label + ' : ignoré (' + s.reason + ')'; });
var loginOk = allResults.step_2_login && allResults.step_2_login.ok;
var parcelsOk = allResults.step_3_parcels && allResults.step_3_parcels.ok;
var color = 'green';
if (!loginOk || !parcelsOk) color = 'red';
else if (errors.length || skips.length) color = 'orange';
var lines = errors.concat(skips);
return { color: color, title: lines.length ? lines.join('\n') : 'Toutes les étapes OK', errors: errors.length, skips: skips.length };
}
function downloadJson(obj, filename) {
var blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
function batchSlug(row, idx) {
return (row.insee || 'adr') + '_' + (idx + 1);
}
// Fenêtre de pagination : max 5 numéros, avec « … » (ex. 1 2 3 … 9 10).
// Copie inline de src/lib/batch.mjs (testé) — garder les deux synchronisées.
function pagerTokens(cur, totalPages) {
if (totalPages <= 7) {
var all = [];
for (var p = 1; p <= totalPages; p++) all.push(p);
return all;
}
var set = {};
[1, totalPages, cur, cur - 1, cur + 1].forEach(function (p) { if (p >= 1 && p <= totalPages) set[p] = true; });
// Complète pour toujours montrer ~5 numéros.
if (cur <= 3) { [2, 3, 4].forEach(function (p) { set[p] = true; }); }
if (cur >= totalPages - 2) { [totalPages - 1, totalPages - 2, totalPages - 3].forEach(function (p) { if (p >= 1) set[p] = true; }); }
var nums = Object.keys(set).map(Number).sort(function (a, b) { return a - b; });
var tokens = [];
for (var k = 0; k < nums.length; k++) {
if (k > 0 && nums[k] - nums[k - 1] > 1) tokens.push('…');
tokens.push(nums[k]);
}
return tokens;
}
function renderBatchTable() {
if (!$batchTable) return;
if (!batchRows.length) { $batchTable.innerHTML = ''; if ($batchPager) $batchPager.innerHTML = ''; return; }
var totalPages = Math.max(1, Math.ceil(batchRows.length / BATCH_PAGE_SIZE));
if (batchPage > totalPages) batchPage = totalPages;
var start = (batchPage - 1) * BATCH_PAGE_SIZE;
var pageRows = batchRows.slice(start, start + BATCH_PAGE_SIZE);
var head = '<table class="df-batch-table"><thead><tr>' +
'<th>#</th><th>Adresse</th><th>Statut</th><th>Téléchargements</th></tr></thead><tbody>';
var body = pageRows.map(function (row, j) {
var idx = start + j;
var st = row.status;
var statusCell = row.pending
? '<span class="df-bstat df-bstat-run" title="En cours…"></span>'
: '<span class="df-bstat df-bstat-' + st.color + '" title="' + esc(st.title) + '">' +
(st.color === 'green' ? '✓' : st.color === 'orange' ? '!' : '✗') + '</span>';
var dl = row.allResults
? '<button class="df-bdl" data-act="complet" data-idx="' + idx + '">Complet</button>' +
'<button class="df-bdl" data-act="simple" data-idx="' + idx + '">Simple</button>' +
'<button class="df-bdl" data-act="surelevation" data-idx="' + idx + '">Surélévation</button>'
: '<span class="text-muted">—</span>';
return '<tr><td>' + (idx + 1) + '</td>' +
'<td>' + esc(row.label) + (row.error ? ' <span class="df-bstat df-bstat-red" title="' + esc(row.error) + '">✗</span>' : '') + '</td>' +
'<td>' + statusCell + '</td>' +
'<td class="df-batch-dl">' + dl + '</td></tr>';
}).join('');
$batchTable.innerHTML = head + body + '</tbody></table>';
$batchTable.querySelectorAll('.df-bdl').forEach(function (b) {
b.addEventListener('click', function () {
var row = batchRows[parseInt(b.dataset.idx, 10)];
if (!row || !row.allResults) return;
var slug = batchSlug(row, parseInt(b.dataset.idx, 10));
if (b.dataset.act === 'complet') downloadJson(row.allResults, 'dataflow_' + slug + '_complet.json');
else if (b.dataset.act === 'simple') downloadJson(buildCompactOutput(row.allResults), 'dataflow_' + slug + '_simple.json');
else downloadJson(buildSurelevationReport(row.allResults), 'dataflow_' + slug + '_surelevation.json');
});
});
renderBatchPager(totalPages);
}
function renderBatchPager(totalPages) {
if (!$batchPager) return;
if (totalPages <= 1) { $batchPager.innerHTML = ''; return; }
var btn = function (label, page, disabled, active) {
return '<button class="df-page' + (active ? ' active' : '') + '"' +
(disabled ? ' disabled' : ' data-page="' + page + '"') + '>' + label + '</button>';
};
var html = btn('«', 1, batchPage === 1) + btn('‹', batchPage - 1, batchPage === 1);
pagerTokens(batchPage, totalPages).forEach(function (t) {
html += t === '…' ? '<span class="df-page-ell">…</span>' : btn(String(t), t, false, t === batchPage);
});
html += btn('›', batchPage + 1, batchPage === totalPages) + btn('»', totalPages, batchPage === totalPages);
$batchPager.innerHTML = html;
$batchPager.querySelectorAll('.df-page[data-page]').forEach(function (b) {
b.addEventListener('click', function () { batchPage = parseInt(b.dataset.page, 10); renderBatchTable(); });
});
}
async function runBatch() {
if (!$batchInput) return;
var addresses = $batchInput.value.split('\n').map(function (s) { return s.trim(); }).filter(Boolean).slice(0, BATCH_MAX);
if (!addresses.length) { $batchProgress.textContent = 'Aucune adresse.'; return; }
await fetchSources(); // switches nationaux à jour pour le rejeu headless
batchRows = addresses.map(function (a) { return { label: a, pending: true, allResults: null }; });
batchPage = 1;
batchCancelled = false;
$batchRun.disabled = true;
$batchCancel.hidden = false;
$batchCompile.hidden = true;
renderBatchTable();
var done = 0;
var updateProgress = function () { $batchProgress.textContent = done + ' / ' + batchRows.length + ' traitée(s)'; };
updateProgress();
// Pool de concurrence : BATCH_CONCURRENCY adresses en parallèle.
var next = 0;
async function worker() {
while (!batchCancelled) {
var idx = next++;
if (idx >= batchRows.length) return;
var row = batchRows[idx];
var feat = await geocodeOne(row.label);
if (!feat || !feat.geometry) {
row.pending = false;
row.error = 'Adresse introuvable (géocodage)';
row.status = { color: 'red', title: row.error };
} else {
var p = feat.properties || {};
var coords = feat.geometry.coordinates;
var sel = { lat: coords[1], lon: coords[0], insee: p.citycode || '', commune: p.city || '', label: p.label || row.label, _feature: feat };
row.label = sel.label;
row.insee = sel.insee;
var out = await runAddressHeadless(sel);
row.allResults = out.allResults;
row.steps = out.steps;
row.status = batchStatusOf(out.allResults, out.steps);
row.pending = false;
}
done++;
updateProgress();
renderBatchTable();
}
}
var pool = [];
for (var w = 0; w < Math.min(BATCH_CONCURRENCY, batchRows.length); w++) pool.push(worker());
await Promise.all(pool);
$batchRun.disabled = false;
$batchCancel.hidden = true;
$batchCompile.hidden = batchRows.every(function (r) { return !r.allResults; });
$batchProgress.textContent = done + ' / ' + batchRows.length + ' traitée(s)' + (batchCancelled ? ' — arrêté' : ' — terminé');
}
function compileSimples() {
var out = batchRows.filter(function (r) { return r.allResults; }).map(function (r, i) {
return { adresse: r.label, insee: r.insee || null, statut: r.status ? r.status.color : null, output: buildCompactOutput(r.allResults) };
});
downloadJson({ generatedAt: new Date().toISOString(), count: out.length, adresses: out }, 'batch_compilation_simple.json');
}
// Le lancement passe par runFlowOrBatch (bouton commun #df-run) : pas de
// listener direct ici, sinon double déclenchement.
if ($batchCancel) $batchCancel.addEventListener('click', function () { batchCancelled = true; });
if ($batchCompile) $batchCompile.addEventListener('click', compileSimples);
// ---- Gestion des clés API tierces (onglet POC) ------------------------------
// Formulaire de saisie masquée. Les valeurs partent au serveur (stockage
// persistant) et ne reviennent jamais en clair : seul un aperçu masqué est
// affiché.
var $keysList = document.getElementById('keys-list');
function keyStatusHtml(k) {
return k.set
? '<span class="adm-key-set">✓ Enregistrée (' + esc(k.hint) + ')</span>'
: '<span class="adm-key-unset">Non renseignée</span>';
}
function renderKeys() {
if (!$keysList) return;
timedFetch(PROXY + '?action=keys_get', { headers: { 'X-Admin-Key': PROXY_KEY } }, 8000)
.then(function (res) { return res.json(); })
.then(function (j) {
if (!j || !j.ok || !Array.isArray(j.keys)) throw new Error('réponse invalide');
$keysList.innerHTML = j.keys.map(function (k) {
return '<div class="adm-key-row" data-name="' + esc(k.name) + '">' +
'<label>' + esc(k.label) + '</label>' +
'<div class="adm-key-controls">' +
'<input type="password" autocomplete="off" spellcheck="false" ' +
'placeholder="' + (k.set ? 'Nouvelle valeur pour remplacer…' : 'Coller la clé…') + '" />' +
'<button type="button" class="adm-key-save">Enregistrer</button>' +
'</div>' +
'<div class="adm-key-status">' + keyStatusHtml(k) + '</div>' +
'</div>';
}).join('');
$keysList.querySelectorAll('.adm-key-row').forEach(function (row) {
row.querySelector('.adm-key-save').addEventListener('click', function () {
saveKey(row);
});
});
})
.catch(function () {
$keysList.innerHTML = '<p class="text-muted text-sm">Impossible de charger les clés (serveur injoignable).</p>';
});
}
function saveKey(row) {
var name = row.getAttribute('data-name');
var input = row.querySelector('input');
var status = row.querySelector('.adm-key-status');
var btn = row.querySelector('.adm-key-save');
var value = input.value;
btn.disabled = true;
btn.textContent = '…';
timedFetch(PROXY + '?action=keys_set', {
method: 'POST',
headers: { 'X-Admin-Key': PROXY_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name, value: value }),
}, 8000)
.then(function (res) { return res.json(); })
.then(function (j) {
if (!j || !j.ok) throw new Error('échec');
input.value = '';
status.innerHTML = keyStatusHtml(j);
btn.textContent = value === '' ? 'Supprimée' : 'Enregistrée !';
setTimeout(function () { btn.textContent = 'Enregistrer'; btn.disabled = false; }, 1500);
})
.catch(function () {
btn.textContent = 'Erreur';
setTimeout(function () { btn.textContent = 'Enregistrer'; btn.disabled = false; }, 1500);
});
}
if ($keysList) renderKeys();
// ---- Sources de données : switches iudo -> APIs nationales (onglet API) -----
var $sourcesList = document.getElementById('sources-list');
function renderSources() {
if (!$sourcesList) return;
timedFetch(PROXY + '?action=sources_get', { headers: { 'X-Admin-Key': PROXY_KEY } }, 8000)
.then(function (res) { return res.json(); })
.then(function (j) {
if (!j || !j.ok || !Array.isArray(j.sources)) throw new Error('réponse invalide');
$sourcesList.innerHTML = j.sources.map(function (s) {
return '<div class="adm-key-row" data-name="' + esc(s.name) + '">' +
'<label style="display:flex;align-items:center;gap:10px;cursor:pointer">' +
'<input type="checkbox" class="adm-source-toggle" style="width:18px;height:18px;accent-color:#16a34a"' + (s.enabled ? ' checked' : '') + ' />' +
'<span>' + esc(s.label) + '</span>' +
'</label>' +
'<div class="adm-key-status">' + (s.enabled
? '<span class="adm-key-set">✓ API nationale active</span>'
: '<span class="adm-key-unset">iudo (défaut)</span>') + '</div>' +
'</div>';
}).join('');
$sourcesList.querySelectorAll('.adm-key-row').forEach(function (row) {
row.querySelector('.adm-source-toggle').addEventListener('change', function (ev) {
saveSource(row, !!ev.target.checked);
});
});
})
.catch(function () {
$sourcesList.innerHTML = '<p class="text-muted text-sm">Impossible de charger les sources (serveur injoignable).</p>';
});
}
function saveSource(row, enabled) {
var name = row.getAttribute('data-name');
var status = row.querySelector('.adm-key-status');
timedFetch(PROXY + '?action=sources_set', {
method: 'POST',
headers: { 'X-Admin-Key': PROXY_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name, enabled: enabled }),
}, 8000)
.then(function (res) { return res.json(); })
.then(function (j) {
if (!j || !j.ok) throw new Error('échec');
nationalSources[name] = !!j.enabled;
status.innerHTML = j.enabled
? '<span class="adm-key-set">✓ API nationale active</span>'
: '<span class="adm-key-unset">iudo (défaut)</span>';
})
.catch(function () {
status.innerHTML = '<span class="adm-key-unset">Erreur d\'enregistrement — réessayer</span>';
renderSources();
});
}
if ($sourcesList) renderSources();
});