From 5b288a47e72a9185c7d110ee6e33b58ebf60c9dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Adel=C3=A9?= Date: Mon, 12 Oct 2026 09:43:06 +0200 Subject: [PATCH] feat: update vigilo components --- observable/src/components/vigilo-map.js | 20 ++- .../src/components/wda-chart-actions.js | 130 +++++++++++------- observable/src/dataset/nafrev2.md | 57 +++----- observable/src/dataset/sirene.md | 57 +++----- observable/src/dataset/vigilo.md | 57 +++----- observable/src/style.css | 25 +++- observable/src/vigilo.md | 11 +- observable/src/vigilo/[scope].md | 78 ++++++++--- observable/src/vigilo/cities.md | 69 +++++++--- 9 files changed, 285 insertions(+), 219 deletions(-) diff --git a/observable/src/components/vigilo-map.js b/observable/src/components/vigilo-map.js index c09771c..8a8708b 100644 --- a/observable/src/components/vigilo-map.js +++ b/observable/src/components/vigilo-map.js @@ -10,15 +10,25 @@ const MAP_STYLE = "https://tiles.openfreemap.org/styles/positron"; export function createVigiloMap( container, observations, - { center = [2.35, 46.5], zoom = 5 } = {}, + { center = [2.35, 46.5], zoom = 5, bounds = null, padding = 40 } = {}, ) { - const map = new maplibregl.Map({ + const mapOptions = { container, style: MAP_STYLE, - center, - zoom, attributionControl: { compact: true }, - }); + preserveDrawingBuffer: true, + }; + if (bounds) { + mapOptions.bounds = bounds; + mapOptions.fitBoundsOptions = { padding }; + } else { + mapOptions.center = center; + mapOptions.zoom = zoom; + } + const map = new maplibregl.Map(mapOptions); + + // Expose map instance so wda-chart-actions can trigger repaint before PNG export + container._wdaMap = map; map.on("load", () => { const geojson = { diff --git a/observable/src/components/wda-chart-actions.js b/observable/src/components/wda-chart-actions.js index bab1154..27f93cf 100644 --- a/observable/src/components/wda-chart-actions.js +++ b/observable/src/components/wda-chart-actions.js @@ -12,28 +12,14 @@ function loadImage(url) { }); } -async function downloadChart(block) { - const svg = [...block.querySelectorAll("svg")].find( - (s) => parseInt(s.getAttribute("width") || "0") > 100, - ); - if (!svg) return; - - const svgW = svg.viewBox?.baseVal?.width || svg.clientWidth || 640; - const svgH = svg.viewBox?.baseVal?.height || svg.clientHeight || 400; - +async function composeAndDownload(contentW, contentH, drawContent, block) { const titleText = block.querySelector(".wda-chart-title")?.textContent?.trim() || ""; const subtitleText = block.querySelector(".wda-chart-subtitle")?.textContent?.trim() || ""; const sourceEl = block.querySelector(".wda-chart-source"); const sourceText = sourceEl?.textContent?.trim() || ""; - - // Use actual rendered width of source element for the separator line - const screenSvgW = svg.getBoundingClientRect().width || svgW; - const domToSvg = svgW / screenSvgW; - const sourceLineW = sourceEl - ? sourceEl.getBoundingClientRect().width * domToSvg - : 0; + const sourceLineW = sourceEl ? sourceEl.getBoundingClientRect().width : 0; const scale = 2; const pad = 20; @@ -41,22 +27,13 @@ async function downloadChart(block) { const subtitleH = subtitleText ? 20 : 0; const gapH = titleH || subtitleH ? 10 : 0; const sourceH = sourceText ? 28 : 0; - const totalW = svgW + pad * 2; - const totalH = pad + titleH + subtitleH + gapH + svgH + sourceH + pad; + const totalW = contentW + pad * 2; + const totalH = pad + titleH + subtitleH + gapH + contentH + sourceH + pad; - const serializer = new XMLSerializer(); - const svgUrl = URL.createObjectURL( - new Blob([serializer.serializeToString(svg)], { type: "image/svg+xml" }), - ); const logoUrl = URL.createObjectURL( new Blob([LOGO_SVG], { type: "image/svg+xml" }), ); - - const [chartImg, logoImg] = await Promise.all([ - loadImage(svgUrl), - loadImage(logoUrl), - ]); - URL.revokeObjectURL(svgUrl); + const logoImg = await loadImage(logoUrl); URL.revokeObjectURL(logoUrl); const canvas = document.createElement("canvas"); @@ -82,16 +59,15 @@ async function downloadChart(block) { } y += gapH; - ctx.drawImage(chartImg, pad, y, svgW, svgH); + drawContent(ctx, pad, y, contentW, contentH); - // WDA logo — top-right, aligned with title row const logoW = 58, logoH = 20; ctx.globalAlpha = 0.5; ctx.drawImage(logoImg, totalW - pad - logoW, pad - 4, logoW, logoH); ctx.globalAlpha = 1; - y += svgH; + y += contentH; if (sourceText) { ctx.strokeStyle = "#3d3d6b"; ctx.lineWidth = 0.5; @@ -113,6 +89,54 @@ async function downloadChart(block) { }); } +async function downloadChart(block) { + // SVG chart + const svg = [...block.querySelectorAll("svg")].find( + (s) => parseInt(s.getAttribute("width") || "0") > 100, + ); + if (svg) { + const svgW = svg.viewBox?.baseVal?.width || svg.clientWidth || 640; + const svgH = svg.viewBox?.baseVal?.height || svg.clientHeight || 400; + const serializer = new XMLSerializer(); + const svgUrl = URL.createObjectURL( + new Blob([serializer.serializeToString(svg)], { type: "image/svg+xml" }), + ); + const chartImg = await loadImage(svgUrl); + URL.revokeObjectURL(svgUrl); + await composeAndDownload( + svgW, + svgH, + (ctx, x, y, w, h) => ctx.drawImage(chartImg, x, y, w, h), + block, + ); + return; + } + + // MapLibre GL canvas — trigger repaint, wait for frame, then read via toDataURL + const mapEl = block.querySelector(".vigilo-map"); + const mapCanvas = block.querySelector(".maplibregl-canvas"); + if (mapCanvas) { + const mapInstance = mapEl?._wdaMap; + if (mapInstance) { + await new Promise((resolve) => { + mapInstance.once("render", resolve); + mapInstance.triggerRepaint(); + // fallback if render event never fires (e.g. headless) + setTimeout(resolve, 300); + }); + } + const rect = mapCanvas.getBoundingClientRect(); + const dataUrl = mapCanvas.toDataURL(); + const mapImg = await loadImage(dataUrl); + await composeAndDownload( + rect.width, + rect.height, + (ctx, x, y, cw, ch) => ctx.drawImage(mapImg, x, y, cw, ch), + block, + ); + } +} + function shareChart(block) { const title = block.querySelector(".wda-chart-title")?.textContent?.trim() || "Chart"; @@ -145,34 +169,42 @@ function toggleFullscreen(block, btn) { btn.title = document.fullscreenElement ? "Quitter le plein écran" : "Plein écran"; + // MapLibre uses ResizeObserver but needs a nudge after fullscreen transition + const mapInstance = block.querySelector(".vigilo-map")?._wdaMap; + if (mapInstance) setTimeout(() => mapInstance.resize(), 50); }, { once: false }, ); } -export function initChartActions() { - document.querySelectorAll(".wda-chart-block").forEach((block) => { - if (block.querySelector(".wda-chart-actions")) return; +function processBlock(block) { + if (block.querySelector(".wda-chart-actions")) return; - const bar = document.createElement("div"); - bar.className = "wda-chart-actions"; - bar.innerHTML = ` + const bar = document.createElement("div"); + bar.className = "wda-chart-actions"; + bar.innerHTML = ` `; - bar.addEventListener("click", (e) => { - const btn = e.target.closest("[data-action]"); - if (!btn) return; - if (btn.dataset.action === "download") downloadChart(block); - else if (btn.dataset.action === "share") shareChart(block); - else if (btn.dataset.action === "fullscreen") - toggleFullscreen(block, btn); - }); - - const source = block.querySelector(".wda-chart-source"); - if (source) block.insertBefore(bar, source); - else block.appendChild(bar); + bar.addEventListener("click", (e) => { + const btn = e.target.closest("[data-action]"); + if (!btn) return; + if (btn.dataset.action === "download") downloadChart(block); + else if (btn.dataset.action === "share") shareChart(block); + else if (btn.dataset.action === "fullscreen") toggleFullscreen(block, btn); }); + + const source = block.querySelector(".wda-chart-source"); + if (source) block.insertBefore(bar, source); + else block.appendChild(bar); +} + +export function initBlockActions(block) { + processBlock(block); +} + +export function initChartActions() { + document.querySelectorAll(".wda-chart-block").forEach(processBlock); } diff --git a/observable/src/dataset/nafrev2.md b/observable/src/dataset/nafrev2.md index add691d..fe756ce 100644 --- a/observable/src/dataset/nafrev2.md +++ b/observable/src/dataset/nafrev2.md @@ -3,47 +3,32 @@ title: Dataset — NAF Rév. 2 --- -```js -const datasets = await FileAttachment("../data/wda-datasets.json").json(); -const meta = datasets.find((d) => d.provider === "nafrev2"); - -display( - html`
-

Informations

-
-
-
Description
-
${meta.description}
-
-
-
Source officielle
- -
-
-
Observations
-
- ${meta.nb_observations.toLocaleString("fr-FR")} -
-
-
-
Variables
-
${meta.nb_variables}
-
-
-
Périmètres (${meta.wda_scope})
-
${meta.nb_scopes.toLocaleString("fr-FR")}
-
+
+

Informations

+
+
+
Description
+
Nomenclature française des activités économiques, référentiel hiérarchique à 5 niveaux publié par l'INSEE.
+
+
+
Source officielle
+ +
+
+
Licence
+
Open Data — Licence Ouverte / Open Licence
-
`, -); -``` +
+
Couverture
+
France — 732 sous-classes (codes APE)
+
+
+

Explorer les données

diff --git a/observable/src/dataset/sirene.md b/observable/src/dataset/sirene.md index 62c13b9..6a8e422 100644 --- a/observable/src/dataset/sirene.md +++ b/observable/src/dataset/sirene.md @@ -3,47 +3,32 @@ title: Dataset — SIRENE --- -```js -const datasets = await FileAttachment("../data/wda-datasets.json").json(); -const meta = datasets.find((d) => d.provider === "sirene"); - -display( - html`
-

Informations

-
-
-
Description
-
${meta.description}
-
-
-
Source officielle
- -
-
-
Observations
-
- ${meta.nb_observations.toLocaleString("fr-FR")} -
-
-
-
Variables
-
${meta.nb_variables}
-
-
-
Périmètres (${meta.wda_scope})
-
${meta.nb_scopes.toLocaleString("fr-FR")}
-
+
+

Informations

+
+
+
Description
+
Registre national des entreprises et établissements français, géré par l'INSEE.
+
+
+
Source officielle
+ +
+
+
Licence
+
Open Data — Licence Ouverte / Open Licence
-
`, -); -``` +
+
Couverture
+
France entière (~12 millions d'établissements)
+
+
+

Explorer les données

diff --git a/observable/src/dataset/vigilo.md b/observable/src/dataset/vigilo.md index 11a14ba..de6bf2d 100644 --- a/observable/src/dataset/vigilo.md +++ b/observable/src/dataset/vigilo.md @@ -3,47 +3,32 @@ title: Dataset — Vigilo --- -```js -const datasets = await FileAttachment("../data/wda-datasets.json").json(); -const meta = datasets.find((d) => d.provider === "vigilo"); - -display( - html`
-

Informations

-
-
-
Description
-
${meta.description}
-
-
-
Source officielle
- -
-
-
Observations
-
- ${meta.nb_observations.toLocaleString("fr-FR")} -
-
-
-
Variables
-
${meta.nb_variables}
-
-
-
Périmètres (${meta.wda_scope})
-
${meta.nb_scopes.toLocaleString("fr-FR")}
-
+
+

Informations

+
+
+
Description
+
Signalements citoyens liés aux déplacements vélo et piétons dans les villes françaises.
+
+
+
Source officielle
+ +
+
+
Licence
+
Open Data — CC BY
-
`, -); -``` +
+
Couverture
+
France métropolitaine
+
+
+

Explorer les données

diff --git a/observable/src/style.css b/observable/src/style.css index 63c0955..8ead205 100644 --- a/observable/src/style.css +++ b/observable/src/style.css @@ -84,16 +84,25 @@ figure { margin-bottom: 0.4rem; } +/* When inside a block that has a map, float the bar over the map */ +.wda-chart-block:has(.vigilo-map) .wda-chart-actions { + position: absolute; + bottom: 2.75rem; + right: 0.75rem; + margin: 0; + z-index: 10; +} + .wda-action-btn { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(26, 26, 46, 0.75); + border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 5px; - color: #666; + color: #aaa; cursor: pointer; transition: background 0.15s, @@ -102,8 +111,8 @@ figure { } .wda-action-btn:hover { - background: rgba(255, 255, 255, 0.14); - color: #aaa; + background: rgba(26, 26, 46, 0.95); + color: #e0e0e0; } .wda-chart-block:fullscreen { @@ -128,6 +137,12 @@ figure { min-height: 0; } +.wda-chart-block:fullscreen .vigilo-map { + flex: 1; + min-height: 0; + height: auto !important; +} + /* ── Map popup dark style ─────────────────────────────────────────────── */ .vigilo-popup .maplibregl-popup-content { background: #1e1e2e; diff --git a/observable/src/vigilo.md b/observable/src/vigilo.md index e8b1673..03cdee8 100644 --- a/observable/src/vigilo.md +++ b/observable/src/vigilo.md @@ -505,15 +505,7 @@ display( stats .filter((d) => d.count > 0) .map((d) => ({ - Instance: { - href: `https://vigilo.city/fr/villes/${(d.display_name + "-" + d.iso) - .normalize("NFD") - .replace(/[̀-ͯ]/g, "") - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, "")}`, - text: d.display_name, - }, + Instance: { href: `vigilo/${d.id}`, text: d.display_name }, Statut: d.is_active ? "Actif" : "Inactif", Signalements: Number(d.count), "Première obs.": fmt(d.first_ts), @@ -534,7 +526,6 @@ display( Instance: ({ href, text }) => Object.assign(document.createElement("a"), { href, - target: "_blank", textContent: text, style: "white-space: nowrap", }), diff --git a/observable/src/vigilo/[scope].md b/observable/src/vigilo/[scope].md index d03cb97..96afa91 100644 --- a/observable/src/vigilo/[scope].md +++ b/observable/src/vigilo/[scope].md @@ -6,6 +6,10 @@ title: Vigilo — Instance import { createVigiloMap } from "../components/vigilo-map.js"; import { createMultiSelect } from "../components/wda-multiselect.js"; import { initVigiloDB } from "../components/vigilo-db.js"; +import { + initChartActions, + initBlockActions, +} from "../components/wda-chart-actions.js"; import * as Plot from "npm:@observablehq/plot"; import * as d3 from "npm:d3"; ``` @@ -48,7 +52,7 @@ const observations = _obsResult.toArray().map((r) => ({ })); const _scopeResult = await conn.query(` - SELECT id, display_name, is_active, country, department, + SELECT id, display_name, iso, is_active, country, department, map_center_string, map_zoom FROM read_parquet('scopes.parquet') WHERE id = '${scopeId}' @@ -59,6 +63,7 @@ const scopeMeta = ? { id: _scopeRows[0].id, display_name: _scopeRows[0].display_name, + iso: _scopeRows[0].iso, is_active: _scopeRows[0].is_active, country: _scopeRows[0].country, department: _scopeRows[0].department, @@ -157,8 +162,30 @@ const cities = [
+ +```js +{ + const _slug = (scopeLabel + "-" + (scopeMeta.iso ?? "")) + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + display( + html``, + ); +} +``` +
@@ -273,29 +300,37 @@ const filtered = const obsWithCoords = observations.filter( (d) => d.latitude != null && d.longitude != null && d.latitude !== "", ); -let clat = 46.5, - clon = 2.35, - autoZoom = 11; +let mapBounds = null; if (obsWithCoords.length) { - const lats = obsWithCoords.map((d) => +d.latitude).sort((a, b) => a - b); - const lons = obsWithCoords.map((d) => +d.longitude).sort((a, b) => a - b); - clat = (lats[0] + lats[lats.length - 1]) / 2; - clon = (lons[0] + lons[lons.length - 1]) / 2; - const span = Math.max( - lats[lats.length - 1] - lats[0], - lons[lons.length - 1] - lons[0], - ); - if (span > 0) - autoZoom = Math.max( - 9, - Math.min(14, Math.round(Math.log2(0.5 / span) + 10)), - ); + const lats = obsWithCoords.map((d) => +d.latitude); + const lons = obsWithCoords.map((d) => +d.longitude); + mapBounds = [ + [Math.min(...lons), Math.min(...lats)], + [Math.max(...lons), Math.max(...lats)], + ]; } -const mapDiv = display(document.createElement("div")); +const mapBlock = document.createElement("div"); +mapBlock.className = "wda-chart-block"; +mapBlock.id = "chart-carte-des-signalements"; +const mapTitleEl = document.createElement("p"); +mapTitleEl.className = "wda-chart-title"; +mapTitleEl.textContent = "Carte des signalements"; +const mapSubEl = document.createElement("p"); +mapSubEl.className = "wda-chart-subtitle"; +mapSubEl.textContent = + "Heatmap de densité (zoom arrière) et points individuels (zoom avant)."; +const mapDiv = document.createElement("div"); mapDiv.className = "vigilo-map"; +const mapSourceEl = document.createElement("p"); +mapSourceEl.className = "wda-chart-source"; +mapSourceEl.innerHTML = `Source : Dataset Vigilo — données collectées via vigilo.city.`; +mapBlock.append(mapTitleEl, mapSubEl, mapDiv, mapSourceEl); +display(mapBlock); +initBlockActions(mapBlock); + const map = createVigiloMap(mapDiv, filtered, { - center: [clon, clat], - zoom: autoZoom, + bounds: mapBounds, + padding: 40, }); invalidation.then(() => map.remove()); ``` @@ -471,7 +506,6 @@ display(
```js -import { initChartActions } from "../components/wda-chart-actions.js"; initChartActions(); ``` diff --git a/observable/src/vigilo/cities.md b/observable/src/vigilo/cities.md index d8ebed8..15e840b 100644 --- a/observable/src/vigilo/cities.md +++ b/observable/src/vigilo/cities.md @@ -6,6 +6,10 @@ title: Vigilo — Toutes les villes import { createVigiloMap } from "../components/vigilo-map.js"; import { createMultiSelect } from "../components/wda-multiselect.js"; import { initVigiloDB } from "../components/vigilo-db.js"; +import { + initChartActions, + initBlockActions, +} from "../components/wda-chart-actions.js"; import * as Plot from "npm:@observablehq/plot"; ``` @@ -113,7 +117,7 @@ for (const s of stats display( html`
${Number(s.count).toLocaleString("fr-FR")} signalements @@ -176,33 +180,58 @@ const observations = ( const obsWithCoords = observations.filter( (d) => d.latitude != null && d.longitude != null && d.latitude !== "", ); -let mapLat = 46.5, - mapLon = 2.35, - mapZoom = 5; +// One centroid per city → equal weight regardless of observation count +let mapBounds = null; if (obsWithCoords.length) { - const lats = obsWithCoords.map((d) => +d.latitude).sort((a, b) => a - b); - const lons = obsWithCoords.map((d) => +d.longitude).sort((a, b) => a - b); - mapLat = (lats[0] + lats[lats.length - 1]) / 2; - mapLon = (lons[0] + lons[lons.length - 1]) / 2; - const span = Math.max( - lats[lats.length - 1] - lats[0], - lons[lons.length - 1] - lons[0], - ); - if (span > 0) - mapZoom = Math.max(4, Math.min(13, Math.round(Math.log2(0.5 / span) + 10))); + const cityMap = new Map(); + for (const d of obsWithCoords) { + const key = d.geonames_city || `${d.latitude},${d.longitude}`; + if (!cityMap.has(key)) cityMap.set(key, { latSum: 0, lonSum: 0, n: 0 }); + const c = cityMap.get(key); + c.latSum += +d.latitude; + c.lonSum += +d.longitude; + c.n++; + } + const centroids = [...cityMap.values()].map((c) => ({ + lat: c.latSum / c.n, + lon: c.lonSum / c.n, + })); + const lats = centroids.map((c) => c.lat).sort((a, b) => a - b); + const lons = centroids.map((c) => c.lon).sort((a, b) => a - b); + mapBounds = [ + [lons[0], lats[0]], + [lons[lons.length - 1], lats[lats.length - 1]], + ]; } -const mapDiv = display(document.createElement("div")); +const mapBlock = document.createElement("div"); +mapBlock.className = "wda-chart-block"; +mapBlock.id = "chart-carte-toutes-villes"; +const mapTitleEl = document.createElement("p"); +mapTitleEl.className = "wda-chart-title"; +mapTitleEl.textContent = "Carte de tous les signalements"; +const mapSubEl = document.createElement("p"); +mapSubEl.className = "wda-chart-subtitle"; +mapSubEl.textContent = + "Heatmap de densité (zoom arrière) et points individuels (zoom avant). Toutes instances confondues."; +const mapDiv = document.createElement("div"); mapDiv.className = "vigilo-map"; +const mapSourceEl = document.createElement("p"); +mapSourceEl.className = "wda-chart-source"; +mapSourceEl.innerHTML = `Source : Dataset Vigilo — signalements collectés par des collectifs cyclistes locaux via vigilo.city.`; +mapBlock.append(mapTitleEl, mapSubEl, mapDiv, mapSourceEl); +display(mapBlock); +initBlockActions(mapBlock); + const map = createVigiloMap(mapDiv, observations, { - center: [mapLon, mapLat], - zoom: mapZoom, + bounds: mapBounds, + padding: 30, }); invalidation.then(() => map.remove()); ``` -
- Source : Dataset Vigilo — signalements collectés par des collectifs cyclistes locaux via vigilo.city. -
+```js +initChartActions(); +```

Voir aussi