SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
1 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
4.1 KB · 99 lines tsx
Raw Blame History
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