import React, { useMemo, useState, useRef } from "react";
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
import MarkerClusterGroup from "react-leaflet-markercluster";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import "react-leaflet-markercluster/dist/styles.min.css";
// Optional libraries expected to be available in the host app (install via npm/yarn):
// react, react-dom, react-leaflet, leaflet, react-leaflet-markercluster, xlsx, file-saver, recharts, tailwindcss
/*
Carte interactive des métiers — version améliorée
- Export CSV & XLSX (client-side)
- Clustering des marqueurs
- Mini-sparklines générées dynamiquement dans les popups
- Toggle clustering, filtres, recherche, et export
- Iframe/embed instructions incluses en commentaire
Dépose ce fichier dans une application React avec Tailwind. Toutes les dépendances listées ci-dessus doivent être installées.
*/
// ---------------------------
// Dataset (identique à celui fourni)
// ---------------------------
const DATA = [
/* (le même tableau DATA que précédemment) */
{
profession: "Infirmiers diplômés d'État",
description: "Professionnels de santé assurant les soins infirmiers et l'accompagnement des patients",
secteur: "Santé",
effectifs: 764000,
departures: 185000,
densite: 1140.5,
formation: "Diplôme d'État infirmier (3 ans)",
remuneration: "28 000 - 45 000 € brut/an",
tension: "Modérée",
evolution: 3.2,
},
// ... (les autres entrées exactement comme dans la version précédente)
];
// ---------------------------
// Helpers
// ---------------------------
function hashToLatLng(key) {
let h = 2166136261;
for (let i = 0; i < key.length; i++) {
h ^= key.charCodeAt(i);
h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24);
}
const rand = (h >>> 0) / 4294967295;
const lat = 42 + rand * 9; // 42 to 51
const lon = -5 + ((rand * 9973) % 13); // -5 to +8
return [lat, lon];
}
const SECTOR_COLORS = {
Santé: "#e34a33",
Artisanat: "#2b8cbe",
Commerce: "#66c2a5",
Services: "#f1a340",
};
function getColorBySector(sector) {
return SECTOR_COLORS[sector] || "#999999";
}
function makeIcon(sector, effectifs) {
const size = Math.max(26, Math.min(56, 26 + Math.log10(effectifs) * 8));
const color = getColorBySector(sector);
return L.divIcon({
className: "custom-marker",
html: `
${sector[0]}
`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
}
// Generate a small synthetic time series for sparklines based on 'effectifs' and 'evolution'
function syntheticTrend(effectifs, evolution) {
const base = Math.round(effectifs / 1000);
const points = 8;
const arr = [];
for (let i = 0; i < points; i++) {
// older -> more negative drift depending on evolution
const factor = 1 + ((evolution / 100) * (i - points + 1)) / points;
arr.push(Math.max(1, Math.round(base * factor + (Math.sin(i) * 0.2 * base))));
}
return arr;
}
// Simple inline sparkline SVG (small, dependency-free)
function Sparkline({ data }) {
const w = 120;
const h = 28;
const max = Math.max(...data);
const min = Math.min(...data);
const points = data
.map((d, i) => {
const x = (i / (data.length - 1)) * w;
const y = h - ((d - min) / (max - min || 1)) * h;
return `${x},${y}`;
})
.join(" ");
const last = data[data.length - 1];
const color = last >= data[0] ? "#16a34a" : "#ef4444";
return (
);
}
// CSV & XLSX export helpers
async function exportCSV(data) {
const header = [
"profession",
"secteur",
"effectifs",
"departures",
"densite",
"formation",
"remuneration",
"tension",
"evolution",
];
const rows = [header.join(",")];
data.forEach((d) => {
const row = [
`"${d.profession.replace(/"/g, '""')}"`,
d.secteur,
d.effectifs,
d.departures,
d.densite,
`"${(d.formation || "").replace(/"/g, '""')}"`,
`"${(d.remuneration || "").replace(/"/g, '""')}"`,
d.tension,
d.evolution,
];
rows.push(row.join(","));
});
const csvContent = rows.join("
");
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `metiers_export_${new Date().toISOString().slice(0,10)}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async function exportXLSX(data) {
// lazy-import xlsx & file-saver to avoid bundling if not used
const [{ utils, write }, { saveAs }] = await Promise.all([import("xlsx"), import("file-saver")]);
const wsData = [
[
"profession",
"secteur",
"effectifs",
"departures",
"densite",
"formation",
"remuneration",
"tension",
"evolution",
],
];
data.forEach((d) => {
wsData.push([d.profession, d.secteur, d.effectifs, d.departures, d.densite, d.formation, d.remuneration, d.tension, d.evolution]);
});
const ws = utils.aoa_to_sheet(wsData);
const wb = utils.book_new();
utils.book_append_sheet(wb, ws, "Metiers");
const wbout = write(wb, { bookType: "xlsx", type: "array" });
saveAs(new Blob([wbout], { type: "application/octet-stream" }), `metiers_export_${new Date().toISOString().slice(0,10)}.xlsx`);
}
// ---------------------------
// Main component
// ---------------------------
export default function InteractiveMetiersMapEnhanced() {
const [sectorFilter, setSectorFilter] = useState("Tous");
const [search, setSearch] = useState("");
const [tensionFilter, setTensionFilter] = useState("Tous");
const [clusterEnabled, setClusterEnabled] = useState(true);
const [embedOpen, setEmbedOpen] = useState(false);
const mapRef = useRef(null);
const professions = useMemo(() => {
return DATA.map((d) => ({ ...d, coords: hashToLatLng(d.profession) }));
}, []);
const sectors = useMemo(() => Array.from(new Set(DATA.map((d) => d.secteur))), []);
const tensions = ["Très forte", "Forte", "Modérée", "Faible"];
const filtered = professions.filter((p) => {
if (sectorFilter !== "Tous" && p.secteur !== sectorFilter) return false;
if (tensionFilter !== "Tous" && p.tension !== tensionFilter) return false;
if (search && !p.profession.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
return (
Carte interactive des métiers — version pro
Vue nationale — style coloré & dynamique — export & embed inclus
Recherche
setSearch(e.target.value)} className="w-full p-2 border rounded" placeholder="Rechercher un métier..." />
Secteur
setSectorFilter(e.target.value)} className="w-full p-2 border rounded">
Tous
{sectors.map((s) => (
{s}
))}
Tension
setTensionFilter(e.target.value)} className="w-full p-2 border rounded">
Tous
{tensions.map((t) => (
{t}
))}
Cluster
setClusterEnabled(e.target.checked)} />
{ setSectorFilter("Tous"); setTensionFilter("Tous"); setSearch(""); }} className="px-3 py-1 border rounded text-sm">Réinitialiser
exportCSV(filtered)}>Exporter CSV
exportXLSX(filtered)}>Exporter XLSX
Légende
{Object.keys(SECTOR_COLORS).map((s) => (
))}
Liste ({filtered.length})
{filtered.map((p) => (
{
if (mapRef.current) {
mapRef.current.setView(p.coords, 7, { animate: true });
}
}}>
{p.profession}
{p.secteur}
Effectifs: {p.effectifs.toLocaleString()} • {p.tension}
))}
Embed: setEmbedOpen(true)}>Afficher le code
(mapRef.current = m)} center={[46.5, 2.0]} zoom={5} style={{ height: "100%", width: "100%" }}>
{clusterEnabled ? (
{filtered.map((p) => (
{p.profession}
{p.secteur} • {p.tension}
Effectifs : {p.effectifs.toLocaleString()}
Départs : {p.departures.toLocaleString()}
Densité : {p.densite} /100k hab.
Salaire : {p.remuneration}
Formation : {p.formation}
Évolution : {p.evolution}%
{ navigator.clipboard && navigator.clipboard.writeText(p.profession); }}>Copier nom
))}
) : (
filtered.map((p) => (
{p.profession}
{p.secteur} • {p.tension} tension
Effectifs : {p.effectifs.toLocaleString()}
Départs prévus : {p.departures.toLocaleString()}
Densité : {p.densite} /100k hab.
Salaire (approx.) : {p.remuneration}
Formation : {p.formation}
Évolution : {p.evolution}%
))
)}
{embedOpen && (
Code d'intégration (iframe)
Copie/colle dans ton site
setEmbedOpen(false)}>✕
)}
);
}
/*
Instructions rapides (README) :
1) Installer dépendances :
npm install react-leaflet leaflet react-leaflet-markercluster xlsx file-saver
2) Importer ce composant et l'utiliser dans une page React.
3) Pour l'embed : créer une route légère qui monte ce composant seul, puis intégrer via iframe.
Notes :
- Les positions géographiques sont synthétiques (hashing du nom) pour une répartition visuelle nationale.
- Si tu fournis une distribution réelle par région/département, je peux remplacer les coordonnées hashées par des coordonnées réelles précises et ajouter un mode choroplèthe.
*/