From efa3359aa70ce504575e4948f0570cd6aafacf30 Mon Sep 17 00:00:00 2001 From: breaching Date: Tue, 28 Apr 2026 16:50:38 +0200 Subject: [PATCH 01/49] feat(web): fix satellite map + Windows flag rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - frontend/app.js: split zone-outline + penguin-buffer-outline layers (MapLibre 4.x rejects data expressions on line-dasharray); add flagImg() helper using flagcdn.com so flags render reliably on Windows - web/components/map/FleetMap.tsx: add ESRI World Imagery satellite source + custom toggle control (đŸ—ș/🛰) with localStorage persistence + locale-aware labels - web/components/Flag.tsx (new): SVG flag component using flag-icons CSS, with ISO-3 → ISO-2 normalization and CPWF → 🩐 special-case - migrate 3 flagEmoji() callsites in web/ to ; mark flagEmoji deprecated - add flag-icons dependency --- frontend/app.js | 101 +++++++++++++------ web/app/[locale]/vessels/[slug]/page.tsx | 8 +- web/app/[locale]/vessels/page.tsx | 7 +- web/components/Flag.tsx | 57 +++++++++++ web/components/home/ActiveAlertsStrip.tsx | 6 +- web/components/map/FleetMap.tsx | 113 +++++++++++++++++++--- web/components/map/VesselsMapClient.tsx | 1 + web/lib/utils.ts | 4 + web/package-lock.json | 7 ++ web/package.json | 1 + 10 files changed, 249 insertions(+), 56 deletions(-) create mode 100644 web/components/Flag.tsx diff --git a/frontend/app.js b/frontend/app.js index b6148ed..0e90733 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -131,6 +131,23 @@ function flagEmoji(code) { return String.fromCodePoint(A + a2.charCodeAt(0) - 65, A + a2.charCodeAt(1) - 65); } +/** + * HTML for a country flag using flagcdn.com (SVG renders reliably on + * Windows where regional-indicator emoji do not). `code` may be alpha-2, + * alpha-3, or 'CPWF'. Returns an empty string when no flag is known so the + * caller can collapse the slot. The 🩐 fallback is rendered for CPWF only. + */ +function flagImg(code, size = 16) { + if (!code) return 'đŸłïž'; + const c = String(code).toUpperCase(); + if (c === 'CPWF') return '🩐'; + const a2 = c.length === 3 ? (ALPHA3_TO_ALPHA2[c] || '') : c; + if (a2.length !== 2) return 'đŸłïž'; + const w = size, h = Math.round(size * 0.75); + const lc = a2.toLowerCase(); + return `${a2}`; +} + function flagName(code) { if (!code) return 'Unknown'; const c = String(code).toUpperCase(); @@ -260,31 +277,47 @@ function installLayers() { ], }, }); + // MapLibre 4.x rejects data expressions on `line-dasharray`, so we split the + // single `zone-outline` layer into one layer per category (each carries a + // static dasharray). The first id stays `zone-outline` for back-compat with + // visibility toggles below. map.addLayer({ id: 'zone-outline', type: 'line', source: 'zones', filter: ['all', ['==', ['geometry-type'], 'Polygon'], + ['==', ['get', 'category'], 'mpa'], + ], + paint: { 'line-color': '#ff3860', 'line-width': 1.6, 'line-dasharray': [1] }, + }); + map.addLayer({ + id: 'zone-outline-buffer', type: 'line', source: 'zones', + filter: ['all', + ['==', ['geometry-type'], 'Polygon'], + ['==', ['get', 'category'], 'buffer_zone'], + ], + paint: { 'line-color': '#ffb020', 'line-width': 1.0, 'line-dasharray': [3, 2] }, + }); + map.addLayer({ + id: 'zone-outline-subarea', type: 'line', source: 'zones', + filter: ['all', + ['==', ['geometry-type'], 'Polygon'], + ['==', ['get', 'category'], 'fishery_subarea'], + ], + paint: { 'line-color': '#3b82f6', 'line-width': 1.0, 'line-dasharray': [2, 2] }, + }); + map.addLayer({ + id: 'zone-outline-other', type: 'line', source: 'zones', + filter: ['all', + ['==', ['geometry-type'], 'Polygon'], + ['!=', ['get', 'category'], 'mpa'], + ['!=', ['get', 'category'], 'buffer_zone'], + ['!=', ['get', 'category'], 'fishery_subarea'], ['!=', ['get', 'category'], 'penguin_buffer'], ], paint: { - 'line-color': ['match', ['get', 'category'], - 'mpa', '#ff3860', - 'buffer_zone', '#ffb020', - 'fishery_subarea', '#3b82f6', - 'convention_area', '#475569', - '#888', - ], - 'line-width': ['match', ['get', 'category'], - 'mpa', 1.6, - 'buffer_zone', 1.0, - 'fishery_subarea', 1.0, - 0.8, - ], - 'line-dasharray': ['match', ['get', 'category'], - 'mpa', ['literal', [1]], - 'buffer_zone', ['literal', [3, 2]], - ['literal', [2, 2]], - ], + 'line-color': ['match', ['get', 'category'], 'convention_area', '#475569', '#888'], + 'line-width': 0.8, + 'line-dasharray': [2, 2], }, }); map.addLayer({ @@ -295,14 +328,22 @@ function installLayers() { 'fill-opacity': ['case', ['==', ['get', 'tier'], 'campaign'], 0.18, 0.08], }, }); + // Same constraint: split into campaign vs other tiers for static dasharrays. map.addLayer({ id: 'penguin-buffer-outline', type: 'line', source: 'zones', - filter: ['==', ['get', 'category'], 'penguin_buffer'], - paint: { - 'line-color': '#22d3ee', - 'line-width': ['case', ['==', ['get', 'tier'], 'campaign'], 1.4, 0.8], - 'line-dasharray': ['case', ['==', ['get', 'tier'], 'campaign'], ['literal', [1]], ['literal', [4, 3]]], - }, + filter: ['all', + ['==', ['get', 'category'], 'penguin_buffer'], + ['==', ['get', 'tier'], 'campaign'], + ], + paint: { 'line-color': '#22d3ee', 'line-width': 1.4, 'line-dasharray': [1] }, + }); + map.addLayer({ + id: 'penguin-buffer-outline-other', type: 'line', source: 'zones', + filter: ['all', + ['==', ['get', 'category'], 'penguin_buffer'], + ['!=', ['get', 'tier'], 'campaign'], + ], + paint: { 'line-color': '#22d3ee', 'line-width': 0.8, 'line-dasharray': [4, 3] }, }); map.addLayer({ id: 'zone-label', type: 'symbol', source: 'zones', @@ -1004,7 +1045,7 @@ function renderFleetList() { : ''; return `
  • - ${flagEmoji(v.flag)} + ${flagImg(v.flag, 18)}
    ${escapeHtml(v.name)}${ownerBadge} @@ -1042,7 +1083,7 @@ async function renderDetail(slug) { data = await api(`/api/vessels/${slug}`); } catch (err) { console.error(err); return; } const s = data.summary; - const flag = flagEmoji(s.flag); + const flag = flagImg(s.flag, 22); $('#detail-name').innerHTML = ` ${flag} ${escapeHtml(s.name)} @@ -1304,12 +1345,12 @@ function bindControls() { map.setLayoutProperty('graticule-line', 'visibility', 'visible'); } if (!$('#toggle-zones').checked) { - ['zone-fill', 'zone-outline', 'zone-label'].forEach((id) => + ['zone-fill', 'zone-outline', 'zone-outline-buffer', 'zone-outline-subarea', 'zone-outline-other', 'zone-label'].forEach((id) => map.getLayer(id) && map.setLayoutProperty(id, 'visibility', 'none') ); } if (!$('#toggle-d1mpa').checked) { - ['penguin-buffer-fill', 'penguin-buffer-outline', 'penguin-colony-dot', 'penguin-colony-label'].forEach((id) => + ['penguin-buffer-fill', 'penguin-buffer-outline', 'penguin-buffer-outline-other', 'penguin-colony-dot', 'penguin-colony-label'].forEach((id) => map.getLayer(id) && map.setLayoutProperty(id, 'visibility', 'none') ); } @@ -1327,14 +1368,14 @@ function bindControls() { $('#toggle-zones').addEventListener('change', (e) => { const vis = e.target.checked ? 'visible' : 'none'; - ['zone-fill', 'zone-outline', 'zone-label'].forEach((id) => { + ['zone-fill', 'zone-outline', 'zone-outline-buffer', 'zone-outline-subarea', 'zone-outline-other', 'zone-label'].forEach((id) => { if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', vis); }); }); $('#toggle-d1mpa').addEventListener('change', (e) => { const vis = e.target.checked ? 'visible' : 'none'; - ['penguin-buffer-fill', 'penguin-buffer-outline', 'penguin-colony-dot', 'penguin-colony-label'].forEach((id) => { + ['penguin-buffer-fill', 'penguin-buffer-outline', 'penguin-buffer-outline-other', 'penguin-colony-dot', 'penguin-colony-label'].forEach((id) => { if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', vis); }); }); diff --git a/web/app/[locale]/vessels/[slug]/page.tsx b/web/app/[locale]/vessels/[slug]/page.tsx index 014338e..1c7451a 100644 --- a/web/app/[locale]/vessels/[slug]/page.tsx +++ b/web/app/[locale]/vessels/[slug]/page.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { isLocale, getDict, type Locale } from "@/lib/i18n"; -import { flagEmoji } from "@/lib/utils"; +import { Flag } from "@/components/Flag"; import { fetchVesselDetail } from "@/lib/krill-watch"; import { EventLogWithFilters } from "@/components/vessels/EventLogWithFilters"; import { IncidentList } from "@/components/vessels/IncidentList"; @@ -71,11 +71,7 @@ export default async function VesselDetailPage({
    - {flag && ( - - {flag === "CPWF" ? "🩐" : flagEmoji(flag)} - - )} + {flag && }

    {name}

    {friendly && ( CPWF diff --git a/web/app/[locale]/vessels/page.tsx b/web/app/[locale]/vessels/page.tsx index ef9f453..34c1ab6 100644 --- a/web/app/[locale]/vessels/page.tsx +++ b/web/app/[locale]/vessels/page.tsx @@ -3,7 +3,8 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { VESSELS, type VesselMission } from "@/data/vessels"; import { isLocale, getDict, localized, type Locale } from "@/lib/i18n"; -import { flagEmoji, slugify } from "@/lib/utils"; +import { slugify } from "@/lib/utils"; +import { Flag } from "@/components/Flag"; import { LiveStatusBanner } from "@/components/LiveStatusBanner"; import { MissionBadge } from "@/components/MissionBadge"; import { Acronym } from "@/components/Acronym"; @@ -106,9 +107,7 @@ export default async function VesselsPage({ >
    - - {isCpwf ? "🩐" : flagEmoji(v.flag)} - +

    {v.name}

    {v.flag} diff --git a/web/components/Flag.tsx b/web/components/Flag.tsx new file mode 100644 index 0000000..b0ea01d --- /dev/null +++ b/web/components/Flag.tsx @@ -0,0 +1,57 @@ +import "flag-icons/css/flag-icons.min.css"; + +const ISO3_TO_ISO2: Record = { + JPN: "JP", ISL: "IS", NOR: "NO", CHN: "CN", RUS: "RU", KOR: "KR", + FRA: "FR", GBR: "GB", USA: "US", DEU: "DE", ESP: "ES", ITA: "IT", + ARG: "AR", PRT: "PT", IND: "IN", AUS: "AU", NZL: "NZ", CAN: "CA", + BRA: "BR", ZAF: "ZA", CHL: "CL", PER: "PE", ECU: "EC", SEN: "SN", + MRT: "MR", GIN: "GN", IRL: "IE", DNK: "DK", FIN: "FI", SWE: "SE", + NLD: "NL", BEL: "BE", POL: "PL", PAN: "PA", LBR: "LR", MLT: "MT", + CYP: "CY", GRC: "GR", TUR: "TR", VNM: "VN", THA: "TH", IDN: "ID", + PHL: "PH", MYS: "MY", SGP: "SG", TWN: "TW", HKG: "HK", +}; + +function normalize(code: string): string | null { + const up = code.trim().toUpperCase(); + if (up.length === 2) return up.toLowerCase(); + if (up.length === 3) { + const m = ISO3_TO_ISO2[up]; + return m ? m.toLowerCase() : null; + } + return null; +} + +type Size = "sm" | "md" | "lg"; + +const SIZE_CLASS: Record = { + sm: "text-sm", + md: "text-base", + lg: "text-2xl", +}; + +type Props = { + code?: string | null; + size?: Size; + className?: string; +}; + +export function Flag({ code, size = "md", className = "" }: Props) { + if (!code) { + return đŸłïž; + } + if (code === "CPWF") { + return 🩐; + } + const iso2 = normalize(code); + if (!iso2) { + return đŸłïž; + } + return ( + + ); +} diff --git a/web/components/home/ActiveAlertsStrip.tsx b/web/components/home/ActiveAlertsStrip.tsx index fc7eda4..339498b 100644 --- a/web/components/home/ActiveAlertsStrip.tsx +++ b/web/components/home/ActiveAlertsStrip.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; import { fetchTopAlerts, type VesselSummary } from "@/lib/krill-watch"; import { type Locale, getDict } from "@/lib/i18n"; -import { flagEmoji } from "@/lib/utils"; +import { Flag } from "@/components/Flag"; import { VESSELS, type VesselMission } from "@/data/vessels"; import { slugify } from "@/lib/utils"; import { MissionBadge } from "@/components/MissionBadge"; @@ -81,9 +81,7 @@ function AlertCard({ className="card p-4 block hover:translate-y-[-2px] transition-transform h-full" >
    - - {alert.flag ? flagEmoji(alert.flag) : "đŸłïž"} - +

    {alert.name}

    diff --git a/web/components/map/FleetMap.tsx b/web/components/map/FleetMap.tsx index b415d84..c874ed7 100644 --- a/web/components/map/FleetMap.tsx +++ b/web/components/map/FleetMap.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useRef, useCallback } from "react"; -import type { Map, Popup } from "maplibre-gl"; +import type { Map, Popup, IControl } from "maplibre-gl"; export type FleetPosition = { mmsi: string; @@ -26,6 +26,7 @@ type Props = { ambient: AmbientPosition[]; showAmbient: boolean; apiBase: string; + locale?: "fr" | "en"; }; function formatTs(ts: string | null): string { @@ -44,12 +45,81 @@ function formatTs(ts: string | null): string { } } -export function FleetMap({ fleet, ambient, showAmbient }: Props) { +const STYLE_KEY = "krillMapStyle"; +type StyleMode = "map" | "satellite"; + +function getInitialStyle(): StyleMode { + if (typeof window === "undefined") return "map"; + const saved = window.localStorage.getItem(STYLE_KEY); + return saved === "satellite" ? "satellite" : "map"; +} + +function makeStyleToggleControl( + initial: StyleMode, + onToggle: (next: StyleMode) => void, + locale: "fr" | "en", +): IControl { + let current: StyleMode = initial; + let container: HTMLDivElement | null = null; + let button: HTMLButtonElement | null = null; + + const labels = locale === "fr" + ? { map: "Vue carte", satellite: "Vue satellite" } + : { map: "Map view", satellite: "Satellite view" }; + + const render = () => { + if (!button) return; + const next: StyleMode = current === "map" ? "satellite" : "map"; + button.textContent = next === "satellite" ? "🛰" : "đŸ—ș"; + button.title = labels[next]; + button.setAttribute("aria-label", labels[next]); + button.setAttribute("aria-pressed", current === "satellite" ? "true" : "false"); + }; + + return { + onAdd() { + container = document.createElement("div"); + container.className = "maplibregl-ctrl maplibregl-ctrl-group"; + + button = document.createElement("button"); + button.type = "button"; + button.style.fontSize = "18px"; + button.style.lineHeight = "1"; + button.addEventListener("click", () => { + current = current === "map" ? "satellite" : "map"; + try { window.localStorage.setItem(STYLE_KEY, current); } catch {} + onToggle(current); + render(); + }); + container.appendChild(button); + + render(); + return container; + }, + onRemove() { + if (container?.parentNode) container.parentNode.removeChild(container); + container = null; + button = null; + }, + }; +} + +function applyStyle(map: Map, mode: StyleMode) { + const osmVisible = mode === "map" ? "visible" : "none"; + const satVisible = mode === "satellite" ? "visible" : "none"; + if (map.getLayer("osm-background")) { + map.setLayoutProperty("osm-background", "visibility", osmVisible); + } + if (map.getLayer("satellite-background")) { + map.setLayoutProperty("satellite-background", "visibility", satVisible); + } +} + +export function FleetMap({ fleet, ambient, showAmbient, locale = "en" }: Props) { const containerRef = useRef(null); const mapRef = useRef(null); const popupRef = useRef(null); - // Build GeoJSON from fleet positions const fleetGeoJson = { type: "FeatureCollection" as const, features: fleet @@ -78,7 +148,6 @@ export function FleetMap({ fleet, ambient, showAmbient }: Props) { })), }; - // Update source data when positions change useEffect(() => { const map = mapRef.current; if (!map) return; @@ -119,10 +188,10 @@ export function FleetMap({ fleet, ambient, showAmbient }: Props) { }); }, []); - // Initialise map once useEffect(() => { if (!containerRef.current || mapRef.current) return; let map: Map; + const initialStyle = getInitialStyle(); import("maplibre-gl").then(({ Map: MapGL, NavigationControl }) => { map = new MapGL({ @@ -136,8 +205,29 @@ export function FleetMap({ fleet, ambient, showAmbient }: Props) { tileSize: 256, attribution: "© OpenStreetMap contributors", }, + "satellite-tiles": { + type: "raster", + tiles: [ + "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", + ], + tileSize: 256, + attribution: "Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community", + }, }, - layers: [{ id: "osm-background", type: "raster", source: "osm-tiles" }], + layers: [ + { + id: "osm-background", + type: "raster", + source: "osm-tiles", + layout: { visibility: initialStyle === "map" ? "visible" : "none" }, + }, + { + id: "satellite-background", + type: "raster", + source: "satellite-tiles", + layout: { visibility: initialStyle === "satellite" ? "visible" : "none" }, + }, + ], }, center: [-45, -60], zoom: 3, @@ -145,9 +235,12 @@ export function FleetMap({ fleet, ambient, showAmbient }: Props) { mapRef.current = map; map.addControl(new NavigationControl(), "top-right"); + map.addControl( + makeStyleToggleControl(initialStyle, (next) => applyStyle(map, next), locale), + "top-right", + ); map.on("load", () => { - // Fleet positions source + layer map.addSource("fleet-positions", { type: "geojson", data: fleetGeoJson }); map.addLayer({ id: "fleet-dots", @@ -164,7 +257,6 @@ export function FleetMap({ fleet, ambient, showAmbient }: Props) { }, }); - // Ambient positions source + layer map.addSource("ambient-positions", { type: "geojson", data: ambientGeoJson }); map.addLayer({ id: "ambient-dots", @@ -178,17 +270,14 @@ export function FleetMap({ fleet, ambient, showAmbient }: Props) { }, }); - // Fleet click — navigate to vessel detail map.on("click", "fleet-dots", (e) => { if (!e.features?.length) return; const slug = e.features[0].properties.slug; - if (slug) window.location.href = `/en/vessels/${slug}`; + if (slug) window.location.href = `/${locale}/vessels/${slug}`; }); - // Ambient click — popup map.on("click", "ambient-dots", handleAmbientClick); - // Cursor pointers for (const layer of ["fleet-dots", "ambient-dots"]) { map.on("mouseenter", layer, () => { map.getCanvas().style.cursor = "pointer"; }); map.on("mouseleave", layer, () => { map.getCanvas().style.cursor = ""; }); diff --git a/web/components/map/VesselsMapClient.tsx b/web/components/map/VesselsMapClient.tsx index c2a5edd..16af8ea 100644 --- a/web/components/map/VesselsMapClient.tsx +++ b/web/components/map/VesselsMapClient.tsx @@ -90,6 +90,7 @@ export function VesselsMapClient({ fleet, apiBase, locale }: Props) { ambient={ambient} showAmbient={showAmbient} apiBase={apiBase ?? ""} + locale={locale === "fr" ? "fr" : "en"} />
    ); diff --git a/web/lib/utils.ts b/web/lib/utils.ts index 90ded54..2eac067 100644 --- a/web/lib/utils.ts +++ b/web/lib/utils.ts @@ -6,6 +6,10 @@ export function slugify(name: string): string { return name.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/g, ""); } +/** + * @deprecated Use `` from `@/components/Flag` instead. + * Emoji-based flags don't render reliably on Windows. Kept only for legacy callers. + */ export function flagEmoji(country: string): string { if (country.length !== 2) return "đŸłïž"; const codePoints = country diff --git a/web/package-lock.json b/web/package-lock.json index 0f608c4..317aa15 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,7 @@ "name": "krill-free", "version": "0.1.0", "dependencies": { + "flag-icons": "^7.5.0", "maplibre-gl": "^5.24.0", "next": "^15.5.0", "react": "^19.0.0", @@ -1190,6 +1191,12 @@ "node": ">=10.13.0" } }, + "node_modules/flag-icons": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz", + "integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", diff --git a/web/package.json b/web/package.json index 8d3b2c4..792edaf 100644 --- a/web/package.json +++ b/web/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "flag-icons": "^7.5.0", "maplibre-gl": "^5.24.0", "next": "^15.5.0", "react": "^19.0.0", From 62cad415214e5c5c63c850a074cde5c975aa46cd Mon Sep 17 00:00:00 2001 From: breaching Date: Tue, 28 Apr 2026 16:51:00 +0200 Subject: [PATCH 02/49] =?UTF-8?q?docs(plan):=20krill-watch=20refonte=20spe?= =?UTF-8?q?c=20+=203=20phases=20=C3=97=208=20tasks=20(multi-agent=20ready)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brainstorm output for unifying the legacy frontend/ tracker and the Next.js web/ site into a single product, with satellite history (timeline scrubber + colored cumulative trail), shareable atoms (incident pages + vessel dossiers + campaign hubs), and parallel-friendly task plans for multiple parallel sessions instances. - 2026-04-28-krill-refonte-design.md: top-level spec (vision, architecture, routes, surfaces UX, phasing, risks, hors-scope) - 2026-04-28-krill-refonte/README.md: orchestration + dependency graph - 2026-04-28-krill-refonte/CONVENTIONS.md: multi-agent rules (file ownership, i18n pending pattern, branches, checkpoints) - 24 task files (P1-T01..P3-T08) with frontmatter declaring files_owned, depends_on, parallelizable_with, wave — designed for 2-3 parallel branches instances to work simultaneously without file conflicts. - .gitignore: ignore .superpowers/ brainstorm working directory Phasing: Phase 1 · Fondation (10d, 8 tasks) — /live in Next.js, mobile, hero hybride Phase 2 · Historique (10d, 8 tasks) — trail + scrubber + 4 campaign hubs Phase 3 · ViralitĂ© (12d, 8 tasks) — incident pages + OG + share + contrib --- .gitignore | 3 + .../specs/2026-04-28-krill-refonte-design.md | 226 ++++++++++++++++++ .../2026-04-28-krill-refonte/CONVENTIONS.md | 89 +++++++ .../specs/2026-04-28-krill-refonte/README.md | 81 +++++++ .../phase-1-fondation/P1-T01-design-system.md | 166 +++++++++++++ .../P1-T02-tracker-map-migration.md | 139 +++++++++++ .../phase-1-fondation/P1-T03-fleet-sidebar.md | 138 +++++++++++ .../phase-1-fondation/P1-T04-vessel-drawer.md | 104 ++++++++ .../P1-T05-live-page-assembly.md | 112 +++++++++ .../phase-1-fondation/P1-T06-hero-hybride.md | 162 +++++++++++++ .../P1-T07-mobile-responsive.md | 137 +++++++++++ .../P1-T08-legacy-cleanup.md | 131 ++++++++++ .../phase-1-fondation/README.md | 60 +++++ .../P2-T01-backend-positions-endpoint.md | 146 +++++++++++ .../P2-T02-vessel-trail-component.md | 174 ++++++++++++++ .../P2-T03-timeline-scrubber.md | 158 ++++++++++++ .../P2-T04-vessel-page-history.md | 130 ++++++++++ .../P2-T05-campaign-hub-routes.md | 132 ++++++++++ .../P2-T06-campaign-content.md | 143 +++++++++++ .../P2-T07-glossary-extension.md | 95 ++++++++ .../P2-T08-discoverability-checkpoint.md | 100 ++++++++ .../phase-2-historique/README.md | 58 +++++ .../P3-T01-revalidate-webhook.md | 168 +++++++++++++ .../phase-3-viralite/P3-T02-incident-page.md | 178 ++++++++++++++ .../P3-T03-og-images-dynamic.md | 161 +++++++++++++ .../phase-3-viralite/P3-T04-share-button.md | 119 +++++++++ .../P3-T05-contribution-form.md | 153 ++++++++++++ .../phase-3-viralite/P3-T06-digest-route.md | 122 ++++++++++ .../P3-T07-pipeline-incident-emit.md | 117 +++++++++ .../P3-T08-support-cta-checkpoint.md | 129 ++++++++++ .../phase-3-viralite/README.md | 58 +++++ 31 files changed, 3889 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte-design.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/CONVENTIONS.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/README.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-1-fondation/P1-T01-design-system.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-1-fondation/P1-T02-tracker-map-migration.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-1-fondation/P1-T03-fleet-sidebar.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-1-fondation/P1-T04-vessel-drawer.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-1-fondation/P1-T05-live-page-assembly.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-1-fondation/P1-T06-hero-hybride.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-1-fondation/P1-T07-mobile-responsive.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-1-fondation/P1-T08-legacy-cleanup.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-1-fondation/README.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-2-historique/P2-T01-backend-positions-endpoint.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-2-historique/P2-T02-vessel-trail-component.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-2-historique/P2-T03-timeline-scrubber.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-2-historique/P2-T04-vessel-page-history.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-2-historique/P2-T05-campaign-hub-routes.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-2-historique/P2-T06-campaign-content.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-2-historique/P2-T07-glossary-extension.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-2-historique/P2-T08-discoverability-checkpoint.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-2-historique/README.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-3-viralite/P3-T01-revalidate-webhook.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-3-viralite/P3-T02-incident-page.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-3-viralite/P3-T03-og-images-dynamic.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-3-viralite/P3-T04-share-button.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-3-viralite/P3-T05-contribution-form.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-3-viralite/P3-T06-digest-route.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-3-viralite/P3-T07-pipeline-incident-emit.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-3-viralite/P3-T08-support-cta-checkpoint.md create mode 100644 docs/superpowers/specs/2026-04-28-krill-refonte/phase-3-viralite/README.md diff --git a/.gitignore b/.gitignore index c00b734..e15e357 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ web/.vercel web/.turbo web/next-env.d.ts web/*.tsbuildinfo + +# Superpowers brainstorming working directory (mockups, server state) +.superpowers/ diff --git a/docs/superpowers/specs/2026-04-28-krill-refonte-design.md b/docs/superpowers/specs/2026-04-28-krill-refonte-design.md new file mode 100644 index 0000000..7e2a543 --- /dev/null +++ b/docs/superpowers/specs/2026-04-28-krill-refonte-design.md @@ -0,0 +1,226 @@ +# krill-watch · refonte unifiĂ©e — design spec + +> **Status** : draft validĂ© · brainstorming 2026-04-28 +> **Owner** : @breaching +> **Spec ID** : 2026-04-28-krill-refonte +> **Approbation** : pending user review of this document +> **Companion artefacts** : `2026-04-28-krill-refonte/` (orchestration + phase plans + tasks) + +--- + +## 1. Contexte & motivation + +`krill-watch` exploite aujourd'hui **deux frontends parallĂšles** : un tracker dense (HTML/JS vanilla servi par FastAPI sur `127.0.0.1:8000`) et un site Next.js bilingue (`localhost:3000`). Il n'y a aucune synergie entre les deux : design systems divergents, navigation sĂ©parĂ©e, le tracker n'est pas mobile-friendly, les pages Next.js ne tirent pas parti des donnĂ©es live. + +L'utilisateur veut une refonte qui : +1. **Unifie** les deux apps en un seul produit cohĂ©rent (Next.js). +2. **Ajoute un historique satellite** des bateaux ("comme Google Maps") : timeline scrubber + trail cumulatif colorĂ©. +3. **Sert simultanĂ©ment** quatre audiences — activistes Sea Shepherd / CPWF (primaire), journalistes d'investigation, lanceurs d'alerte, et public gĂ©nĂ©ral curieux. + +DĂ©cisions prises pendant le brainstorming : +- **Persona primaire** : activiste CPWF — toute friction UX se rĂ©sout en sa faveur (mobile-first, partage, urgence visuelle, CTA "soutenir / signaler"). +- **Architecture** : app Next.js unifiĂ©e, le tracker legacy `frontend/app.js` est réécrit en composants React puis supprimĂ©. +- **Historique satellite** : timeline scrubber + trail cumulatif colorĂ© (sĂ©vĂ©ritĂ© = couleur). Pas d'imagerie Sentinel-2 rĂ©elle au MVP (gardĂ©e pour phase ultĂ©rieure). +- **Home** : hero hybride — phrase-choc tirĂ©e du live + 3 stats sĂ©vĂ©ritĂ© + 2 CTA + mini-map preview. +- **Atomes partageables** : pages incident, dossiers navires, hubs de campagne (les trois ont URL stable + OG image dĂ©diĂ©e). Digest hebdo exposĂ© en route depuis le HTML existant. +- **Scope** : refonte complĂšte activiste, 5-7 semaines, 3 phases livrables (chacune utile en standalone). + +--- + +## 2. Vision & promesse + +> *Un seul tracker open-source qui rend visibles les flottes industrielles en zones protĂ©gĂ©es — vivant pour les activistes, citable pour les journalistes, accessible pour qui veut comprendre.* + +**Ton** : sobre + prĂ©cis. L'activisme transparaĂźt par le choix du sujet (et la sĂ©vĂ©ritĂ© visuelle des incidents), jamais par la rhĂ©torique. Pas de "pillage", "scandale", "honte" — la donnĂ©e parle. + +**Bilingue FR/EN** maintenu en paritĂ© totale. Pas d'autres langues au MVP. + +**Mobile-first** sur les surfaces critiques (`/`, `/live`, `/incident/[slug]`). Le cockpit `/live` a un layout mobile *redessinĂ©* (BottomSheet pour les filtres, drawer pour le navire sĂ©lectionnĂ©), pas un shrink desktop. + +--- + +## 3. Architecture cible + +### 3.1 Couches + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Next.js 15 App Router · port 3000 · TypeScript / React 19 │ +│ Tailwind 4 · MapLibre 5 · flag-icons · @vercel/og │ +└──────────────┬──────────────────────────────────────────────┘ + │ REST + SSE +┌──────────────┮──────────────────────────────────────────────┐ +│ FastAPI · port 8000 · Python 3.11+ │ +│ inchangĂ© sauf : webhook revalidate + endpoint trail-historique +│ + endpoint contributions │ +└──────────────┬──────────────────────────────────────────────┘ + │ +┌──────────────┮──────────────────────────────────────────────┐ +│ DuckDB (data/krill_watch.duckdb) · GeoJSON config │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 3.2 Routes Next.js cibles + +| Route | Type | Phase | Description | +|---|---|---|---| +| `/[locale]` | static + ISR | 1 | Home (hero hybride, alerts strip, missions, mĂ©thode) | +| `/[locale]/live` | client | 1 | Tracker absorbĂ© (cockpit) | +| `/[locale]/live/v/[slug]` | client | 1 | Deep-link vers un navire dans le cockpit | +| `/[locale]/vessels` | static | 1 | Liste flotte (existant — adaptĂ© DS) | +| `/[locale]/vessels/[slug]` | ISR | 2 | Dossier navire + trail + scrubber | +| `/[locale]/campagnes/[slug]` | ISR | 2 | 4 hubs de campagne | +| `/[locale]/incident/[slug]` | ISR | 3 | Pages incident auto-gĂ©nĂ©rĂ©es | +| `/[locale]/digest/[isoweek]` | static | 3 | Digest hebdo depuis HTML existant | +| `/[locale]/about` | static | — | Existe dĂ©jĂ  | +| `/[locale]/contribuer` | client | 3 | Formulaire contribution | +| `/api/og/[type]/[slug]` | edge | 3 | OG images dynamiques | +| `/api/revalidate` | route | 3 | Webhook FastAPI → revalidate | +| `/api/contribute` | route | 3 | Endpoint formulaire contribution | + +### 3.3 Composants critiques mutualisĂ©s + +- **``** — composant map unique remplaçant `FleetMap` actuel, prend `mode` (`live | vessel | incident | campaign`) et configure layers/sources en consĂ©quence. DĂ©jĂ  partiellement prĂ©sent. +- **``** — layer GeoJSON line avec gradient de couleur selon sĂ©vĂ©ritĂ©. Phase 2. +- **``** — slider temporel avec keyboard a11y. Phase 2. +- **``** — strip d'alertes critiques, rĂ©utilisable home + campaign hub. +- **``** — copy-link + intents Twitter/Bluesky/Mastodon + download OG image. Phase 3. +- **``** — bouton "Soutenir CPWF" cohĂ©rent. Phase 3. +- **``, ``, ``, ``** — dĂ©jĂ  existants, restent en place. + +### 3.4 Data layer + +- `web/lib/krill-watch.ts` (existe) — wrap REST + SSE. Étendu en phase 2 avec `getVesselTrail(slug, days)`. +- GĂ©nĂ©ration statique pour pages stables (about, vessels, vessels/[slug] ISR, campagnes/[slug] ISR). +- `/live` et `/incident/[slug]` server-rendered (pour OG images correctes). + +--- + +## 4. Surfaces UX + +### 4.1 Home (`/`) + +**Above-the-fold** (hero hybride) : +- Tag mono : `● MAINTENANT · CCAMLR 48.1` (zone la plus chaude dĂ©tectĂ©e) +- Headline : `11 chalutiers industriels en zone protĂ©gĂ©e antarctique.` (gĂ©nĂ©rĂ© depuis l'API : nombre + zone + qualifieur) +- 3 stats par sĂ©vĂ©ritĂ© (CRITIQUE / SUSPECT / SURVEILLÉ) +- 2 CTA : "Voir la carte live →" (primary, krill orange) et "Comprendre la cause" (ghost) +- Mini-map preview Ă  droite (180×120 desktop) avec dots clignotants + +**Below-the-fold** : LiveAlertsStrip (3 cartes alerte) → Grille 4 missions (cards cliquables vers `/campagnes/[slug]`) → MĂ©thode 3 Ă©tapes → Footer. + +### 4.2 Cockpit (`/live`) + +**Desktop** : layout 3 colonnes — sidebar gauche (filtres + liste fleet collabsible) · map plein viewport · drawer droit (dĂ©tails navire sĂ©lectionnĂ©, fermable). Toggle DARK / SATELLITE en flotte top-left de la map. + +**Mobile** : map plein Ă©cran. BottomSheet remontante pour filtres + liste. Drawer plein Ă©cran pour dĂ©tails. FAB pour toggle satellite. + +URL state : `?severity=CRITICAL&zone=48.1&v=antarctic-endurance` (deep-linkable, copiable). + +### 4.3 Dossier navire (`/vessels/[slug]`) + +**Header** : drapeau (Flag SVG) + nom + sĂ©vĂ©ritĂ© badge + flag CPWF si friendly + IMO/MMSI mono. + +**Section map historique** : `` avec `` rendu en gradient color (vert → orange → rouge selon sĂ©vĂ©ritĂ© instantanĂ©e). En bas : `` avec marqueurs (entrĂ©es/sorties zones, AIS gaps). + +**Sections suivantes** : Tableau identitĂ© · Log Ă©vĂ©nements · Incidents documentĂ©s (liens vers `/incident/[slug]` en phase 3) · Sources · `` + Embed code. + +### 4.4 Page incident (`/incident/[slug]`) + +**Above-the-fold** : map zoomĂ©e sur la violation + headline auto-gĂ©nĂ©rĂ©e (`{vessel} entered {zone} at {ts}`) + score transhipment (si applicable) + sources (3-5 liens externes). + +**Below-the-fold** : contexte campagne (hub link) · trail du navire 7 jours autour de l'incident · `` · ``. + +URL slug : `---` (ex: `2026-04-28-antarctic-endurance-zone-entered-48-1`). Idempotent — si réémis, met Ă  jour la mĂȘme page. Voir [P3-T01](./2026-04-28-krill-refonte/phase-3-viralite/P3-T01-revalidate-webhook.md) pour la fonction `makeIncidentSlug` partagĂ©e TS/Python. + +### 4.5 Hub campagne (`/campagnes/[slug]`) + +**Hero Ă©ditorial** : image satellite teaser (statique en MVP, Sentinel-2 plus tard) + 3 chiffres-clĂ©s + CTA Soutenir. + +**Sections** : Cadre lĂ©gal & enjeu · Liste flotte filtrĂ©e par mission · Incidents rĂ©cents (live) · Calendrier saison · Sources documentaires. + +Contenu Ă©ditorial : MD bilingue par mission (`web/data/campaigns/.{fr,en}.md`). + +--- + +## 5. Plan de phasage + +3 phases livrables. Chacune fournit de la valeur en standalone (`/live` Ă  paritĂ© aprĂšs phase 1, dossiers enrichis aprĂšs phase 2, viralitĂ© aprĂšs phase 3). + +| Phase | DurĂ©e | TĂąches | Livrable principal | +|---|---|---|---| +| **1 · Fondation** | 10 jours | 8 tĂąches | `/live` Ă  paritĂ© fonctionnelle dans Next.js, mobile-friendly. Hero hybride home. Legacy supprimĂ©. | +| **2 · Historique & contenu** | 10 jours | 8 tĂąches | Trail + scrubber sur dossiers navires. 4 hubs de campagne. Glossaire enrichi. | +| **3 · ViralitĂ© & contribution** | 12 jours | 8 tĂąches | Pages incident auto-gĂ©nĂ©rĂ©es + OG dynamiques. Boutons partage. Formulaire contribution. Digest en route. | + +DĂ©tail dans `2026-04-28-krill-refonte/phase-N-*/README.md`. + +--- + +## 6. Risques & questions ouvertes + +### Risques techniques + +- **MapLibre + React StrictMode** : double-init en dev. Pattern ref + cleanup dĂ©jĂ  en place dans `FleetMap.tsx` Ă  conserver. +- **SSE dans Next.js App Router** : pas de support natif streaming SSE dans les Route Handlers pour l'instant. Solution : EventSource cĂŽtĂ© client (`'use client'`), fallback polling 30s. +- **OG images dynamiques** : `@vercel/og` (Satori) prend du SVG / Ă©lĂ©ments React simples. Une carte raster MapLibre ne s'y intĂšgre pas. Solution prĂ©vue : prĂ©-render cĂŽtĂ© serveur Python qui retourne PNG basique (tile statique + dot vessel + headline), ou mock SVG avec `` simplifiĂ© dans Satori. +- **Performance trail 30j × 23 navires** : 50k-100k points GeoJSON potentiels. Mitigation : downsample cĂŽtĂ© API (1 point / 15 min suffit visuellement), cluster cĂŽtĂ© client si > 5k. +- **Webhook revalidate FastAPI → Next.js** : nĂ©cessite un secret partagĂ© + une URL publique. En dev local on peut utiliser ngrok ; en prod ça dĂ©pend de l'hĂ©bergement. + +### Questions ouvertes (Ă  trancher pendant l'exĂ©cution) + +- **HĂ©bergement Next.js** : Vercel (gratuit, OG natif) ou auto-hĂ©bergĂ© (Docker compose avec FastAPI) ? *DĂ©cision recommandĂ©e : Vercel pour simplicitĂ©.* +- **Newsletter** : intĂ©grĂ©e (Buttondown/Substack embed) ou simple lien externe ? *DĂ©cision recommandĂ©e : externe pour MVP.* +- **Storage formulaire contribution** : DuckDB (rĂ©utilise infra existante) ou GitHub Issues (auditabilitĂ© publique) ? *DĂ©cision recommandĂ©e : DuckDB + admin export.* +- **CTA Soutenir CPWF** : redirige vers `paulwatsonfoundation.org/donate` directement ou page intermĂ©diaire krill-watch ? *DĂ©cision recommandĂ©e : redirige direct, ne pas crĂ©er un funnel.* + +--- + +## 7. CritĂšres de succĂšs globaux + +À la fin des 3 phases, le produit livrĂ© doit valider : + +1. **Synergie** : un seul header / footer / DS / locale switcher pour TOUT le produit. +2. **Mobile** : un activiste sur smartphone peut consulter `/live`, sĂ©lectionner un navire, et voir un incident dĂ©taillĂ© sans frustration. +3. **ViralitĂ©** : partager une URL `/incident/` sur Twitter gĂ©nĂšre un preview avec OG image lisible (carte + headline + drapeau). +4. **CitabilitĂ©** : un journaliste peut citer `/vessels/` comme rĂ©fĂ©rence stable pour un article ; la page existait Ă  la mĂȘme URL il y a 6 mois. +5. **PĂ©dagogie** : un random qui arrive sur `/` comprend en 30 secondes ce qu'il regarde et oĂč il peut agir. +6. **AccessibilitĂ©** : Lighthouse a11y score ≄ 90 sur toutes les pages critiques. +7. **Performance** : Lighthouse perf ≄ 80 sur mobile, LCP < 2.5s, CLS < 0.1. + +--- + +## 8. Hors scope (explicite) + +- Imagerie Sentinel-2 rĂ©elle alignĂ©e AIS (gardĂ©e pour phase ultĂ©rieure post-MVP). +- Flux lanceur d'alerte chiffrĂ© bout-en-bout / Tor-friendly (gardĂ© pour phase ultĂ©rieure). +- API publique versionnĂ©e avec auth journaliste. +- Widget embeddable `