HTML 82.1%
Python 14.6%
TypeScript 1.9%
CSS 1%
JavaScript 0.5%
1/**2 * =============================================================================3 * Job·Ka — Groupe KA4 * Auteur : Simon-Pierre Boucher5 * Contact : contact@spboucher.ai6 * Fichier : frontend/src/pages/MapPage.tsx7 * Rôle : Carte des offres géolocalisées (MapLibre GL, fond libre Carto —8 * aucun jeton requis, contrairement à Mapbox/ka-maps)9 * Créé : 2026-08-17 Modifié : 2026-08-1710 * =============================================================================11 */12import maplibregl from "maplibre-gl";13import "maplibre-gl/dist/maplibre-gl.css";14import { useEffect, useRef, useState } from "react";15import { geojsonUrl } from "../api";1617const STYLE = "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json";18const QUEBEC_CENTER: [number, number] = [-71.9, 46.9];1920export default function MapPage() {21 const el = useRef<HTMLDivElement>(null);22 const [counts, setCounts] = useState<{ geo: number; all: number } | null>(null);2324 useEffect(() => {25 if (!el.current) return;26 const map = new maplibregl.Map({27 container: el.current,28 style: STYLE,29 center: QUEBEC_CENTER,30 zoom: 5.6,31 attributionControl: { compact: true },32 });33 map.addControl(new maplibregl.NavigationControl(), "top-right");3435 map.on("load", async () => {36 const data = await fetch(geojsonUrl({})).then((r) => r.json());37 setCounts({ geo: data.totalGeocoded, all: data.totalMatching });38 map.addSource("jobs", { type: "geojson", data, cluster: true, clusterRadius: 44 });39 map.addLayer({40 id: "clusters", type: "circle", source: "jobs", filter: ["has", "point_count"],41 paint: {42 "circle-color": "#0c8599",43 "circle-radius": ["step", ["get", "point_count"], 16, 25, 22, 100, 28],44 "circle-stroke-width": 2, "circle-stroke-color": "#ffffff",45 },46 });47 map.addLayer({48 id: "cluster-count", type: "symbol", source: "jobs", filter: ["has", "point_count"],49 layout: { "text-field": "{point_count_abbreviated}", "text-size": 13 },50 paint: { "text-color": "#ffffff" },51 });52 map.addLayer({53 id: "points", type: "circle", source: "jobs", filter: ["!", ["has", "point_count"]],54 paint: {55 "circle-color": "#0c8599", "circle-radius": 7,56 "circle-stroke-width": 2, "circle-stroke-color": "#ffffff",57 },58 });59 map.on("click", "clusters", async (e) => {60 const f = map.queryRenderedFeatures(e.point, { layers: ["clusters"] })[0];61 const src = map.getSource("jobs") as maplibregl.GeoJSONSource;62 const zoom = await src.getClusterExpansionZoom(f.properties!.cluster_id);63 map.easeTo({ center: (f.geometry as GeoJSON.Point).coordinates as [number, number], zoom });64 });65 map.on("click", "points", (e) => {66 const f = e.features?.[0];67 if (!f) return;68 const p = f.properties as Record<string, string>;69 const salaire = p.salary_min && p.salary_min !== "null"70 ? `<br/>💰 ${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"}`71 : "";72 new maplibregl.Popup({ offset: 12 })73 .setLngLat((f.geometry as GeoJSON.Point).coordinates as [number, number])74 .setHTML(75 `<div class="map-popup"><h4>${p.title}</h4>${p.employer} — ${p.city}${salaire}` +76 `<br/><a href="/emploi/${p.uid}">Voir l'offre →</a></div>`,77 )78 .addTo(map);79 });80 map.on("mouseenter", "points", () => { map.getCanvas().style.cursor = "pointer"; });81 map.on("mouseleave", "points", () => { map.getCanvas().style.cursor = ""; });82 });8384 return () => map.remove();85 }, []);8687 return (88 <div className="map-wrap">89 {counts && (90 <div className="map-count">91 {counts.geo.toLocaleString("fr-CA")} offres sur la carte92 {counts.all > counts.geo ? ` · ${(counts.all - counts.geo).toLocaleString("fr-CA")} sans position` : ""}93 </div>94 )}95 <div ref={el} style={{ height: "100%" }} />96 </div>97 );98}99