Vortex-Flux / src /modules /carte_html.py
jaimalklyde's picture
Claude Haiku 5.5
feat(logistique): carte de renseignement — liens adresses/IP/tel/email/piece entre clients et garants
1030d8c
Raw History Blame Contribute Delete
16 kB
"""
CARTE DE RENSEIGNEMENT — page Mapbox autonome (fond noir, style poste de commandement)
=======================================================================================
Tout se passe DANS la page (aucun aller-retour Streamlit, donc aucune lecture Sheets au
clic) : clic sur un point -> dossier du lieu (personnes, IP, telephones...) + traits vers
tous les lieux relies ; recherche d'une IP / adresse / telephone / nom ; liste des alertes.
Donnees : sortie de core.liens_logistique.analyser() -> donnees_carte() -> construire_html().
Les textes viennent de saisies utilisateur : ils sont toujours inseres par textContent
(jamais innerHTML) et le JSON est protege contre la fermeture de balise <script>.
"""
import json
STYLE_MAPBOX = "mapbox://styles/mapbox/dark-v11"
def donnees_carte(resultat: dict) -> dict:
"""Ajoute a chaque lieu une chaine de recherche en minuscules."""
lieux = []
for l in resultat["lieux"]:
morceaux = [l["label"]]
for m in l["membres"]:
morceaux += [m["id"], m["nom"], *m["tel"], *m["email"], *m["ip"], *m["employeur"], *m["piece"]]
lieux.append({**l, "s": " | ".join(str(x) for x in morceaux if x).lower()})
return {"lieux": lieux, "groupes": resultat["groupes"], "stats": resultat["stats"]}
def construire_html(token: str, donnees: dict, hauteur: int = 760) -> str:
charge = json.dumps(donnees, ensure_ascii=False).replace("</", "<\\/")
return (_MODELE.replace("__DATA__", charge)
.replace("__TOKEN__", json.dumps(token))
.replace("__STYLE__", json.dumps(STYLE_MAPBOX))
.replace("__H__", str(hauteur)))
_MODELE = r"""<!DOCTYPE html>
<html><head><meta charset="utf-8">
<script src="https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js"></script>
<link href="https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css" rel="stylesheet">
<style>
html,body{margin:0;padding:0;background:#000;color:#cfcfcf;font-family:'Courier New',monospace;font-size:12px}
#wrap{position:relative;height:__H__px;border:1px solid #262626;background:#000}
#map{position:absolute;inset:0}
.hud{position:absolute;background:rgba(0,0,0,.88);border:1px solid #2b2b2b;z-index:5}
#left{top:10px;left:10px;width:300px;max-height:calc(100% - 20px);display:flex;flex-direction:column}
#right{top:10px;right:10px;width:360px;max-height:calc(100% - 20px);overflow:auto;display:none}
#legend{bottom:28px;left:10px;padding:7px 9px;font-size:10px;line-height:1.7;color:#8f8f8f}
.tt{font-size:10px;letter-spacing:2px;color:#f2c94c;padding:7px 9px;border-bottom:1px solid #2b2b2b;text-transform:uppercase}
#q{background:#050505;border:0;border-bottom:1px solid #2b2b2b;color:#f0f0f0;width:100%;box-sizing:border-box;
padding:9px;font-family:inherit;font-size:12px;outline:none}
#q:focus{border-bottom-color:#f2c94c}
#nres{padding:4px 9px;color:#8f8f8f;font-size:10px;min-height:14px}
#alertes{overflow:auto;flex:1}
.al{padding:6px 9px;border-bottom:1px solid #141414;cursor:pointer;display:flex;gap:7px;align-items:baseline}
.al:hover,.rel:hover{background:#101010}
.al .v,.rel .v{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#dedede}
.b{font-size:9px;padding:1px 5px;border:1px solid;letter-spacing:1px;white-space:nowrap}
.sec{padding:6px 9px;font-size:10px;letter-spacing:2px;color:#8f8f8f;border-top:1px solid #2b2b2b;
border-bottom:1px solid #141414;text-transform:uppercase}
.pers{padding:8px 9px;border-bottom:1px solid #141414}
.pers .nom{color:#f0f0f0;font-size:12px}
.kv{display:flex;gap:6px;margin-top:2px;color:#8f8f8f;font-size:11px;flex-wrap:wrap}
.kv .k{width:62px;color:#6a6a6a;flex:none}
.val{color:#cfcfcf;cursor:pointer;border-bottom:1px dotted #444}
.val:hover{color:#f2c94c;border-color:#f2c94c}
.rel{padding:7px 9px;border-bottom:1px solid #141414;cursor:pointer}
.rel .l1{display:flex;gap:7px;align-items:baseline}
.rel .l2{color:#6a6a6a;font-size:10px;margin-top:2px}
.btn{display:inline-block;margin:6px 9px;padding:4px 9px;border:1px solid #444;color:#cfcfcf;cursor:pointer;font-size:10px;letter-spacing:1px}
.btn:hover{border-color:#f2c94c;color:#f2c94c}
.x{float:right;cursor:pointer;color:#8f8f8f}.x:hover{color:#f2c94c}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px}
.ln{display:inline-block;width:16px;height:0;border-top:2px solid;margin-right:5px;vertical-align:middle}
.mapboxgl-ctrl-attrib{background:rgba(0,0,0,.6)!important}
::-webkit-scrollbar{width:6px}::-webkit-scrollbar-thumb{background:#2b2b2b}
</style></head><body>
<div id="wrap">
<div id="map"></div>
<div id="left" class="hud">
<div class="tt">Recherche · IP / adresse / tél / nom</div>
<input id="q" placeholder="ex. 41.82.10.5 · 77 123 45 67 · Fass · CLI-2026-0001" autocomplete="off">
<div id="nres"></div>
<div class="tt">Liens détectés</div>
<div id="alertes"></div>
</div>
<div id="right" class="hud"></div>
<div id="legend" class="hud"></div>
</div>
<script>
const DATA = __DATA__;
mapboxgl.accessToken = __TOKEN__;
const NIV = {critique:'#e05a4f', eleve:'#f2994a', attention:'#f2c94c', info:'#9aa7b2', normal:'#6fcf97'};
const NIV_LIB = {critique:'CRITIQUE', eleve:'ÉLEVÉ', attention:'ATTENTION', info:'INFO', normal:'RAS'};
const TYPE_COL = {tel:'#f2c94c', email:'#c792ea', ip:'#4fc3f7', piece:'#e05a4f', adresse:'#ffffff', employeur:'#8f8f8f'};
const LIEUX = DATA.lieux, GROUPES = DATA.groupes;
const G = Object.fromEntries(GROUPES.map(g => [g.gid, g]));
const L = Object.fromEntries(LIEUX.map(l => [l.id, l]));
const $ = id => document.getElementById(id);
function el(tag, cls, txt) { const e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; }
function badge(txt, col) { const b = el('span', 'b', txt); b.style.color = col; b.style.borderColor = col; return b; }
const map = new mapboxgl.Map({container:'map', style:__STYLE__, center:[-17.45, 14.7], zoom:11, attributionControl:true});
map.addControl(new mapboxgl.NavigationControl({showCompass:false}), 'bottom-right');
map.addControl(new mapboxgl.ScaleControl({unit:'metric'}), 'bottom-right');
const vide = () => ({type:'FeatureCollection', features:[]});
const pointsFC = () => ({type:'FeatureCollection', features: LIEUX.map(l => ({
type:'Feature', geometry:{type:'Point', coordinates:[l.lon, l.lat]},
properties:{id:l.id, color:NIV[l.niveau]||NIV.normal, r:6+Math.min(l.n,6)*2.2, n:l.n, decl:l.declare, risque:l.risque}}))});
const ptsOf = ids => ({type:'FeatureCollection', features: ids.map(i => ({type:'Feature',
geometry:{type:'Point', coordinates:[L[i].lon, L[i].lat]}, properties:{id:i, r:6+Math.min(L[i].n,6)*2.2}}))});
let selectedId = null, selectedGid = null;
map.on('load', () => {
map.addSource('lieux', {type:'geojson', data:pointsFC()});
map.addSource('liens', {type:'geojson', data:vide()});
map.addSource('rel', {type:'geojson', data:vide()});
map.addSource('match', {type:'geojson', data:vide()});
map.addLayer({id:'liens-halo', type:'line', source:'liens', layout:{'line-cap':'round'},
paint:{'line-color':['get','color'], 'line-width':7, 'line-opacity':.18, 'line-blur':3}});
map.addLayer({id:'liens', type:'line', source:'liens', layout:{'line-cap':'round'},
paint:{'line-color':['get','color'], 'line-width':2.2,
'line-opacity':['case', ['==',['get','type'],'employeur'], .45, .95]}});
map.addLayer({id:'halo', type:'circle', source:'lieux',
paint:{'circle-radius':['+',['get','r'],7], 'circle-color':['get','color'], 'circle-opacity':.14, 'circle-blur':.8}});
map.addLayer({id:'risque', type:'circle', source:'lieux', filter:['==',['get','risque'],true],
paint:{'circle-radius':['+',['get','r'],4], 'circle-color':'rgba(0,0,0,0)', 'circle-stroke-color':'#e05a4f',
'circle-stroke-width':1.5, 'circle-stroke-opacity':.9}});
map.addLayer({id:'pts', type:'circle', source:'lieux',
paint:{'circle-radius':['get','r'], 'circle-color':['get','color'],
'circle-opacity':['case',['get','decl'],.92,.18], 'circle-stroke-color':['get','color'],
'circle-stroke-width':2, 'circle-stroke-opacity':1}});
map.addLayer({id:'rel', type:'circle', source:'rel',
paint:{'circle-radius':['+',['get','r'],5], 'circle-color':'rgba(0,0,0,0)', 'circle-stroke-color':'#ffffff',
'circle-stroke-width':1.5, 'circle-stroke-opacity':.9}});
map.addLayer({id:'match', type:'circle', source:'match',
paint:{'circle-radius':['+',['get','r'],10], 'circle-color':'rgba(0,0,0,0)', 'circle-stroke-color':'#f2c94c',
'circle-stroke-width':2.5, 'circle-stroke-opacity':1}});
map.addLayer({id:'nb', type:'symbol', source:'lieux', filter:['>',['get','n'],1],
layout:{'text-field':['to-string',['get','n']], 'text-font':['DIN Pro Bold','Arial Unicode MS Bold'], 'text-size':11, 'text-allow-overlap':true},
paint:{'text-color':'#000000'}});
map.on('click', 'pts', e => { select(e.features[0].properties.id, null); });
map.on('click', e => { if (!map.queryRenderedFeatures(e.point, {layers:['pts']}).length) deselect(); });
map.on('mouseenter', 'pts', () => map.getCanvas().style.cursor = 'pointer');
map.on('mouseleave', 'pts', () => map.getCanvas().style.cursor = '');
cadrer(LIEUX.map(l => l.id), false);
});
function cadrer(ids, animer) {
if (!ids.length) return;
const b = new mapboxgl.LngLatBounds();
ids.forEach(i => b.extend([L[i].lon, L[i].lat]));
map.fitBounds(b, {padding:{top:60, bottom:60, left:340, right:selectedId != null ? 400 : 60}, maxZoom:ids.length === 1 ? 15 : 13,
duration: animer === false ? 0 : 700});
}
function lignes(id, groupes) {
const feats = [], vus = new Set(), a = L[id];
groupes.forEach(g => g.lieux.forEach(li => {
if (li === id) return;
const cle = g.type + '|' + li;
if (vus.has(cle)) return; vus.add(cle);
feats.push({type:'Feature', geometry:{type:'LineString', coordinates:[[a.lon, a.lat],[L[li].lon, L[li].lat]]},
properties:{type:g.type, color:TYPE_COL[g.type]||'#fff'}});
}));
return {type:'FeatureCollection', features:feats};
}
function select(id, gid) {
selectedId = id; selectedGid = gid || null;
const lieu = L[id];
const groupes = (selectedGid ? [G[selectedGid]] : lieu.groupes.map(x => G[x]));
map.getSource('liens').setData(lignes(id, groupes));
const lies = [...new Set(groupes.flatMap(g => g.lieux).filter(x => x !== id))];
map.getSource('rel').setData(ptsOf([id, ...lies]));
cadrer([id, ...lies], true);
dossier(lieu, groupes);
}
function deselect() {
selectedId = null; selectedGid = null;
map.getSource('liens').setData(vide()); map.getSource('rel').setData(vide());
$('right').style.display = 'none';
}
function chercher(txt) { $('q').value = txt; recherche(true); }
function valeurs(parent, cle, liste) {
if (!liste || !liste.length) return;
const kv = el('div', 'kv'); kv.appendChild(el('span', 'k', cle));
liste.forEach((v, i) => { const s = el('span', 'val', v); s.title = 'Chercher partout'; s.onclick = () => chercher(v); kv.appendChild(s); });
parent.appendChild(kv);
}
function dossier(lieu, groupes) {
const p = $('right'); p.textContent = ''; p.style.display = 'block';
const t = el('div', 'tt'); const x = el('span', 'x', '✕'); x.onclick = deselect; t.appendChild(x);
t.appendChild(document.createTextNode('Dossier du lieu')); p.appendChild(t);
const h = el('div', 'pers'); h.appendChild(el('div', 'nom', lieu.label));
const kv = el('div', 'kv'); kv.appendChild(badge(NIV_LIB[lieu.niveau] || 'RAS', NIV[lieu.niveau] || NIV.normal));
kv.appendChild(el('span', '', lieu.lat.toFixed(5) + ', ' + lieu.lon.toFixed(5)));
kv.appendChild(el('span', '', lieu.declare ? 'adresse déclarée' : 'position observée uniquement'));
h.appendChild(kv); p.appendChild(h);
p.appendChild(el('div', 'sec', 'Personnes (' + lieu.membres.length + ')'));
lieu.membres.forEach(m => {
const c = el('div', 'pers'); const l1 = el('div', 'kv'); l1.style.marginTop = '0';
l1.appendChild(badge(m.kind === 'garant' ? 'GARANT' : 'CLIENT', m.kind === 'garant' ? '#c792ea' : '#cfcfcf'));
l1.appendChild(el('span', 'val', m.id)).onclick = () => chercher(m.id);
if (m.statut && m.statut !== 'AUCUN') l1.appendChild(badge(m.statut, ['EN_RETARD','LITIGE'].includes(m.statut) ? '#e05a4f' : '#8f8f8f'));
c.appendChild(l1); c.appendChild(el('div', 'nom', m.nom || '—'));
c.appendChild(el('div', 'kv', m.roles.join(' · ')));
valeurs(c, 'TÉL', m.tel); valeurs(c, 'EMAIL', m.email); valeurs(c, 'IP', m.ip);
valeurs(c, 'EMPLOYEUR', m.employeur); valeurs(c, 'PIÈCE', m.piece);
p.appendChild(c);
});
const tous = lieu.groupes.map(x => G[x]);
p.appendChild(el('div', 'sec', 'Relations (' + tous.length + ')'));
if (!tous.length) p.appendChild(el('div', 'pers', 'Aucun recoupement : ce lieu n’est relié à aucun autre.'));
tous.forEach(g => {
const r = el('div', 'rel'); const l1 = el('div', 'l1');
l1.appendChild(badge(g.libelle, TYPE_COL[g.type] || '#fff'));
l1.appendChild(el('span', 'v', g.valeur));
l1.appendChild(badge(NIV_LIB[g.niveau], NIV[g.niveau]));
r.appendChild(l1);
r.appendChild(el('div', 'l2', g.entites.map(e => e.id + ' ' + (e.nom || '')).join(' · ') +
(g.garant_emprunteur ? ' ⚠ garant ↔ emprunteur' : '') + ' · ' + g.lieux.length + ' lieu(x)'));
r.onclick = () => select(selectedId, g.gid);
if (selectedGid === g.gid) r.style.background = '#161616';
p.appendChild(r);
});
if (selectedGid) { const b = el('div', 'btn', 'AFFICHER TOUTES LES RELATIONS'); b.onclick = () => select(selectedId, null); p.appendChild(b); }
}
// ---- alertes ---------------------------------------------------------------
(function () {
const box = $('alertes');
const liste = GROUPES.filter(g => g.niveau !== 'info');
if (!liste.length) box.appendChild(el('div', 'pers', 'Aucun recoupement détecté.'));
liste.slice(0, 60).forEach(g => {
const a = el('div', 'al');
a.appendChild(badge(g.libelle, TYPE_COL[g.type] || '#fff'));
a.appendChild(el('span', 'v', g.valeur));
a.appendChild(badge(String(g.score), NIV[g.niveau]));
a.title = g.entites.map(e => e.id + ' ' + (e.nom || '')).join(' / ');
a.onclick = () => select(g.lieux[0], g.gid);
box.appendChild(a);
});
})();
// ---- recherche --------------------------------------------------------------
function recherche(ouvrir) {
const q = $('q').value.trim().toLowerCase();
if (q.length < 2) { map.getSource('match').setData(vide()); $('nres').textContent = ''; return; }
const ids = LIEUX.filter(l => l.s.includes(q)).map(l => l.id);
map.getSource('match').setData(ptsOf(ids));
$('nres').textContent = ids.length + ' lieu(x) trouvé(s)';
if (ouvrir && ids.length === 1) select(ids[0], null);
else if (ouvrir && ids.length > 1) { deselect(); cadrer(ids, true); }
}
$('q').addEventListener('input', () => recherche(false));
$('q').addEventListener('keydown', e => { if (e.key === 'Enter') recherche(true); });
// ---- legende ---------------------------------------------------------------
(function () {
const lg = $('legend');
const l1 = el('div'); Object.keys(NIV).forEach(k => {
const d = el('span', 'dot'); d.style.background = NIV[k]; l1.appendChild(d); l1.appendChild(document.createTextNode(NIV_LIB[k] + ' '));
}); lg.appendChild(l1);
const l2 = el('div'); ['tel','email','ip','piece','adresse','employeur'].forEach(k => {
const d = el('span', 'ln'); d.style.borderColor = TYPE_COL[k]; l2.appendChild(d);
l2.appendChild(document.createTextNode(({tel:'TÉL',email:'EMAIL',ip:'IP',piece:'PIÈCE',adresse:'ADR.',employeur:'EMPL.'})[k] + ' '));
}); lg.appendChild(l2);
lg.appendChild(el('div', '', 'Plein = adresse déclarée · creux = position observée · anneau rouge = prêt en retard/litige'));
})();
</script></body></html>
"""