/** * ============================================================================= * Job·Ka — Groupe KA * Auteur : Simon-Pierre Boucher * Contact : contact@spboucher.ai * Fichier : frontend/src/pages/MapPage.tsx * Rôle : Carte des offres géolocalisées (MapLibre GL, fond libre Carto — * aucun jeton requis, contrairement à Mapbox/ka-maps) * Créé : 2026-08-17 Modifié : 2026-08-17 * ============================================================================= */ import maplibregl from "maplibre-gl"; import "maplibre-gl/dist/maplibre-gl.css"; import { useEffect, useRef, useState } from "react"; import { geojsonUrl } from "../api"; const STYLE = "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"; const QUEBEC_CENTER: [number, number] = [-71.9, 46.9]; export default function MapPage() { const el = useRef(null); const [counts, setCounts] = useState<{ geo: number; all: number } | null>(null); useEffect(() => { if (!el.current) return; const map = new maplibregl.Map({ container: el.current, style: STYLE, center: QUEBEC_CENTER, zoom: 5.6, attributionControl: { compact: true }, }); map.addControl(new maplibregl.NavigationControl(), "top-right"); map.on("load", async () => { const data = await fetch(geojsonUrl({})).then((r) => r.json()); setCounts({ geo: data.totalGeocoded, all: data.totalMatching }); map.addSource("jobs", { type: "geojson", data, cluster: true, clusterRadius: 44 }); map.addLayer({ id: "clusters", type: "circle", source: "jobs", filter: ["has", "point_count"], paint: { "circle-color": "#0c8599", "circle-radius": ["step", ["get", "point_count"], 16, 25, 22, 100, 28], "circle-stroke-width": 2, "circle-stroke-color": "#ffffff", }, }); map.addLayer({ id: "cluster-count", type: "symbol", source: "jobs", filter: ["has", "point_count"], layout: { "text-field": "{point_count_abbreviated}", "text-size": 13 }, paint: { "text-color": "#ffffff" }, }); map.addLayer({ id: "points", type: "circle", source: "jobs", filter: ["!", ["has", "point_count"]], paint: { "circle-color": "#0c8599", "circle-radius": 7, "circle-stroke-width": 2, "circle-stroke-color": "#ffffff", }, }); map.on("click", "clusters", async (e) => { const f = map.queryRenderedFeatures(e.point, { layers: ["clusters"] })[0]; const src = map.getSource("jobs") as maplibregl.GeoJSONSource; const zoom = await src.getClusterExpansionZoom(f.properties!.cluster_id); map.easeTo({ center: (f.geometry as GeoJSON.Point).coordinates as [number, number], zoom }); }); map.on("click", "points", (e) => { const f = e.features?.[0]; if (!f) return; const p = f.properties as Record; const salaire = p.salary_min && p.salary_min !== "null" ? `
💰 ${Number(p.salary_min).toLocaleString("fr-CA")} $${p.salary_max && p.salary_max !== p.salary_min ? ` à ${Number(p.salary_max).toLocaleString("fr-CA")} $` : ""} / ${p.salary_unit === "hour" ? "h" : "an"}` : ""; new maplibregl.Popup({ offset: 12 }) .setLngLat((f.geometry as GeoJSON.Point).coordinates as [number, number]) .setHTML( `

${p.title}

${p.employer} — ${p.city}${salaire}` + `
Voir l'offre →
`, ) .addTo(map); }); map.on("mouseenter", "points", () => { map.getCanvas().style.cursor = "pointer"; }); map.on("mouseleave", "points", () => { map.getCanvas().style.cursor = ""; }); }); return () => map.remove(); }, []); return (
{counts && (
{counts.geo.toLocaleString("fr-CA")} offres sur la carte {counts.all > counts.geo ? ` · ${(counts.all - counts.geo).toLocaleString("fr-CA")} sans position` : ""}
)}
); }