diff --git a/observable/src/components/theme.js b/observable/src/components/theme.js index 8ac81b0..cc2ae2a 100644 --- a/observable/src/components/theme.js +++ b/observable/src/components/theme.js @@ -24,7 +24,7 @@ export const theme = { }; export const plotStyle = { - background: theme.bg, + background: "transparent", color: theme.text, fontFamily: theme.fontFamily, fontSize: "12px", diff --git a/observable/src/components/wda-chart-actions.js b/observable/src/components/wda-chart-actions.js new file mode 100644 index 0000000..bab1154 --- /dev/null +++ b/observable/src/components/wda-chart-actions.js @@ -0,0 +1,178 @@ +const ICON_DOWNLOAD = ``; +const ICON_SHARE = ``; +const ICON_FULLSCREEN = ``; + +const LOGO_SVG = `WDA`; + +function loadImage(url) { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve(img); + img.src = 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; + + 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 scale = 2; + const pad = 20; + const titleH = titleText ? 26 : 0; + 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 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); + URL.revokeObjectURL(logoUrl); + + const canvas = document.createElement("canvas"); + canvas.width = totalW * scale; + canvas.height = totalH * scale; + const ctx = canvas.getContext("2d"); + ctx.scale(scale, scale); + ctx.fillStyle = "#1a1a2e"; + ctx.fillRect(0, 0, totalW, totalH); + + let y = pad; + if (titleText) { + ctx.font = "bold 16px sans-serif"; + ctx.fillStyle = "#e0e0e0"; + ctx.fillText(titleText, pad, y + 16); + y += titleH; + } + if (subtitleText) { + ctx.font = "13px sans-serif"; + ctx.fillStyle = "#aaaaaa"; + ctx.fillText(subtitleText, pad, y + 14); + y += subtitleH; + } + y += gapH; + + ctx.drawImage(chartImg, pad, y, svgW, svgH); + + // 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; + if (sourceText) { + ctx.strokeStyle = "#3d3d6b"; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(pad, y + 6); + ctx.lineTo(pad + sourceLineW, y + 6); + ctx.stroke(); + ctx.font = "11px sans-serif"; + ctx.fillStyle = "#3d3d6b"; + ctx.fillText(sourceText, pad, y + 18); + } + + const filename = titleText.replace(/\s+/g, "-").toLowerCase() || "chart"; + canvas.toBlob((pngBlob) => { + const a = document.createElement("a"); + a.href = URL.createObjectURL(pngBlob); + a.download = `wda-${filename}.png`; + a.click(); + }); +} + +function shareChart(block) { + const title = + block.querySelector(".wda-chart-title")?.textContent?.trim() || "Chart"; + const url = + window.location.origin + window.location.pathname + window.location.search; + if (navigator.share) { + navigator.share({ title, url }).catch(() => {}); + } else { + navigator.clipboard.writeText(url).then(() => { + const btn = block.querySelector('[data-action="share"]'); + if (btn) { + const prev = btn.innerHTML; + btn.innerHTML = + ''; + setTimeout(() => (btn.innerHTML = prev), 1500); + } + }); + } +} + +function toggleFullscreen(block, btn) { + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + block.requestFullscreen(); + } + document.addEventListener( + "fullscreenchange", + () => { + btn.title = document.fullscreenElement + ? "Quitter le plein écran" + : "Plein écran"; + }, + { once: false }, + ); +} + +export function initChartActions() { + document.querySelectorAll(".wda-chart-block").forEach((block) => { + if (block.querySelector(".wda-chart-actions")) return; + + 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); + }); +} diff --git a/observable/src/style.css b/observable/src/style.css index e5f4c42..63c0955 100644 --- a/observable/src/style.css +++ b/observable/src/style.css @@ -1,5 +1,132 @@ @import url("observablehq:default.css"); @import url("observablehq:theme-wide.css"); +@import url("https://fonts.googleapis.com/css2?family=Lato:wght@400;700&display=swap"); + +/* ── Page content width ───────────────────────────────────────────────── */ +main.observablehq { + max-width: 50vw !important; + margin-left: auto !important; + margin-right: auto !important; +} + +/* ── Figures transparentes — héritent du fond du conteneur parent ─────── */ +figure { + background: transparent !important; + margin: 0; + padding: 0; +} + +/* ── WDA chart header/footer pattern ─────────────────────────────────── */ +.wda-chart-title { + font-size: 18px; + font-weight: 700; + color: var(--theme-foreground); + margin: 0 0 0.25rem 0; +} + +.wda-chart-subtitle { + font-size: 13px; + color: var(--theme-foreground-muted); + margin: 0 0 1rem 0; + line-height: 1.5; +} + +.wda-chart-source { + font-size: 11px; + color: var(--theme-foreground-faint); + margin-top: 0.75rem; + border-top: 1px solid var(--theme-foreground-faintest); + padding-top: 0.5rem; +} + +/* ── WDA chart block container ────────────────────────────────────────── */ +.wda-chart-block { + position: relative; + background: #1a1a2e; + border: 1px solid #555; + border-radius: 8px; + padding: 1rem 1.25rem; + margin-bottom: 1rem; +} + +.wda-chart-block::after { + content: ""; + position: absolute; + top: 0.85rem; + right: 1rem; + width: 58px; + height: 20px; + background-image: url("data:image/svg+xml,WDA"); + background-repeat: no-repeat; + background-size: contain; + opacity: 0.5; + pointer-events: none; +} + +.wda-chart-block .wda-chart-title { + color: #e0e0e0; +} + +.wda-chart-block .wda-chart-subtitle { + color: #aaa; +} + +.wda-chart-block .wda-chart-source { + color: #3d3d6b; + border-top-color: #3d3d6b; +} + +.wda-chart-actions { + display: flex; + gap: 0.35rem; + justify-content: flex-end; + margin-top: 0.6rem; + margin-bottom: 0.4rem; +} + +.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); + border-radius: 5px; + color: #666; + cursor: pointer; + transition: + background 0.15s, + color 0.15s; + padding: 0; +} + +.wda-action-btn:hover { + background: rgba(255, 255, 255, 0.14); + color: #aaa; +} + +.wda-chart-block:fullscreen { + display: flex; + flex-direction: column; + padding: 1.5rem; + overflow-y: auto; + border-radius: 0; +} + +.wda-chart-block:fullscreen figure { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.wda-chart-block:fullscreen figure > svg { + flex: 1; + width: 100% !important; + height: 100% !important; + min-height: 0; +} /* ── Map popup dark style ─────────────────────────────────────────────── */ .vigilo-popup .maplibregl-popup-content { @@ -388,15 +515,25 @@ h1 { /* ── Page header with breadcrumb ─────────────────────────────────────── */ .page-header { - padding-bottom: 1.25rem; - margin-bottom: 1.75rem; + padding-top: 1rem; + padding-bottom: 1.5rem; + margin-bottom: 2rem; border-bottom: 1px solid var(--theme-foreground-faintest); } +.page-header h1 { + font-family: Georgia, "Times New Roman", serif; + font-size: 2.75rem; + font-weight: 700; + line-height: 1.15; + letter-spacing: -0.01em; + margin: 0.5rem 0 0; +} + .breadcrumb { font-size: 0.78rem; color: var(--theme-foreground-muted); - margin-bottom: 0.6rem; + margin-bottom: 0.5rem; } .breadcrumb a { @@ -408,11 +545,36 @@ h1 { } .page-subtitle { - font-size: 1rem; + font-size: 1.15rem; color: var(--theme-foreground-muted); - line-height: 1.6; - margin-top: 0.3rem; - max-width: 720px; + line-height: 1.55; + margin-top: 0.75rem; + max-width: 680px; +} + +.page-meta { + font-size: 0.82rem; + color: var(--theme-foreground-faint); + margin-top: 0.75rem; +} + +.page-meta a { + color: var(--theme-foreground-focus); + text-decoration: none; +} +.page-meta a:hover { + text-decoration: underline; +} + +/* ── Inline note callout ─────────────────────────────────────────────── */ +.wda-note { + font-size: 0.82rem; + color: var(--theme-foreground-faint); + border-left: 3px solid var(--theme-foreground-faintest); + padding: 0.35rem 0.75rem; + margin-top: 0.5rem; + margin-bottom: 1.5rem; + line-height: 1.5; } /* ── Too-many warning ────────────────────────────────────────────────── */ diff --git a/observable/src/vigilo.md b/observable/src/vigilo.md index f033d11..e8b1673 100644 --- a/observable/src/vigilo.md +++ b/observable/src/vigilo.md @@ -6,9 +6,10 @@ title: Vigilo — Vue globale

Signalements Vigilo

- Répartition des signalements citoyens liés à la pratique du vélo et des déplacements piétons, - par instance Vigilo. L’application Vigilo - a été créée par Quentin Hess suite au mouvement + Répartition des signalements citoyens liés à la pratique du vélo et des déplacements piétons, par instance Vigilo. +

+

+ Par Vigilo, créée par Quentin Hess suite au mouvement #JeSuisUnDesDeux à Montpellier.

@@ -86,20 +87,17 @@ display(pills); ## Périodes d'activité et signalements par instance -Pour étudier les incivilités urbaines liées aux mobilités douces, nous disposons -d'un panel de **${totalScopes} instances** Vigilo représentant -**${grandTotalObs.toLocaleString("fr-FR")} signalements**. Seules les instances -comptant plus de **200 observations** sont incluses dans les graphiques -(**${stats.length} instances** et **${total.toLocaleString("fr-FR")} -signalements** sélectionnés). Bien que le mouvement `#JeSuisUnDesDeux` ait -démarré en 2018 et que l'application Vigilo soit sortie en 2019, les -signalements peuvent avoir été saisis rétrospectivement, Vigilo se basant sur la -date de prise de photo plutôt que sur la date de saisie. - -
-Chronologie des instances : date du premier et dernier signalement enregistré. +Les graphiques portent sur les **${stats.length} instances** comptant plus de 200 observations +(**${total.toLocaleString("fr-FR")} signalements** sur ${grandTotalObs.toLocaleString("fr-FR")}). + +
+Les signalements antérieurs à 2019 peuvent être rétrospectifs : Vigilo se base sur la date de prise de photo, pas la date de saisie.
+
+

Chronologie des instances

+

Date du premier et dernier signalement enregistré pour chaque instance active.

+ ```js // Graphe 3 — période d'activité par instance const statsWithDates = stats @@ -138,6 +136,12 @@ const fig3 = Plot.plot({ legend: true, }, marks: [ + Plot.gridX({ + interval: "year", + stroke: theme.border, + strokeOpacity: 0.2, + strokeWidth: 0.75, + }), Plot.link(statsWithDates, { x1: "first_date", x2: "last_date", @@ -174,10 +178,15 @@ const fig3 = Plot.plot({ display(applyThemeToLegend(fig3)); ``` -
-Nombre de signallements par instances. +

Source : Dataset Vigilo — données collectées par des collectifs cyclistes locaux via vigilo.city.

+Le volume de signalements varie fortement d'une instance à l'autre, reflétant des différences d'ancienneté et d'adoption locale. + +
+

Signalements par instance

+

Nombre total de signalements enregistrés pour chaque instance (instances > 200 observations).

+ ```js import * as Plot from "npm:@observablehq/plot"; import { theme, plotStyle } from "./components/theme.js"; @@ -390,10 +399,12 @@ display( }), Plot.text(stats, { y: "display_name", - x: (d) => Number(d.count) / 2, + x: (d) => Number(d.count), text: (d) => Number(d.count).toLocaleString("fr-FR"), - textAnchor: "middle", - fill: "black", + textAnchor: "start", + dx: 6, + fill: theme.text, + fontSize: 11, fontWeight: "bold", }), Plot.ruleX([0]), @@ -402,14 +413,20 @@ display( ); ``` +

Source : Dataset Vigilo — données collectées par des collectifs cyclistes locaux via vigilo.city.

+
+ ## Analyse des incivilités -
-Répartition globale de l'ensemble des signalements, toutes instances confondues. -
+Répartition des types d'incivilités signalées, globalement et par instance. + +
Les catégories représentant moins de 2 % du total toutes instances confondues sont regroupées dans « Autres ».
+ +
+

Répartition globale des signalements

+

Distribution des catégories d'incivilités, toutes instances confondues.

```js -// figA — répartition globale par catégorie display( Plot.plot({ style: plotStyle, @@ -459,20 +476,22 @@ display( ], }), ); +``` -// Répartition par instance (fig2 construit dans le bloc précédent) -display( - html`

- Répartition par instance -

-

- Distribution des catégories pour chaque instance (100% empilé), classées - par proportion des deux premières catégories. -

`, -); +

Source : Dataset Vigilo — données collectées par des collectifs cyclistes locaux via vigilo.city.

+
+ +
+

Répartition par instance

+

Distribution des catégories pour chaque instance (100% empilé), classées par proportion des deux premières catégories.

+ +```js display(applyThemeToLegend(fig2)); ``` +

Source : Dataset Vigilo — données collectées par des collectifs cyclistes locaux via vigilo.city.

+
+ ## Détail par instance ```js @@ -532,9 +551,10 @@ display( ); ``` -
- Source : Dataset Vigilo — données collectées par des collectifs cyclistes locaux via vigilo.city. -
+```js +import { initChartActions } from "./components/wda-chart-actions.js"; +initChartActions(); +```

Voir aussi

diff --git a/observable/src/vigilo/[scope].md b/observable/src/vigilo/[scope].md index 4e042cf..d03cb97 100644 --- a/observable/src/vigilo/[scope].md +++ b/observable/src/vigilo/[scope].md @@ -124,7 +124,8 @@ const cities = [ ## Vue d'ensemble @@ -161,8 +162,9 @@ const cities = [
-
-

Comparaison à la moyenne nationale

+
+

Comparaison à la moyenne nationale

+

Part de chaque catégorie — instance vs moyenne de toutes les instances actives.

```js const natMap = new Map(); @@ -245,6 +247,7 @@ display( ); ``` +

Source : Dataset Vigilo — données collectées par des collectifs cyclistes locaux via vigilo.city.

@@ -297,9 +300,9 @@ const map = createVigiloMap(mapDiv, filtered, { invalidation.then(() => map.remove()); ``` -## Évolution temporelle - -
Nombre de signalements par mois. Permet de voir si l'activité de l'instance est stable, en croissance ou saisonnière.
+
+

Évolution mensuelle des signalements

+

Nombre de signalements déposés par mois. Permet de voir si l'activité de l'instance est stable, en croissance ou saisonnière.

```js const byMonth = d3 @@ -351,9 +354,12 @@ display( ); ``` -## Top adresses +

Source : Dataset Vigilo — données collectées par des collectifs cyclistes locaux via vigilo.city.

+
-
Les 10 lieux les plus souvent signalés dans l'instance.
+
+

Top 10 adresses signalées

+

Les lieux les plus fréquemment mentionnés dans cette instance.

```js const topAddresses = d3 @@ -392,9 +398,12 @@ display( ); ``` -## Activité par jour et heure +

Source : Dataset Vigilo — données collectées par des collectifs cyclistes locaux via vigilo.city.

+
-
Densité des signalements selon le jour de la semaine et l'heure de dépôt.
+
+

Activité par jour et heure

+

Densité des signalements selon le jour de la semaine et l'heure de dépôt.

```js const DAYS = ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"]; @@ -456,10 +465,16 @@ display( ); ``` -
+

Source : Dataset Vigilo — signalements collectés par des collectifs cyclistes locaux via vigilo.city. +

+```js +import { initChartActions } from "../components/wda-chart-actions.js"; +initChartActions(); +``` +

Voir aussi