PageSourceSearch

https://mezieres-lez-clery.fr/js/mat-mel.js?v=4.5.3

js mezieres-lez-clery.fr collected 2026-10-03 00:41:48 UTC 91,610 bytes, 1,455 lines download raw bytes

1/* ════════════════════════════════════════════════════════════
2   MAT — MEL Assistante IA v3.7.2 (Phase 3)
3   ════════════════════════════════════════════════════════════
4   Contenu :
5   - _PLU_DATA_FALLBACK  : base PLU embarquée (résilience offline 1er lancement)
6   - _MEL_TREE_FALLBACK  : arbre de décision embarqué
7   - PLU_DATA, MEL_TREE  : variables mutables, initialisées au fallback,
8                            puis écrasées par les JSON externes dès qu'ils
9                            arrivent via loadMelData() (arrière-plan).
10   - loadMelData()       : fetch non-bloquant de data/plu-data.json et
11                            data/mel-tree.json — appelé depuis mat-init.js.
12   - PLU helpers : pluNormalizeZone, pluGetZone, pluRenderAuth, pluRenderZoneCard, pluBuildAnswer
13   - MEL UI     : melShowTree, melSelectCat, melSelectQuestion, _showPluAnswer, pluCloseAnswer, pluSwitchTab
14   - Direct     : _renderDirectAnswer, _showDirectAnswer, melOpenChatFromDirect
15   - Chat       : melOpenChat, melResetTree, addMsg, showTyping, sendMel, MEL_THEME_SUGGESTIONS
16   - Zone PLU   : melFindZoneByAddr, melFindZoneByGPS, _fetchZonePLU, _showManualZoneSelector, _setZoneResult
17
18   ── PHASE 3 : externalisation des données ──
19   Les données PLU et MEL_TREE vivent désormais dans :
20     - data/plu-data.json
21     - data/mel-tree.json
22   Pour modifier une zone, une question ou une réponse, éditez le JSON
23   sur GitHub directement — AUCUN changement de code nécessaire.
24   Le fallback embarqué ci-dessous garantit que MEL fonctionne même si
25   les fichiers JSON ne sont pas accessibles (offline, 404, etc.).
26   Copyright (c) 2024-2026 Commune de Mézières-lez-Cléry — Licence MIT
27   ════════════════════════════════════════════════════════════ */
28
29// ════════════════════════════════════════════════════════
30// PLU — Fallback embarqué (extrait du règlement 4.1)
31// ⚠️ Ne pas modifier ce bloc : éditez data/plu-data.json à la place
32// ════════════════════════════════════════════════════════
33const _PLU_DATA_FALLBACK = {
34  zones: {
35    Ua: {
36      label: "Ua – Centre-bourg / hameaux anciens",
37      description: "Secteurs bâtis les plus anciens du bourg. Vocation principale : habitat.",
38      interdictions: ["Industrie","Agriculture","Carrières","Caravanes/camping","ICPE soumises à autorisation"],
39      emprise: "50% max", espaces_verts: "non réglementé",
40      hauteur_principale: "6 m max à l'égout du toit", hauteur_annexe: "non limitée spécifiquement",
41      recul_voie_principale: "à l'alignement ou ≥ 2 m (si clôture assure la continuité)",
42      recul_voie_annexe: "à l'alignement ou ≥ 3 m", recul_voie_abri: "≥ 3 m",
43      recul_sep: "en contiguïté ou ≥ 3 m",
44      toiture_principale: "≥ 2 pentes ≥ 35°, ardoises ou tuiles plates",
45      toiture_annexe_sup30: "≥ 1 pente ≥ 25°, ardoises ou tuiles plates",
46      toiture_annexe_inf30: "pente ≥ 25°, teinte ardoise ou brun rouge",
47      cloture_voie: "mur plein ou claire-voie, max 1,50 m", cloture_sep: "mur plein ou grillage/treillage + haie, max 1,80 m",
48      cloture_carrefour: "max 1,20 m sur 20 m", cloture_dp: true,
49      abri_materiaux: "pierre, brique, parpaing enduit ou bois",
50      piscine: "✅ Autorisée (emprise totale ≤ 50%)", stationnement: "2 places min/logement",
51      rdc_max: "≤ 0,50 m / sol naturel"
52    },
53    Ub: {
54      label: "Ub – Habitat résidentiel récent",
55      description: "Secteur bâti à dominante d'habitat construit principalement dans la 2e moitié du XXe siècle.",
56      interdictions: ["Industrie","Agriculture/sylviculture","Dépôts à l'air libre","Carrières","Caravane
56s/camping"],
57      emprise: "30% max", espaces_verts: "30% min (libre de construction et imperméabilisation)",
58      hauteur_principale: "4 m max à l'égout du toit", hauteur_annexe: "2,5 m max",
59      recul_voie_principale: "≥ 5 m", recul_voie_annexe: "à l'alignement ou ≥ 5 m", recul_voie_abri: "≥ 3 m",
60      recul_sep: "en contiguïté ou ≥ 3 m",
61      toiture_principale: "≥ 2 pentes ≥ 35°, ardoises ou tuiles plates",
62      toiture_annexe_sup30: "≥ 1 pente ≥ 25°, ardoises ou tuiles plates",
63      toiture_annexe_inf30: "pente ≥ 25°, teinte ardoise ou brun rouge",
64      cloture_voie: "mur à claire-voie UNIQUEMENT, max 1,50 m", cloture_sep: "mur plein pierre/maçonnerie ou grillage/treillage + haie, max 1,80 m",
65      cloture_carrefour: "max 1,20 m sur 20 m", cloture_dp: true,
66      abri_materiaux: "pierre, brique, parpaing enduit ou bois — tôle ondulée INTERDITE",
67      piscine: "✅ Autorisée (emprise ≤ 30%, espaces verts ≥ 30%)", stationnement: "2 places min/logement",
68      rdc_max: "≤ 0,30 m / sol naturel",
69      facades: "palettes traditionnelles — blanc pur et couleurs vives INTERDITS"
70    },
71    Ub1: {
72      label: "Ub1 – Clos de Manthelon (prescriptions spécifiques)",
73      description: "Extension du lotissement Clos de Manthelon. Mêmes règles que Ub + prescriptions spécifiques ci-dessous.",
74      interdictions: ["Terrasses en toiture","Toits à un seul versant (sauf annexes)","Toits à 4 pans","Tuiles couleur ardoise","Tôle ondulée","Amiante-ciment","Plaques ciment en clôture"],
75      emprise: "30% max + implantation dans zones constructibles du plan parcellaire",
76      espaces_verts: "30% min + 1 arbre haute tige / 250 m²",
77      hauteur_principale: "4 m max (égout) ET 8 m max absolu", hauteur_annexe: "2,5 m max",
78      recul_voie_principale: "≥ 5 m", recul_voie_abri: "≥ 5 m", recul_sep: "≥ 3 m",
79      toiture_principale: "2 pentes entre 40° et 45°, tuiles plates TERRE CUITE uniquement, min 22 tuiles/m², faîtage selon plan parcellaire",
80      cloture_voie: "haie végétale ≥ 3 variétés + grillage vert max 1,50 m (retrait 0,80 m de la limite)",
81      cloture_sep: "grillage vert max 1,50 m + haie max 1,80 m — plaques ciment INTERDITES",
82      cloture_portail: "retrait 5 m espaces communs, hauteur max 1,50 m, piliers max 1,80 m", cloture_dp: true,
83      niveaux: "RDC seul ou RDC + combles UNIQUEMENT",
84      piscine: "✅ Autorisée (selon zones constructibles du plan parcellaire)"
85    },
86    Ue: {
87      label: "Ue – Équipements publics",
88      description: "Zone réservée aux équipements publics.",
89      interdictions: ["Industrie","Artisanat","Agriculture","Dépôts","Caravanes","Carrières"],
90      emprise: "non réglementée", hauteur_principale: "7 m max",
91      recul_voie_principale: "à l'alignement ou ≥ 2 m", recul_sep: "en contiguïté ou ≥ 3 m",
92      piscine: "❌ Zone réservée aux équipements publics — consulter mairie"
93    },
94    Ui: {
95      label: "Ui – Zone industrielle",
96      interdictions: ["Hébergement hôtelier","Agriculture","Caravanes","Carrières"],
97      emprise: "60% max", hauteur_principale: "8 m max",
98      recul_voie_principale: "à l'alignement ou ≥ 5 m", recul_sep: "≥ moitié de la hauteur, min 3 m",
99      cloture_voie: "grillage couleur sombre, max 1,50 m", cloture_sep: "grillage couleur sombre, max 1,80 m", cloture_dp: true,
100      piscine: "❌ Zone industrielle — non destinée à cet usage"
101    },
102    "1AU": {
103      label: "1AU – Zone à urbaniser (court terme)",
104      description: "Construction uniquement dans le cadre d'une opération d'aménagement d'ensemble.",
105      interdictions: ["Constructions isolées hors opération d'ensemble","Industrie","Agriculture","Dépôts","Caravanes"],
106      emprise: "40% max", espaces_verts: "30% min",
107      hauteur_principale: "5 m max", hauteur_annexe: "2,5 m max",
108      recul_voie_principale: "≥ 5 m", recul_voie_annexe: "≥ 5 m", recul_sep: "en contiguïté ou ≥ 3 m",
109      cloture_voie: "non obligatoire — si clôture : grillage sombre max 1,20 m + haie",
110      cloture_sep: "non obligatoire — si clôture : grillage sombre max 1,50 m + haie", cloture_dp: true,
111      abri_materiaux: "matériaux traditionnels (pierre, brique, parpaing enduit) et bois",
112      piscine: "✅ Autorisée (emprise ≤ 40%)", stationnement: "2 places min/logement",
113      alerte: "⚠️ Toute construction doit s'inscrire dans une opération d'aménagement d'ensemble (lotissement, ZAC…)."
114    },
115    "2AU": {
116      label: "2AU – Réserve foncière (long terme)",
117      description: "Zone inconstructible. Urbanisation uniquement après révision du PLU.",
118      interdictions: ["TOUTE construction privée"],
119      piscine: "❌ INTERDITE — zone inconstructible",
120      alerte: "🚫 Cette zone est inconstructible. Seuls les équipements publics peuvent être autorisés après révision du PLU."
121    },
122    A: {
123      label: "A – Zone agricole",
124      description: "Seules les constructions liées à l'exploitation agricole sont autorisées.",
125      interdictions: ["Constructions non agricoles","Habitations (sauf logement de fonction)","Grillages/piquets fer/poteaux béton"],
126      hauteur_principale: "10 m max hors tout (exploitation) / 5 m max (habitation) / 2,5 m (abris animaux)",
127      recul_voie_principale: "≥ 8 m (100 m de l'axe A71)", recul_sep: "en contiguïté ou ≥ 3 m",
128      cloture_voie: "pieux de bois + 3 fils métalliques, max 1,20 m",
129      cloture_sep: "pieux de bois + 3 fils, max 1,20 m (1,80 m si élevage déclaré)",
130      piscine: "❌ INTERDITE en zone A",
131      alerte: "⚠️ Zone strictement agricole. Constructions non agricoles interdites. Voir secteur Ah pour les hameaux."
132    },
133    Ah: {
134      label: "Ah – Hameaux agricoles",
135      description: "Hameaux et écarts non agricoles en zone A. Extensions limitées.",
136      emprise: "Extensions ≤ 20% de la SP existante / Annexes ≤ 50 m²",
137      espaces_verts: "30% min", hauteur_principale: "5 m max", hauteur_annexe: "2,5 m max",
138      recul_voie_principale: "à l'alignement ou ≥ 5 m", recul_sep: "en contiguïté ou ≥ 3 m",
139      toiture_principale: "≥ 2 pentes ≥ 35°, ardoises ou tuiles plates",
140      piscine: "⚠️ Possible si dans le cadre de l'extension autorisée (≤ 20% SP existante)",
141      changement_destination: "Autorisé vers habitat, bureaux, commerce, artisanat, tourisme, loisirs",
142      alerte: "Seules extensions (+20% max) et annexes (≤ 50 m²) autorisées. Pas de nouvelle construction principale."
143    },
144    N: {
145      label: "N – Zone naturelle et forestière",
146      interdictions: ["Industrie","Artisanat","Bureaux","Entrepôts","Dépôts","Camping","Tout changement dans les EBC"],
147      piscine: "❌ INTERDITE en zone N",
148      alerte: "🌲 Zone naturelle protégée. Seules constructions agricoles/forestières et d'intérêt public autorisées."
149    },
150    Nh: {
151      label: "Nh – Hameaux naturels",
152      emprise: "Extensions ≤ 20% de la SP existante / Annexes ≤ 50 m²",
153      espaces_verts: "30% min", hauteur_principale: "5 m max", hauteur_annexe: "2,5 m max",
154      recul_voie_principale: "≥ 5 m", recul_sep: "en contiguïté ou ≥ 3 m",
155      cloture_voie: "pieux de bois + 3 fils, max 1,20 m",
156      toiture_principale: "≥ 2 pentes ≥ 35°, ardoises ou tuiles d'aspect plat",
157      piscine: "❌ INTERDITE — pas de nouvelle construction principale",
158      changement_destination: "Autorisé vers habitat, tourisme, loisirs uniquement",
159      alerte: "Seules extensions (+20% max) et annexes (≤ 50 m²) autorisées. Pas de nouvelle construction principale."
160    },
161    Nj: {
162      label: "Nj – Jardins naturels",
163      emprise: "≤ 20 m²", hauteur_principale: "2,5 m max",
164      recul_voie_principale: "≥ 10 m", recul_sep: "en contiguïté ou ≥ 3 m",
165      piscine: "✅ Autorisée explicitement avec local technique dédié",
166      alerte: "Seuls abris de jardin (≤ 20 m²), piscines et locaux techniques autorisés."
167    },
168    Nl: {
169      label: "Nl – Loisirs naturels",
170      piscine: "❌ Zone publique collective — projets privés non admis",
171      alerte: "Seuls aménagements de loisirs à vocation publique et collective autorisés."
172    }
173  },
174
175  autorisations: {
176    permis: {
177      label: "Permis de construire / Déclaration préalable",
178      tableau: [
179        {cas:"Extension accolée < 40 m² (SP totale ≤ 150 m²)", auth:"Déclaration Préalable (DP)"},
180        {cas:"Extension accolée ≥ 40 m² ou SP totale >
180 150 m²", auth:"Permis de Construire (PC)"},
181        {cas:"Garage/annexe non accolé < 20 m²", auth:"Déclaration Préalable (DP)"},
182        {cas:"Garage/annexe non accolé ≥ 20 m²", auth:"Permis de Construire (PC)"},
183        {cas:"Construction neuve", auth:"Permis de Construire (PC) — architecte si > 150 m² SP"},
184      ],
185      notes: ["Délai DP : 1 mois | PC : 2 mois","Validité : 3 ans","Dépôt : mairie ou GNAU CCTVL","Architecte obligatoire si SP > 150 m²"]
186    },
187    piscine: {
188      tableau: [
189        {cas:"< 10 m², non couverte, restant < 3 mois", auth:"Aucune formalité"},
190        {cas:"< 100 m², non couverte", auth:"Déclaration Préalable (DP)"},
191        {cas:"< 100 m², couverte < 1,80 m de hauteur", auth:"Déclaration Préalable (DP)"},
192        {cas:"> 100 m²", auth:"Permis de Construire (PC)"},
193      ]
194    },
195    cloture: {
196      tableau: [
197        {cas:"Tout type de clôture, mur, portail", auth:"Déclaration Préalable obligatoire (délibération CM 01/03/2012)"}
198      ]
199    },
200    abri: {
201      tableau: [
202        {cas:"< 5 m²", auth:"Aucune formalité"},
203        {cas:"5 à 20 m²", auth:"Déclaration Préalable (DP)"},
204        {cas:"> 20 m²", auth:"Permis de Construire (PC)"},
205      ]
206    },
207    toiture: {
208      tableau: [
209        {cas:"Remplacement à l'identique (même matériau, même couleur)", auth:"Aucune formalité"},
210        {cas:"Changement de matériau ou de couleur", auth:"Déclaration Préalable (DP)"},
211        {cas:"Velux / fenêtre de toit", auth:"Déclaration Préalable (DP)"},
212        {cas:"Lucarne (ajout ou modification)", auth:"Déclaration Préalable (DP)"},
213      ]
214    },
215    extension: {
216      tableau: [
217        {cas:"Extension accolée < 20 m² (si aspect modifié)", auth:"Déclaration Préalable (DP)"},
218        {cas:"Extension 20 à 40 m² (zone urbaine, SP ≤ 150 m²)", auth:"Déclaration Préalable (DP)"},
219        {cas:"Extension > 40 m² ou SP totale > 150 m²", auth:"Permis de Construire (PC)"},
220      ]
221    },
222    panneaux_solaires: {
223      tableau: [{cas:"Panneaux photovoltaïques en toiture", auth:"Déclaration Préalable (DP)"}],
224      note: "Les ouvrages ENR sont exclus du calcul de hauteur maximale dans toutes les zones."
225    }
226  }
227};
228
229// ════════════════════════════════════════════════════════
230// PLU — Helpers : normalisation, accès, rendu
231// ════════════════════════════════════════════════════════
232
233function pluNormalizeZone(raw) {
234  // L'IGN peut retourner "1AU1", "1AUe", "Ub1" etc.
235  // On cherche d'abord exact, puis avec troncature progressive
236  if (!raw) return null;
237  const z = String(raw).trim();
238  if (PLU_DATA.zones[z]) return z;
239  // Essayer sans suffixe numérique final
240  const base = z.replace(/\d+$/, '');
241  if (PLU_DATA.zones[base]) return base;
242  // Essayer majuscule
243  const up = z.toUpperCase();
244  for (const k of Object.keys(PLU_DATA.zones)) {
245    if (k.toUpperCase() === up) return k;
246  }
247  // Chercher une clé qui commence pareil
248  for (const k of Object.keys(PLU_DATA.zones)) {
249    if (z.startsWith(k) || k.startsWith(z)) return k;
250  }
251  return null;
252}
253
254function pluGetZone(zoneKey) {
255  const normalized = pluNormalizeZone(zoneKey);
256  return normalized ? PLU_DATA.zones[normalized] : null;
257}
258
259function pluAuthLink(auth) {
260  // Retourne un lien cliquable si DP ou PC reconnu.
261  //
262  // ⚠️ Les cerfa d'urbanisme changent de NUMÉRO, pas seulement de millésime.
263  // Au 1er janvier 2025, les cerfa 13703 (DP maison individuelle), 13702
264  // (lotissement) et 13404 (DP constructions et travaux) ont été ABROGÉS et
265  // remplacés par le 16702 (constructions et travaux) et le 16703
266  // (aménagements). La fiche R11646, qui portait le 13703, n'a pas déménagé :
267  // elle a été supprimée, et répondait 404 (issue #400). La référence courante
268  // de la DP côté particuliers est R2028. Ne jamais figer un millésime
269  // (« *02 ») dans une URL ni dans un texte : seul le numéro à 5 chiffres est
270  // stable. Voir docs/adr/0029.
271  if (/Déclaration Préalable|\bDP\b/.test(auth)) {
272    return auth + ' <a href="https://www.service-public.gouv.fr/particuliers/vosdroits/R2028" target="_blank" class="plu-form-link">📄 Cerfa DP</a>';
273  }
274  if (/Permis de Construire|\bPC\b/.test(auth)) {
275    return auth + ' <a href="https://www.service-public.gouv.fr/particuliers/vosdroits/R11637" target="_blank" class="plu-form-link">📄 Cerfa PC</a>';
276  }
277  return auth;
278}
279
280function pluRenderAuth(authKey) {
281  const a = PLU_DATA.autorisations[authKey];
282  if (!a) return '';
283  let h = '<div class="plu-auth"><div class="plu-auth-title">📋 Autorisation requise</div><table class="plu-table">';
284  for (const row of (a.tableau||[])) {
285    h += `<tr><td>${row.cas}</td><td class="plu-auth-val">${pluAuthLink(row.auth)}</td></tr>`;
286  }
287  h += '</table>';
288  if (a.notes) h += '<ul class="plu-notes">'+a.notes.map(n=>`<li>${n}</li>`).join('')+'</ul>';
289  if (a.note) h += '<p class="plu-note">ℹ️ '+a.note+'</p>';
290  h += '</div>';
291  return h;
292}
293
294function pluRenderZoneCard(zone) {
295  if (!zone) return '';
296  let h = '';
297  if (zone.alerte) h += `<div class="plu-alerte">${zone.alerte}</div>`;
298  const rows = [
299    ['Emprise au sol max', zone.emprise],
300    ['Espaces verts min', zone.espaces_verts],
301    ['Hauteur construction principale', zone.hauteur_principale],
302    ['Hauteur annexes', zone.hauteur_annexe],
303    ['Recul par rapport à la voie', zone.recul_voie_principale],
304    ['Recul voie (annexes)', zone.recul_voie_annexe],
305    ['Recul voie (abri jardin)', zone.recul_voie_abri],
306    ['Recul limites séparatives', zone.recul_sep],
307    ['Toiture principale', zone.toiture_principale],
308    ['Toiture annexe > 30 m²', zone.toiture_annexe_sup30],
309    ['Toiture annexe < 30 m²', zone.toiture_annexe_inf30],
310    ['Clôture sur voie', zone.cloture_voie],
311    ['Clôture limite séparative', zone.cloture_sep],
312    ['Carrefour', zone.cloture_carrefour],
313    ['DP clôture obligatoire', zone.cloture_dp ? 'OUI — Déclaration Préalable obligatoire' : null],
314    ['Portail (Ub1)', zone.cloture_portail],
315    ['Abri jardin — matériaux', zone.abri_materiaux],
316    ['Piscine', zone.piscine],
317    ['Façades', zone.facades],
318    ['Niveaux autorisés', zone.niveaux],
319    ['Stationnement', zone.stationnement],
320    ['Changement de destination', zone.changement_destination],
321    ['Plancher RDC / sol', zone.rdc_max],
322  ].filter(([,v]) => v);
323  if (rows.length) {
324    h += '<table class="plu-table">';
325    for (const [label, val] of rows) {
326      const cls = (String(val).includes('INTERDIT') || String(val).includes('❌') || String(val).includes('🚫')) ? ' plu-nok' : (String(val).includes('✅')) ? ' plu-ok' : '';
327      h += `<tr><td class="plu-label">${label}</td><td class="plu-val${cls}">${val}</td></tr>`;
328    }
329    h += '</table>';
330  }
331  if (zone.interdictions && zone.interdictions.length) {
332    h += '<div class="plu-interdits-title">🚫 Interdictions</div><ul class="plu-interdits">';
333    zone.interdictions.forEach(i => h += `<li>${i}</li>`);
334    h += '</ul>';
335  }
336  return h;
337}
338
339function pluBuildAnswer(qid, zone, zoneKey) {
340  // Retourne un objet {html, hasPluAnswer}
341  // hasPluAnswer=true si on a une réponse précise du JSON, false si fallback MEL
342  const z = zone;
343  const zKey = pluNormalizeZone(zoneKey) || zoneKey;
344  let html = '';
345  let hasPluAnswer = false;
346
347  if (qid === 'piscine') {
348    hasPluAnswer = true;
349    // Autorisation selon surface
350    html += pluRenderAuth('piscine');
351    // Règle spécifique à la zone
352    if (z && z.piscine) {
353      const ok = z.piscine.includes('✅');
354      const nok = z.piscine.includes('❌');
355      html += `<div class="plu-zone-rule ${ok?'plu-ok-block':nok?'plu-nok-block':'plu-warn-block'}">
356        <strong>En zone ${zoneKey} :</strong> ${z.piscine}
357        ${z.emprise ? '<br><small>Emprise totale (maison + piscine) : '+z.emprise+'</small>' : ''}
358        ${z.espaces_verts ? '<br><small>Espaces verts obligatoires : '+z.espaces_verts+'</small>' : ''}
359      </div>`;
360    }
361    if (z && z.alerte) html += `<div class="plu-alerte">${z.alerte}</div>`;
362  }
363  else if (qid === 'cloture') {
364    hasPluAnswer = true;
365    html += pluRenderAuth('cloture');
366    if (z) {
367      html += `<div class="plu-zone-rule plu-warn-block"><strong>En zone ${zoneKey} :</strong><br>`;
368      if (z.cloture_voie) html += `• Sur voie : ${z.cloture_voie}<br>`;
369      if (z.cloture_sep) html += `• Limite séparative : ${z.cloture_sep}<br>`;
370      if (z.cloture_carrefour) html += `• Carrefour : ${z.cloture_carrefour}<br>`;
371      if (z.cloture_portail) html += `• Portail : ${z.cloture_portail}`;
372      html += '</div>';
373    }
374    if (z && z.alerte) html += `<div class="plu-alerte">${z.alerte}</div>`;
375  }
376  else if (qid === 'abri') {
377    hasPluAnswer = true;
378    html += pluRenderAuth('abri');
379    if (z) {
380      html += `<div class="plu-zone-rule plu-warn-block"><strong>En zone ${zoneKey} — matériaux :</strong><br>`;
381      if (z.abri_materiaux) html += z.abri_materiaux;
382      if (z.recul_voie_abri) html += `<br>Recul par rapport à la voie : ${z.recul_voie_abri}`;
383      html += '</div>';
384    }
385    if (z && z.alerte) html += `<div class="plu-alerte">${z.alerte}</div>`;
386  }
387  else if (qid === 'toiture') {
388    hasPluAnswer = true;
389    html += pluRenderAuth('toiture');
390    if (z) {
391      html += `<div class="plu-zone-rule plu-warn-block"><strong>En zone ${zoneKey} :</strong><br>`;
392      if (z.toiture_principale) html += `• Construction principale : ${z.toiture_principale}<br>`;
393      if (z.toiture_annexe_sup30) html += `• Annexe > 30 m² : ${z.toiture_annexe_sup30}<br>`;
394      if (z.toiture_annexe_inf30) html += `• Annexe < 30 m² : ${z.toiture_annexe_inf30}`;
395      html += '<br><small>Exception : appentis, vérandas, verrières non soumis à ces règles.</small></div>';
396    }
397    if (z && z.alerte) html += `<div class="plu-alerte">${z.alerte}</div>`;
398  }
399  else if (qid === 'extension') {
400    hasPluAnswer = true;
401    html += pluRenderAuth('extension');
402    if (z) {
403      html += `<div class="plu-zone-rule plu-warn-block"><strong>En zone ${zoneKey} :</strong><br>`;
404      if (z.emprise) html += `• Emprise au sol max (maison + extension) : ${z.emprise}<br>`;
405      if (z.hauteur_principale) html += `• Hauteur max : ${z.hauteur_principale}<br>`;
406      if (z.recul_voie_principale) html += `• Recul voie : ${z.recul_voie_principale}<br>`;
407      if (z.recul_sep) html += `• Limites séparatives : ${z.recul_sep}`;
408      if (['Ah','Nh'].includes(zoneKey)) html += '<br>⚠️ Extension limitée à +20% de la surface de plancher existante.';
409      html += '</div>';
410    }
411    if (z && z.alerte) html += `<div class="plu-alerte">${z.alerte}</div>`;
412  }
413  else if (qid === 'permis') {
414    hasPluAnswer = true;
415    html += pluRenderAuth('permis');
416    if (z) {
417      html += `<div class="plu-zone-rule plu-warn-block"><strong>Règles générales en zone ${zoneKey} :</strong><br>`;
418      if (z.emprise) html += `• Emprise au sol max : ${z.emprise}<br>`;
419      if (z.hauteur_principale) html += `• Hauteur max : ${z.hauteur_principale}<br>`;
420      if (z.recul_voie_principale) html += `• Recul voie : ${z.recul_voie_principale}<br>`;
421      if (z.recul_sep) html += `• Limites séparatives : ${z.recul_sep}`;
422      if (z.alerte) html += '<br>'+z.alerte;
423      html += '</div>';
424    }
425  }
426   else if (qid === 'changement_destination') {
427     hasPluAnswer = true;
428   
429     html += `<div class="plu-auth">
430       <div class="plu-auth-title">📋 Changement de destination</div>
431       <div class="plu-zone-rule plu-warn-block">
432         Le changement de destination consiste à faire passer un bâtiment d’une destination à une autre
433         (par exemple : agricole vers habitation, habitation vers commerce, local vers bureaux).
434         <br><br>
435         <strong>Autorisation :</strong><br>
436         • Sans modification de façade ni de structure porteuse : <strong>Déclaration préalable (DP)</strong><br>
437         • Avec modification de façade et/ou de structure porteuse : <strong>
437Permis de construire (PC)</strong>
438       </div>
439     </div>`;
440   
441     if (zoneKey === 'A') {
442       html += `<div class="plu-zone-rule plu-nok-block">
443         <strong>En zone A :</strong><br>
444         Le changement de destination n’est autorisé que pour les <strong>bâtiments agricoles identifiés</strong>
445         dans les documents graphiques du PLU.<br>
446         Si le bâtiment n’est pas identifié, le changement de destination n’est pas autorisé en l’état.
447       </div>`;
448     }
449     else if (zoneKey === 'Ah') {
450       html += `<div class="plu-zone-rule plu-ok-block">
451         <strong>En secteur Ah :</strong><br>
452         Le changement de destination des constructions existantes est autorisé vers :<br>
453         • l’habitat<br>
454         • les bureaux<br>
455         • le commerce<br>
456         • l’artisanat<br>
457         • le tourisme<br>
458         • les loisirs<br><br>
459         Le projet doit rester compatible avec le voisinage en termes de nuisances et d’aspect extérieur.
460       </div>`;
461     }
462     else if (zoneKey === 'Nh') {
463       html += `<div class="plu-zone-rule plu-ok-block">
464         <strong>En secteur Nh :</strong><br>
465         Le changement de destination des constructions existantes est autorisé vers :<br>
466         • l’habitat<br>
467         • le tourisme<br>
468         • les loisirs<br><br>
469         Le projet doit rester compatible avec le caractère de la zone.
470       </div>`;
471     }
472     else {
473       html += `<div class="plu-zone-rule plu-warn-block">
474         <strong>En zone ${zoneKey || '?' } :</strong><br>
475         Le PLU ne prévoit pas ici, dans les extraits intégrés à MEL, de règle spéciale aussi explicite que pour
476         les secteurs A, Ah ou Nh.<br>
477         Le projet doit donc être vérifié au cas par cas par le service urbanisme, selon la destination actuelle
478         du bâtiment, la destination envisagée et les éventuelles modifications extérieures ou structurelles.
479       </div>`;
480     }
481   
482     html += `<div class="plu-note">
483       ℹ️ Avant dépôt, vérifiez aussi si le bâtiment est situé dans un lotissement, s’il est repéré au PLU,
484       ou si d’autres contraintes s’appliquent.
485     </div>`;
486   }
487  // Footer commun : contact mairie
488  if (hasPluAnswer) {
489    html += `<div class="plu-footer">
490      ⚠️ Informations issues du PLU approuvé le 30/01/2013 — à titre indicatif.<br>
491      Toute décision définitive relève du service urbanisme.
492      <a href="mailto:[email protected]" onclick="event.stopPropagation();window.location.href='mailto:[email protected]';return false;" style="display:block;margin-top:6px;padding:8px 12px;background:var(--forest);color:white;border-radius:8px;text-decoration:none;font-weight:700;text-align:center;">✉️ Contacter le service urbanisme</a>
493      <a href="tel:0238456176" onclick="event.stopPropagation();window.location.href='tel:0238456176';return false;" style="display:block;margin-top:5px;padding:8px 12px;background:var(--leaf);color:white;border-radius:8px;text-decoration:none;font-weight:700;text-align:center;">📞 Mairie : 02 38 45 61 76</a>
494    </div>`;
495  }
496
497  return { html, hasPluAnswer };
498}
499
500// ════════════════════════════════════════════════════════
501// MEL — Arbre de décision (fallback embarqué)
502// ⚠️ Ne pas modifier ce bloc : éditez data/mel-tree.json à la place
503// ════════════════════════════════════════════════════════
504const _MEL_TREE_FALLBACK = {
505
506  urbanisme:{
507    label:"Urbanisme & Construction", ico:"🏗️", needZone:true,
508    questions:[
509
510      {id:"piscine",ico:"🏊",label:"Piscine",prompt:"Je souhaite construire une piscine. Quelles sont les règles PLU applicables à mon terrain ?",topic:"urbanisme"},
511      {id:"cloture",ico:"🔲",label:"Clôture / Mur / Portail",prompt:"Je souhaite installer une clôture, un mur ou un portail. Quelles sont les règles et formalités ?",topic:"urbanisme"},
512      {id:"extension",ico:"📐",label:"Extension / Agrandissement",prompt:"Je souhaite agrandir ma maison. Quelles sont les règles d'extension applicables ?",topic:"urbanisme"},
513      {id:"abri",ico:"🌿",label:"Abri de jardin / Garage / Annexe",prompt:"Je souhaite construire un abri de jardin, un garage ou une dépendance. Quelles sont les règles ?",topic:"urbanisme"},
514      {id:"toiture",ico:"🏠",label:"Toiture / Façade / Matériaux",prompt:"Je souhaite modifier la toiture ou la façade de ma maison. Quelles sont les règles applicables ?",topic:"urbanisme"},
515    ]
516  },
517
518  enfance:{
519    label:"Enfance & Jeunesse", ico:"🎒", needZone:false,
520    questions:[
521      // ⚠️ Source : règlement intérieur scolaire et périscolaire 2026-2027 de
522      // l'école de la Forêt. Deux garde-fous, verrouillés par
523      // tests/e2e/ecole-periscolaire.spec.js :
524      //   • AUCUN nom de personnel — pas même en commentaire. Ce bloc nommait
525      //     la directrice de l'école et la directrice du périscolaire : des
526      //     personnes qui changent d'une rentrée à l'autre, et rien ne le
527      //     signalait. On désigne une fonction, jamais quelqu'un.
528      //   • AUCUN montant, pas même en commentaire. La ligne cantine affichait
529      //     encore, en septembre 2026, une grille tarifaire de quatre ans son
530      //     aînée — datée dans le texte, et personne ne l'avait vue. Les tarifs
531      //     sont votés chaque année par le conseil municipal et calculés sur le
532      //     quotient familial CAF : ils vivent sur le portail parents, pas ici.
533      //     Même règle que la salle communale et la crèche.
534      {id:"ecole",ico:"📚",label:"École de la Forêt",
535        directAnswer:{text:"L'école de la Forêt est l'école primaire publique de la commune, 36 rue du Bourg. La semaine est de 4 jours — lundi, mardi, jeudi et vendredi — de 8h30 à 11h45 et de 13h45 à 16h30 ; il n'y a pas de classe le mercredi. L'accueil des élèves est assuré dix minutes avant la classe, à 8h20 et à 13h35, et le portail ferme à 8h30 et à 13h45. Des activités pédagogiques complémentaires (APC) peuvent s'ajouter pour les élèves de la GS au CM2, les lundis et jeudis sur le temps méridien. Toute absence est à signaler le jour même en précisant le motif, par téléphone, par mail ou dans le cahier de liaison. Aucun médicament n'est administré à l'école, sauf dans le cadre d'un PAI ; seul le traitement contre l'asthme est admis, avec copie de l'ordonnance. Votre premier interlocuteur est l'enseignante de votre enfant ; pour une question administrative, adressez-vous à la direction de l'école.",
536          links:[{label:"📞 École : 02 38 45 65 00",tel:"0238456500"},{label:"✉️ [email protected]",url:"mailto:[email protected]"}]}
537      },
538      {id:"periscolaire",ico:"🌅",label:"Accueil périscolaire",
539        directAnswer:{text:"Les services périscolaires — « L'Accueil enchanté », chemin des écoliers — sont gérés par la commune. L'accueil du matin se fait de 7h30 à 8h20 en arrivée échelonnée, celui du soir de 16h30 à 18h30, avec le goûter fourni, les lundi, mardi, jeudi et vendredi. Tout passe par le portail parents : dossier d'inscription unique pour tous les services périscolaires (copie des vaccins, attestation d'assurance mentionnant la responsabilité civile et l'individuelle accident), réservations, annulations, mise à jour des personnes autorisées à venir chercher l'enfant, paiement par carte. Les réservations et annulations du matin et du soir se font au plus tard 3 jours avant ; passé ce délai, l'inscription se fait par mail et un surcoût est facturé. Au-delà de 18h30, un retard est facturé par enfant. Les tarifs sont fixés chaque année par délibération du conseil municipal et calculés sur le quotient familial CAF au 1er août : la grille en vigueur est sur le portail parents.",
540          links:[{label:"🌐 Portail parents",url:"https://parents.logiciel-enfance.fr/mezieres-lez-clery"},{label:"📞 Périscolaire : 09 67 28 01 20",tel:"0967280120"},{label:"✉️ [email protected]",url:"mailto:[email protected]"}]}
541      },
542      {id:"mercredi",ico:"🗓️",label:"Accueil du mercredi",
543        directAnswer:{text:"L'accueil périscolaire du mercredi est ouvert tous les mercredis en période scolaire, de 7h30 à 18h00, en journée entière ou en demi-journée, avec ou sans repas. Les arrivées du matin se font entre 7h30 et 9h00, celles de l'après-midi entre 11h50 et 12h00 avec repas ou entre 13h15 et 13h30 sans repas ; les départs de fin de journée à partir de 16h00. Les réservations et annulations se font sur le portail parents, au plus tard le mardi de la semaine précédente ; passé ce délai, l'inscription se fait par mail et un surcoût est facturé. Le goûter est fourni. Au-delà de 18h00 le mercredi, un retard est facturé par enfant. Les tarifs suivent le quotient familial CAF et sont fixés chaque année par délibération du conseil municipal.",
544          links:[{label:"🌐 Portail parents",url:"https://parents.logiciel-enfance.fr/mezieres-lez-clery"},{label:"📞 Périscolaire : 09 67 28 01 20",tel:"0967280120"},{label:"✉️ [email protected]",url:"mailto:[email protected]"}]}
545      },
546      {id:"cantine",ico:"🍽️",label:"Restaurant scolaire",
547        directAnswer:{text:"Le restaurant scolaire est ouvert les lundi, mardi, jeudi et vendredi de 11h45 à 13h30. Les repas — une centaine par jour — sont confectionnés sur place par un cuisinier communal, qui élabore les menus en veillant à l'équilibre nutritionnel et en privilégiant les circuits courts ; prévoyez une serviette de table dans une trousse marquée au nom de l'enfant. Seuls les enfants venus en classe le matin sont admis, et aucun départ ni retour n'est accepté entre 11h45 et 13h30. L'inscription et les réservations se font sur le portail parents, au plus tard le mardi de la semaine précédente ; passé ce délai, l'inscription se fait par mail et un surcoût est facturé. En cas de régime alimentaire médical, un PAI doit être établi et réactualisé chaque rentrée : la commune ne réalise pas de menu spécifique et s'en tient à la neutralité religieuse en matière de repas. Les tarifs suivent le quotient familial CAF au 1er août et sont fixés chaque année par délibération du conseil municipal : la grille en vigueur est sur le portail parents.",
548          links:[{label:"🌐 Portail parents",url:"https://parents.logiciel-enfance.fr/mezieres-lez-clery"},{label:"📞 Périscolaire : 09 67 28 01 20",tel:"0967280120"},{label:"✉️ [email protected]",url:"mailto:[email protected]"}]}
549      },
550      {id:"creche",ico:"👶",label:"Crèche familiale Les Marmousets",
551        // ⚠️ Chiffres tirés du règlement de fonctionnement « parents » 2026-2027
552        // du SIVU (modifications au 1er septembre 2026). Deux erreurs y ont été
553        // corrigées : « 17 assistantes maternelles » (le règlement en compte 16)
554        // et « enfants de moins de 6 ans » — la crèche prend de 10 semaines à
555        // l'entrée en maternelle ; les « moins de six ans » du règlement ne
556        // visent que les places garanties de l'article D.214-7. Ne pas rétablir.
557        directAnswer:{text:"C'est une crèche « familiale » : 16 assistantes maternelles salariées accueillent les enfants à leur domicile, avec des temps collectifs chaque semaine dans les locaux de la crèche ou en salle communale. Elle accueille les enfants de 10 semaines jusqu'à leur entrée 
557à l'école maternelle ; les enfants déjà scolarisés peuvent l'être le mercredi et pendant les vacances, selon les places disponibles. Ouverte du lundi au vendredi de 7h30 à 18h30, au 92 rue du Maréchal Foch à Cléry-Saint-André. Elle est gérée par un syndicat intercommunal (SIVU) qui regroupe Cléry-Saint-André, Mareau-aux-Prés et Mézières-lez-Cléry. L'inscription se fait en liste d'attente, puis une commission d'attribution statue sur les places ; aucune condition d'activité professionnelle des parents n'est exigée. Le tarif est calculé selon le barème national de la CNAF, d'après vos revenus et le nombre d'enfants à charge.",
558          links:[{label:"📞 Crèche Les Marmousets : 02 38 45 76 56",tel:"0238457656"},{label:"✉️ [email protected]",url:"mailto:[email protected]"}]}
559      },
560      // ⚠️ Doublon volontaire avec data/mel-tree.json (l'arbre éditable depuis
561      // l'admin) : les deux copies doivent rester en phase. Le LAEP n'est PAS
562      // un mode de garde et ne passe PAS par Mézières — ne pas « simplifier ».
563      {id:"laep",ico:"🤱",label:"LAEP — Lieu d'Accueil Enfants-Parents",
564        directAnswer:{text:"Un lieu convivial d'écoute, d'échanges, de jeux et de rencontres, ouvert par la Communauté de Communes à compter du 7 septembre 2026. Gratuit, confidentiel, sans inscription. Pour les enfants de moins de 6 ans accompagnés d'un parent, d'un grand-parent ou d'un adulte référent, et pour les futurs parents. Ce n'est pas un mode de garde : l'adulte reste avec l'enfant pendant toute la durée de l'accueil. Au moins deux professionnels, les « accueillants », sont présents et tenus au secret professionnel. Le LAEP est itinérant : il se déplace sur Beauce la Romaine, Beaugency, Cléry-Saint-André et Meung-sur-Loire — pas à Mézières, Cléry-Saint-André étant la commune d'accueil la plus proche. Le planning des créneaux n'est pas encore publié : renseignements auprès de la Communauté de Communes.",
565          links:[{label:"📞 LAEP : 06 62 65 59 04",tel:"0662655904"},{label:"✉️ [email protected]",url:"mailto:[email protected]"},{label:"🌐 Le LAEP sur le site de la CCTVL",url:"https://www.ccterresduvaldeloire.fr/laep-lieu-accueil-enfants-parents/"}]}
566      },
567      {id:"loisirs",ico:"🎨",label:"Centre de loisirs",
568        directAnswer:{text:"L'accueil de loisirs sans hébergement (ALSH) du Val d'Ardoux, organisé par la Communauté de Communes des Terres du Val de Loire, accueille les 3-13 ans pendant les vacances scolaires — à l'exception des vacances de Noël. Il est basé à Cléry-Saint-André, et ponctuellement à Mézières-lez-Cléry. 1ʳᵉ inscription : dossier à retirer auprès de la CCTVL. Réinscription : dossier pré-rempli par email. Vacances 2026-2027 : automne du 19 octobre au 1er novembre 2026, hiver du 22 février au 7 mars 2027, printemps du 19 avril au 2 mai 2027, été à partir du 5 juillet 2027 — sous réserve du maintien du calendrier scolaire.",
569          links:[{label:"✉️ [email protected]",url:"mailto:[email protected]"},{label:"🌐 Site CCTVL",url:"https://www.ccterresduvaldeloire.fr/listes/enfance-jeunesse/"}]}
570      },
571    ]
572  },
573
574  administratif:{
575    label:"Démarches Administratives", ico:"📋", needZone:false,
576    questions:[
577      {id:"etatcivil",ico:"📜",label:"État civil (naissance, mariage, décès, actes)",
578        directAnswer:{text:"Les actes d'état civil (naissance, mariage, décès, acte de naissance…) sont délivrés par la mairie. Accueil physique : lundi 14h-17h30, vendredi 8h30-11h30. Mercredi sur rendez-vous uniquement.",
579          links:[{label:"📞 Mairie : 02 38 45 61 76",tel:"0238456176"},{label:"✉️ [email protected]",url:"mailto:[email protected]"}]}
580      },
581      {id:"cni",ico:"🪪",label:"Carte Nationale d'Identité",
582        directAnswer:{text:"La CNI se fait dans une mairie équipée d'un dispositif biométrique (la mairie de Mézières vous orientera vers la plus proche, souvent Cléry-Saint-André). Démarche en ligne sur le site ANTS : créer un compte → commencer la démarche → renseigner vos informations → acheter votre timbre fiscal → valider → prendre rendez-vous en mairie → préparer vos pièces justificatives → déposer le dossier en mairie.",
583          links:[{label:"🌐 Démarche CNI sur ANTS",url:"https://ants.gouv.fr"},{label:"📅 Prendre rendez-vous",url:"https://www.service-public.gouv.fr/particuliers/vosdroits/R63715"},{label:"📞 Mairie : 02 38 45 61 76",tel:"0238456176"}]}
584      },
585      {id:"passeport",ico:"🛂",label:"Passeport",
586        directAnswer:{text:"Le passeport se fait dans une mairie équipée d'un dispositif biométrique. Vous pouvez faire une pré-demande sur ants.gouv.fr. Documents nécessaires selon la situation : première demande ou renouvellement, majeur ou mineur. Un t
586imbre fiscal est requis. Le délai varie selon la période et le lieu.",
587          links:[{label:"🌐 Toutes les infos passeport",url:"https://www.service-public.gouv.fr/particuliers/vosdroits/N360"},{label:"🌐 Pré-demande sur ANTS",url:"https://ants.gouv.fr"},{label:"📞 Mairie : 02 38 45 61 76",tel:"0238456176"}]}
588      },
589      {id:"pacs",ico:"💑",label:"PACS — Pacte Civil de Solidarité",
590        directAnswer:{text:"Le PACS se déclare à la mairie. Il faut remplir la déclaration conjointe (cerfa 15725*03) avec attestations sur l'honneur de non-parenté, non-alliance et résidence commune, joindre une convention-type (cerfa 15726*02) ou une convention personnalisée, et les justificatifs d'identité.",
591          links:[{label:"📄 Déclaration PACS (cerfa 15725*03)",url:"https://www.formulaires.service-public.gouv.fr/gf/cerfa_15725.do"},{label:"📄 Convention-type PACS (cerfa 15726*02)",url:"https://www.formulaires.service-public.gouv.fr/gf/cerfa_15726.do"},{label:"📞 Mairie : 02 38 45 61 76",tel:"0238456176"}]}
592      },
593      {id:"vote",ico:"🗳️",label:"Inscription sur les listes électorales",
594        directAnswer:{text:"Pour voter, vous devez être inscrit sur les listes électorales. Inscription en mairie ou en ligne sur service-public.gouv.fr. Documents à prévoir : pièce d'identité et justificatif de domicile.",
595          links:[{label:"📄 Formulaire d'inscription (cerfa 12669)",url:"https://www.service-public.gouv.fr/particuliers/vosdroits/R16024"},{label:"🌐 Inscription en ligne",url:"https://www.service-public.gouv.fr"},{label:"📞 Mairie : 02 38 45 61 76",tel:"0238456176"}]}
596      },
597      {id:"assainissement",ico:"🚰",label:"Assainissement (fosse, raccordement, SPANC)",
598        directAnswer:{text:"L'assainissement collectif et le SPANC (Service Public d'Assainissement Non Collectif, ANC) sont gérés par la CCTVL depuis 2018. Pour un raccordement, un contrôle de fosse ou toute question, contactez directement la CCTVL. La facturation est accessible sur le portail usagers.",
599          links:[{label:"📞 CCTVL : 02 38 44 59 35",tel:"0238445935"},{label:"✉️ [email protected]",url:"mailto:[email protected]"},{label:"🌐 Portail usagers CCTVL",url:"https://portail-usagers.ccterresduvaldeloire.fr"}]}
600      },
601      {id:"location",ico:"🪑",label:"Location de matériel communal",
602        directAnswer:{text:"⚠️ La salle communale (salle des fêtes) n'est plus proposée à la location : ni pour une fête de famille, ni pour un événement privé. En revanche, la commune met du matériel à disposition des Macériens (tables, chaises, barnums…), avec un chèque de caution restitué en fin de location si aucune dégradation n'est constatée. Pour connaître le matériel disponible, les tarifs et les modalités de réservation, contactez la mairie.",
603          links:[{label:"📞 Mairie : 02 38 45 61 76",tel:"0238456176"},{label:"✉️ [email protected]",url:"mailto:[email protected]"}]}
604      },
605    ]
606  },
607
608  dechets:{
609    label:"Déchets & Propreté", ico:"♻️", needZone:false,
610    questions:[
611      {id:"collecte",ico:"🗑️",label:"Jours de collecte ordures / tri sélectif",
612        directAnswer:{text:"Sortez vos bacs la veille au soir ou avant 7h le matin du jour de collecte. La collecte n'a pas lieu les jours fériés — elle est reportée au lendemain. Des points d'apport volontaire (verre, papiers, plastique-cartons, vêtements) sont présents sur la commune. Consultez votre calendrier personnalisé en ligne.",
613          links:[{label:"🗓️ Mon calendrier de collecte",action:"openDechets()"}]}
614      },
615      {id:"dechetterie",ico:"♻️",label:"Déchetterie — horaires & inscription",
616        directAnswer:{text:"La déchetterie de Cléry-Saint-André est la plus proche. Inscription OBLIGATOIRE (lecture automatique de plaque). Enregistrez vos plaques SANS tiret (ex : AA123BB). Horaires : hiver (oct-mars) 10h-12h / 14h-17h, été (avr-sep) 9h-12h / 14h-18h, lun-sam sauf jours fériés. Une inscription vaut pour tous les sites CCTVL.",
617          links:[{label:"🌐 S'inscrire à la déchetterie",url:"https://portail-usagers.ccterresduvaldeloire.fr"},{label:"📞 CCTVL : 02 38 44 59 35",tel:"0238445935"}
617]}
618      },
619      {id:"encombrants",ico:"🛋️",label:"Encombrants / objets volumineux",
620        directAnswer:{text:"Les encombrants (meubles, électroménager, gros objets) se déposent à la déchetterie de Cléry-Saint-André. Inscription préalable obligatoire sur le portail usagers CCTVL. Certains équipements électriques peuvent être repris en magasin (obligation légale de reprise 1 pour 1).",
621          links:[{label:"🌐 Portail usagers CCTVL",url:"https://portail-usagers.ccterresduvaldeloire.fr"},{label:"📞 CCTVL : 02 38 44 59 35",tel:"0238445935"}]}
622      },
623      {id:"tri",ico:"📦",label:"Tri sélectif — que mettre où ?",
624        directAnswer:{text:"Sur la commune : bac jaune (plastiques, cartons, papiers, métaux), bac vert (ordures résiduelles). Points d'apport volontaire pour le verre, les papiers-magazines, plastique-cartons et vêtements. En cas de doute sur un déchet, consultez le guide du tri en ligne.",
625          links:[{label:"🌐 Guide du tri CCTVL",url:"https://www.ccterresduvaldeloire.fr/reseau-des-dechetteries/"}]}
626      },
627    ]
628  },
629
630  numerique:{
631    label:"Numérique & Internet", ico:"📡", needZone:false,
632    questions:[
633      // ⚠️ Lysséo n'est pas un fournisseur d'accès : c'est le réseau public fibre
634      // du Loiret, construit et exploité par Loiret THD / Loiret Fibre (groupe
635      // XpFibre) en délégation de service public du Département. Il ne vend aucun
636      // abonnement. Ne pas réécrire ces réponses comme si l'habitant pouvait
637      // « souscrire chez Lysséo », ni renvoyer vers la page d'accueil en décrivant
638      // un bouton « en haut à droite » : ces instructions de navigation cassent
639      // au premier changement de menu du site. Pointer les URL directes.
640      {id:"fibre",ico:"🌐",label:"Raccordement fibre / Nouvelle construction",
641        directAnswer:{text:"Lysséo est le réseau public fibre du Loiret : il construit et gère l'infrastructure, mais ne vend aucun abonnement — les forfaits affichés sur son site sont ceux des opérateurs commerciaux présents sur le réseau.\n\n1️⃣ Déclarez d'abord votre construction neuve. Une maison neuve n'est pas automatiquement présente dans la base d'adresses de l'opérateur d'infrastructure : tant que la vôtre n'y figure pas comme « raccordable », aucun opérateur ne pourra enregistrer votre commande. La démarche se fait auprès de XpFibre / Loiret THD, ou via le formulaire de contact Lysséo. Prévoyez de joindre votre permis de construire, votre certificat de numérotation et un plan de masse localisant le regard et les fourreaux en limite de propriété.\n\n2️⃣ Une fois l'adresse déclarée éligible, souscrivez auprès de l'opérateur de votre choix (Orange, SFR, Bouygues, Free…). C'est son prestataire qui réalise le tirage de la fibre depuis le point de branchement de la voirie jusqu'au logement, en empruntant vos fourreaux existants. Aucun génie civil supplémentaire n'est à prévoir de votre côté, sous réserve que les fourreaux soient bien aiguillés et non obstrués — leur bon état relève du propriétaire sur la partie privative.\n\n3️⃣ De son côté, la mairie vérifie que la numérotation de votre parcelle a bien été transmise et intégrée à la Base Adresse Nationale : c'est la condition nécessaire à la prise en compte de votre adresse par l'opérateur d'infrastructure.",
642          links:[{label:"🏗️ Déclarer une nouvelle construction — XpFibre / Loiret THD",url:"https://www.xpfibre.com/loiret-thd"},
643                 {label:"🌐 Lysséo — site officiel",url:"https://lysseo.fr"},
644                 {label:"🏛️ Mairie : 02 38 45 61 76",tel:"0238456176"}]}
645      },
646      {id:"thd",ico:"📶",label:"Questions fibre (raccordement, éligibilité…)",
647        directAnswer:{text:"Pour une question portant sur le réseau lui-même — adresse raccordable, avancement du déploiement, point de branchement — écrivez à Lysséo via son formulaire de contact.\n\n⚠️ Lysséo ne commercialise aucune offre : pour un abonnement, une facture, un rendez-vous d'installation ou une panne de votre ligne, votre interlocuteur est votre opérateur (Orange, SFR, Bouygues, Free…).",
648          links:[{label:"🌐 Lysséo — site officiel",url:"https://lysseo.fr"}]}
649      },
650      {id:"probleme",ico:"🔧",label:"Problème sur les installations (armoires, câbles…)",
651        directAnswer:{text:"En cas de problème constaté sur les équipements du réseau public (armoires de rue, boîtiers, câbles arrachés), remplissez le formulaire de déclaration de dommage réseau de Lysséo.",
652          links:[{label:"🛠️ Déclarer un dommage réseau",url:"https://lysseo.fr/declaration-dommages-reseaux/45"}]}
653      },
654    ]
655  },
656
657  autre:{label:"Autre question",ico:"💬",needZone:false,openChatDirectly:true,questions:[]}
658};
659
660// ════════════════════════════════════════════════════════
661// PHASE 3 — Données MEL/PLU externalisées
662// ────────────────────────────────────────────────────────
663// PLU_DATA et MEL_TREE sont initialisées au fallback embarqué,
664// puis écrasées par les fichiers JSON externes dès qu'ils arrivent.
665// Avantage : MEL fonctionne IMMÉDIATEMENT au chargement du script,
666// sans attendre le fetch réseau. Le SW cache les JSON dès la 1ère
667// visite, donc à partir du 2e lancement tout est instantané.
668// ════════════════════════════════════════════════════════
669let PLU_DATA = _PLU_DATA_FALLBACK;
670let MEL_TREE = _MEL_TREE_FALLBACK;
671let _melDataLoaded = false;  // true dès que les JSON externes sont chargés (info debug)
672
673async function loadMelData() {
674  const V = '3.7.6';
675
676  try {
677    // allSettled : un fetch qui timeout/échoue n'invalide pas les deux autres,
678    // contrairement à Promise.all() où le 1er reject coupait toute la chaîne
679    // (le .catch sur le 3e fetch n'était qu'un cataplasme partiel).
680    const settled = await Promise.allSettled([
681      matFetch('./data/plu-data.json?v=' + V, { cache: 'no-cache' }, 6000),
682      matFetch('./data/mel-tree.json?v=' + V, { cache: 'no-cache' }, 6000),
683      matFetch(MEL_BACKEND + '/mel/tree',     { cache: 'no-cache' }, 6000)
684    ]);
685    const pluRes        = settled[0].status === 'fulfilled' ? settled[0].value : null;
686    const treeRes       = settled[1].status === 'fulfilled' ? settled[1].value : null;
687    const serverTreeRes = settled[2].status === 'fulfilled' ? settled[2].value : null;
688
689    if (pluRes && pluRes.ok) {
690      const plu = await pluRes.json();
691      if (plu && plu.zones && Object.keys(plu.zones).length >= 5) {
692        PLU_DATA = plu;
693      }
694    }
695
696    let tree = null;
697
698    if (serverTreeRes && serverTreeRes.ok) {
699      const remote = await serverTreeRes.json();
700      if (remote && remote.ok && remote.tree && typeof remote.tree === 'object' && Object.keys(remote.tree).length >= 1) {
701        tree = remote.tree;
702      }
703    }
704
705    if (!tree && treeRes && treeRes.ok) {
706      const localTree = await treeRes.json();
707      if (localTree && typeof localTree === 'object' && Object.keys(localTree).length >= 3) {
708        tree = localTree;
709      }
710    }
711
712    if (tree) {
713      MEL_TREE = tree;
714    }
715
716    _melDataLoaded = true;
717    console.log('[MEL] Données chargées : ' +
718      Object.keys(PLU_DATA.zones || {}).length + ' zones PLU, ' +
719      Object.keys(MEL_TREE || {}).length + ' catégories MEL');
720  } catch (e) {
721    console.warn('[MEL] Fallback embarqué utilisé :', e.message || e);
722  }
723}
724
725function _melEsc(v){
726  if (typeof esc === 'function') return esc(v);
727  return String(v == null ? '' : v)
728    .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
729    .replace(/"/g,'&quot;').replace(/'/g,'&#39;');
730}
731
732// Argument sûr pour un onclick="fn(…)" : JSON.stringify gère la couche JS
733// (quotes, backslash, retours ligne), puis " est encodé pour la couche
734// attribut HTML. À insérer SANS quotes additionnelles autour.
735function _melJsArg(v){
736  return JSON.stringify(v == null ? '' : String(v)).replace(/"/g,'&quot;');
737}
738
739// ════════════════════════════════════════════════════════
740// MEL — État global du module
741// ════════════════════════════════════════════════════════
742let _melCat=null, _melZone=null, _melZoneLabel=null, _melAddr=null;
743let _melLat=null, _melLon=null;
744let _melChatContext='', _melChatCategory='autre', _melChatTopic='autre';
745let _melZoneNorm=null;
746
747// Variables chat MEL
748let melInited=false, melBusy=false;
749const hist=[];
750
751// ════════════════════════════════════════════════════════
752// MEL — Chat (addMsg, showTyping, sendMel)
753// ════════════════════════════════════════════════════════
754
755function addMsg(role,text){
756  const c=document.getElementById('msgs');
757  const d=document.createElement('div'); d.className='msg '+role;
758  // Double contexte : chaîne JS ('...') imbriquée dans un attribut HTML ("...").
759  // 1) échappement JS (antislash, apostrophe, saut de ligne) ; 2) échappement
760  // HTML via _melEsc pour empêcher tout " ou < de casser l'attribut onclick.
761  const ttsArg = _melEsc(text.replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/\n/g,' '));
762  const ttsBtn = (role==='bot' && _ttsEnabled)
763    ? '<button class="tts-btn" onclick="ttsRead(\''+ttsArg+'\',\'MEL\')">🔊 Écouter</button>'
764    : '';
765  const formatted = formatMelText(text);
766  d.innerHTML='<div class="mav">
766'+(role==='bot'?'\ud83d\udc69':'\ud83d\udc64')+'</div><div class="bub">'+formatted+ttsBtn+'</div>';
767  c.appendChild(d); c.scrollTop=c.scrollHeight;
768  // Lire automatiquement si TTS actif
769  if(role==='bot' && _ttsEnabled) ttsSpeak(text, 'MEL');
770}
771
772function showTyping(){
773  const c=document.getElementById('msgs');
774  const d=document.createElement('div'); d.className='msg bot'; d.id='typ';
775  d.innerHTML='<div class="mav">\ud83d\udc69</div><div class="bub"><div class="typ"><span></span><span></span><span></span></div></div>';
776  c.appendChild(d); c.scrollTop=c.scrollHeight;
777}
778
779async function sendMel(){
780  const inp=document.getElementById('minp');
781  const txt=inp.value.trim(); if(!txt||melBusy)return;
782  inp.value=''; inp.placeholder='Votre question…'; const _sugs=document.getElementById('sugs'); if(_sugs)_sugs.style.display='none';
783  addMsg('user',txt); hist.push({role:'user',content:txt}); melBusy=true; showTyping();
784  try{
785    const r=await fetch(MEL_PROXY,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({messages:hist.slice(-8),category:_melChatCategory||'autre',extraCtx:_melChatContext||''}),signal:matAbortTimeout(20000)});
786    if(!r.ok) throw new Error('HTTP '+r.status);
787    const d=await r.json();
788    const rep=(d.reply||'Erreur technique. Contactez la mairie au 02 38 45 61 76.').replace(/\*\*(.*?)\*\*/g,'$1').replace(/\*(.*?)\*/g,'$1');
789    var _typ=document.getElementById('typ'); if(_typ) _typ.remove(); hist.push({role:'assistant',content:rep}); addMsg('bot',rep);
790    if(d.showElus){
791      const c=document.getElementById('msgs');
792      const sug=document.createElement('div'); sug.className='msg bot';
793      sug.innerHTML='<div class="mav">👩</div><div class="bub" style="background:var(--mist);border:1px solid rgba(0,0,0,0.06);font-size:0.78rem;display:flex;align-items:center;gap:8px;padding:10px 12px;flex-wrap:wrap;">'
794        +'<span style="font-size:1.1rem;flex-shrink:0;">🏛️</span>'
795        +'<span style="flex:1;color:var(--forest);min-width:100px;">Consulter la fiche ou contacter un élu ?</span>'
796        +'<button onclick="closeOv(\'mel\');openConseil();" style="background:var(--forest);color:white;border:none;border-radius:8px;padding:7px 12px;font-size:0.72rem;font-weight:900;cursor:pointer;flex-shrink:0;">🏛️ Voir les élus</button>'
797        +'</div>';
798      c.appendChild(sug); c.scrollTop=99999;
799    }
800    if(d.showUrbanisme){
801      const c=document.getElementById('msgs');
802      const sug=document.createElement('div'); sug.className='msg bot';
803      sug.innerHTML='<div class="mav">👩</div><div class="bub" style="background:var(--mist);border:1px solid rgba(0,0,0,0.06);padding:10px 12px;">'
804        +'<div style="font-size:0.72rem;font-weight:900;color:var(--forest);margin-bottom:6px">🏗️ Schéma des autorisations d\'urbanisme</div>'
805         +'<img src="./img/autorisation-urbanisme.jpg?v=3.7.4" '
806         +'alt="Autorisations urbanisme" '
807         +'style="width:100%;border-radius:8px;display:block;margin-bottom:6px;cursor:zoom-in" '
808         +'onclick="openMelImageModal(this.src, \'Schéma des autorisations d\\\'urbanisme\')" '
809         +'onerror="this.style.display=\'none\'">'
810        +'<div style="font-size:0.67rem;color:var(--muted);line-height:1.5">DP = Déclaration Préalable · PC = Permis de Construire · SP = Surface Plancher</div>'
811        +'</div>';
812      c.appendChild(sug); c.scrollTop=99999;
813    }
814  }catch(e){ var _typ=document.getElementById('typ'); if(_typ) _typ.remove(); addMsg('bot','Je ne peux pas répondre pour le moment.\nMairie\u00a0: 02\u00a038\u00a045\u00a061\u00a076 \ud83d\ude0a'); if(navigator.onLine && typeof window.matSignalServerError==='function') window.matSignalServerError(); }
815  melBusy=false;
816}
817
818// ════════════════════════════════════════════════════════
819// MEL — Suggestions thématiques (affichées en haut du chat)
820// ════════════════════════════════════════════════════════
821const MEL_THEME_SUGGESTIONS = {
822  urbanisme: [
823    "Faut-il une déclaration préalable pour une clôture ?",
824    "Ai-je besoin d'un permis pour une extension ?",
825    "Comment trouver ma zone PLU sur le Géoportail ?",
826    "Quelles règles pour un abri de jardin en zone Ub ?",
827    "Quelle hauteur maximale pour construire en zone Ua ?",
828    "Puis-je construire une piscine sans permis ?",
829    "Quelles règles pour une véranda ou une terrasse ?",
830    "Qu'est-ce que la zone A agricole ?",
831    "Quelles sont les zones constructibles à Mézières ?",
832  ],
833  enfance:   ["Comment fonctionne la cantine ?","Quels sont les services périscolaires ?","Comment s'inscrire au centre de loisirs ?","Quelles infos sur la crèche familiale ?"],
834  demarches: ["Comment demander un acte de naissance ?","Où faire une carte d'identité ?","Comment obtenir un passeport ?","Quelles démarches pour un mariage ?"],
835  fibre:     ["Suis-je éligible à la fibre ?","Qui contacter pour le raccordement fibre ?","Que faire si la fibre n'est pas disponible ?","Où trouver les infos numériques utiles ?"],
836  cctvl: ["Comment m'inscrire à la déchetterie de Cléry ?","Quelle maison de santé est proche de Mézières ?","Comment bénéficier de l'OPAH pour rénover mon logement ?","Ma fosse septique doit-elle être contrôlée ?"]
837};
838
839var MEL_ACTION_WHITELIST={openDechets:function(){typeof openDechets==='function'&&openDechets();},openAgenda:function(){typeof openAgenda==='function'&&openAgenda();},openNotifs:function(){typeof openNotifs==='function'&&openNotifs();}};
840function melRunAction(name){var fn=MEL_ACTION_WHITELIST[name];if(fn)fn();}
841
842function renderMelThemes(){
843  const sugs = document.getElementById('sugs');
844  if(!sugs) return;
845  sugs.style.display='';
846  sugs.innerHTML = '<button class="sug" onclick="showMelThemeQuestions(\'urbanisme\')">🏗️ Urbanisme</button>'
847    + '<button class="sug" onclick="showMelThemeQuestions(\'enfance\')">🧒 Enfance</button>'
848    + '<button class="sug" onclick="showMelThemeQuestions(\'demarches\')">📄 Démarches</button>'
849    + '<button class="sug" onclick="showMelThemeQuestions(\'fibre\')">🌐 Fibre</button>'
850    + '<button class="sug" onclick="showMelThemeQuestions(\'cctvl\')">🏛️ CCTVL</button>';
851}
852
853function showMelThemeQuestions(theme){
854  const sugs = document.getElementById('sugs');
855  const qs = MEL_THEME_SUGGESTIONS[theme] || [];
856  if(!sugs) return;
857  let extra = '';
858  if(theme === 'urbanisme'){
859    extra = '<a class="sug" href="https://www.geoportail-urbanisme.gouv.fr/map/#tile=1&lon=1.8048256197128292&lat=47.81814028325263&zoom=15&mlon=1.805022&mlat=47.818434" target="_blank" style="text-decoration:none">🗺️ Voir le PLU sur Géoportail →</a>';
860  }
861  sugs.innerHTML = '<button class="sug" onclick="renderMelThemes()">← Thèmes</button>'
862    + qs.map(q => '<button class="sug" onclick="askMelSuggestedQuestion(this)">'+q+'</button>').join('')
863    + extra;
864}
865
866function askMelSuggestedQuestion(btn){
867  document.getElementById('minp').value = btn.textContent.trim();
868  sendMel();
869}
870
871function sendSug(btn){ document.getElementById('minp').value=btn.textContent.trim(); sendMel(); }
872
873// ════════════════════════════════════════════════════════
874// MEL — Navigation arbre de décision
875// ════════════════════════════════════════════════════════
876
877function melShowTree(){
878  const tree=document.getElementById('mel-tree');
879  const l1=document.getElementById('mel-tree-l1');
880  const l2=document.getElementById('mel-tree-l2');
881  const msgs=document.getElementById('msgs');
882  const sugs=document.getElementById('sugs');
883  const mbar=document.querySelector('.mbar');
884  const backBtn=document.getElementById('mel-back-btn');
885  if(tree){tree.style.display='flex';tree.style.flexDirection='column';}
886  if(l1){
887    l1.style.display='flex';
888    l1.style.flexDirection='column';
889    l1.innerHTML = Object.entries(MEL_TREE || {}).map(function(entry){
890      var cat = entry[0], def = entry[1] || {};
891      return '<button class="mel-cat-btn" onclick="melSelectCat(' + _melJsArg(cat) + ')"><span class="ico">'
892        + _melEsc(def.ico || '💬') + '</span>'
893        + _melEsc(def.label || cat) + '</button>';
894    }).join('');
895  }
896  if(l2){l2.classList.remove('active');l2.style.display='none';l2.innerHTML='';}
897  if(msgs)msgs.style.display='none';
898  if(sugs)sugs.style.display='none';
899  if(mbar)mbar.style.display='none';
900  const privacyHint=document.getElementById('mel-privacy-hint');
901  if(privacyHint)privacyHint.style.display='none';
902  if(backBtn)backBtn.remove();
903  _melCat=null;_melZone=null;_melZoneLabel=null;_melAddr=null;
904  _melChatContext='';_melChatCategory='autre';
905}
906
907function melSelectCat(cat){
908  _melCat=cat;
909  const def=MEL_TREE[cat];
910  if(!def)return;
911  if(def.openChatDirectly){
912    melOpenChat('',cat,'');
913    return;
914  }
915  const l1=document.getElementById('mel-tree-l1');
916  const l2=document.getElementById('mel-tree-l2');
917  if(!l2){console.error('mel-tree-l2 introuvable');return;}
918  if(l1)l1.style.display='none';
919  // Injecter le contenu AVANT d'afficher pour éviter le flash vide
920  l2.innerHTML=_buildSubLevel(cat,def);
921  l2.classList.add('active');
922  l2.style.display='flex';
923  l2.style.flexDirection='column';
924  l2.scrollTop=0;
925}
926
927function _buildSubLevel(cat,def){
928  let h=`<div class="mel-sub"><div class="mel-sub-hdr"><button class="mel-sub-back" onclick="melResetTree()">← Retour</button><span>${_melEsc(def.ico)} ${_melEsc(def.label)}</span></div>`;
929  if(def.needZone){
930    h+=`<div class="mel-zone-block"><label>📍 Votre adresse ou position GPS (pour identifier votre zone PLU)</label><div class="mel-zone-row"><input type="text" id="mel-addr-input" class="mel-addr-input" placeholder="Ex : 12 rue du Bourg" oninput="this.style.borderColor='';document.getElementById('mel-addr-warn')?.remove()" onkeydown="if(event.key==='Enter')melFindZoneByAddr()"/><button class="mel-addr-btn" onclick="melFindZoneByAddr()">🔍</button><button class="mel-gps-btn" onclick="melFindZoneByGPS()" title="Utiliser mon GPS">📍</button></div><div id="mel-zone-result" class="mel-zone-result"></div></div>`;
931    h+=`<div class="mel-schema-block">
932  <div class="mel-schema-label">📋 Schéma des autorisations</div>
933  <img
934    src="./img/autorisation-urbanisme.jpg?v=3.7.4"
935    alt="Autorisations urbanisme"
936    class="mel-schema-img"
937    onclick="openMelImageModal(this.src, 'Schéma des autorisations d\\'urbanisme')"
938    style="cursor:zoom-in"
939    onerror="this.closest('.mel-schema-block').style.display='none'"/>
940</div>`;
941  }
942  for(const q of def.questions){
943    h+=`<button class="mel-q-btn" onclick="melSelectQuestion(${_melJsArg(cat)},${_melJsArg(q.id)})"><span class="q-ico">${_melEsc(q.ico)}</span><span>${_melEsc(q.label)}</span></button>`;
944  }
945  h+=`<button class="mel-q-btn mel-autre" onclick="melSelectQuestion(${_melJsArg(cat)},'__autre__')"><span class="q-ico">💬</span><span>Autre question ou cas particulier…</span></button>`;
946  h+=`</div>`;
947  return h;
948}
949
950function openMelImageModal(src, title){
951  closeMelImageModal();
952
953  const ov = document.createElement('div');
954  ov.id = 'mel-image-modal';
955  ov.style.cssText = `
956    position:fixed; inset:0; z-index:99999;
957    background:rgba(0,0,0,.82);
958    display:flex; align-items:center; justify-content:center;
959    padding:16px;
960  `;
961  ov.onclick = function(e){
962    if(e.target === ov) closeMelImageModal();
963  };
964
965  ov.innerHTML = `
966    <div style="position:relative;max-width:95vw;max-height:95vh;width:100%;display:flex;flex-direction:column;align-items:center;">
967      <button onclick="closeMelImageModal()" style="
968        position:absolute;top:-6px;right:-6px;
969        width:40px;height:40px;border:none;border-radius:999px;
970        background:white;color:#111;font-size:22px;font-weight:900;cursor:pointer;
971        box-shadow:0 4px 16px rgba(0,0,0,.35);
972      ">×</button>
973      <div style="color:white;font-weight:800;font-size:.95rem;margin-bottom:10px;text-align:center;">${title || ''}</div>
974      <img src="${src}" alt="${title || 'Image'}" style="
975        max-width:100%;
976        max-height:calc(95vh - 50px);
977        border-radius:12px;
978        box-shadow:0 12px 40px rgba(0,0,0,.45);
979        background:white;
980      ">
981    </div>
982  `;
983
984  document.body.appendChild(ov);
985  document.body.style.overflow = 'hidden';
986}
987
988function closeMelImageModal(){
989  const el = document.getElementById('mel-image-modal');
990  if(el) el.remove();
991  document.body.style.overflow = '';
992}
993
994function melSelectQuestion(cat,qid){
995  const def=MEL_TREE[cat];
996  if(!def)return;
997
998  // ── Urbanisme : adresse obligatoire avant de continuer ──────────
999  if(def.needZone && !_melAddr){
1000    const inp=document.getElementById('mel-addr-input');
1001    if(inp){
1002      inp.style.borderColor='#e53e3e';
1003      inp.placeholder='⚠️ Saisissez votre adresse d\'abord…';
1004      inp.focus();
1005      let warn=document.getElementById('mel-addr-warn');
1006      if(!warn){
1007        warn=document.createElement('div');
1008        warn.id='mel-addr-warn';
1009        warn.style.cssText='color:#e53e3e;font-size:.78rem;font-weight:700;margin-top:5px;padding:6px 10px;background:#fff5f5;border-radius:7px;';
1010        inp.closest('.mel-zone-row').after(warn);
1011      }
1012      warn.textContent='⚠️ Veuillez saisir votre adresse ou utiliser le GPS pour identifier votre zone PLU.';
1013    }
1014    return;
1015  }
1016
1017  const zoneCtx=[
1018    _melAddr ? 'Adresse : '+_melAddr+'.' : '',
1019    _melZone ? 'Zone PLU : '+_melZone+(_melZoneLabel?' ('+_melZoneLabel+')':'')+'.' : ''
1020  ].filter(Boolean).join(' ');
1021
1022  if(qid==='__autre__'){
1023    melOpenChat('',cat,'Catégorie : '+def.label+'. '+zoneCtx);
1024    return;
1025  }
1026  const q=def.questions.find(x=>x.id===qid);
1027  if(!q)return;
1028
1029  // ── Urbanisme : réponse directe depuis le JSON PLU ───────────────
1030  if(cat==='urbanisme' && _melZone){
1031    const zoneData=pluGetZone(_melZone);
1032    const {html:pluHtml, hasPluAnswer}=pluBuildAnswer(qid, zoneData, _melZone);
1033    if(hasPluAnswer){
1034      _showPluAnswer(pluHtml, q.label, cat, qid, zoneCtx);
1035      return;
1036    }
1037  }
1038
1039  // ── Autres catégories ou cas non couverts : directAnswer ou MEL ──
1040  if(q.directAnswer){
1041    _showDirectAnswer(q.directAnswer,cat,q.topic||cat,zoneCtx+' '+(q.prompt||''),q.label);
1042    return;
1043  }
1044  const fullPrompt=[zoneCtx,q.prompt].filter(Boolean).join('\n');
1045  melOpenChat(fullPrompt,cat,zoneCtx,q.topic||cat);
1046}
1047
1048// ════════════════════════════════════════════════════════
1049// MEL — Affichage réponse PLU avec onglets
1050// ════════════════════════════════════════════════════════
1051function _showPluAnswer(pluHtml, questionLabel, cat, qid, zoneCtx){
1052  const l2=document.getElementById('mel-tree-l2');
1053  if(!l2)return;
1054
1055  // Masquer les boutons de questions (on garde juste le header et le bloc zone)
1056  const subDiv=l2.querySelector('.mel-sub');
1057  if(subDiv){
1058    subDiv.querySelectorAll('.mel-q-btn').forEach(b=>b.style.display='none');
1059  }
1060
1061  // Supprimer réponse précédente si elle existe
1062  l2.querySelector('.mel-plu-answer')?.remove();
1063
1064  const zoneData = _melZone ? pluGetZone(_melZone) : null;
1065  const ctx=encodeURIComponent((zoneCtx||'').substring(0,300));
1066
1067  // ── Construire TOUT le HTML en une seule passe (évite innerHTML += qui casse les liens) ──
1068  let fullHtml = '';
1069
1070  // Titre + bouton fermer
1071  fullHtml += '<div class="plu-answer-hdr">'
1072    +'<div class="plu-answer-title">📐 '+questionLabel+' — Zone <strong>'+(_melZone||'?')+'</strong></div>'
1073    +'<button class="plu-close-btn" onclick="pluCloseAnswer()" title="Retour aux questions">✕ Retour</button>'
1074    +'</div>';
1075
1076  // Onglets
1077  fullHtml += '<div class="plu-tabs">'
1078    +'<button class="plu-tab active" onclick="pluSwitchTab(this,&quot;auth&quot;)">📋 Autorisation</button>'
1079    +'<button class="plu-tab" onclick="pluSwitchTab(this,&quot;rules&quot;)">📐 Règles de zone</button>'
1080    +'<button class="plu-tab" onclick="pluSwitchTab(this,&quot;interdits&quot;)">🚫 Interdictions</button>'
1081    +'</div>';
1082
1083  // Onglet 1 : Autorisation (contenu principal)
1084  fullHtml += '<div class="plu-tab-content" id="plu-tab-auth">'+pluHtml+'</div>';
1085
1086  // Onglet 2 : Toutes les règles de la zone
1087  let rulesHtml = '';
1088  if(zoneData){
1089    rulesHtml = pluRenderZoneCard(zoneData).replace(/<ul class="plu-interdits[\s\S]*?<\/ul>/,'').replace(/<div class="plu-interdits[\s\S]*?<\/div>/,'').replace(/<div class="plu-alerte[\s\S]*?<\/div>/,'');
1090  } else {
1091    rulesHtml = '<p style="color:var(--muted);font-size:.82rem;padding:8px 0;">Zone non identifiée — saisissez votre adresse pour obtenir les règles spécifiques.</p>';
1092  }
1093  fullHtml += '<div class="plu-tab-content" id="plu-tab-rules" style="display:none">'+rulesHtml+'</div>';
1094
1095  // Onglet 3 : Interdictions
1096  let interditsHtml = '';
1097  if(zoneData && zoneData.interdictions && zoneData.interdictions.length){
1098    interditsHtml = '<ul class="plu-interdits">'
1099      + zoneData.interdictions.map(i=>'<li>'+i+'</li>').join('')
1100      + '</ul>';
1101    if(zoneData.alerte) interditsHtml = '<div class="plu-alerte">'+zoneData.alerte+'</div>'+interditsHtml;
1102  } else {
1103    interditsHtml = '<p style="color:var(--muted);font-size:.82rem;padding:8px 0;">Aucune interdiction spécifique trouvée pour cette zone.</p>';
1104  }
1105  fullHtml += '<div class="plu-tab-content" id="plu-tab-interdits" style="display:none">
1105'+interditsHtml+'</div>';
1106
1107  // Bouton MEL + bouton retour en bas
1108  fullHtml += '<div style="display:flex;flex-direction:column;gap:6px;margin-top:10px;">'
1109    +'<button class="mel-open-chat-btn" onclick="melOpenChatFromDirect('+_melJsArg(cat)+',\'urbanisme\','+_melJsArg(ctx)+')">💬 Question complémentaire ? Parler à MEL</button>'
1110    +'<button class="plu-back-bottom-btn" onclick="pluCloseAnswer()">← Retour aux questions</button>'
1111    +'</div>';
1112
1113  // ── Injection unique dans le DOM ──
1114  const div=document.createElement('div');
1115  div.className='mel-plu-answer';
1116  div.innerHTML = fullHtml;
1117
1118  if(subDiv) subDiv.appendChild(div);
1119  else l2.appendChild(div);
1120  setTimeout(()=>div.scrollIntoView({behavior:'smooth',block:'start'}),50);
1121}
1122
1123function pluCloseAnswer(){
1124  // Réafficher les boutons de questions, supprimer la réponse
1125  const l2=document.getElementById('mel-tree-l2');
1126  if(!l2)return;
1127  l2.querySelectorAll('.mel-q-btn').forEach(b=>b.style.display='');
1128  l2.querySelector('.mel-plu-answer')?.remove();
1129}
1130
1131function pluSwitchTab(btn, tabId){
1132  const answer=btn.closest('.mel-plu-answer');
1133  if(!answer)return;
1134  answer.querySelectorAll('.plu-tab').forEach(t=>t.classList.remove('active'));
1135  btn.classList.add('active');
1136  answer.querySelectorAll('.plu-tab-content').forEach(c=>c.style.display='none');
1137  const target=answer.querySelector('#plu-tab-'+tabId);
1138  if(target)target.style.display='';
1139}
1140
1141// ════════════════════════════════════════════════════════
1142// MEL — Réponses directes (catégories non-urbanisme)
1143// ════════════════════════════════════════════════════════
1144
1145// Rend cliquables les adresses contenues dans une réponse de MEL.
1146//
1147// Les réponses sont rédigées en texte libre côté backend (DIRECT_RULES), donc
1148// une adresse peut y apparaître sous plusieurs formes. Avant, seules
1149// les adresses en `https:` et `www.` devenaient des liens : un domaine nu
1150// (« valdeloire-fibre.fr ») s'affichait mais ne s'ouvrait pas — 14 règles sur
1151// 27 étaient dans ce cas. Les courriels non plus n'étaient pas cliquables.
1152//
1153// ⚠️ UN SEUL passage, avec une alternance unique : en chaînant plusieurs
1154// .replace(), le second re-matchait le HTML fabriqué par le premier (le
1155// le `href` en `https:` déjà inséré) et produisait des liens imbriqués.
1156// L'ordre de l'alternance compte : le courriel doit passer AVANT le domaine
1157// nu, sinon « [email protected] » deviendrait un lien vers le domaine.
1158const _MEL_LIEN = new RegExp(
1159  '(https?:\\/\\/[^\\s<>]+)'                                        // 1. URL complète
1160  + '|([a-z0-9._%+-]+@(?:[a-z0-9-]+\\.)+[a-z]{2,})'                 // 2. courriel
1161  + '|(?<![\\w@.\\-\\/])(www\\.[^\\s<>]+)'                          // 3. www.…
1162  + '|(?<![\\w@.\\-\\/])((?:[a-z0-9-]+\\.)+(?:gouv\\.fr|fr|com|org|eu)(?:\\/[^\\s<>]*)?)', // 4. domaine nu
1163  'gi'
1164);
1165
1166function _melLinkify(texte){
1167  return String(texte==null?'':texte).replace(_MEL_LIEN, function(m, url, mail){
1168    // La ponctuation qui suit l'adresse n'en fait pas partie. Le motif d'URL
1169    // est volontairement large (`[^\s<>]+`) pour accepter les chemins et les
1170    // paramètres ; sans ce rognage, « (voir https://example.com) » produisait
1171    // un href « https://example.com) » — lien cassé, constaté en production.
1172    let suffixe='';
1173    const p=/[.,;:!?)\]»…]+$/.exec(m);
1174    if(p){ suffixe=p[0]; m=m.slice(0,-suffixe.length); }
1175    if(!m) return suffixe;
1176    const style='color:var(--leaf);font-weight:700;word-break:break-all;';
1177    if(mail) return '<a href="mailto:'+m+'" style="'+style+'">'+m+'</a>'+suffixe;
1178    const href=/^https?:\/\//i.test(m)?m:'https://'+m;
1179    return '<a href="'+href+'" target="_blank" rel="noopener noreferrer" style="'+style+'">'+m+'</a>'+suffixe;
1180  });
1181}
1182
1183function _renderDirectAnswer(answer){
1184  // answer peut être une string ou un objet {text, links:[{label,url,tel}]}
1185  if(typeof answer==='string'){
1186    // Transformer URLs, courriels et tél en liens cliquables
1187    let t=_melLinkify(answer.replace(/\n/g,'<br>'))
1188      .replace(/(?<!\d)(0[1-9](?:[\s\.\-]?\d{2}){4})(?!\d)/g,'<a href="tel:$1" style="color:var(--leaf);font-weight:700;">📞 $1</a>');
1189    return '<p style="margin:0 0 8px;line-height:1.6;">'+t+'</p>';
1190  }
1191  // Format objet
1192  let h='<p style="margin:0 0 10px;line-height:1.6;">'+(answer.text||'').replace(/\r\n|\r|\n/g,'<br>')+'</p>';
1193  if(answer.links&&answer.links.length){
1194    h+='<div style="display:flex;flex-direction:column;gap:6px;">';
1195    for(const lk of answer.links){
1196      if(lk.action){
1197        var safeAct=lk.action.replace(/[^a-zA-Z0-9_]/g,'');
1198        h+='<button onclick="event.stopPropagation();melRunAction(\''+safeAct+'\')" style="display:block;width:100%;padding:10px 13px;background:var(--forest);color:white;border-radius:9px;text-decoration:none;font-weight:700;font-size:.84rem;border:none;cursor:pointer;font-family:inherit;text-align:left;">'+_melEsc(lk.label)+'</button>';
1199        continue;
1200      }
1201      const href=lk.tel?'tel:'+lk.tel:lk.url;
1202      const target=lk.tel?'':'target="_blank"';
1203      const isLocal=href.startsWith('tel:')||href.startsWith('mailto:');
1204      const clickHandler=isLocal?' onclick="event.stopPropagation();window.location.href='+_melJsArg(href)+';return false;"':'';
1205      h+='<a href="'+_melEsc(href)+'" '+target+clickHandler+' style="display:block;padding:10px 13px;background:var(--forest);color:white;border-radius:9px;text-decoration:none;font-weight:700;font-size:.84rem;">'+_melEsc(lk.label)+'</a>';
1206    }
1207    h+='</div>';
1208  }
1209  return h;
1210}
1211
1212function _showDirectAnswer(answer,cat,topic,ctxForMel,questionLabel){
1213  const l2=document.getElementById('mel-tree-l2');
1214  if(!l2)return;
1215  l2.querySelector('.mel-direct-answer')?.remove();
1216  const container=l2.querySelector('.mel-sub');
1217  if(!container){console.warn('mel-sub not found in l2');return;}
1218  const div=document.createElement('div');
1219  div.className='mel-direct-answer';
1220  // Enrichir le contexte avec le libellé de la question posée
1221  const fullCtx = (questionLabel ? 'Question initiale : '+questionLabel+'. ' : '') + (ctxForMel||'');
1222  div.innerHTML='<span class="da-label">Réponse</span>'
1223    +_renderDirectAnswer(answer)
1224    +'<button class="mel-open-chat-btn" style="margin-top:10px;" onclick="melOpenChatFromDirect('+_melJsArg(cat)+','+_melJsArg(topic)+','+_melJsArg(fullCtx.substring(0,400))+')">💬 Question complémentaire ? Parler à MEL</button>';
1225  container.appendChild(div);
1226  setTimeout(()=>div.scrollIntoView({behavior:'smooth',block:'nearest'}),50);
1227}
1228
1229function melOpenChatFromDirect(cat,topic,ctx){
1230  melOpenChat('',cat,ctx||'',topic||cat);
1231}
1232
1233// ════════════════════════════════════════════════════════
1234// MEL — Bascule vers le mode chat libre
1235// ════════════════════════════════════════════════════════
1236function melOpenChat(initialMsg,category,extraCtx,topic){
1237  _melChatContext=extraCtx||'';
1238  _melChatCategory=category||'autre';
1239  _melChatTopic=topic||category||'autre';
1240
1241  // Construire le contexte complet à injecter dans _melChatContext
1242  // Zone PLU + adresse si disponibles
1243  const zonePrefix = _melZone
1244    ? '[Zone PLU : '+_melZone+(_melAddr?' — '+_melAddr:'')+'] '
1245    : (_melAddr ? '[Adresse : '+_melAddr+'] ' : '');
1246  const catLabel = {
1247    urbanisme:'Urbanisme & Construction',
1248    enfance:'Enfance & Jeunesse',
1249    administratif:'Démarches administratives',
1250    dechets:'Déchets & Propreté',
1251    numerique:'Numérique & Internet',
1252    autre:''
1253  }[_melChatCategory]||'';
1254  const catPrefix = catLabel ? '[Catégorie : '+catLabel+'] ' : '';
1255  if(!_melChatContext) _melChatContext = zonePrefix + catPrefix;
1256  else _melChatContext = zonePrefix + catPrefix + _melChatContext;
1257
1258  // Afficher le chat, cacher l'arbre
1259  const tree=document.getElementById('mel-tree');
1260  const msgs=document.getElementById('msgs');
1261  const mbar=document.querySelector('.mbar');
1262  if(tree)tree.style.display='none';
1263  if(msgs){msgs.style.display='flex';msgs.style.flexDirection='column';}
1264  if(mbar)mbar.style.display='flex';
1265  const privacyHint=document.getElementById('mel-privacy-hint');
1266  if(privacyHint)privacyHint.style.display='block';
1267
1268  // Bouton retour vers l'arbre
1269  if(!document.getElementById('mel-back-btn')){
1270    const backBtn=document.createElement('button');
1271    backBtn.id='mel-back-btn';
1272    backBtn.textContent='← Retour aux thèmes';
1273    backBtn.style.cssText='width:100%;padding:8px;background:var(--mist);border:none;border-radius:8px;font-family:Nunito,sans-serif;font-size:.78rem;font-weight:700;color:var(--leaf);cursor:pointer;margin-bottom:6px;flex-shrink:0;';
1274    backBtn.onclick=()=>{document.getElementById('mel-back-btn')?.remove();melShowTree();};
1275    if(msgs)msgs.parentNode.insertBefore(backBtn,msgs);
1276  }
1277
1278  // Message d'accueil MEL (une seule fois)
1279  if(!melInited){
1280    melInited=true;
1281    addMsg('bot','Bonjour\u00a0! Je suis MEL.\n\nComment puis-je vous aider\u00a0? \ud83d\ude0a');
1282  }
1283
1284  // Reset melBusy (sécurité si état corrompu)
1285  melBusy=false;
1286
1287  const inp=document.getElementById('minp');
1288  if(!inp) return;
1289
1290  // Labels catégories pour le pré-remplissage
1291  const catLabels = {
1292    urbanisme:"une question d'urbanisme",
1293    enfance:"une question sur l'enfance et la jeunesse",
1294    administratif:"une question sur les démarches administratives",
1295    dechets:"une question sur les déchets",
1296    numerique:"une question sur le numérique"
1297  };
1298  const catPhrase = catLabels[_melChatCategory] || '';
1299  const zonePhrase = _melZone ? ', je suis en zone '+_melZone : '';
1300
1301  if(initialMsg && initialMsg.trim()){
1302    inp.value=initialMsg;
1303    inp.placeholder='Votre question…';
1304    setTimeout(()=>{ melBusy=false; sendMel(); }, 200);
1305  } else {
1306    // Format demandé : "J'ai une question [catégorie], je suis en zone [zone]. "
1307    inp.value = catPhrase ? 'J\'ai '+catPhrase+zonePhrase+'. ' : '';
1308    inp.placeholder='Votre question…';
1309    inp.focus();
1310    const len=inp.value.length;
1311    inp.setSelectionRange(len,len);
1312  }
1313}
1314
1315function melResetTree(){
1316  _melCat=null;_melZone=null;_melZoneLabel=null;_melAddr=null;
1317  const l1=document.getElementById('mel-tree-l1');
1318  const l2=document.getElementById('mel-tree-l2');
1319  if(l1)l1.style.display='';
1320  if(l2){l2.classList.remove('active');l2.style.display='';l2.innerHTML='';}
1321}
1322
1323// ════════════════════════════════════════════════════════
1324// MEL — Détection de zone PLU (adresse + GPS)
1325// ════════════════════════════════════════════════════════
1326
1327async function melFindZoneByAddr(){
1328  const inp=document.getElementById('mel-addr-input');
1329  const addr=(inp?.value||'').trim();
1330  if(!addr){_setZoneResult('warn','⚠️ Saisissez une adresse');return;}
1331  document.getElementById('mel-addr-warn')?.remove();
1332  _setZoneResult('','⏳ Recherche…',true);
1333  try{
1334    const q=encodeURIComponent(addr+' Mézières-lez-Cléry 45370');
1335    const geo=await fetch(`https://api-adresse.data.gouv.fr/search/?q=${q}&limit=1&citycode=45204`);
1336    const gd=await geo.json();
1337    const feat=gd?.features?.[0];
1338    if(!feat){_setZoneResult('warn','⚠️ Adresse introuvable. Essayez avec moins de mots ou utilisez le GPS.');return;}
1339    const [lon,lat]=feat.geometry.coordinates;
1340    _melAddr=feat.properties?.label||addr;
1341    await _fetchZonePLU(lat,lon);
1342  }catch(e){_setZoneResult('err','❌ Erreur réseau. Réessayez ou utilisez le GPS.');}
1343}
1344
1345function melFindZoneByGPS(){
1346  if(!navigator.geolocation){_setZoneResult('warn','⚠️ GPS non disponible.');return;}
1347  _setZoneResult('','📍 Localisation…',true);
1348  navigator.geolocation.getCurrentPosition(
1349    async pos=>{
1350      document.getElementById('mel-addr-warn')?.remove();
1351      _melAddr=`GPS (${pos.coords.latitude.toFixed(5)}, ${pos.coords.longitude.toFixed(5)})`;
1352      await _fetchZonePLU(pos.coords.latitude,pos.coords.longitude);
1353    },
1354    ()=>_setZoneResult('warn','⚠️ GPS refusé. Saisissez votre adresse.'),
1355    {enableHighAccuracy:true,timeout:8000}
1356  );
1357}
1358
1359async function _fetchZonePLU(lat,lon){
1360  _melLat=lat; _melLon=lon;
1361  try{
1362    const r=await fetch(`${MEL_BACKEND}/api/zone-plu?lat=${lat}&lon=${lon}`);
1363    const d=await r.json();
1364    if(!d.ok||!d.zone){
1365      _setZoneResult('warn','⚠️ Zone PLU non trouvée automatiquement. Sélectionnez-la manuellement :');
1366      _showManualZoneSelector();return;
1367    }
1368    _melZone=d.zone;_melZoneLabel=d.liblong||'';_melZoneNorm=pluNormalizeZone(d.zone);
1369    _setZoneResult('ok',`✅ Zone PLU détectée : <strong>${d.zone}</strong>${d.liblong?' — '+d.liblong:''}`);    _appendCadastreBtn();
1370  }catch(e){
1371    _setZoneResult('err','❌ Service IGN indisponible. Sélectionnez votre zone :');
1372    _showManualZoneSelector();
1373  }
1374}
1375
1376
1377function _appendCadastreBtn(){
1378  const el=document.getElementById('mel-zone-result');
1379  if(!el||el.querySelector('.mel-cadastre-btn'))return;
1380  const btn=document.createElement('button');
1381  btn.className='mel-cadastre-btn';
1382  btn.style.cssText='margin-top:8px;width:100%;background:#f0f4f8;border:1px solid var(--border);border-radius:10px;padding:8px 12px;font-family:inherit;font-size:0.76rem;font-weight:800;color:var(--forest);cursor:pointer;display:flex;align-items:center;justify-content:center;gap:6px';
1383  btn.innerHTML='🗺️ Ouvrir la carte cadastrale';
1384  btn.onclick=melShowCadastreData;
1385  el.appendChild(btn);
1386  _appendCarte3dBtn(el);
1387}
1388
1389// Passerelle vers la carte 3D. On NE refait PAS de recherche d'adresse :
1390// melFindZoneByAddr / melFindZoneByGPS ont déjà obtenu les coordonnées et la
1391// zone, on les transmet telles quelles. MEL répond pour un point ; la carte
1392// montre ce qu'il y a autour — les deux se complètent au lieu de se doubler.
1393function _appendCarte3dBtn(el){
1394  if(!el||el.querySelector('.mel-carte3d-btn'))return;
1395  if(typeof window.matOuvrirCarte3D!=='function')return;   // module non chargé
1396  const b=document.createElement('button');
1397  b.className='mel-carte3d-btn';
1398  b.style.cssText='margin-top:8px;width:100%;background:var(--mist);border:1px solid var(--sage);border-radius:10px;padding:8px 12px;font-family:inherit;font-size:0.76rem;font-weight:800;color:var(--forest);cursor:pointer;display:flex;align-items:center;justify-content:center;gap:6px';
1399  b.innerHTML='🏘️ Voir ma zone en 3D';
1400  b.onclick=function(){
1401    window.matOuvrirCarte3D({ lat:_melLat, lon:_melLon, zone:_melZoneNorm||_melZone });
1402  };
1403  el.appendChild(b);
1404}
1405
1406async function melShowCadastreData(){
1407  if(_melLat==null||_melLon==null)return;
1408  const el=document.getElementById('mel-zone-result');
1409  const btn=el&&el.querySelector('.mel-cadastre-btn');
1410  if(btn){btn.disabled=true;btn.textContent='⏳ Chargement des données cadastrales…';}
1411  const url=`https://cadastre.data.gouv.fr/map?style=plan#17/${_melLat.toFixed(6)}/${_melLon.toFixed(6)}`;
1412  window.open(url,'_blank','noopener');
1413  if(btn){btn.innerHTML='🗺️ Carte ouverte →';}
1414}
1415
1416function _appendCadastreInfo(html){
1417  const el=document.getElementById('mel-zone-result');
1418  if(!el)return;
1419  let card=el.querySelector('.mel-cadastre-result');
1420  if(!card){card=document.createElement('div');card.className='mel-cadastre-result';el.appendChild(card);}
1421  card.innerHTML=html;
1422}
1423function _showManualZoneSelector(){
1424  const result=document.getElementById('mel-zone-result');
1425  if(!result||result.querySelector('select'))return;
1426  const sel=document.createElement('select');
1427  sel.className='mel-zone-manual';
1428  sel.innerHTML='<option value="">— Sélectionner votre zone —</option>'
1429    +'<option value="Ua">Ua – Centre-bourg / hameaux anciens</option>'
1430    +'<option value="Ub">Ub – Habitat résidentiel récent</option>'
1431    +'<option value="Ub1">Ub1 – Clos de Manthelon</option>'
1432    +'<option value="Ue">Ue – Équipements publics</option>'
1433    +'<option value="Ui">Ui – Zone industrielle</option>'
1434    +'<option value="1AU">1AU – Zone à urbaniser</option>'
1435    +'<option value="2AU">2AU – Réserve foncière (inconstructible)</option>'
1436    +'<option value="A">A – Zone agricole</option>'
1437    +'<option value="Ah">Ah – Hameaux agricoles</option>'
1438    +'<option value="N">N – Zone naturelle</option>'
1439    +'<option value="Nh">Nh – Hameaux naturels</option>'
1440    +'<option value="Nj">Nj – Jardins</option>';
1441  sel.onchange=()=>{
1442    _melZone=sel.value||null;
1443    _melZoneLabel=sel.options[sel.selectedIndex]?.text?.split('–')[1]?.trim()||'';
1444    if(_melZone)_setZoneResult('ok',`✅ Zone sélectionnée : <strong>${_melZone}</strong> — ${_melZoneLabel}`);
1445  };
1446  result.after(sel);
1447}
1448
1449function _setZoneResult(type,html,spin=false){
1450  const el=document.getElementById('mel-zone-result');
1451  if(!el)return;
1452  el.style.display='block';
1453  el.className='mel-zone-result'+(type?' '+type:'');
1454  el.innerHTML=spin?`<span class="mel-zone-spin">⏳</span> ${html}`:html;
1455}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.