feat: tooltip carte + tri/group alertes + safelist couleurs + legal Nocleus
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run

- FranceMap : tooltip riche au hover (HTML overlay), liste les phénomènes
  + niveaux du département. Touch-friendly (1er tap = preview, 2e = clic).
- index.astro : layout refactored, carte toujours visible full-width centrée,
  liste par région en details collapsible sous (plus de side-by-side cassé sur PC).
- Alertes actives groupées par département, triées par numéro asc (2A/2B après 19).
- Tailwind safelist vigilance-chip-{1..4} : les classes générées dynamiquement
  n'étaient pas captées par le scanner statique → CSS absent en prod.
- Mentions légales : distinction explicite entre Nocleus (micro-entreprise
  commerciale) et Info Canicule (projet perso non lucratif, hors cadre pro).
- Liens code source git.nocleus.com retirés partout (autres repos privés y sont
  visibles) → code "disponible sur demande" par mail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian 2026-05-25 20:23:02 +02:00
parent 58053b72ed
commit 89e48c18e4
8 changed files with 287 additions and 81 deletions

View file

@ -1,23 +1,56 @@
--- ---
import franceMap from '../data/france-map.json'; import franceMap from '../data/france-map.json';
import { COLORS } from '../lib/phenomena'; import { COLORS, COLOR_LABEL, PHENOMENA } from '../lib/phenomena';
import type { ColorId } from '../lib/phenomena'; import type { ColorId, PhenomenonId } from '../lib/phenomena';
import type { VigilanceAlert } from '../lib/vigilance';
import { getDepartement } from '../lib/departements';
interface Props { interface Props {
colorsByDept: Map<string, ColorId>; colorsByDept: Map<string, ColorId>;
alertsByDept?: Map<string, VigilanceAlert[]>;
} }
const { colorsByDept } = Astro.props; const { colorsByDept, alertsByDept = new Map() } = Astro.props;
const entries = Object.entries(franceMap.paths) as [string, { d: string; name: string }][]; const entries = Object.entries(franceMap.paths) as [string, { d: string; name: string }][];
// Construire un objet { code: { name, color, alerts: [{phenLabel, colorName, colorId}] } }
// transmis au JS du tooltip via JSON inline.
const tooltipData: Record<string, {
name: string;
region: string;
colorId: ColorId;
alerts: Array<{ phen: string; colorId: ColorId; colorName: string }>;
}> = {};
for (const [code] of entries) {
const dept = getDepartement(code);
if (!dept) continue;
const colorId = colorsByDept.get(code) ?? 1;
const list = (alertsByDept.get(code) ?? [])
.slice()
.sort((a, b) => b.colorId - a.colorId);
tooltipData[code] = {
name: dept.name,
region: dept.region,
colorId,
alerts: list.map((a) => ({
phen: PHENOMENA[a.phenomenonId].label,
colorId: a.colorId,
colorName: COLORS[a.colorId].name,
})),
};
}
--- ---
<svg <div class="relative">
<svg
viewBox={franceMap.viewBox} viewBox={franceMap.viewBox}
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
role="img" role="img"
aria-label="Carte des départements français colorée selon le niveau Vigilance" aria-label="Carte des départements français colorée selon le niveau Vigilance"
class="h-auto w-full max-w-3xl" class="h-auto w-full max-w-3xl mx-auto"
> id="france-map"
>
<title>Carte Vigilance Météo France</title> <title>Carte Vigilance Météo France</title>
{ {
entries.map(([code, dept]) => { entries.map(([code, dept]) => {
@ -25,9 +58,10 @@ const entries = Object.entries(franceMap.paths) as [string, { d: string; name: s
const color = COLORS[colorId]; const color = COLORS[colorId];
return ( return (
<a href={`/departement/${code}`} class="cursor-pointer"> <a href={`/departement/${code}`} class="cursor-pointer">
<title>{`${dept.name} (${code}) — ${color.name}`}</title>
<path <path
d={dept.d} d={dept.d}
data-code={code}
data-name={dept.name}
fill={color.hex} fill={color.hex}
stroke="#ffffff" stroke="#ffffff"
stroke-width="0.8" stroke-width="0.8"
@ -37,4 +71,131 @@ const entries = Object.entries(franceMap.paths) as [string, { d: string; name: s
); );
}) })
} }
</svg> </svg>
<div
id="map-tooltip"
class="pointer-events-none fixed z-50 hidden max-w-xs rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm shadow-lg"
role="tooltip"
aria-hidden="true"
></div>
</div>
<script type="application/json" id="vigilance-tooltip-data" set:html={JSON.stringify(tooltipData)} />
<script is:inline>
(function () {
const dataEl = document.getElementById('vigilance-tooltip-data');
if (!dataEl) return;
const data = JSON.parse(dataEl.textContent || '{}');
const tooltip = document.getElementById('map-tooltip');
const map = document.getElementById('france-map');
if (!tooltip || !map) return;
const COLOR_LABEL = {
1: 'Pas de vigilance particulière',
2: 'Soyez attentif',
3: 'Soyez très vigilant',
4: 'Vigilance absolue',
};
const COLOR_HEX = { 1: '#5cb85c', 2: '#f6d800', 3: '#f08c1a', 4: '#d9534f' };
function colorChip(colorId, label) {
return (
'<span class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs" style="background:' +
COLOR_HEX[colorId] +
'; color:' +
(colorId >= 3 ? '#fff' : '#1e293b') +
'">' +
label +
'</span>'
);
}
function render(code) {
const d = data[code];
if (!d) return '';
const header =
'<div class="font-semibold text-slate-900">' +
d.name +
' <span class="text-xs text-slate-500">(' +
code +
')</span></div>' +
'<div class="text-xs text-slate-500">' +
d.region +
'</div>';
let body;
if (d.alerts.length === 0) {
body = '<div class="mt-1">' + colorChip(1, 'Aucune vigilance') + '</div>';
} else {
body =
'<ul class="mt-1 space-y-1">' +
d.alerts
.map(function (a) {
return '<li>' + colorChip(a.colorId, a.phen) + '</li>';
})
.join('') +
'</ul>';
}
const hint = '<div class="mt-1 text-xs text-canicule-700">Cliquer pour le détail →</div>';
return header + body + hint;
}
function position(evt) {
const margin = 14;
const tw = tooltip.offsetWidth;
const th = tooltip.offsetHeight;
let x = evt.clientX + margin;
let y = evt.clientY + margin;
if (x + tw > window.innerWidth - 8) x = evt.clientX - tw - margin;
if (y + th > window.innerHeight - 8) y = evt.clientY - th - margin;
tooltip.style.left = x + 'px';
tooltip.style.top = y + 'px';
}
function show(target, evt) {
const code = target.getAttribute('data-code');
if (!code) return;
tooltip.innerHTML = render(code);
tooltip.classList.remove('hidden');
tooltip.setAttribute('aria-hidden', 'false');
position(evt);
}
function hide() {
tooltip.classList.add('hidden');
tooltip.setAttribute('aria-hidden', 'true');
}
map.addEventListener('mouseover', function (e) {
const t = e.target;
if (t && t.tagName === 'path' && t.getAttribute('data-code')) {
show(t, e);
}
});
map.addEventListener('mousemove', function (e) {
if (!tooltip.classList.contains('hidden')) position(e);
});
map.addEventListener('mouseout', function (e) {
const t = e.target;
const rt = e.relatedTarget;
if (t && t.tagName === 'path') {
// Don't hide if moving between sibling paths
if (!rt || rt.tagName !== 'path') hide();
}
});
// Touch devices: tap to show, second tap follows link.
map.addEventListener('touchstart', function (e) {
const t = e.target;
if (t && t.tagName === 'path' && t.getAttribute('data-code')) {
if (tooltip.dataset.code !== t.getAttribute('data-code')) {
e.preventDefault();
tooltip.dataset.code = t.getAttribute('data-code');
const rect = t.getBoundingClientRect();
show(t, { clientX: rect.left + rect.width / 2, clientY: rect.top });
setTimeout(function () { tooltip.dataset.code = ''; }, 3000);
}
}
}, { passive: false });
})();
</script>

View file

@ -33,7 +33,6 @@ const jsonLd = {
publisher: { publisher: {
'@type': 'Person', '@type': 'Person',
name: 'Florian Bouchet', name: 'Florian Bouchet',
url: 'https://nocleus.com',
}, },
potentialAction: { potentialAction: {
'@type': 'SearchAction', '@type': 'SearchAction',
@ -110,7 +109,7 @@ const jsonLd = {
<li><a href="/a-propos">À propos</a></li> <li><a href="/a-propos">À propos</a></li>
<li><a href="/mentions-legales">Mentions légales</a></li> <li><a href="/mentions-legales">Mentions légales</a></li>
<li><a href="/dependances">Dépendances</a></li> <li><a href="/dependances">Dépendances</a></li>
<li><a href="/soutenir">Soutenir</a></li> <li><a href="/soutenir">Soutenir sur Ko-fi</a></li>
<li><a href="/api/vigilance">API JSON publique</a></li> <li><a href="/api/vigilance">API JSON publique</a></li>
</ul> </ul>
</div> </div>
@ -124,10 +123,8 @@ const jsonLd = {
</div> </div>
</div> </div>
<p class="mt-4 text-xs text-slate-400"> <p class="mt-4 text-xs text-slate-400">
Un projet Édité à titre personnel, sans but lucratif —
<a href="https://nocleus.com" class="text-canicule-700" rel="noopener">Nocleus</a>. <a href="/mentions-legales" class="text-canicule-700">mentions légales</a>.
Code source :
<a href="https://git.nocleus.com/florian/info-canicule" class="text-canicule-700" rel="noopener">git.nocleus.com</a>.
</p> </p>
</div> </div>
</footer> </footer>

View file

@ -77,9 +77,9 @@ export const prerender = false;
<h2>Code source</h2> <h2>Code source</h2>
<p> <p>
Le site est entièrement open source. Le code est disponible sur Le code est disponible sur demande à
<a href="https://git.nocleus.com/florian/info-canicule" rel="noopener">git.nocleus.com/florian/info-canicule</a> <a href="mailto:florian@nocleus.com">florian@nocleus.com</a> (contributions, signalements de
(instance Forgejo personnelle). Contributions, signalements de bugs et améliorations bienvenus. bugs et améliorations bienvenus).
</p> </p>
<h2>Contact</h2> <h2>Contact</h2>

View file

@ -206,10 +206,8 @@ const INFRA = [
</div> </div>
<p class="text-sm text-slate-500"> <p class="text-sm text-slate-500">
Code source du site : Code source du site disponible sur demande à
<a href="https://git.nocleus.com/florian/info-canicule" rel="noopener"> <a href="mailto:florian@nocleus.com">florian@nocleus.com</a>.
git.nocleus.com/florian/info-canicule
</a>
</p> </p>
</section> </section>
</Base> </Base>

View file

@ -6,7 +6,7 @@ import VigilanceLegend from '../components/VigilanceLegend.astro';
import VigilanceChip from '../components/VigilanceChip.astro'; import VigilanceChip from '../components/VigilanceChip.astro';
import { getVigilanceSnapshot, maxColorByDepartement, activeAlerts } from '../lib/vigilance'; import { getVigilanceSnapshot, maxColorByDepartement, activeAlerts } from '../lib/vigilance';
import { getDepartement } from '../lib/departements'; import { getDepartement } from '../lib/departements';
import { PHENOMENA, COLORS } from '../lib/phenomena'; import type { VigilanceAlert } from '../lib/vigilance';
export const prerender = false; export const prerender = false;
@ -20,6 +20,29 @@ try {
const colorsByDept = snapshot ? maxColorByDepartement(snapshot, 'J') : new Map(); const colorsByDept = snapshot ? maxColorByDepartement(snapshot, 'J') : new Map();
const alertsToday = snapshot ? activeAlerts(snapshot, 2) : []; const alertsToday = snapshot ? activeAlerts(snapshot, 2) : [];
// Group all today's alerts (any level) by department, for tooltip + active alerts list.
const alertsByDept = new Map<string, VigilanceAlert[]>();
if (snapshot) {
for (const a of snapshot.alerts) {
if (a.echeance !== 'J') continue;
if (a.colorId < 2) continue;
const list = alertsByDept.get(a.departement) ?? [];
list.push(a);
alertsByDept.set(a.departement, list);
}
}
// Tri des départements actifs par code (2A/2B inséré après 19).
function deptSortKey(code: string): string {
if (code === '2A') return '19.1';
if (code === '2B') return '19.2';
return code;
}
const activeDeptCodes = [...alertsByDept.keys()].sort((a, b) =>
deptSortKey(a).localeCompare(deptSortKey(b), 'en', { numeric: true }),
);
const canicule = alertsToday.filter((a) => a.phenomenonId === 6).length; const canicule = alertsToday.filter((a) => a.phenomenonId === 6).length;
const orages = alertsToday.filter((a) => a.phenomenonId === 3).length; const orages = alertsToday.filter((a) => a.phenomenonId === 3).length;
const orange = alertsToday.filter((a) => a.colorId >= 3).length; const orange = alertsToday.filter((a) => a.colorId >= 3).length;
@ -82,11 +105,13 @@ const productDate = snapshot?.productDatetime
<h2 class="text-xl font-semibold text-slate-900">Niveau par département (aujourd'hui)</h2> <h2 class="text-xl font-semibold text-slate-900">Niveau par département (aujourd'hui)</h2>
<VigilanceLegend /> <VigilanceLegend />
</div> </div>
<div class="grid gap-8 lg:grid-cols-[2fr_1fr]"> <p class="mb-3 text-sm text-slate-500">
Survolez un département pour voir le détail des alertes, cliquez pour la page complète.
</p>
<div class="flex justify-center"> <div class="flex justify-center">
<FranceMap colorsByDept={colorsByDept} /> <FranceMap colorsByDept={colorsByDept} alertsByDept={alertsByDept} />
</div> </div>
<details class="rounded border border-slate-200 bg-white p-4"> <details class="mt-6 rounded border border-slate-200 bg-white p-4">
<summary class="cursor-pointer font-medium text-slate-700"> <summary class="cursor-pointer font-medium text-slate-700">
Vue par région (liste) Vue par région (liste)
</summary> </summary>
@ -94,29 +119,37 @@ const productDate = snapshot?.productDatetime
<DepartementGrid colorsByDept={colorsByDept} /> <DepartementGrid colorsByDept={colorsByDept} />
</div> </div>
</details> </details>
</div>
</section> </section>
) )
} }
{ {
!error && alertsToday.length > 0 && ( !error && activeDeptCodes.length > 0 && (
<section class="border-t border-slate-200 bg-white"> <section class="border-t border-slate-200 bg-white">
<div class="container-tight py-8"> <div class="container-tight py-8">
<h2 class="mb-4 text-xl font-semibold text-slate-900">Alertes actives</h2> <h2 class="mb-4 text-xl font-semibold text-slate-900">
Départements en alerte ({activeDeptCodes.length})
</h2>
<p class="mb-4 text-sm text-slate-500">
Triés par numéro de département. Un département peut cumuler plusieurs phénomènes (ex: canicule + orages).
</p>
<ul class="space-y-2"> <ul class="space-y-2">
{alertsToday {activeDeptCodes.map((code) => {
.sort((a, b) => b.colorId - a.colorId) const dept = getDepartement(code);
.slice(0, 50)
.map((a) => {
const dept = getDepartement(a.departement);
if (!dept) return null; if (!dept) return null;
const alerts = (alertsByDept.get(code) ?? []).slice().sort((a, b) => b.colorId - a.colorId);
return ( return (
<li class="flex flex-wrap items-center justify-between gap-3 rounded border border-slate-200 px-3 py-2"> <li class="rounded border border-slate-200 px-3 py-2">
<a href={`/departement/${a.departement}`} class="font-medium no-underline"> <div class="flex flex-wrap items-center justify-between gap-3">
{dept.name} ({a.departement}) <a href={`/departement/${code}`} class="font-medium no-underline">
<span class="font-mono text-slate-500">{code}</span> · {dept.name}
</a> </a>
<div class="flex flex-wrap gap-1.5">
{alerts.map((a) => (
<VigilanceChip colorId={a.colorId} phenomenonId={a.phenomenonId} /> <VigilanceChip colorId={a.colorId} phenomenonId={a.phenomenonId} />
))}
</div>
</div>
</li> </li>
); );
})} })}

View file

@ -18,16 +18,22 @@ export const prerender = false;
<section class="container-tight py-8 prose prose-slate max-w-none"> <section class="container-tight py-8 prose prose-slate max-w-none">
<h2>Éditeur du site</h2> <h2>Éditeur du site</h2>
<p> <p>
Site édité à titre personnel, <strong>sans but lucratif</strong>, par : Site édité <strong>à titre personnel, sans but lucratif</strong>, par :
</p> </p>
<ul> <ul>
<li>Florian Bouchet — développeur indépendant</li> <li>Florian Bouchet — personne physique</li>
<li>Contact : <a href="mailto:florian@nocleus.com">florian@nocleus.com</a></li> <li>Contact : <a href="mailto:florian@nocleus.com">florian@nocleus.com</a></li>
<li>Statut : personne physique (pas d'entreprise immatriculée, pas d'association)</li>
</ul> </ul>
<p> <p>
Le site n'a aucune vocation commerciale. Aucun chiffre d'affaires, aucune publicité, aucune Info Canicule n'a <strong>aucune vocation commerciale</strong> : aucune publicité, aucun chiffre
collecte de données à des fins de monétisation. d'affaires, aucune collecte de données à des fins de monétisation.
</p>
<p>
<strong>Distinction importante</strong> : l'éditeur exerce par ailleurs une activité de
développement sous le statut de micro-entreprise « <em>Nocleus</em> ». Cette activité commerciale
est <strong>totalement indépendante</strong> du site Info Canicule, qui est édité hors cadre
professionnel, sur fonds propres, à titre purement personnel et bénévole. Aucun service de la
micro-entreprise n'est financé, promu ou rattaché à ce site.
</p> </p>
<h2>Hébergement</h2> <h2>Hébergement</h2>
@ -100,10 +106,10 @@ export const prerender = false;
<h2>Propriété intellectuelle</h2> <h2>Propriété intellectuelle</h2>
<p> <p>
Le code source du site est sous licence libre ( Le code source du site est disponible sur demande à
<a href="https://git.nocleus.com/florian/info-canicule" rel="noopener">repo Forgejo</a> <a href="mailto:florian@nocleus.com">florian@nocleus.com</a>. Les données affichées sont sous
). Les données affichées sont sous Licence Ouverte 2.0 et réutilisables librement, y compris Licence Ouverte 2.0 et réutilisables librement, y compris commercialement, à condition de citer
commercialement, à condition de citer Météo France et la licence. Météo France et la licence.
</p> </p>
<p> <p>
L'endpoint <code>/api/vigilance</code> diffuse le snapshot courant en JSON (CORS *), pour L'endpoint <code>/api/vigilance</code> diffuse le snapshot courant en JSON (CORS *), pour

View file

@ -52,8 +52,7 @@ export const prerender = false;
<ul> <ul>
<li> <li>
<strong>Signaler un bug ou une typo</strong> : par mail à <strong>Signaler un bug ou une typo</strong> : par mail à
<a href="mailto:florian@nocleus.com">florian@nocleus.com</a> ou via une issue sur le <a href="mailto:florian@nocleus.com">florian@nocleus.com</a>.
<a href="https://git.nocleus.com/florian/info-canicule" rel="noopener">repo Forgejo</a>.
</li> </li>
<li> <li>
<strong>Partager le site</strong> autour de vous, en particulier auprès de personnes fragiles <strong>Partager le site</strong> autour de vous, en particulier auprès de personnes fragiles

View file

@ -4,6 +4,18 @@ const require = createRequire(import.meta.url);
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
// Les classes vigilance-chip-{1..4} sont construites dynamiquement (`vigilance-chip-${colorId}`),
// donc le scanner statique ne les voit pas — on les force ici.
safelist: [
'vigilance-chip-1',
'vigilance-chip-2',
'vigilance-chip-3',
'vigilance-chip-4',
'bg-vigilance-vert',
'bg-vigilance-jaune',
'bg-vigilance-orange',
'bg-vigilance-rouge',
],
theme: { theme: {
extend: { extend: {
colors: { colors: {