Skip to content

Instantly share code, notes, and snippets.

@ColinMaudry
Last active May 12, 2026 10:21
Show Gist options
  • Select an option

  • Save ColinMaudry/2ba36a2fe0ae98999e73eb53472313be to your computer and use it in GitHub Desktop.

Select an option

Save ColinMaudry/2ba36a2fe0ae98999e73eb53472313be to your computer and use it in GitHub Desktop.
JS adapté pour utiliser decp.info et non data.economie
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>Portail Commande Publique — Bêta</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Source+Sans+3:ital,wght@0,300;0,400;0,600;0,700;1,400&family=Source+Code+Pro:wght@400;500&display=swap');
:root {
--blue: #003189;
--blue-dark: #001F5A;
--blue-light: #E8EDF8;
--blue-mid: #B3C2E0;
--green: #18753C;
--green-pale: #DFFBE8;
--orange: #B34000;
--orange-pale: #FFF0DC;
--purple: #6A00F4;
--purple-pale: #EDE3FF;
--teal: #0E7490;
--teal-pale: #E0F2FE;
--grey-50: #F8F9FB;
--grey-100: #EFEFEF;
--grey-400: #9E9E9E;
--grey-600: #666666;
--grey-800: #3A3A3A;
--grey-900: #1E1E1E;
--white: #FFFFFF;
--border: #D8D8D8;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
--shadow-md: 0 2px 8px rgba(0,0,0,0.1);
}
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Source Sans 3',Arial,sans-serif; background:var(--grey-50); color:var(--grey-900); font-size:15px; line-height:1.5; }
/* ══ HEADER ══ */
.site-header { background:var(--white); border-bottom:3px solid var(--blue); padding:0 32px; position:sticky; top:0; z-index:100; }
.site-header-inner { max-width:1280px; margin:0 auto; display:flex; align-items:center; height:64px; gap:16px; }
.site-title { font-size:18px; font-weight:700; color:var(--blue); white-space:nowrap; }
.site-sub { font-size:11px; color:var(--grey-400); margin-top:1px; }
.hd-divider { width:1px; height:32px; background:var(--border); flex-shrink:0; }
.beta-badge { background:var(--orange-pale); color:var(--orange); font-size:10px; font-weight:700; letter-spacing:1px; padding:3px 8px; border-radius:3px; text-transform:uppercase; white-space:nowrap; }
.hd-nav { display:flex; gap:8px; margin-left:auto; align-items:center; }
.hd-nav-btn {
padding:8px 20px; height:40px; display:flex; align-items:center; gap:6px;
font-size:14px; font-weight:600; color:var(--grey-600);
background:var(--grey-100); border:2px solid transparent; border-radius:6px;
cursor:pointer; font-family:inherit; transition:all 0.15s; white-space:nowrap;
}
.hd-nav-btn:hover { background:var(--blue-light); color:var(--blue); border-color:var(--blue-mid); }
.hd-nav-btn.active { background:var(--blue); color:white; border-color:var(--blue); font-weight:700; box-shadow:0 2px 6px rgba(0,49,137,0.3); }
/* ══ BANNER ══ */
.beta-banner { background:#EFF6FF; border-bottom:1px solid var(--blue-mid); padding:7px 32px; font-size:12px; color:var(--blue); display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
.ldot { display:inline-flex; align-items:center; gap:4px; font-size:10px; font-weight:700; text-transform:uppercase; padding:2px 7px; border-radius:3px; letter-spacing:0.5px; }
.ldot.live { background:var(--green-pale); color:var(--green); }
.ldot::before { content:''; width:5px; height:5px; border-radius:50%; }
.ldot.live::before { background:var(--green); animation:pulse 2s infinite; }
@keyframes pulse { 0%,100%{opacity:1;}50%{opacity:0.3;} }
/* ══ VUES ══ */
.view { display:none; }
.view.active { display:block; }
/* ══ VUE RECHERCHE ══ */
.hero { background:linear-gradient(135deg,var(--blue) 0%,var(--blue-dark) 100%); padding:36px 32px 44px; }
.hero-inner { max-width:860px; margin:0 auto; }
.hero h1 { font-size:22px; font-weight:700; color:white; margin-bottom:6px; }
.hero p { font-size:14px; color:rgba(255,255,255,0.6); margin-bottom:22px; }
.search-wrap { background:white; border-radius:4px; display:flex; box-shadow:0 4px 16px rgba(0,0,0,0.25); overflow:hidden; }
.search-icon-wrap { display:flex; align-items:center; padding:0 14px; color:var(--grey-400); border-right:1px solid var(--border); flex-shrink:0; }
#qInput { flex:1; border:none; outline:none; font-size:15px; font-family:inherit; color:var(--grey-900); height:52px; padding:0 14px; }
#qInput::placeholder { color:var(--grey-400); }
#searchBtn { background:var(--blue); color:white; border:none; padding:0 28px; font-size:15px; font-weight:600; font-family:inherit; cursor:pointer; transition:background 0.15s; white-space:nowrap; flex-shrink:0; }
#searchBtn:hover { background:var(--blue-dark); }
.filter-bar { margin-top:12px; display:flex; flex-wrap:wrap; gap:8px; }
.fbtn { display:flex; align-items:center; gap:5px; padding:6px 13px; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.22); border-radius:4px; color:rgba(255,255,255,0.85); font-size:13px; font-family:inherit; cursor:pointer; transition:all 0.15s; }
.fbtn:hover { background:rgba(255,255,255,0.18); }
.fbtn.on { background:white; color:var(--blue); border-color:white; font-weight:600; }
.fbtn .caret { font-size:9px; opacity:0.6; }
.freset { margin-left:auto; background:transparent; border:1px solid rgba(255,255,255,0.25); color:rgba(255,255,255,0.55); font-size:12px; font-family:inherit; padding:5px 11px; border-radius:4px; cursor:pointer; }
.freset:hover { color:white; border-color:white; }
/* PAGE BODY */
.page-body { max-width:1280px; margin:0 auto; padding:28px 32px 60px; }
/* ÉTAT INITIAL */
.initial-state { text-align:center; padding:60px 20px; color:var(--grey-400); }
.initial-state .big-icon { font-size:48px; margin-bottom:16px; }
.initial-state p { font-size:15px; line-height:1.6; }
.initial-state strong { color:var(--grey-600); }
/* FICHE */
.result-header { background:white; border:1px solid var(--border); border-left:4px solid var(--blue); border-radius:4px; padding:18px 22px; margin-bottom:22px; display:flex; align-items:flex-start; justify-content:space-between; gap:20px; box-shadow:var(--shadow-sm); }
.rh-name { font-size:18px; font-weight:700; color:var(--blue); margin-bottom:3px; }
.kpis { display:flex; gap:10px; flex-shrink:0; }
.kpi { text-align:center; padding:10px 16px; background:var(--grey-50); border:1px solid var(--border); border-radius:4px; min-width:88px; }
.kpi-val { font-size:20px; font-weight:700; color:var(--blue); display:block; }
.kpi-lbl { font-size:10px; color:var(--grey-600); margin-top:2px; line-height:1.3; }
/* ONGLETS */
.tabs { display:flex; border-bottom:2px solid var(--border); margin-bottom:22px; }
.tab { padding:10px 18px; font-size:14px; font-weight:500; color:var(--grey-600); cursor:pointer; border-bottom:3px solid transparent; margin-bottom:-2px; display:flex; align-items:center; gap:6px; transition:color 0.15s; }
.tab:hover { color:var(--blue); }
.tab.active { color:var(--blue); border-bottom-color:var(--blue); font-weight:700; }
.tab .tbadge { background:var(--grey-100); color:var(--grey-600); padding:1px 7px; border-radius:10px; font-size:11px; }
.tab.active .tbadge { background:var(--blue-light); color:var(--blue); }
.tab-pane { display:none; }
.tab-pane.active { display:block; }
/* LAYOUT 2 COL */
.two-col { display:grid; grid-template-columns:240px 1fr; gap:18px; align-items:start; }
/* SIDEBAR */
.sidebar { background:white; border:1px solid var(--border); border-radius:4px; overflow:hidden; box-shadow:var(--shadow-sm); }
.sb-hd { padding:10px 14px; font-size:11px; font-weight:700; color:var(--grey-600); text-transform:uppercase; letter-spacing:0.6px; background:var(--grey-50); border-bottom:1px solid var(--border); }
.fg { padding:12px 14px; border-bottom:1px solid var(--border); }
.fg:last-child { border-bottom:none; }
.fg-lbl { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.5px; color:var(--grey-800); margin-bottom:7px; }
.fo { display:flex; align-items:center; justify-content:space-between; padding:3px 0; cursor:pointer; }
.fo-l { display:flex; align-items:center; gap:7px; }
.fo-t { font-size:13px; color:var(--grey-800); }
.fo:hover .fo-t { color:var(--blue); }
.fo-c { font-size:10px; color:var(--grey-400); background:var(--grey-100); padding:1px 6px; border-radius:10px; }
.fo input[type=checkbox] { width:14px; height:14px; accent-color:var(--blue); cursor:pointer; }
.src-box { padding:9px 11px; background:var(--grey-50); border:1px solid var(--border); border-radius:3px; font-size:11px; color:var(--grey-600); line-height:1.5; margin-top:4px; }
/* RESULT CARDS */
.result-list { display:flex; flex-direction:column; gap:10px; }
.rcard { background:white; border:1px solid var(--border); border-radius:4px; padding:14px 18px; cursor:pointer; transition:all 0.15s; box-shadow:var(--shadow-sm); position:relative; }
.rcard:hover { border-color:var(--blue); box-shadow:var(--shadow-md); }
.rcard.isnew::after { content:'Nouveau'; position:absolute; top:10px; right:10px; background:var(--green); color:white; font-size:9px; font-weight:700; letter-spacing:1px; padding:2px 7px; border-radius:2px; }
.rc-top { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; margin-bottom:7px; }
.rc-title { font-size:14px; font-weight:600; color:var(--blue); text-decoration:none; line-height:1.4; }
.rc-title:hover { text-decoration:underline; }
.rc-badges { display:flex; gap:5px; flex-shrink:0; }
.badge { display:inline-flex; align-items:center; padding:3px 8px; border-radius:3px; font-size:11px; font-weight:600; white-space:nowrap; }
.badge.ouvert { background:var(--green-pale); color:var(--green); }
.badge.prog { background:var(--orange-pale); color:var(--orange); }
.badge.attr { background:var(--purple-pale); color:var(--purple); }
.rc-meta { display:flex; flex-wrap:wrap; gap:12px; font-size:12px; color:var(--grey-600); }
.rc-mi { display:flex; align-items:center; gap:4px; }
.rc-mi strong { color:var(--grey-900); font-weight:600; }
.rc-ft { display:flex; align-items:center; justify-content:space-between; margin-top:9px; padding-top:9px; border-top:1px solid var(--grey-100); }
.rc-src { display:flex; align-items:center; gap:5px; font-size:11px; color:var(--grey-400); }
.rc-lnk { font-size:12px; color:var(--blue); text-decoration:none; font-weight:600; }
.rc-lnk:hover { text-decoration:underline; }
/* LOADING / EMPTY */
.loading-state { display:flex; align-items:center; gap:10px; padding:22px 16px; color:var(--grey-600); font-size:13px; background:white; border:1px solid var(--border); border-radius:4px; }
.spinner { width:18px; height:18px; border:2px solid var(--border); border-top-color:var(--blue); border-radius:50%; animation:spin 0.7s linear infinite; flex-shrink:0; }
@keyframes spin { to { transform:rotate(360deg); } }
.empty-state { padding:22px 16px; color:var(--grey-600); font-size:13px; background:white; border:1px solid var(--border); border-radius:4px; line-height:1.6; }
.empty-state a { color:var(--blue); }
/* ══ VUE OBSERVATOIRE ══ */
.obs-hero { background:linear-gradient(135deg,var(--blue) 0%,var(--blue-dark) 100%); padding:36px 32px 44px; }
.obs-hero-inner { max-width:1280px; margin:0 auto; }
.obs-hero h1 { font-size:22px; font-weight:700; color:white; margin-bottom:6px; }
.obs-hero p { font-size:14px; color:rgba(255,255,255,0.65); margin-bottom:24px; }
/* FILTRES OBSERVATOIRE */
.obs-filters { display:flex; flex-wrap:wrap; gap:10px; align-items:center; }
.obs-filter-group { display:flex; flex-direction:column; gap:4px; }
.obs-filter-label { font-size:10px; color:rgba(255,255,255,0.5); text-transform:uppercase; letter-spacing:1px; font-weight:600; }
.obs-filter-select { padding:7px 12px; border-radius:4px; border:1px solid rgba(255,255,255,0.25); background:rgba(255,255,255,0.1); color:white; font-size:13px; font-family:inherit; cursor:pointer; }
.obs-filter-select option { background:var(--blue-dark); color:white; }
.obs-search-btn { padding:8px 20px; background:white; color:var(--blue); border:none; border-radius:4px; font-size:14px; font-weight:700; font-family:inherit; cursor:pointer; transition:all 0.15s; align-self:flex-end; }
.obs-search-btn:hover { background:var(--blue-light); }
/* OBSERVATOIRE BODY */
.obs-body { max-width:1280px; margin:0 auto; padding:28px 32px 60px; }
/* AXES TABS */
.obs-axes { display:flex; gap:8px; margin-bottom:24px; flex-wrap:wrap; }
.obs-axis-btn {
display:flex; align-items:center; gap:6px; padding:8px 16px;
border-radius:4px; border:2px solid var(--border);
background:white; color:var(--grey-600);
font-size:13px; font-weight:500; font-family:inherit; cursor:pointer;
transition:all 0.15s;
}
.obs-axis-btn:hover { border-color:var(--blue); color:var(--blue); }
.obs-axis-btn.active.acheteur { background:var(--blue-light); color:var(--blue); border-color:var(--blue); font-weight:700; }
.obs-axis-btn.active.territoire { background:var(--green-pale); color:var(--green); border-color:var(--green); font-weight:700; }
.obs-axis-btn.active.secteur { background:var(--orange-pale); color:var(--orange); border-color:var(--orange); font-weight:700; }
.obs-axis-btn.active.titulaire { background:var(--purple-pale); color:var(--purple); border-color:var(--purple); font-weight:700; }
/* CONTEXTE OBSERVATOIRE */
.obs-context {
background:white; border:1px solid var(--border); border-radius:4px;
padding:14px 18px; margin-bottom:20px;
display:flex; align-items:center; gap:12px; flex-wrap:wrap;
box-shadow:var(--shadow-sm);
}
.obs-ctx-scope { font-size:14px; font-weight:700; color:var(--grey-900); }
.obs-ctx-sub { font-size:12px; color:var(--grey-400); font-style:italic; }
.obs-ctx-right { margin-left:auto; font-size:12px; color:var(--grey-400); }
/* GRILLES ET CHARTS */
.obs-kpi-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:20px; }
.obs-kpi { background:white; border:1px solid var(--border); border-radius:4px; padding:16px; box-shadow:var(--shadow-sm); }
.obs-kpi-val { font-size:26px; font-weight:700; color:var(--blue); }
.obs-kpi-lbl { font-size:12px; color:var(--grey-600); margin-top:3px; }
.obs-kpi-src { margin-top:6px; }
.obs-charts-grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:20px; }
.chart-card { background:white; border:1px solid var(--border); border-radius:4px; overflow:hidden; box-shadow:var(--shadow-sm); }
.chart-hd { padding:12px 16px; border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; }
.chart-title { font-size:13px; font-weight:700; color:var(--grey-900); }
.chart-sub { font-size:11px; color:var(--grey-400); }
.chart-body { padding:14px 16px; }
.bar-row { display:grid; grid-template-columns:150px 1fr 75px; align-items:center; gap:8px; margin-bottom:6px; }
.bar-lbl { font-size:11px; color:var(--grey-800); text-align:right; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.bar-track { background:var(--grey-100); border-radius:2px; height:16px; overflow:hidden; }
.bar-fill { height:100%; border-radius:2px; transition:width 0.8s ease; }
.bar-val { font-size:11px; color:var(--grey-600); font-weight:600; }
.data-table { width:100%; border-collapse:collapse; font-size:12px; }
.data-table th { background:var(--grey-50); padding:7px 10px; text-align:left; font-size:10px; font-weight:700; color:var(--grey-600); text-transform:uppercase; letter-spacing:0.5px; border-bottom:2px solid var(--border); }
.data-table td { padding:7px 10px; border-bottom:1px solid var(--grey-100); color:var(--grey-800); }
.data-table tr:hover td { background:var(--grey-50); }
.data-table .num { font-weight:700; color:var(--grey-400); text-align:center; }
.data-table .amt { font-weight:600; color:var(--blue); text-align:right; }
/* ALERTE DONNÉES */
.data-alert { background:#FFFBEB; border:1px solid #FCD34D; border-radius:4px; padding:12px 16px; font-size:12px; color:#92400E; line-height:1.6; margin-bottom:20px; }
/* DECP PROMO */
.decp-promo { background:var(--blue-light); border:1px solid var(--blue-mid); border-radius:4px; padding:14px 18px; display:flex; align-items:center; gap:14px; margin-top:20px; }
.decp-promo-title { font-weight:700; color:var(--blue); font-size:13px; margin-bottom:3px; }
.decp-promo-text { font-size:12px; color:var(--grey-600); line-height:1.5; }
.decp-promo-btn { background:var(--blue); color:white; padding:8px 16px; border-radius:3px; text-decoration:none; font-size:13px; font-weight:600; white-space:nowrap; flex-shrink:0; }
.decp-promo-btn:hover { background:var(--blue-dark); }
/* FOOTER */
footer { background:var(--grey-900); color:rgba(255,255,255,0.5); padding:20px 32px; font-size:12px; margin-top:48px; text-align:center; }
footer a { color:rgba(255,255,255,0.7); text-decoration:none; }
</style>
</head>
<body>
<!-- HEADER -->
<header class="site-header">
<div class="site-header-inner">
<div>
<div class="site-title">Portail Commande Publique</div>
<div class="site-sub">Consultations · Programmations · Données d'attribution</div>
</div>
<div class="hd-divider"></div>
<span class="beta-badge">Bêta</span>
<div class="hd-nav">
<button class="hd-nav-btn active" id="navRecherche" onclick="showView('recherche')">🔍 Recherche</button>
<button class="hd-nav-btn" id="navObservatoire" onclick="showView('observatoire')">📊 Observatoire</button>
</div>
</div>
</header>
<!-- BANNER -->
<div class="beta-banner">
<span>Données en temps réel :</span>
<span class="ldot live">BOAMP</span>
<span class="ldot live">DECP data.gouv.fr</span>
<span class="ldot live">APProch data.gouv.fr</span>
</div>
<!-- ══════════════════════════════════════════
VUE RECHERCHE
══════════════════════════════════════════ -->
<div class="view active" id="view-recherche">
<div class="hero">
<div class="hero-inner">
<h1>Et si tous les marchés publics étaient accessibles en un point unique ?</h1>
<p>Consultations en cours · Programmations prévisionnelles · Données d'attribution</p>
<div class="search-wrap">
<div class="search-icon-wrap">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
</div>
<input type="text" id="qInput" placeholder="Nom d'acheteur, objet du marché, code CPV, territoire…"
onkeydown="if(event.key==='Enter') lancerRecherche()" />
<button id="searchBtn" onclick="lancerRecherche()">Rechercher</button>
</div>
<div class="filter-bar">
<button class="fbtn" onclick="this.classList.toggle('on')">Période <span class="caret"></span></button>
<button class="fbtn" onclick="this.classList.toggle('on')">Catégorie d'achat <span class="caret"></span></button>
<button class="fbtn" onclick="this.classList.toggle('on')">Montant estimé <span class="caret"></span></button>
<button class="fbtn" onclick="this.classList.toggle('on')">Périmètre géographique <span class="caret"></span></button>
<button class="fbtn" onclick="this.classList.toggle('on')">Code CPV <span class="caret"></span></button>
<button class="freset" onclick="document.querySelectorAll('.fbtn').forEach(b=>b.classList.remove('on'))">Réinitialiser</button>
</div>
</div>
</div>
<div class="page-body">
<div class="initial-state" id="initialState">
<div class="big-icon">🔍</div>
<p>
Saisissez le nom d'un acheteur public, un objet de marché ou un code CPV.<br>
<strong>Exemple : Euroméditerranée · Métropole Aix-Marseille · MOE aménagement · 45</strong>
</p>
</div>
<div id="resultsZone" style="display:none;">
<!-- FICHE -->
<div class="result-header">
<div>
<div class="rh-name" id="rhName"></div>
<div style="font-size:13px;color:var(--grey-400);margin-top:4px;" id="rhSub"></div>
</div>
<div class="kpis">
<div class="kpi"><span class="kpi-val" id="kpiBoamp"></span><div class="kpi-lbl">Avis BOAMP</div></div>
<div class="kpi"><span class="kpi-val" id="kpiApproch"></span><div class="kpi-lbl">Projets APProch</div></div>
<div class="kpi"><span class="kpi-val" id="kpiDecp"></span><div class="kpi-lbl">Marchés attribués</div></div>
<div class="kpi"><span class="kpi-val" id="kpiVol"></span><div class="kpi-lbl">Volume (DECP)</div></div>
</div>
</div>
<!-- ONGLETS -->
<div class="tabs">
<div class="tab active" id="tabBoamp" onclick="switchTab('boamp',this)">📋 Avis BOAMP <span class="tbadge" id="tBoamp"></span></div>
<div class="tab" id="tabApproch" onclick="switchTab('approch',this)">📅 Programmation <span class="tbadge" id="tApproch"></span></div>
<div class="tab" id="tabDecp" onclick="switchTab('decp',this)">✅ Marchés attribués <span class="tbadge" id="tDecp"></span></div>
<div class="tab" id="tabObs" onclick="switchTab('obs',this)">📊 Observatoire <span style="font-size:11px;color:var(--grey-400);">· zoom</span></div>
</div>
<!-- BOAMP -->
<div class="tab-pane active" id="pane-boamp">
<div class="two-col">
<div class="sidebar">
<div class="sb-hd">Affiner</div>
<div class="fg">
<div class="fg-lbl">Type d'avis</div>
<div class="fo"><label class="fo-l"><input type="checkbox" checked /><span class="fo-t">Appel à la concurrence</span></label><span class="fo-c" id="fc-aac"></span></div>
<div class="fo"><label class="fo-l"><input type="checkbox" checked /><span class="fo-t">Avis d'attribution</span></label><span class="fo-c" id="fc-attr"></span></div>
</div>
<div class="fg">
<div class="fg-lbl">Période</div>
<div class="fo"><label class="fo-l"><input type="checkbox" checked /><span class="fo-t">2025 — 2026</span></label><span class="fo-c" id="fc-rec"></span></div>
<div class="fo"><label class="fo-l"><input type="checkbox" /><span class="fo-t">2023 — 2024</span></label><span class="fo-c" id="fc-old"></span></div>
</div>
<div class="fg"><div class="src-box"><span class="ldot live">LIVE</span> BOAMP · DILA<br><span style="font-size:10px;color:var(--grey-400)">Mis à jour 2×/jour</span></div></div>
</div>
<div id="boampList"><div class="loading-state"><div class="spinner"></div>Chargement des avis BOAMP…</div></div>
</div>
</div>
<!-- APPROCH -->
<div class="tab-pane" id="pane-approch">
<div class="two-col">
<div class="sidebar">
<div class="sb-hd">Affiner</div>
<div class="fg">
<div class="fg-lbl">Catégorie</div>
<div class="fo"><label class="fo-l"><input type="checkbox" checked /><span class="fo-t">Travaux</span></label><span class="fo-c" id="fp-t"></span></div>
<div class="fo"><label class="fo-l"><input type="checkbox" checked /><span class="fo-t">Services</span></label><span class="fo-c" id="fp-s"></span></div>
<div class="fo"><label class="fo-l"><input type="checkbox" checked /><span class="fo-t">Fournitures</span></label><span class="fo-c" id="fp-f"></span></div>
</div>
<div class="fg"><div class="src-box"><span class="ldot live">LIVE</span> APProch · data.gouv.fr<br><span style="font-size:10px;color:var(--grey-400)">Publication volontaire</span></div></div>
</div>
<div id="approchList"><div class="loading-state"><div class="spinner"></div>Chargement des données APProch…</div></div>
</div>
</div>
<!-- DECP -->
<div class="tab-pane" id="pane-decp">
<div class="two-col">
<div class="sidebar">
<div class="sb-hd">Affiner</div>
<div class="fg">
<div class="fg-lbl">Année</div>
<div class="fo"><label class="fo-l"><input type="checkbox" checked /><span class="fo-t">2025</span></label></div>
<div class="fo"><label class="fo-l"><input type="checkbox" checked /><span class="fo-t">2024</span></label></div>
<div class="fo"><label class="fo-l"><input type="checkbox" /><span class="fo-t">2023</span></label></div>
<div class="fo"><label class="fo-l"><input type="checkbox" /><span class="fo-t">2022</span></label></div>
</div>
<div class="fg">
<div class="fg-lbl">Montant</div>
<div class="fo"><label class="fo-l"><input type="checkbox" checked /><span class="fo-t">&lt; 100 k€</span></label></div>
<div class="fo"><label class="fo-l"><input type="checkbox" checked /><span class="fo-t">100 k€ — 1 M€</span></label></div>
<div class="fo"><label class="fo-l"><input type="checkbox" checked /><span class="fo-t">&gt; 1 M€</span></label></div>
</div>
<div class="fg"><div class="src-box"><span class="ldot live">LIVE</span> DECP · data.economie.gouv.fr<br><span style="font-size:10px;color:var(--grey-400)">Rechargé quotidiennement</span></div></div>
</div>
<div id="decpList"><div class="loading-state"><div class="spinner"></div>Chargement des données DECP…</div></div>
</div>
</div>
<!-- OBS ZOOM (résultats recherche) -->
<div class="tab-pane" id="pane-obs">
<div class="data-alert">
📊 Cet Observatoire est filtré sur votre recherche en cours. Pour une vue nationale sans filtre, accédez à l'<a href="#" onclick="showView('observatoire')" style="color:var(--blue);font-weight:600;">Observatoire →</a>
</div>
<div class="obs-kpi-grid">
<div class="obs-kpi"><div class="obs-kpi-val" id="zo-total"></div><div class="obs-kpi-lbl">Marchés attribués</div><div class="obs-kpi-src"><span class="ldot live">LIVE</span></div></div>
<div class="obs-kpi"><div class="obs-kpi-val" id="zo-vol"></div><div class="obs-kpi-lbl">Volume total</div><div class="obs-kpi-src"><span class="ldot live">LIVE</span></div></div>
<div class="obs-kpi"><div class="obs-kpi-val" id="zo-moy"></div><div class="obs-kpi-lbl">Montant moyen</div><div class="obs-kpi-src"><span class="ldot live">LIVE</span></div></div>
<div class="obs-kpi"><div class="obs-kpi-val" id="zo-tit"></div><div class="obs-kpi-lbl">Titulaires distincts</div><div class="obs-kpi-src"><span class="ldot live">LIVE</span></div></div>
</div>
<div class="obs-charts-grid">
<div class="chart-card">
<div class="chart-hd"><div><div class="chart-title">Volume par année</div><div class="chart-sub">DECP · notification</div></div><span class="ldot live">LIVE</span></div>
<div class="chart-body"><div id="zo-annual"></div></div>
</div>
<div class="chart-card">
<div class="chart-hd"><div><div class="chart-title">Top titulaires</div><div class="chart-sub">Par volume</div></div><span class="ldot live">LIVE</span></div>
<div class="chart-body">
<table class="data-table"><thead><tr><th style="width:24px">#</th><th>Titulaire</th><th style="text-align:right">Volume</th><th style="text-align:right">Nb</th></tr></thead>
<tbody id="zo-tit-rows"></tbody></table>
</div>
</div>
<div class="chart-card">
<div class="chart-hd"><div><div class="chart-title">Répartition par type</div><div class="chart-sub">Code CPV</div></div><span class="ldot live">LIVE</span></div>
<div class="chart-body"><div id="zo-type"></div></div>
</div>
<div class="chart-card">
<div class="chart-hd"><div><div class="chart-title">Top secteurs CPV</div><div class="chart-sub">Par volume</div></div><span class="ldot live">LIVE</span></div>
<div class="chart-body"><div id="zo-cpv"></div></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════
VUE OBSERVATOIRE
══════════════════════════════════════════ -->
<div class="view" id="view-observatoire">
<div class="obs-hero">
<div class="obs-hero-inner">
<h1>Observatoire de la Commande Publique</h1>
<p>Vue nationale sur les données d'attribution · Filtrez par année, secteur ou territoire</p>
<div class="obs-filters">
<div class="obs-filter-group">
<span class="obs-filter-label">Année</span>
<select class="obs-filter-select" id="obsYear">
<option value="">Toutes années</option>
<option value="2025">2025</option>
<option value="2024" selected>2024</option>
<option value="2023">2023</option>
<option value="2022">2022</option>
</select>
</div>
<div class="obs-filter-group">
<span class="obs-filter-label">Type d'acheteur</span>
<select class="obs-filter-select" id="obsTypeAcheteur">
<option value="">Tous types</option>
<option value="commune">Communes</option>
<option value="departement">Départements</option>
<option value="region">Régions</option>
<option value="etat">État / EPA</option>
<option value="hopital">Hôpitaux</option>
</select>
</div>
<div class="obs-filter-group">
<span class="obs-filter-label">Recherche libre</span>
<input type="text" id="obsQ" class="obs-filter-select" placeholder="Acheteur, secteur, territoire…" style="width:220px;" onkeydown="if(event.key==='Enter') lancerObservatoire()" />
</div>
<button class="obs-search-btn" onclick="lancerObservatoire()">Actualiser</button>
</div>
</div>
</div>
<div class="obs-body">
<!-- AXES -->
<div class="obs-axes">
<button class="obs-axis-btn active acheteur" data-axis="acheteur" onclick="switchObsAxis('acheteur',this)">🏛️ Par acheteur</button>
<button class="obs-axis-btn territoire" data-axis="territoire" onclick="switchObsAxis('territoire',this)">📍 Par territoire</button>
<button class="obs-axis-btn secteur" data-axis="secteur" onclick="switchObsAxis('secteur',this)">🏷️ Par secteur CPV</button>
<button class="obs-axis-btn titulaire" data-axis="titulaire" onclick="switchObsAxis('titulaire',this)">🏢 Par titulaire</button>
<span style="margin-left:auto;font-size:12px;color:var(--grey-400);align-self:center;" id="obsAxisDesc">Vue par acheteur · Top acheteurs et leurs fournisseurs</span>
</div>
<!-- CONTEXTE -->
<div class="obs-context">
<div>
<div class="obs-ctx-scope" id="obsCtxScope">Vue nationale · Toutes données DECP</div>
<div class="obs-ctx-sub" id="obsCtxSub">Chargement en cours…</div>
</div>
<div class="obs-ctx-right"><span class="ldot live">LIVE</span> DECP data.economie.gouv.fr</div>
</div>
<!-- ALERTE COUVERTURE -->
<div class="data-alert">
⚠️ <strong>Couverture partielle</strong> — Ces données représentent les marchés publiés en open data (DECP ≥ 40 000 €). Les consultations en cours (PLACE) et les programmations prévisionnelles (APProch) ne sont pas encore intégrées à l'échelle nationale — c'est précisément l'objet de ce portail.
</div>
<!-- KPIs -->
<div class="obs-kpi-grid">
<div class="obs-kpi"><div class="obs-kpi-val" id="o-total"></div><div class="obs-kpi-lbl" id="o-total-lbl">Marchés attribués</div><div class="obs-kpi-src"><span class="ldot live">LIVE</span></div></div>
<div class="obs-kpi"><div class="obs-kpi-val" id="o-vol"></div><div class="obs-kpi-lbl">Volume total</div><div class="obs-kpi-src"><span class="ldot live">LIVE</span></div></div>
<div class="obs-kpi"><div class="obs-kpi-val" id="o-moy"></div><div class="obs-kpi-lbl">Montant moyen</div><div class="obs-kpi-src"><span class="ldot live">LIVE</span></div></div>
<div class="obs-kpi"><div class="obs-kpi-val" id="o-tit"></div><div class="obs-kpi-lbl" id="o-tit-lbl">Titulaires distincts</div><div class="obs-kpi-src"><span class="ldot live">LIVE</span></div></div>
</div>
<!-- GRAPHIQUES -->
<div class="obs-charts-grid">
<div class="chart-card">
<div class="chart-hd"><div><div class="chart-title">Volume attribué par année</div><div class="chart-sub">DECP · date de notification</div></div><span class="ldot live">LIVE</span></div>
<div class="chart-body"><div id="o-annual"><div class="loading-state"><div class="spinner"></div>Chargement…</div></div></div>
</div>
<div class="chart-card">
<div class="chart-hd"><div><div class="chart-title" id="o-chart2-title">Top titulaires par volume</div><div class="chart-sub" id="o-chart2-sub">Toutes années</div></div><span class="ldot live">LIVE</span></div>
<div class="chart-body">
<table class="data-table"><thead><tr><th style="width:24px">#</th><th id="o-chart2-col">Titulaire</th><th style="text-align:right">Volume</th><th style="text-align:right">Nb</th></tr></thead>
<tbody id="o-chart2-rows"><tr><td colspan="4" style="padding:14px;text-align:center;color:var(--grey-400)">Chargement…</td></tr></tbody></table>
</div>
</div>
<div class="chart-card">
<div class="chart-hd"><div><div class="chart-title">Répartition par type de marché</div><div class="chart-sub">DECP · code CPV</div></div><span class="ldot live">LIVE</span></div>
<div class="chart-body"><div id="o-type"><div class="loading-state"><div class="spinner"></div>Chargement…</div></div></div>
</div>
<div class="chart-card">
<div class="chart-hd"><div><div class="chart-title" id="o-chart4-title">Top acheteurs par volume</div><div class="chart-sub" id="o-chart4-sub">Pour cette sélection</div></div><span class="ldot live">LIVE</span></div>
<div class="chart-body"><div id="o-chart4"><div class="loading-state"><div class="spinner"></div>Chargement…</div></div></div>
</div>
</div>
<div class="decp-promo">
<div style="font-size:24px;flex-shrink:0;">🔗</div>
<div>
<div class="decp-promo-title">Ces données sont propulsées par decp.info</div>
<div class="decp-promo-text">Ce portail s'appuie sur le travail de Colin Maudry (decp.info 2.7.0). Analyses avancées, tendances nationales et données enrichies disponibles sur son Observatoire.</div>
</div>
<a class="decp-promo-btn" href="https://decp.info/observatoire" target="_blank">Voir l'Observatoire →</a>
</div>
</div>
</div>
<footer>
Données : <a href="https://www.boamp.fr">BOAMP · DILA</a> · <a href="https://data.gouv.fr">DECP data.gouv.fr</a> · <a href="https://projets-achats.marches-publics.gouv.fr">APProch</a> · <a href="https://decp.info">decp.info</a>
</footer>
<script>
const PROXY = 'https://magaliparlemarches.fr/.netlify/functions/proxy?url=';
function px(url) { return PROXY + encodeURIComponent(url); }
const DECP_API = 'https://tabular-api.data.gouv.fr/api/resources/22847056-61df-452d-837d-8b8ceadbfc52/data/';
async function fetchDecp(params) {
const url = new URL(DECP_API);
Object.entries(params).forEach(([k, v]) => { if (v !== null && v !== undefined && v !== '') url.searchParams.set(k, v); });
if (!url.searchParams.has('page_size')) url.searchParams.set('page_size', '100');
const r = await fetch(url.toString());
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return (await r.json()).data || [];
}
let decpData = [];
let obsData = [];
let currentObsAxis = 'acheteur';
// ══ NAVIGATION ══
function showView(v) {
document.querySelectorAll('.view').forEach(el => el.classList.remove('active'));
document.getElementById('view-' + v).classList.add('active');
document.getElementById('navRecherche').classList.toggle('active', v === 'recherche');
document.getElementById('navObservatoire').classList.toggle('active', v === 'observatoire');
if (v === 'observatoire' && !obsData.length) lancerObservatoire();
}
// ══ RECHERCHE ══
function lancerRecherche() {
const q = document.getElementById('qInput').value.trim();
if (!q) return;
decpData = [];
document.getElementById('initialState').style.display = 'none';
document.getElementById('resultsZone').style.display = 'block';
document.getElementById('rhName').textContent = q;
document.getElementById('rhSub').textContent = 'Recherche en cours…';
['kpiBoamp','kpiApproch','kpiDecp','kpiVol'].forEach(id => document.getElementById(id).textContent = '…');
['tBoamp','tApproch','tDecp'].forEach(id => document.getElementById(id).textContent = '…');
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
document.getElementById('tabBoamp').classList.add('active');
document.getElementById('pane-boamp').classList.add('active');
document.getElementById('boampList').innerHTML = `<div class="loading-state"><div class="spinner"></div>Chargement des avis BOAMP…</div>`;
document.getElementById('approchList').innerHTML = `<div class="loading-state"><div class="spinner"></div>Chargement des données APProch…</div>`;
document.getElementById('decpList').innerHTML = `<div class="loading-state"><div class="spinner"></div>Chargement des données DECP…</div>`;
loadBoamp(q);
loadApproch(q);
loadDecp(q);
}
function switchTab(id, el) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
el.classList.add('active');
document.getElementById('pane-' + id).classList.add('active');
if (id === 'obs' && decpData.length) renderZoomObs(decpData);
}
// ══ BOAMP ══
async function loadBoamp(q) {
try {
// API v1 BOAMP via opendatasoft - plus permissive
const apiUrl = new URL('https://boamp-datadila.opendatasoft.com/api/records/1.0/search/');
apiUrl.searchParams.set('dataset', 'boamp');
apiUrl.searchParams.set('q', q);
apiUrl.searchParams.set('rows', '25');
apiUrl.searchParams.set('sort', '-dateparution');
const r = await fetch(px(apiUrl.toString()));
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const d = await r.json();
const records = (d.records || []).map(rec => rec.fields || rec);
renderBoamp(records);
} catch(e) {
document.getElementById('boampList').innerHTML = `<div class="empty-state">Données BOAMP indisponibles (${e.message}). <a href="https://www.boamp.fr/pages/recherche/" target="_blank">Consulter BOAMP →</a></div>`;
document.getElementById('kpiBoamp').textContent = '—';
document.getElementById('tBoamp').textContent = '—';
}
}
function renderBoamp(records) {
const count = records.length;
document.getElementById('kpiBoamp').textContent = count || '0';
document.getElementById('tBoamp').textContent = count;
if (!count) { document.getElementById('boampList').innerHTML = `<div class="empty-state">Aucun avis BOAMP trouvé.<br><a href="https://www.boamp.fr/pages/recherche/" target="_blank">Rechercher sur BOAMP →</a></div>`; return; }
let aac=0,attr=0,rec=0,old=0;
records.forEach(r => {
const t=(r.typeavis||r.famille||'').toLowerCase();
if(t.includes('attribution')||t.includes('résultat')) attr++; else aac++;
const y=(r.dateparution||'').substring(0,4);
if(y>='2025') rec++; else if(y>='2023') old++;
});
['fc-aac','fc-attr','fc-rec','fc-old'].forEach((id,i)=>{ const el=document.getElementById(id); if(el) el.textContent=[aac,attr,rec,old][i]; });
const list=document.createElement('div'); list.className='result-list';
records.forEach((r,i)=>{
const titre=r.objet||r.intitule||'Avis sans intitulé';
const date=fmtDate(r.dateparution||'');
const type=r.typeavis||r.famille||'';
const acheteur=r.acheteur||r.organisme||'';
const ref=r.idweb||r.reference||'';
const isAttr=type.toLowerCase().includes('attribution')||type.toLowerCase().includes('résultat');
const c=document.createElement('div'); c.className='rcard'+(((r.dateparution||'').substring(0,4)>='2025'&&i<3)?' isnew':'');
c.innerHTML=`<div class="rc-top"><a class="rc-title" href="#">${esc(titre.substring(0,110))}${titre.length>110?'…':''}</a><div class="rc-badges"><span class="badge ${isAttr?'attr':'ouvert'}">${isAttr?'✓ Attribution':'● Avis'}</span></div></div>
<div class="rc-meta">${date?`<span class="rc-mi">📅 <strong>${date}</strong></span>`:''}${type?`<span class="rc-mi">📂 <strong>${esc(type.substring(0,40))}</strong></span>`:''}${acheteur?`<span class="rc-mi">🏛️ <strong>${esc(acheteur.substring(0,50))}</strong></span>`:''}</div>
<div class="rc-ft"><div class="rc-src"><span class="ldot live">LIVE</span> BOAMP · DILA</div><a class="rc-lnk" href="https://www.boamp.fr/avis/detail/${esc(ref)}" target="_blank">Voir sur BOAMP →</a></div>`;
list.appendChild(c);
});
document.getElementById('boampList').innerHTML=''; document.getElementById('boampList').appendChild(list);
}
// ══ APPROCH ══
async function loadApproch(q) {
try {
const apiUrl = new URL('https://data.economie.gouv.fr/api/records/1.0/search/');
apiUrl.searchParams.set('dataset', 'projets-dachats-publics');
apiUrl.searchParams.set('q', q);
apiUrl.searchParams.set('rows', '20');
const r = await fetch(px(apiUrl.toString()));
const records = r.ok ? (await r.json()).records?.map(rec=>rec.fields||rec) || [] : [];
renderApproch(records, q);
} catch(e) { renderApproch([], q); }
}
function renderApproch(records, q) {
document.getElementById('kpiApproch').textContent = records.length || '0';
document.getElementById('tApproch').textContent = records.length || '0';
if (!records.length) {
document.getElementById('approchList').innerHTML = `<div class="empty-state">Aucun projet APProch trouvé pour "<strong>${esc(q)}</strong>".<br>Publication volontaire — cet acheteur n'a peut-être pas publié sa programmation.<br><a href="https://projets-achats.marches-publics.gouv.fr" target="_blank">Consulter APProch →</a></div>`; return;
}
let t=0,s=0,f=0;
records.forEach(r=>{ const c=(r.categorieAchat||r.typeAchat||'').toLowerCase(); if(c.includes('travaux'))t++; else if(c.includes('fourniture'))f++; else s++; });
['fp-t','fp-s','fp-f'].forEach((id,i)=>{ const el=document.getElementById(id); if(el) el.textContent=[t,s,f][i]; });
const list=document.createElement('div'); list.className='result-list';
records.forEach(r=>{
const titre=r.objet||r.intitule||r.libelle||'Projet sans intitulé';
const dateEch=fmtDate(r.dateEcheancePublication||'');
const montant=r.montantEstime?fmtMontant(r.montantEstime):'';
const cat=r.categorieAchat||r.typeAchat||'';
const acheteur=r.acheteurNom||r.nomAcheteur||'';
const c=document.createElement('div'); c.className='rcard';
c.innerHTML=`<div class="rc-top"><a class="rc-title" href="#">${esc(titre.substring(0,110))}${titre.length>110?'…':''}</a><div class="rc-badges"><span class="badge prog">◐ Programmé</span></div></div>
<div class="rc-meta">${dateEch?`<span class="rc-mi">📅 Échéance : <strong>${dateEch}</strong></span>`:''}${montant?`<span class="rc-mi">💶 <strong>${montant}</strong></span>`:''}${cat?`<span class="rc-mi">📂 <strong>${esc(cat)}</strong></span>`:''}${acheteur?`<span class="rc-mi">🏛️ <strong>${esc(acheteur.substring(0,45))}</strong></span>`:''}</div>
<div class="rc-ft"><div class="rc-src"><span class="ldot live">LIVE</span> APProch · data.gouv.fr</div><a class="rc-lnk" href="https://projets-achats.marches-publics.gouv.fr" target="_blank">APProch →</a></div>`;
list.appendChild(c);
});
document.getElementById('approchList').innerHTML=''; document.getElementById('approchList').appendChild(list);
}
// ══ RÉSOLUTION SIRET via Annuaire des Entreprises (DINUM) ══
async function getSiret(q) {
// Ne pas appeler si q ressemble déjà à un SIRET/SIREN
if (/^\d{9,14}$/.test(q.replace(/\s/g,''))) return q.replace(/\s/g,'');
try {
const url = `https://recherche-entreprises.api.gouv.fr/search?q=${encodeURIComponent(q)}&page=1&per_page=5`;
const r = await fetch(url);
if (!r.ok) return null;
const d = await r.json();
const results = d.results || [];
const qLow = q.toLowerCase();
const match = results.find(e => {
const nom = (e.nom_raison_sociale || e.nom_complet || '').toLowerCase();
return nom.includes(qLow.substring(0, 6));
}) || results[0];
if (!match) return null;
return match.siege?.siret || match.siren || null;
} catch(e) { return null; }
}
// ══ DECP — requête par SIRET ══
async function decpBySiret(siret, year) {
const params = { 'acheteur_id__exact': siret, 'dateNotification__sort': 'desc' };
if (year) {
params['dateNotification__greater'] = `${parseInt(year)-1}-12-31`;
params['dateNotification__less'] = `${parseInt(year)+1}-01-01`;
}
return fetchDecp(params);
}
// ══ DECP RECHERCHE ══
async function loadDecp(q) {
let records = [];
let siretFound = null;
// Essai 1 : résoudre SIRET → recherche précise
const siret = await getSiret(q);
if (siret) {
siretFound = siret;
records = await decpBySiret(siret, null);
}
// Essai 2 : fulltext si rien
if (!records.length) {
try { records = await fetchDecp({ 'q': q, 'dateNotification__sort': 'desc' }); } catch(e) {}
}
decpData = records;
renderDecpList(records, q);
updateKpis(records);
const siretInfo = siretFound ? ` · SIRET ${siretFound}` : '';
document.getElementById('rhSub').textContent = `${records.length} marchés DECP trouvés${siretInfo}`;
}
function updateKpis(records) {
const vol = records.reduce((a,r)=>a+parseFloat(r.montant||0),0);
document.getElementById('kpiDecp').textContent = records.length || '0';
document.getElementById('kpiVol').textContent = vol>0 ? fmtMontant(vol) : '—';
document.getElementById('tDecp').textContent = records.length || '0';
}
function renderDecpList(records, q) {
if (!records.length) { document.getElementById('decpList').innerHTML=`<div class="empty-state">Aucun marché DECP trouvé pour "<strong>${esc(q)}</strong>".<br><a href="https://data.gouv.fr" target="_blank">data.gouv.fr →</a></div>`; return; }
const list=document.createElement('div'); list.className='result-list';
records.slice(0,15).forEach(r=>{
const titre = r.objet || 'Marché sans intitulé';
const date = fmtDate(r.dateNotification || '');
const montant = fmtMontant(r.montant || 0);
const tit = r.titulaire_nom || '';
const acheteur = r.acheteur_nom || '';
const cpv = r.codeCPV || '';
const nature = r.nature || '';
const c=document.createElement('div'); c.className='rcard';
c.innerHTML=`<div class="rc-top"><a class="rc-title" href="#">${esc(titre.substring(0,110))}${titre.length>110?'…':''}</a><div class="rc-badges"><span class="badge attr">✓ Attribué</span></div></div>
<div class="rc-meta">
${date?`<span class="rc-mi">📅 Notifié : <strong>${date}</strong></span>`:''}
${montant?`<span class="rc-mi">💶 <strong>${montant}</strong></span>`:''}
${tit?`<span class="rc-mi">🏢 <strong>${esc(tit.substring(0,45))}${tit.length>45?'…':''}</strong></span>`:''}
${acheteur?`<span class="rc-mi">🏛️ <strong>${esc(acheteur.substring(0,45))}</strong></span>`:''}
${cpv?`<span class="rc-mi">🏷️ <strong>${esc(String(cpv).substring(0,40))}</strong></span>`:''}
${nature?`<span class="rc-mi">📂 <strong>${esc(nature)}</strong></span>`:''}
</div>
<div class="rc-ft"><div class="rc-src"><span class="ldot live">LIVE</span> DECP · data.gouv.fr</div><a class="rc-lnk" href="https://data.gouv.fr" target="_blank">data.gouv →</a></div>`;
list.appendChild(c);
});
document.getElementById('decpList').innerHTML=''; document.getElementById('decpList').appendChild(list);
}
// ══ OBS ZOOM (recherche) ══
function renderZoomObs(records) {
if (!records.length) return;
const total=records.length, vol=records.reduce((a,r)=>a+parseFloat(r.montant||0),0), moy=vol/total;
const titSet=new Set(records.map(r=>r.titulaire_id||r.titulaire_nom).filter(Boolean));
document.getElementById('zo-total').textContent=total;
document.getElementById('zo-vol').textContent=vol>0?fmtMontant(vol):'—';
document.getElementById('zo-moy').textContent=moy>0?fmtMontant(moy):'—';
document.getElementById('zo-tit').textContent=titSet.size||'—';
document.getElementById('zo-annual').innerHTML=renderAnnual(records,'var(--blue)');
document.getElementById('zo-tit-rows').innerHTML=renderTopTable(records,'titulaire');
document.getElementById('zo-type').innerHTML=renderTypeChart(records);
document.getElementById('zo-cpv').innerHTML=renderCpvChart(records,'var(--teal)');
}
// ══ OBSERVATOIRE NATIONAL ══
async function lancerObservatoire() {
const q = document.getElementById('obsQ').value.trim();
const year = document.getElementById('obsYear').value;
document.getElementById('obsCtxSub').textContent = 'Chargement…';
['o-total','o-vol','o-moy','o-tit'].forEach(id=>{ const el=document.getElementById(id); if(el) el.textContent='…'; });
['o-annual','o-type','o-chart4'].forEach(id=>{ const el=document.getElementById(id); if(el) el.innerHTML=`<div class="loading-state"><div class="spinner"></div>Chargement…</div>`; });
document.getElementById('o-chart2-rows').innerHTML=`<tr><td colspan="4" style="padding:14px;text-align:center;color:var(--grey-400)">Chargement…</td></tr>`;
try {
const params = { 'dateNotification__sort': 'desc' };
if (q) params['q'] = q;
if (year) {
params['dateNotification__greater'] = `${parseInt(year)-1}-12-31`;
params['dateNotification__less'] = `${parseInt(year)+1}-01-01`;
}
const records = await fetchDecp(params);
obsData = records;
const scope = q ? `Recherche : "${q}"` : `Vue nationale${year ? ` · ${year}` : ''}`;
const sub = `${obsData.length} marchés chargés (100 max)`;
document.getElementById('obsCtxScope').textContent = scope;
document.getElementById('obsCtxSub').textContent = sub;
renderObservatoire(obsData, currentObsAxis);
} catch(e) {
document.getElementById('obsCtxSub').textContent = `Erreur : ${e.message}`;
['o-annual','o-type','o-chart4'].forEach(id=>{ const el=document.getElementById(id); if(el) el.innerHTML=`<div class="empty-state">Données indisponibles.</div>`; });
}
}
function switchObsAxis(axis, el) {
currentObsAxis = axis;
document.querySelectorAll('.obs-axis-btn').forEach(b => { b.classList.remove('active','acheteur','territoire','secteur','titulaire'); });
el.classList.add('active', axis);
const descs = {
acheteur:'Vue par acheteur · Top acheteurs et leurs fournisseurs',
territoire:'Vue par territoire · Qui achète où ?',
secteur:'Vue par secteur CPV · Qui achète quoi ?',
titulaire:'Vue par titulaire · Qui remporte quoi ?'
};
document.getElementById('obsAxisDesc').textContent = descs[axis];
if (obsData.length) renderObservatoire(obsData, axis);
}
function renderObservatoire(records, axis) {
if (!records.length) return;
const total=records.length, vol=records.reduce((a,r)=>a+parseFloat(r.montant||0),0), moy=vol/total;
// KPIs selon axe
const kpiTitLbl = document.getElementById('o-tit-lbl');
const kpiTotLbl = document.getElementById('o-total-lbl');
if (axis==='acheteur') {
const titSet=new Set(records.map(r=>r.titulaire_nom).filter(Boolean));
document.getElementById('o-tit').textContent=titSet.size||'—';
if(kpiTitLbl) kpiTitLbl.textContent='Fournisseurs distincts';
if(kpiTotLbl) kpiTotLbl.textContent='Marchés attribués';
} else if (axis==='titulaire') {
const achSet=new Set(records.map(r=>r.acheteur_nom).filter(Boolean));
document.getElementById('o-tit').textContent=achSet.size||'—';
if(kpiTitLbl) kpiTitLbl.textContent='Acheteurs clients';
if(kpiTotLbl) kpiTotLbl.textContent='Marchés remportés';
} else {
const achSet=new Set(records.map(r=>r.acheteur_nom).filter(Boolean));
document.getElementById('o-tit').textContent=achSet.size||'—';
if(kpiTitLbl) kpiTitLbl.textContent='Acheteurs distincts';
if(kpiTotLbl) kpiTotLbl.textContent='Marchés dans cette vue';
}
document.getElementById('o-total').textContent=total;
document.getElementById('o-vol').textContent=vol>0?fmtMontant(vol):'—';
document.getElementById('o-moy').textContent=moy>0?fmtMontant(moy):'—';
// Graphe 1 : volume annuel
document.getElementById('o-annual').innerHTML=renderAnnual(records,'var(--blue)');
// Graphe 2 : top selon axe
const c2title=document.getElementById('o-chart2-title');
const c2col=document.getElementById('o-chart2-col');
if (axis==='titulaire') {
if(c2title) c2title.textContent='Top acheteurs clients';
if(c2col) c2col.textContent='Acheteur';
document.getElementById('o-chart2-rows').innerHTML=renderTopTable(records,'acheteur');
} else {
if(c2title) c2title.textContent='Top fournisseurs par volume';
if(c2col) c2col.textContent='Fournisseur';
document.getElementById('o-chart2-rows').innerHTML=renderTopTable(records,'titulaire');
}
// Graphe 3 : type
document.getElementById('o-type').innerHTML=renderTypeChart(records);
// Graphe 4 : selon axe
const c4=document.getElementById('o-chart4');
const c4title=document.getElementById('o-chart4-title');
const c4sub=document.getElementById('o-chart4-sub');
if (axis==='acheteur'||axis==='territoire') {
if(c4title) c4title.textContent='Top acheteurs par volume';
if(c4sub) c4sub.textContent='Pour cette sélection';
if(c4) c4.innerHTML=renderTopBar(records,'acheteur','var(--green)');
} else if (axis==='secteur') {
if(c4title) c4title.textContent='Top secteurs CPV';
if(c4sub) c4sub.textContent='Par volume attribué';
if(c4) c4.innerHTML=renderCpvChart(records,'var(--orange)');
} else {
if(c4title) c4title.textContent='Secteurs d\'intervention';
if(c4sub) c4sub.textContent='Par volume · code CPV';
if(c4) c4.innerHTML=renderCpvChart(records,'var(--purple)');
}
}
// ══ RENDERERS COMMUNS ══
function renderAnnual(records, color) {
const annMap={};
records.forEach(r=>{
const y=(r.dateNotification||'').substring(0,4);
if(y>='2018') annMap[y]=(annMap[y]||0)+parseFloat(r.montant||0);
});
const years=Object.keys(annMap).sort(), maxV=Math.max(...Object.values(annMap),1);
return years.length ? years.map(y=>`<div class="bar-row"><div class="bar-lbl">${y}</div><div class="bar-track"><div class="bar-fill" style="width:${(annMap[y]/maxV*100).toFixed(0)}%;background:${color}"></div></div><div class="bar-val">${fmtMontant(annMap[y])}</div></div>`).join('') : '<div style="padding:12px;font-size:12px;color:var(--grey-400)">Pas de données</div>';
}
function renderTopTable(records, type) {
const map={};
records.forEach(r=>{
const nom = type==='titulaire'
? (r.titulaire_nom||'Inconnu')
: (r.acheteur_nom||'Inconnu');
if(!map[nom]) map[nom]={nom,vol:0,nb:0};
map[nom].vol+=parseFloat(r.montant||0); map[nom].nb++;
});
const top=Object.values(map).sort((a,b)=>b.vol-a.vol).slice(0,7);
return top.length ? top.map((t,i)=>`<tr><td class="num">${i+1}</td><td>${esc(t.nom.substring(0,38))}${t.nom.length>38?'…':''}</td><td class="amt">${t.vol>0?fmtMontant(t.vol):'—'}</td><td style="text-align:right;color:var(--grey-600)">${t.nb}</td></tr>`).join('') : '<tr><td colspan="4" style="padding:12px;text-align:center;color:var(--grey-400)">Pas de données</td></tr>';
}
function renderTypeChart(records) {
const types={Services:0,Travaux:0,Fournitures:0,Autres:0};
records.forEach(r=>{
const cpv=(r.codeCPV||'').substring(0,2);
const n=parseFloat(r.montant||0);
if(['71','72','73','74','75','76','77','78','79','80','85','90','92','98'].includes(cpv)) types.Services+=n;
else if(['44','45'].includes(cpv)) types.Travaux+=n;
else if(parseInt(cpv)>0&&parseInt(cpv)<45) types.Fournitures+=n;
else types.Autres+=n;
});
const totalT=Object.values(types).reduce((a,b)=>a+b,0);
const colors={Services:'var(--blue)',Travaux:'var(--green)',Fournitures:'var(--orange)',Autres:'var(--grey-400)'};
return Object.entries(types).filter(([,v])=>v>0).sort((a,b)=>b[1]-a[1]).map(([k,v])=>`<div class="bar-row"><div class="bar-lbl">${k}</div><div class="bar-track"><div class="bar-fill" style="width:${totalT>0?(v/totalT*100).toFixed(0):0}%;background:${colors[k]}"></div></div><div class="bar-val">${totalT>0?(v/totalT*100).toFixed(0):0}%</div></div>`).join('') || '<div style="padding:12px;font-size:12px;color:var(--grey-400)">Pas de données CPV</div>';
}
function renderTopBar(records, type, color) {
const map={};
records.forEach(r=>{
const nom = type==='acheteur'
? (r.acheteur_nom||'Inconnu')
: (r.titulaire_nom||'Inconnu');
map[nom]=(map[nom]||0)+parseFloat(r.montant||0);
});
const top=Object.entries(map).sort((a,b)=>b[1]-a[1]).slice(0,6);
const maxV=Math.max(...top.map(([,v])=>v),1);
return top.map(([k,v])=>`<div class="bar-row"><div class="bar-lbl">${esc(k.substring(0,18))}${k.length>18?'…':''}</div><div class="bar-track"><div class="bar-fill" style="width:${(v/maxV*100).toFixed(0)}%;background:${color}"></div></div><div class="bar-val">${fmtMontant(v)}</div></div>`).join('') || '<div style="padding:12px;font-size:12px;color:var(--grey-400)">Pas de données</div>';
}
function renderCpvChart(records, color) {
const cpvMap={};
records.forEach(r=>{
const cpv=(r.codeCPV||'NC').substring(0,2);
cpvMap[cpv]=(cpvMap[cpv]||0)+parseFloat(r.montant||0);
});
const top=Object.entries(cpvMap).sort((a,b)=>b[1]-a[1]).slice(0,6);
const maxV=Math.max(...top.map(([,v])=>v),1);
return top.map(([k,v])=>`<div class="bar-row"><div class="bar-lbl">CPV ${k}</div><div class="bar-track"><div class="bar-fill" style="width:${(v/maxV*100).toFixed(0)}%;background:${color}"></div></div><div class="bar-val">${fmtMontant(v)}</div></div>`).join('') || '<div style="padding:12px;font-size:12px;color:var(--grey-400)">Pas de données</div>';
}
// ══ UTILS ══
function fmtDate(d) { if(!d) return ''; try { return new Date(d).toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit',year:'numeric'}); } catch { return d.substring(0,10); } }
function fmtMontant(m) { const n=parseFloat(m); if(!n||isNaN(n)) return ''; if(n>=1000000) return (n/1000000).toFixed(2)+' M€'; if(n>=1000) return Math.round(n/1000)+' k€'; return n+' €'; }
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment