SPB Git forge

spb/pdb-api

Public
1commits 1branches 0releases
436.0 KBsize
maindefault branch
2 h agolast push
JavaScript 58.4% Python 26.8% CSS 7.5% Objective-C 3% R 2.6% HTML 1.7%
49.9 KB · 415 lines javascript
Raw Blame History
1/* PDB — explorateur SPID. SPA sans dépendance (Chart.js chargé par CDN). */2(() => {3  const $ = (s, r = document) => r.querySelector(s);4  const view = $("#view");5  const API = "/app";6  const TYPE = {};7  const STATUT = { planned: "Planifié", in_progress: "En cours", completed: "Terminé", mentioned: "Mentionné" };8  const COL = { bleu: "#003E7E", bleu2: "#0066B3", or: "#C6A300", teal: "#007979", orange: "#D67A00", vert: "#008046", violet: "#5E35B1", rouge: "#B42318", gris: "#8a8c90" };9  const PAL = [COL.bleu, COL.or, COL.teal, COL.orange, COL.vert, COL.violet, COL.rouge, COL.bleu2, COL.gris, "#8DA9C4", "#E0C36B"];10  let charts = [];11  const fmtN = (n) => n == null ? "—" : Number(n).toLocaleString("fr-CA");12  const fmtUSD = (v) => v == null ? "—" : v >= 1e9 ? (v / 1e9).toLocaleString("fr-CA", { maximumFractionDigits: 1 }) + " G$" : v >= 1e6 ? (v / 1e6).toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " M$" : Math.round(v).toLocaleString("fr-CA") + " $";13  const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));14  const tl = (t) => TYPE[t] || t;15  const stPill = (s) => `<span class="st st-${esc(s)}">${esc(STATUT[s] || s)}</span>`;16  const qs = (o) => Object.entries(o).filter(([, v]) => v !== "" && v != null && v !== false).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");17  const toast = (m) => { const t = $("#toast"); t.textContent = m; t.hidden = false; clearTimeout(t._h); t._h = setTimeout(() => (t.hidden = true), 2600); };1819  async function api(path, params) {20    const url = API + path + (params ? "?" + qs(params) : "");21    const r = await fetch(url, { headers: { Accept: "application/json" } });22    if (!r.ok) { let d = ""; try { d = (await r.json()).detail; } catch { } throw new Error(d || `${r.status} ${r.statusText}`); }23    return r.json();24  }25  function destroyCharts() { charts.forEach((c) => c.destroy()); charts = []; }26  function chart(el, cfg) { if (!window.Chart) return; Chart.defaults.font.family = getComputedStyle(document.body).fontFamily; Chart.defaults.color = "#58595B"; const c = new Chart(el, cfg); charts.push(c); return c; }27  const hashParams = () => { const h = location.hash.slice(2); const [p, q] = h.split("?"); return { path: p || "", parts: (p || "").split("/"), q: Object.fromEntries(new URLSearchParams(q || "")) }; };28  const setQuery = (obj) => { const { parts } = hashParams(); location.hash = "#/" + parts[0] + (Object.keys(obj).length ? "?" + qs(obj) : ""); };2930  /* ------------------------------------------------------------ tableau générique */31  function table(cols, rows, opts = {}) {32    const th = cols.map((c) => `<th class="${c.num ? "num" : ""} ${opts.sort === c.key ? "sorted " + (opts.order || "") : ""}" data-sort="${c.sortKey || ""}">${esc(c.label)}</th>`).join("");33    const tr = rows.map((r) => `<tr class="${opts.href ? "click" : ""}" data-href="${opts.href ? esc(opts.href(r)) : ""}">${cols.map((c) => `<td class="${c.num ? "num" : ""} ${c.trunc ? "trunc" : ""}" title="${c.trunc ? esc(c.title ? c.title(r) : r[c.key]) : ""}">${c.render ? c.render(r) : esc(r[c.key])}</td>`).join("")}</tr>`).join("");34    return `<div class="table-wrap"><table><thead><tr>${th}</tr></thead><tbody>${tr || `<tr><td colspan="${cols.length}" class="muted">Aucun résultat.</td></tr>`}</tbody></table></div>`;35  }36  function bindTable(root, onSort) {37    root.querySelectorAll("tr[data-href]").forEach((tr) => tr.addEventListener("click", (e) => { if (e.target.closest("a")) return; if (tr.dataset.href) location.hash = tr.dataset.href; }));38    if (onSort) root.querySelectorAll("th[data-sort]").forEach((th) => th.addEventListener("click", () => th.dataset.sort && onSort(th.dataset.sort)));39  }40  function pager(total, limit, offset) {41    const page = Math.floor(offset / limit) + 1, pages = Math.max(1, Math.ceil(total / limit));42    return `<div class="pager"><span>${fmtN(total)} résultats · page ${page} / ${fmtN(pages)}</span><span class="sp"></span>43      <button class="btn btn-ghost btn-sm" data-off="${Math.max(0, offset - limit)}" ${offset === 0 ? "disabled" : ""}>← Précédent</button>44      <button class="btn btn-ghost btn-sm" data-off="${offset + limit}" ${offset + limit >= total ? "disabled" : ""}>Suivant →</button></div>`;45  }4647  /* ------------------------------------------------------------ tableau de bord */48  async function dashboard() {49    const s = await api("/stats");50    const ov = s.overview;51    view.innerHTML = `52      <div class="hero"><div><h1>SEC Project Intelligence Database</h1>53        <p>${fmtN(ov.projects)} projets stratégiques et ${fmtN(ov.mentions)} mentions extraits par LLM des filings SEC (10-K, 10-Q, 8-K) de ${fmtN(ov.companies)} sociétés du S&amp;P 500, ${ov.first_seen?.slice(0, 4)}–${ov.last_seen?.slice(0, 4)}. Explorez, filtrez, interrogez en SQL, ou branchez-vous sur l'API.</p></div>54        <div><a class="btn" href="#/projects">Explorer les projets</a> &nbsp; <a class="btn btn-ghost" href="#/api" style="color:#fff;border-color:rgba(255,255,255,.5);background:transparent">API</a></div></div>55      <div class="kpis">56        ${kpi(ov.projects, "projets résolus")}${kpi(ov.mentions, "mentions extraites")}${kpi(ov.filings, "filings avec projets")}${kpi(ov.companies, "entreprises")}57        ${kpi(ov.with_amount, "projets avec montant")}${kpi(fmtUSD(ov.median_amount_usd), "montant médian")}${kpi(ov.kg_nodes, "nœuds du graphe")}${kpi(ov.sections_10k, "sections 10-K")}58      </div>59      <div class="grid g2">60        <div class="card"><h3>Mentions par année et formulaire</h3><div class="chart"><canvas id="c-year"></canvas></div></div>61        <div class="card"><h3>Statut des projets</h3><div class="chart"><canvas id="c-status"></canvas></div></div>62        <div class="card" style="grid-column:1/-1"><h3>Les 21 types de projets (nombre et capital divulgué)</h3><div class="chart tall"><canvas id="c-type"></canvas></div></div>63        <div class="card"><h3>Projets par secteur GICS</h3><div class="chart tall"><canvas id="c-sector"></canvas></div></div>64        <div class="card"><h3>Thèmes émergents — part des mentions annuelles</h3><div class="chart tall"><canvas id="c-themes"></canvas></div></div>65        <div class="card"><h3>Entreprises les plus prolifiques</h3>${table([66          { key: "ticker", label: "Ticker", render: (r) => `<a href="#/company/${esc(r.ticker)}"><b>${esc(r.ticker)}</b></a>` },67          { key: "company_name", label: "Entreprise" }, { key: "sector", label: "Secteur" },68          { key: "n", label: "Projets", num: true, render: (r) => fmtN(r.n) }, { key: "amount_usd", label: "Capital", num: true, render: (r) => fmtUSD(r.amount_usd) },69        ], s.top_companies.slice(0, 12))}</div>70        <div class="card"><h3>Technologies et localisations les plus citées</h3><div class="grid g2">71          <div class="tags">${s.top_technologies.slice(0, 20).map((t) => `<a class="tag t-tech" href="#/projects?tech=${encodeURIComponent(t.technology)}">${esc(t.technology)} <b>${fmtN(t.n)}</b></a>`).join("")}</div>72          <div class="tags">${s.top_locations.slice(0, 20).map((t) => `<a class="tag t-loc" href="#/projects?location=${encodeURIComponent(t.location)}">${esc(t.location)} <b>${fmtN(t.n)}</b></a>`).join("")}</div></div></div>73      </div>`;74    // graphiques75    const years = [...new Set(s.mentions_by_year.map((r) => r.year))].sort();76    const forms = ["10-K", "10-Q", "8-K"];77    chart($("#c-year"), { type: "bar", data: { labels: years, datasets: forms.map((f, i) => ({ label: f, data: years.map((y) => s.mentions_by_year.find((r) => r.year === y && r.form_type === f)?.n || 0), backgroundColor: [COL.bleu, COL.or, COL.teal][i] })) }, options: { responsive: true, maintainAspectRatio: false, scales: { x: { stacked: true }, y: { stacked: true } }, plugins: { legend: { position: "bottom" } } } });78    chart($("#c-status"), { type: "doughnut", data: { labels: s.by_status.map((r) => STATUT[r.status] || r.status), datasets: [{ data: s.by_status.map((r) => r.n), backgroundColor: [COL.bleu2, COL.vert, COL.or, COL.gris] }] }, options: { responsive: true, maintainAspectRatio: false, cutout: "58%", plugins: { legend: { position: "right" } } } });79    chart($("#c-type"), { type: "bar", data: { labels: s.by_type.map((r) => r.label), datasets: [{ label: "Projets", data: s.by_type.map((r) => r.n), backgroundColor: COL.bleu, yAxisID: "y" }, { label: "Capital (G$)", data: s.by_type.map((r) => (r.amount_usd || 0) / 1e9), backgroundColor: COL.or, yAxisID: "y2" }] }, options: { responsive: true, maintainAspectRatio: false, scales: { x: { ticks: { autoSkip: false, maxRotation: 60, minRotation: 45 } }, y: { position: "left", title: { display: true, text: "projets" } }, y2: { position: "right", grid: { drawOnChartArea: false }, title: { display: true, text: "G$" } } }, plugins: { legend: { position: "bottom" } }, onClick: (e, els) => { if (els[0]) location.hash = "#/projects?type=" + s.by_type[els[0].index].project_type; } } });80    chart($("#c-sector"), { type: "bar", data: { labels: s.by_sector.map((r) => r.sector), datasets: [{ label: "Projets", data: s.by_sector.map((r) => r.n), backgroundColor: COL.bleu2 }] }, options: { indexAxis: "y", responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, onClick: (e, els) => { if (els[0]) location.hash = "#/projects?sector=" + encodeURIComponent(s.by_sector[els[0].index].sector); } } });81    const tot = {}; s.mentions_by_year.forEach((r) => (tot[r.year] = (tot[r.year] || 0) + r.n));82    const themes = [...new Set(s.themes_by_year.map((r) => r.project_type))];83    chart($("#c-themes"), { type: "line", data: { labels: years, datasets: themes.map((t, i) => ({ label: tl(t), data: years.map((y) => { const n = s.themes_by_year.find((r) => r.year === y && r.project_type === t)?.n || 0; return tot[y] ? +(100 * n / tot[y]).toFixed(2) : 0; }), borderColor: [COL.rouge, COL.violet, COL.bleu2, COL.teal, COL.vert, COL.or][i], backgroundColor: "transparent", tension: .25, pointRadius: 2 })) }, options: { responsive: true, maintainAspectRatio: false, scales: { y: { title: { display: true, text: "% des mentions" } } }, plugins: { legend: { position: "bottom" } } } });84  }85  const kpi = (v, l) => `<div class="card kpi"><b>${typeof v === "number" ? fmtN(v) : v}</b><span>${l}</span></div>`;8687  /* ------------------------------------------------------------ projets */88  const PCOLS = [89    { key: "ticker", label: "Ticker", sortKey: "ticker", render: (r) => `<a href="#/company/${esc(r.ticker)}"><b>${esc(r.ticker)}</b></a>` },90    { key: "project_name", label: "Projet", sortKey: "name", trunc: true, render: (r) => `<a href="#/project/${esc(r.project_id)}">${esc(r.project_name)}</a><div class="muted small">${esc(r.company_name)}</div>` },91    { key: "project_type", label: "Type", sortKey: "type", render: (r) => `<span class="pill pill-blue">${esc(tl(r.project_type))}</span>` },92    { key: "status", label: "Statut", render: (r) => stPill(r.status) },93    { key: "canonical_location", label: "Lieu" },94    { key: "total_amount_usd", label: "Montant", num: true, sortKey: "amount", render: (r) => fmtUSD(r.total_amount_usd) },95    { key: "n_mentions", label: "Mentions", num: true, sortKey: "mentions", render: (r) => fmtN(r.n_mentions) },96    { key: "first_seen", label: "Première / dernière", sortKey: "first_seen", render: (r) => `${esc(r.first_seen?.slice(0, 7))} → ${esc(r.last_seen?.slice(0, 7))}` },97    { key: "avg_confidence", label: "Conf.", num: true, sortKey: "confidence", render: (r) => (r.avg_confidence ?? 0).toFixed(2) },98  ];99  async function projects() {100    const { q } = hashParams();101    const f = { q: q.q || "", type: q.type || "", sector: q.sector || "", status: q.status || "", ticker: q.ticker || "", location: q.location || "", tech: q.tech || "", partner: q.partner || "", min_amount: q.min_amount || "", year_from: q.year_from || "", year_to: q.year_to || "", min_confidence: q.min_confidence || "", has_amount: q.has_amount || "", sort: q.sort || "mentions", order: q.order || "desc", limit: 50, offset: +q.offset || 0 };102    const [tax, secs] = await Promise.all([api("/taxonomy"), api("/sectors")]);103    view.innerHTML = `<div class="page-head"><div><h1>Projets</h1><p>${fmtN(0)} — chargement…</p></div>104      <div><a class="btn btn-ghost btn-sm" id="csv" href="${API}/projects/export.csv?${qs(f)}" download>Exporter CSV</a></div></div>105      <form class="filters" id="pf">106        <label class="span2">Recherche<input name="q" value="${esc(f.q)}" placeholder="nom, description, entreprise, ticker"></label>107        <label>Type<select name="type"><option value="">Tous</option>${tax.map((t) => `<option value="${t.project_type}" ${f.type === t.project_type ? "selected" : ""}>${esc(t.label)} (${fmtN(t.n)})</option>`).join("")}</select></label>108        <label>Secteur<select name="sector"><option value="">Tous</option>${secs.map((s) => `<option ${f.sector === s.sector ? "selected" : ""}>${esc(s.sector)}</option>`).join("")}</select></label>109        <label>Statut<select name="status"><option value="">Tous</option>${Object.entries(STATUT).map(([k, v]) => `<option value="${k}" ${f.status === k ? "selected" : ""}>${v}</option>`).join("")}</select></label>110        <label>Ticker<input name="ticker" value="${esc(f.ticker)}"></label>111        <label>Lieu<input name="location" value="${esc(f.location)}"></label>112        <label>Technologie<input name="tech" value="${esc(f.tech)}"></label>113        <label>Partenaire<input name="partner" value="${esc(f.partner)}"></label>114        <label>Montant min (US$)<input name="min_amount" type="number" value="${esc(f.min_amount)}" placeholder="ex. 1e9"></label>115        <label>Année de<input name="year_from" type="number" value="${esc(f.year_from)}" min="2010" max="2026"></label>116        <label>Année à<input name="year_to" type="number" value="${esc(f.year_to)}" min="2010" max="2026"></label>117        <label>Confiance min<input name="min_confidence" type="number" step="0.05" min="0" max="1" value="${esc(f.min_confidence)}"></label>118        <label>Avec montant<select name="has_amount"><option value="">Indifférent</option><option value="true" ${f.has_amount ? "selected" : ""}>Oui</option></select></label>119        <label>&nbsp;<span><button class="btn btn-sm">Filtrer</button> <button type="button" class="btn btn-ghost btn-sm" id="reset">Effacer</button></span></label>120      </form><div id="res"><div class="loading">Chargement…</div></div>`;121    $("#pf").addEventListener("submit", (e) => { e.preventDefault(); const d = Object.fromEntries(new FormData(e.target)); setQuery({ ...d, sort: f.sort, order: f.order }); });122    $("#reset").addEventListener("click", () => (location.hash = "#/projects"));123    const data = await api("/projects", f);124    $(".page-head p").textContent = `${fmtN(data.total)} projets correspondent aux filtres.`;125    const res = $("#res");126    res.innerHTML = table(PCOLS, data.items, { href: (r) => `#/project/${r.project_id}`, sort: f.sort, order: f.order }) + pager(data.total, f.limit, f.offset);127    bindTable(res, (k) => setQuery({ ...f, offset: 0, sort: k, order: f.sort === k && f.order === "desc" ? "asc" : "desc" }));128    res.querySelectorAll("[data-off]").forEach((b) => b.addEventListener("click", () => setQuery({ ...f, offset: b.dataset.off })));129  }130131  /* ------------------------------------------------------------ fiche projet */132  async function project(id) {133    const d = await api("/projects/" + id);134    const p = d.project;135    const list = (arr, cls) => (arr || []).length ? `<div class="tags">${arr.map((x) => `<span class="tag ${cls}">${esc(x)}</span>`).join("")}</div>` : `<span class="muted">—</span>`;136    view.innerHTML = `137      <div class="page-head"><div><div class="muted small"><a href="#/projects">Projets</a> › <a href="#/company/${esc(p.ticker)}">${esc(p.company_name)}</a></div>138        <h1>${esc(p.project_name)}</h1><p><span class="pill pill-blue">${esc(tl(p.project_type))}</span> &nbsp; ${stPill(p.status)} &nbsp; <span class="muted">${esc(p.sector || "")}</span></p></div>139        <div><a class="btn btn-ghost btn-sm" href="#/graph?node=${encodeURIComponent("P:" + p.project_id)}">Voir dans le graphe</a> <a class="btn btn-ghost btn-sm" href="/docs#/API%20v1%20(cl%C3%A9%20requise)/project_v1_projects__project_id__get" target="_blank">API ↗</a></div></div>140      <div class="grid g3">141        <div class="card" style="grid-column:span 2"><h3>Description</h3><p>${esc(p.description)}</p>142          <dl class="dl"><dt>Entreprise</dt><dd><a href="#/company/${esc(p.ticker)}">${esc(p.company_name)} (${esc(p.ticker)})</a> · CIK ${esc(p.cik)}</dd>143          <dt>Localisation</dt><dd>${p.canonical_location ? `<a href="#/projects?location=${encodeURIComponent(p.canonical_location)}">${esc(p.canonical_location)}</a>` : "—"}</dd>144          <dt>Montant (max divulgué)</dt><dd>${fmtUSD(p.total_amount_usd)}</dd>145          <dt>Observé</dt><dd>${esc(p.first_seen)} → ${esc(p.last_seen)} · ${fmtN(p.n_mentions)} mentions dans ${fmtN(p.n_filings)} filings</dd>146          <dt>Années citées</dt><dd>${p.first_year ?? "—"} → ${p.last_year ?? "—"}</dd>147          <dt>Confiance moyenne</dt><dd>${(p.avg_confidence ?? 0).toFixed(3)}</dd>148          <dt>Technologies</dt><dd>${list(p.technologies, "t-tech")}</dd>149          <dt>Identifiant</dt><dd class="mono">${esc(p.project_id)}</dd></dl></div>150        <div class="card"><h3>Chronologie (${d.timeline.length})</h3><ul class="timeline">${d.timeline.map((t) => `<li><div><b>${esc(t.form_type)}</b> · ${stPill(t.status)} ${t.amount_usd ? `· ${fmtUSD(t.amount_usd)}` : ""}</div><div class="when">${esc(t.filing_date)} · conf. ${(t.confidence ?? 0).toFixed(2)} · <span class="mono">${esc(t.accession_number)}</span></div><div class="snip">${esc(t.snippet)}</div></li>`).join("")}</ul></div>151        <div class="card" style="grid-column:span 2"><h3>Mentions extraites (${d.mentions.length})</h3>152          ${d.mentions.map((m) => `<div class="mention"><div class="mh"><b>${esc(m.form_type)}</b><span class="muted">${esc(m.filing_date)}</span><span class="pill pill-gris">${esc(m.section_name)}</span>${stPill(m.status)}${m.amount_usd ? `<span class="pill pill-or">${fmtUSD(m.amount_usd)}</span>` : ""}<span class="muted small">conf. ${(m.confidence ?? 0).toFixed(2)}</span><span class="sp" style="flex:1"></span><a class="small" href="#/mention/${esc(m.mention_id)}">détail</a></div>153            <p><b>${esc(m.project_name)}</b> — ${esc(m.description)}</p>${m.objective ? `<p class="muted"><i>Objectif :</i> ${esc(m.objective)}</p>` : ""}154            <div class="grid g2" style="gap:6px">${m.locations?.length ? `<div>${list(m.locations, "t-loc")}</div>` : ""}${m.technologies?.length ? `<div>${list(m.technologies, "t-tech")}</div>` : ""}${m.partners?.length ? `<div><span class="small muted">Partenaires</span> ${list(m.partners, "t-part")}</div>` : ""}${m.benefits?.length ? `<div><span class="small muted">Bénéfices</span> ${list(m.benefits, "t-ben")}</div>` : ""}${m.risks?.length ? `<div><span class="small muted">Risques</span> ${list(m.risks, "t-risk")}</div>` : ""}${m.years?.length ? `<div><span class="small muted">Années</span> ${list(m.years, "")}</div>` : ""}</div></div>`).join("")}</div>155        <div class="card"><h3>Projets similaires (embeddings)</h3>${d.similar.length ? d.similar.map((s) => `<div style="padding:6px 0;border-top:1px solid var(--ligne)"><a href="#/project/${esc(s.project_id)}">${esc(s.project_name)}</a><div class="small muted">${esc(s.ticker)} · ${esc(tl(s.project_type))} · <span class="score">${(s.score * 100).toFixed(0)} %</span></div></div>`).join("") : `<span class="muted">—</span>`}156          <h3 style="margin-top:14px">Voisins dans le graphe</h3><div>${d.graph.map((g) => `<a class="node-chip nt-${esc(g.node_type)}" href="#/graph?node=${encodeURIComponent(g.src === "P:" + p.project_id ? g.dst : g.src)}"><span class="nt">${esc(g.rel)}</span>${esc(g.label)}</a>`).join("") || `<span class="muted">—</span>`}</div></div>157      </div>`;158  }159160  /* ------------------------------------------------------------ mention */161  async function mention(id) {162    const d = await api("/mentions/" + id);163    const m = d.mention;164    view.innerHTML = `<div class="page-head"><div><div class="muted small"><a href="#/mentions">Mentions</a> › <a href="#/project/${esc(m.project_id)}">projet résolu</a></div><h1>${esc(m.project_name)}</h1>165      <p><span class="pill pill-blue">${esc(tl(m.project_type))}</span> ${stPill(m.status)} · <a href="#/company/${esc(m.ticker)}">${esc(m.company_name)}</a> · ${esc(m.form_type)} du ${esc(m.filing_date)} · section <b>${esc(m.section_name)}</b></p></div></div>166      <div class="grid g2"><div class="card"><h3>Extraction</h3><dl class="dl">167        <dt>Description</dt><dd>${esc(m.description)}</dd><dt>Objectif</dt><dd>${esc(m.objective) || "—"}</dd>168        <dt>Montant</dt><dd>${fmtUSD(m.amount_usd)} ${m.amount_raw ? `<span class="muted">(${esc(m.amount_raw)})</span>` : ""}</dd>169        <dt>Localisations</dt><dd>${(m.locations || []).join(", ") || "—"}</dd><dt>Technologies</dt><dd>${(m.technologies || []).join(", ") || "—"}</dd>170        <dt>Partenaires</dt><dd>${(m.partners || []).join(", ") || "—"}</dd><dt>Fournisseurs</dt><dd>${(m.suppliers || []).join(", ") || "—"}</dd>171        <dt>Bénéfices</dt><dd>${(m.benefits || []).join(" · ") || "—"}</dd><dt>Risques</dt><dd>${(m.risks || []).join(" · ") || "—"}</dd>172        <dt>Années</dt><dd>${(m.years || []).join(", ") || "—"}</dd><dt>Confiance</dt><dd>${(m.confidence ?? 0).toFixed(3)} (backend ${esc(m.backend)})</dd>173        <dt>Accession</dt><dd class="mono">${esc(m.accession_number)}</dd><dt>Section</dt><dd class="mono">${esc(m.section_id)}</dd><dt>Mention</dt><dd class="mono">${esc(m.mention_id)}</dd></dl></div>174      <div class="card"><h3>Section source</h3>${d.section ? `<p class="small muted">${esc(d.section.section_name)} · ${fmtN(d.section.word_count)} mots · 10-K</p><button class="btn btn-sm" id="load-sec">Afficher le texte de la section</button><div id="sec"></div>` : `<div class="warn">Le texte source n'est disponible dans la base que pour les sections 10-K ingérées par SPID. Cette mention provient d'un ${esc(m.form_type)} dont la section reste dans le corpus parquet amont.</div>`}175      <p class="small muted" style="margin-top:12px">Filing sur EDGAR : <a target="_blank" rel="noopener" href="https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK=${esc(m.cik)}&type=${esc(m.form_type)}&dateb=&owner=include&count=40">liste des ${esc(m.form_type)} de ${esc(m.ticker)} ↗</a></p></div></div>`;176    $("#load-sec")?.addEventListener("click", async (e) => {177      e.target.disabled = true;178      const s = await api("/sections/" + m.section_id);179      const key = (m.project_name || "").split(/\s+/).filter((w) => w.length > 4)[0];180      let txt = esc(s.text);181      if (key) txt = txt.replace(new RegExp(esc(key).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), (x) => `<mark>${x}</mark>`);182      $("#sec").innerHTML = `<div class="sec-text">${txt}</div>`;183    });184  }185186  /* ------------------------------------------------------------ mentions */187  async function mentions() {188    const { q } = hashParams();189    const f = { q: q.q || "", type: q.type || "", form: q.form || "", ticker: q.ticker || "", status: q.status || "", date_from: q.date_from || "", date_to: q.date_to || "", min_amount: q.min_amount || "", min_confidence: q.min_confidence || "", sort: q.sort || "date", order: q.order || "desc", limit: 50, offset: +q.offset || 0 };190    const tax = await api("/taxonomy");191    view.innerHTML = `<div class="page-head"><div><h1>Mentions</h1><p>Unité d'extraction : un projet cité dans une section d'un filing.</p></div></div>192      <form class="filters" id="mf">193        <label class="span2">Recherche<input name="q" value="${esc(f.q)}"></label>194        <label>Type<select name="type"><option value="">Tous</option>${tax.map((t) => `<option value="${t.project_type}" ${f.type === t.project_type ? "selected" : ""}>${esc(t.label)}</option>`).join("")}</select></label>195        <label>Formulaire<select name="form"><option value="">Tous</option>${["10-K", "10-Q", "8-K"].map((x) => `<option ${f.form === x ? "selected" : ""}>${x}</option>`).join("")}</select></label>196        <label>Statut<select name="status"><option value="">Tous</option>${Object.entries(STATUT).map(([k, v]) => `<option value="${k}" ${f.status === k ? "selected" : ""}>${v}</option>`).join("")}</select></label>197        <label>Ticker<input name="ticker" value="${esc(f.ticker)}"></label>198        <label>Du<input type="date" name="date_from" value="${esc(f.date_from)}"></label><label>Au<input type="date" name="date_to" value="${esc(f.date_to)}"></label>199        <label>Montant min<input type="number" name="min_amount" value="${esc(f.min_amount)}"></label>200        <label>Confiance min<input type="number" step="0.05" name="min_confidence" value="${esc(f.min_confidence)}"></label>201        <label>&nbsp;<span><button class="btn btn-sm">Filtrer</button> <button type="button" class="btn btn-ghost btn-sm" id="reset">Effacer</button></span></label></form>202      <div id="res"><div class="loading">Chargement…</div></div>`;203    $("#mf").addEventListener("submit", (e) => { e.preventDefault(); setQuery({ ...Object.fromEntries(new FormData(e.target)), sort: f.sort, order: f.order }); });204    $("#reset").addEventListener("click", () => (location.hash = "#/mentions"));205    const data = await api("/mentions", f);206    const cols = [207      { key: "filing_date", label: "Date", sortKey: "date" }, { key: "form_type", label: "Form." },208      { key: "ticker", label: "Ticker", sortKey: "ticker", render: (r) => `<a href="#/company/${esc(r.ticker)}"><b>${esc(r.ticker)}</b></a>` },209      { key: "project_name", label: "Projet / description", trunc: true, title: (r) => r.description, render: (r) => `<a href="#/mention/${esc(r.mention_id)}">${esc(r.project_name)}</a><div class="muted small">${esc(r.description)}</div>` },210      { key: "project_type", label: "Type", render: (r) => `<span class="pill pill-blue">${esc(tl(r.project_type))}</span>` },211      { key: "status", label: "Statut", render: (r) => stPill(r.status) }, { key: "section_name", label: "Section" },212      { key: "amount_usd", label: "Montant", num: true, sortKey: "amount", render: (r) => fmtUSD(r.amount_usd) },213      { key: "confidence", label: "Conf.", num: true, sortKey: "confidence", render: (r) => (r.confidence ?? 0).toFixed(2) },214    ];215    const res = $("#res");216    res.innerHTML = `<p class="muted">${fmtN(data.total)} mentions.</p>` + table(cols, data.items, { href: (r) => `#/mention/${r.mention_id}`, sort: f.sort, order: f.order }) + pager(data.total, f.limit, f.offset);217    bindTable(res, (k) => setQuery({ ...f, offset: 0, sort: k, order: f.sort === k && f.order === "desc" ? "asc" : "desc" }));218    res.querySelectorAll("[data-off]").forEach((b) => b.addEventListener("click", () => setQuery({ ...f, offset: b.dataset.off })));219  }220221  /* ------------------------------------------------------------ entreprises */222  async function companies() {223    const { q } = hashParams();224    const f = { q: q.q || "", sector: q.sector || "", sort: q.sort || "projects", order: q.order || "desc", limit: 100, offset: +q.offset || 0 };225    const secs = await api("/sectors");226    view.innerHTML = `<div class="page-head"><div><h1>Entreprises</h1><p>500 sociétés du S&amp;P 500 et la taille de leur portefeuille de projets divulgués.</p></div></div>227      <form class="filters" id="cf"><label class="span2">Recherche<input name="q" value="${esc(f.q)}" placeholder="nom ou ticker"></label>228      <label>Secteur<select name="sector"><option value="">Tous</option>${secs.map((s) => `<option ${f.sector === s.sector ? "selected" : ""}>${esc(s.sector)}</option>`).join("")}</select></label>229      <label>&nbsp;<span><button class="btn btn-sm">Filtrer</button></span></label></form><div id="res"></div>`;230    $("#cf").addEventListener("submit", (e) => { e.preventDefault(); setQuery({ ...Object.fromEntries(new FormData(e.target)), sort: f.sort, order: f.order }); });231    const data = await api("/companies", f);232    const cols = [233      { key: "ticker", label: "Ticker", sortKey: "ticker", render: (r) => `<a href="#/company/${esc(r.ticker)}"><b>${esc(r.ticker)}</b></a>` },234      { key: "company_name", label: "Entreprise" }, { key: "sector", label: "Secteur" },235      { key: "n_projects", label: "Projets", num: true, sortKey: "projects", render: (r) => fmtN(r.n_projects) },236      { key: "n_mentions", label: "Mentions", num: true, sortKey: "mentions", render: (r) => fmtN(r.n_mentions) },237      { key: "n_types", label: "Types", num: true }, { key: "amount_usd", label: "Capital divulgué", num: true, sortKey: "amount", render: (r) => fmtUSD(r.amount_usd) },238      { key: "first_seen", label: "Période", render: (r) => `${esc(r.first_seen?.slice(0, 4))}–${esc(r.last_seen?.slice(0, 4))}` },239    ];240    const res = $("#res");241    res.innerHTML = table(cols, data.items, { href: (r) => `#/company/${r.ticker}`, sort: f.sort, order: f.order }) + pager(data.total, f.limit, f.offset);242    bindTable(res, (k) => setQuery({ ...f, offset: 0, sort: k, order: f.sort === k && f.order === "desc" ? "asc" : "desc" }));243    res.querySelectorAll("[data-off]").forEach((b) => b.addEventListener("click", () => setQuery({ ...f, offset: b.dataset.off })));244  }245  async function company(ident) {246    const d = await api("/companies/" + encodeURIComponent(ident));247    const c = d.company;248    view.innerHTML = `<div class="page-head"><div><div class="muted small"><a href="#/companies">Entreprises</a></div><h1>${esc(c.company_name)} <span class="muted">(${esc(c.ticker)})</span></h1>249      <p>${esc(c.sector || "")} · CIK ${esc(c.cik)} · ${fmtN(c.n_projects)} projets · ${fmtN(c.n_mentions)} mentions · ${esc(c.first_seen?.slice(0, 4))}–${esc(c.last_seen?.slice(0, 4))}</p></div>250      <div><a class="btn btn-ghost btn-sm" href="#/projects?ticker=${esc(c.ticker)}">Filtrer les projets</a> <a class="btn btn-ghost btn-sm" href="#/graph?node=${encodeURIComponent("C:" + c.cik)}">Graphe</a></div></div>251      <div class="kpis">${kpi(c.n_projects, "projets")}${kpi(c.n_mentions, "mentions")}${kpi(fmtUSD(c.amount_usd), "capital divulgué (Σ max)")}${kpi(d.by_type.length, "types de projets")}</div>252      <div class="grid g3">253        <div class="card"><h3>Par type</h3><div class="chart"><canvas id="c1"></canvas></div></div>254        <div class="card"><h3>Mentions par année</h3><div class="chart"><canvas id="c2"></canvas></div></div>255        <div class="card"><h3>Lieux, technologies, partenaires</h3>256          <div class="tags">${d.locations.map((x) => `<a class="tag t-loc" href="#/projects?ticker=${esc(c.ticker)}&location=${encodeURIComponent(x.location)}">${esc(x.location)} ${x.n}</a>`).join("")}</div><br>257          <div class="tags">${d.technologies.map((x) => `<a class="tag t-tech" href="#/projects?ticker=${esc(c.ticker)}&tech=${encodeURIComponent(x.technology)}">${esc(x.technology)} ${x.n}</a>`).join("")}</div><br>258          <div class="tags">${d.partners.map((x) => `<span class="tag t-part">${esc(x.partner)} ${x.n}</span>`).join("")}</div></div>259      </div>260      <h2 style="margin-top:18px">Portefeuille de projets (${fmtN(d.projects.length)})</h2><div id="res"></div>`;261    chart($("#c1"), { type: "bar", data: { labels: d.by_type.map((r) => r.label), datasets: [{ data: d.by_type.map((r) => r.n), backgroundColor: COL.bleu }] }, options: { indexAxis: "y", responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } } });262    chart($("#c2"), { type: "bar", data: { labels: d.by_year.map((r) => r.year), datasets: [{ data: d.by_year.map((r) => r.n), backgroundColor: COL.or }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } } });263    const res = $("#res"); res.innerHTML = table(PCOLS.filter((c) => c.key !== "ticker"), d.projects, { href: (r) => `#/project/${r.project_id}` }); bindTable(res);264  }265266  /* ------------------------------------------------------------ graphe */267  async function graph() {268    const { q } = hashParams();269    view.innerHTML = `<div class="page-head"><div><h1>Graphe de connaissances</h1><p>32 226 nœuds (entreprises, projets, lieux, technologies, partenaires) et 33 176 arêtes <span class="mono">owns · located_in · uses</span>. Cherchez un nœud puis naviguez de voisin en voisin.</p></div></div>270      <form class="filters" id="gf"><label class="span2">Nœud<input name="q" value="${esc(q.q || "")}" placeholder="Texas, Tesla, batterie, AI…"></label>271      <label>Type<select name="type"><option value="">Tous</option>${["Company", "Project", "Location", "Technology", "Partner"].map((t) => `<option ${q.type === t ? "selected" : ""}>${t}</option>`).join("")}</select></label>272      <label>&nbsp;<span><button class="btn btn-sm">Chercher</button></span></label></form><div id="hits"></div><div id="node"></div>`;273    $("#gf").addEventListener("submit", (e) => { e.preventDefault(); const d = Object.fromEntries(new FormData(e.target)); location.hash = "#/graph?" + qs(d); });274    if (q.node) await showNode(q.node);275    else if (q.q) {276      const hits = await api("/graph/search", { q: q.q, type: q.type || "", limit: 60 });277      $("#hits").innerHTML = `<div class="card"><h3>${hits.length} nœuds</h3>${hits.map((h) => `<a class="node-chip nt-${esc(h.node_type)}" href="#/graph?node=${encodeURIComponent(h.node_id)}"><span class="nt">${esc(h.node_type)}</span>${esc(h.label)} <span class="muted small">· ${fmtN(h.degree)}</span></a>`).join("") || "<span class='muted'>Aucun résultat.</span>"}</div>`;278    } else {279      $("#hits").innerHTML = `<div class="card"><h3>Points d'entrée</h3><div>${[["L:texas", "Location"], ["L:china", "Location"], ["L:united states", "Location"], ["T:AI", "Technology"], ["T:Renewable Energy", "Technology"], ["T:Battery/Storage", "Technology"], ["T:Data Center", "Technology"], ["C:0001318605", "Company"], ["C:0000045012", "Company"]].map(([n, t]) => `<a class="node-chip nt-${t}" href="#/graph?node=${encodeURIComponent(n)}"><span class="nt">${t}</span>${esc(n.slice(2))}</a>`).join("")}</div></div>`;280    }281  }282  async function showNode(nodeId) {283    const d = await api("/graph/node/" + nodeId, { limit: 400 });284    const n = d.node; const props = safeJson(n.props);285    const groups = {}; d.edges.forEach((e) => ((groups[e.neighbor_type] ||= []).push(e)));286    const target = n.node_type === "Project" ? `<a class="btn btn-sm" href="#/project/${esc(n.node_id.slice(2))}">Fiche du projet</a>` : n.node_type === "Company" ? `<a class="btn btn-sm" href="#/company/${esc(n.node_id.slice(2))}">Fiche de l'entreprise</a>` : n.node_type === "Location" ? `<a class="btn btn-sm" href="#/projects?location=${encodeURIComponent(n.label)}">Projets à cet endroit</a>` : n.node_type === "Technology" ? `<a class="btn btn-sm" href="#/projects?tech=${encodeURIComponent(n.label)}">Projets avec cette technologie</a>` : `<a class="btn btn-sm" href="#/projects?partner=${encodeURIComponent(n.label)}">Projets avec ce partenaire</a>`;287    $("#node").innerHTML = `<div class="card"><div class="page-head"><div><span class="pill pill-blue">${esc(n.node_type)}</span> <h2 style="display:inline">${esc(n.label)}</h2><div class="muted small mono">${esc(n.node_id)} · degré ${fmtN(d.degree)}</div>288      ${props ? `<div class="small muted">${Object.entries(props).filter(([, v]) => v != null).map(([k, v]) => `${esc(k)} = <b>${esc(k.includes("amount") ? fmtUSD(v) : v)}</b>`).join(" · ")}</div>` : ""}</div><div>${target}</div></div>289      <svg class="svg-graph" id="svg"></svg>290      ${Object.entries(groups).map(([t, es]) => `<h3 style="margin-top:12px">${esc(t)} (${es.length})</h3><div>${es.slice(0, 200).map((e) => `<a class="node-chip nt-${esc(t)}" href="#/graph?node=${encodeURIComponent(e.neighbor_id)}"><span class="nt">${esc(e.rel)} ${e.direction === "out" ? "→" : "←"}</span>${esc(e.neighbor_label)}</a>`).join("")}${es.length > 200 ? `<span class="muted small"> … ${es.length - 200} de plus</span>` : ""}</div>`).join("") || "<p class='muted'>Ce nœud n'a pas d'arête (les partenaires ne sont pas encore reliés dans cette version du graphe).</p>"}</div>`;291    drawRadial($("#svg"), n, d.edges.slice(0, 48));292  }293  function safeJson(s) { if (!s) return null; try { return typeof s === "string" ? JSON.parse(s) : s; } catch { return null; } }294  function drawRadial(svg, center, edges) {295    const W = svg.clientWidth || 900, H = 520, cx = W / 2, cy = H / 2;296    const colors = { Company: COL.bleu, Project: COL.violet, Location: COL.orange, Technology: COL.teal, Partner: COL.or };297    const n = edges.length; const R = Math.min(W, H) / 2 - 70;298    let s = `<g>`;299    edges.forEach((e, i) => { const a = (2 * Math.PI * i) / n - Math.PI / 2; const x = cx + R * Math.cos(a), y = cy + R * Math.sin(a); s += `<line x1="${cx}" y1="${cy}" x2="${x}" y2="${y}"></line>`; });300    edges.forEach((e, i) => { const a = (2 * Math.PI * i) / n - Math.PI / 2; const x = cx + R * Math.cos(a), y = cy + R * Math.sin(a); const lab = e.neighbor_label.length > 28 ? e.neighbor_label.slice(0, 26) + "…" : e.neighbor_label; const anchor = Math.cos(a) > 0.2 ? "start" : Math.cos(a) < -0.2 ? "end" : "middle"; s += `<a href="#/graph?node=${encodeURIComponent(e.neighbor_id)}"><circle cx="${x}" cy="${y}" r="6" fill="${colors[e.neighbor_type] || COL.gris}"></circle><text x="${x + 10 * Math.cos(a)}" y="${y + 10 * Math.sin(a) + 4}" text-anchor="${anchor}">${esc(lab)}</text></a>`; });301    s += `<circle cx="${cx}" cy="${cy}" r="14" fill="${colors[center.node_type] || COL.gris}" stroke="#fff" stroke-width="3"></circle><text x="${cx}" y="${cy + 30}" text-anchor="middle" font-weight="700">${esc(center.label.slice(0, 40))}</text></g>`;302    svg.setAttribute("viewBox", `0 0 ${W} ${H}`); svg.innerHTML = s;303  }304305  /* ------------------------------------------------------------ recherche sémantique */306  async function semantic() {307    const { q } = hashParams();308    view.innerHTML = `<div class="page-head"><div><h1>Recherche sémantique</h1><p>Décrivez un projet en langage naturel (anglais recommandé, langue des filings) : la requête est encodée avec all-MiniLM-L6-v2 et comparée aux 19 227 vecteurs de projets.</p></div></div>309      <form class="filters" id="sf"><label class="span2" style="grid-column:span 3">Requête<input name="q" value="${esc(q.q || "")}" placeholder="ex. battery cell factory in the United States, AI platform for banks, LNG export terminal…"></label>310      <label>&nbsp;<span><button class="btn btn-sm">Chercher</button></span></label></form><div id="res"></div>`;311    $("#sf").addEventListener("submit", (e) => { e.preventDefault(); location.hash = "#/semantic?" + qs(Object.fromEntries(new FormData(e.target))); });312    if (!q.q) { $("#res").innerHTML = `<div class="examples">${["gigafactory for battery cells", "generative AI assistant for customers", "offshore wind farm", "ERP migration to SAP S/4HANA", "new hospital or clinical trial for oncology drug", "data center campus in Virginia", "restructuring plan with plant closures"].map((x) => `<button data-q="${esc(x)}">${esc(x)}</button>`).join("")}</div>`; $("#res").querySelectorAll("button").forEach((b) => b.addEventListener("click", () => (location.hash = "#/semantic?q=" + encodeURIComponent(b.dataset.q)))); return; }313    $("#res").innerHTML = `<div class="loading">Encodage et recherche…</div>`;314    try {315      const d = await api("/search/semantic", { q: q.q, k: 40 });316      const cols = [{ key: "score", label: "Score", num: true, render: (r) => `<span class="score">${(r.score * 100).toFixed(1)} %</span>` }, ...PCOLS.filter((c) => !["avg_confidence", "first_seen"].includes(c.key))];317      const res = $("#res"); res.innerHTML = table(cols, d.items, { href: (r) => `#/project/${r.project_id}` }); bindTable(res);318    } catch (e) { $("#res").innerHTML = `<div class="warn">${esc(e.message)}</div><p class="muted">La similarité entre projets reste disponible sur chaque fiche (« Projets similaires »), car elle n'utilise que les vecteurs déjà stockés.</p>`; }319  }320321  /* ------------------------------------------------------------ SQL */322  const SQL_EXAMPLES = [323    ["Projets par type", "select project_type, count(*) n, round(sum(total_amount_usd)/1e9,1) capital_gusd\nfrom projects group by 1 order by n desc"],324    ["Centres de données > 500 M$", "select ticker, project_name, canonical_location, total_amount_usd, first_seen\nfrom projects where project_type='data_center' and total_amount_usd > 5e8\norder by total_amount_usd desc"],325    ["Mentions IA par année", "select year(filing_date) y, count(*) n\nfrom project_mentions where project_type='ai_initiative' group by 1 order by 1"],326    ["Technologies les plus citées", "select lower(t) technology, count(*) n\nfrom projects, unnest(technologies) as u(t) group by 1 order by n desc limit 25"],327    ["Partenaires de Tesla", "select p partner, count(*) n from project_mentions, unnest(partners) as u(p)\nwhere ticker='TSLA' group by 1 order by n desc"],328    ["Durée de suivi médiane par type", "select project_type, median(last_year-first_year) mediane_annees, count(*) n\nfrom projects where last_year>=first_year group by 1 order by 2 desc"],329    ["Graphe : lieux les plus connectés", "select n.label, count(*) degre from kg_edges e join kg_nodes n on n.node_id=e.dst\nwhere e.rel='located_in' group by 1 order by 2 desc limit 20"],330    ["Texte 10-K : sections les plus longues", "select s.accession_number, s.section_name, s.word_count\nfrom spid_sections s order by word_count desc limit 10"],331  ];332  async function sqlPage() {333    const schema = await api("/schema");334    const saved = localStorage.getItem("pdb_sql") || SQL_EXAMPLES[0][1];335    view.innerHTML = `<div class="page-head"><div><h1>Bac à sable SQL</h1><p>DuckDB en lecture seule sur les 7 tables de la base. SELECT / WITH / DESCRIBE / SUMMARIZE ; 20 s et 5 000 lignes au maximum. ⌘⏎ pour exécuter.</p></div></div>336      <div class="sql-layout"><div class="card schema"><h3>Schéma</h3>${schema.map((t) => `<details ${t.table === "projects" ? "open" : ""}><summary>${esc(t.table)} <span class="muted">(${fmtN(t.rows)})</span></summary><ul>${t.columns.map((c) => `<li>${esc(c.name)} <span>${esc(c.type)}</span></li>`).join("")}</ul></details>`).join("")}</div>337      <div><textarea class="sql" id="sql">${esc(saved)}</textarea>338        <div class="examples">${SQL_EXAMPLES.map((e, i) => `<button data-i="${i}">${esc(e[0])}</button>`).join("")}</div>339        <div style="display:flex;gap:10px;align-items:center;margin:6px 0 12px"><button class="btn" id="run">Exécuter</button><label class="small muted">Limite <input class="inp" id="lim" type="number" value="500" min="1" max="5000" style="width:90px"></label><button class="btn btn-ghost btn-sm" id="csv" disabled>Télécharger CSV</button><span class="muted small" id="info"></span></div>340        <div id="out"></div></div></div>`;341    const ta = $("#sql");342    view.querySelectorAll(".examples button").forEach((b) => b.addEventListener("click", () => { ta.value = SQL_EXAMPLES[b.dataset.i][1]; run(); }));343    let last = null;344    async function run() {345      const sql = ta.value; localStorage.setItem("pdb_sql", sql);346      $("#out").innerHTML = `<div class="loading">Exécution…</div>`; $("#info").textContent = "";347      try {348        const r = await fetch(API + "/sql", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sql, limit: +$("#lim").value || 500 }) });349        const d = await r.json();350        if (!r.ok) throw new Error(d.detail || r.statusText);351        last = d;352        $("#info").textContent = `${fmtN(d.rows.length)} lignes${d.truncated ? " (tronqué)" : ""} · ${d.elapsed_ms} ms`; $("#csv").disabled = false;353        $("#out").innerHTML = `<div class="table-wrap"><table><thead><tr>${d.columns.map((c) => `<th>${esc(c)}</th>`).join("")}</tr></thead><tbody>${d.rows.map((row) => `<tr>${row.map((v) => `<td class="${typeof v === "number" ? "num" : ""}">${esc(Array.isArray(v) ? v.join(" | ") : typeof v === "object" && v ? JSON.stringify(v) : v)}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`;354      } catch (e) { $("#out").innerHTML = `<div class="err">${esc(e.message)}</div>`; }355    }356    $("#run").addEventListener("click", run);357    ta.addEventListener("keydown", (e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") run(); });358    $("#csv").addEventListener("click", () => { if (!last) return; const csv = [last.columns.join(",")].concat(last.rows.map((r) => r.map((v) => `"${String(Array.isArray(v) ? v.join("|") : v ?? "").replace(/"/g, '""')}"`).join(","))).join("\n"); const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([csv], { type: "text/csv" })); a.download = "pdb_query.csv"; a.click(); });359    run();360  }361362  /* ------------------------------------------------------------ à propos */363  async function about() {364    view.innerHTML = `<div class="prose"><h1>La base SPID et son pipeline</h1>365      <p><b>SPID</b> (<i>SEC Project Intelligence Database</i>) reconstruit le portefeuille de projets stratégiques des grandes sociétés cotées américaines à partir du texte brut de leurs documents réglementaires (10-K annuels, 10-Q trimestriels, 8-K d'événements) déposés auprès de la SEC sur EDGAR. Plutôt que de lire ces filings comme des documents financiers, SPID les traite comme une source d'intelligence sur les <i>projets</i> : usines, centres de données, acquisitions, programmes de R&amp;D, initiatives d'IA, transition énergétique…</p>366      <h2>D'où viennent les données</h2>367      <ul><li><b>Univers</b> : les 500 sociétés du S&amp;P 500 (instantané 2026), chacune reliée à son identifiant SEC (CIK).</li>368      <li><b>Corpus</b> : 134 124 dépôts (103 927 8-K, 22 542 10-Q, 7 655 10-K) téléchargés depuis l'API publique de la SEC, janvier 2010 → mai 2026, soit 88 Go de HTML brut, nettoyés et découpés en sections.</li>369      <li><b>Sections analysées</b> : 153 894 sections d'au moins 30 mots (les 10-K sont découpés par SPID en Items 1, 1A, 2, 7 et 7A ; les 8-K sont traités en une seule section).</li></ul>370      <h2>Comment fonctionne le pipeline</h2>371      <ol><li><b>Ingestion</b> des 10-K bruts (nettoyage HTML, découpage en Items).</li>372      <li><b>Extraction</b> par grand modèle de langage (gpt-4o-mini, température 0, JSON strict) : un pré-filtre lexical écarte les sections sans signal de projet, une fenêtre de focalisation limite le texte envoyé ; le modèle renvoie nom, type (taxonomie de 21 catégories), description, objectif, statut, montant, lieux, technologies, partenaires, bénéfices, risques, années et confiance. Les mentions sous 0,45 de confiance sont écartées.</li>373      <li><b>Résolution</b> : les mentions sont fusionnées en projets par entreprise × type × ancre (première localisation ou premier mot significatif du nom) ; chaque projet reçoit une chronologie, un statut courant, le montant maximal divulgué et des dates de première et dernière observation.</li>374      <li><b>Graphe de connaissances</b> : entreprises, projets, lieux, technologies et partenaires reliés par <span class="mono">owns</span>, <span class="mono">located_in</span>, <span class="mono">uses</span>.</li>375      <li><b>Embeddings</b> : chaque projet est encodé en 384 dimensions (all-MiniLM-L6-v2) pour la recherche sémantique et les projets similaires.</li></ol>376      <h2>Ce que contient la base</h2>377      <p>39 930 mentions, 19 227 projets, 39 930 points de chronologie, 32 226 nœuds et 33 176 arêtes, 19 227 vecteurs, 27 164 sections 10-K en texte intégral. Un fichier DuckDB de 2,9 Go, interrogeable ici en SQL.</p>378      <h2>Limites à garder en tête</h2>379      <ul><li>SPID mesure la <i>divulgation</i> de projets, non les projets eux-mêmes ; les incitations à divulguer varient selon les secteurs.</li>380      <li>Les montants sont auto-déclarés, hétérogènes en portée et absents pour la moitié des projets ; quelques valeurs aberrantes subsistent.</li>381      <li>L'extraction par LLM comporte des faux positifs et négatifs ; la résolution est heuristique (elle peut scinder ou fusionner à tort).</li>382      <li>Les années citées (first_year / last_year) peuvent inclure des horizons projetés ; préférez first_seen / last_seen pour dater l'observation.</li></ul>383      <p>Le <a href="/report.pdf" target="_blank">rapport technique complet (PDF)</a> détaille la provenance, chaque étape du pipeline, le schéma et le portrait statistique. Équipe : Manel Kammoun, Charli Tandja Mbianda, Simon-Pierre Boucher — Département des sciences administratives, Université du Québec en Outaouais.</p></div>`;384  }385386  /* ------------------------------------------------------------ routeur */387  const routes = { "": dashboard, projects, companies, mentions, graph, semantic, sql: sqlPage, api: () => window.PDBPlayground.render({ view, api, esc, fmtN, fmtUSD, qs, toast, tl }), about };388  async function render() {389    destroyCharts();390    const { parts } = hashParams();391    const r = parts[0];392    document.querySelectorAll("#nav a[data-route]").forEach((a) => a.classList.toggle("active", a.dataset.route === r));393    $("#burger") && $(".side").classList.remove("open");394    window.scrollTo(0, 0);395    try {396      if (r === "project" && parts[1]) await project(parts[1]);397      else if (r === "company" && parts[1]) await company(parts[1]);398      else if (r === "mention" && parts[1]) await mention(parts[1]);399      else if (routes[r]) await routes[r]();400      else view.innerHTML = `<div class="card"><h2>Page introuvable</h2><a href="#/">Retour au tableau de bord</a></div>`;401    } catch (e) { view.innerHTML = `<div class="err">Erreur : ${esc(e.message)}</div>`; }402  }403  async function init() {404    try { const tax = await api("/taxonomy"); tax.forEach((t) => (TYPE[t.project_type] = t.label)); } catch { }405    try { const h = await fetch("/health").then((r) => r.json()); $("#kpi-projects").textContent = fmtN(h.projects) + " projets"; $("#side-status").textContent = `service ${h.status} · sémantique : ${h.semantic}`; } catch { $("#side-status").textContent = "service indisponible"; }406    fetch("/report.pdf", { method: "HEAD" }).then((r) => { if (!r.ok) $("#report-link").remove(); }).catch(() => { });407    $("#global-search").addEventListener("submit", (e) => { e.preventDefault(); const v = $("#global-q").value.trim(); if (v) location.hash = "#/projects?q=" + encodeURIComponent(v); });408    document.addEventListener("keydown", (e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); $("#global-q").focus(); } });409    $("#burger").addEventListener("click", () => $(".side").classList.toggle("open"));410    window.addEventListener("hashchange", render);411    render();412  }413  init();414})();415