html`<div class="explorer">
<div class="explorer-controls">
${viewof affichage_france}
${viewof niveau_france}
${viewof annee_france}
${viewof drom_rapproches}
${viewof simplification_france}
${telechargements(requete_france, "Fichier complet du niveau, à filtrer en SQL ou avec le client Python")}
</div>
<div class="explorer-viz">${visualisation(donnees_france, affichage_france)}</div>
<div class="explorer-code">${panneauCode(programmes(requete_france, niveau_france.variable), notes)}</div>
</div>`API = "https://cartiflette-api.lab.sspcloud.fr"
STORAGE = "https://minio.lab.sspcloud.fr/projet-cartiflette/production"
// Premier millésime publié en GeoParquet
PARQUET_FIRST_YEAR = 2023
annees = ["2026", "2025", "2024", "2023", "2022"]// Niveaux proposés pour la France entière : libellé, nom de variable dans les
// extraits de code
niveaux = new Map([
["Départements", {code: "DEPARTEMENT", variable: "departements"}],
["Régions", {code: "REGION", variable: "regions"}],
["Bassins de vie", {code: "BASSIN_VIE", variable: "bassins_vie"}],
["Zones d'emploi", {code: "ZONE_EMPLOI", variable: "zones_emploi"}],
["Unités urbaines", {code: "UNITE_URBAINE", variable: "unites_urbaines"}],
["Aires d'attraction des villes", {code: "AIRE_ATTRACTION_VILLES", variable: "aires_attraction"}]
])function apiUrl(q) {
const params = new URLSearchParams({
year: q.year,
borders: q.borders,
filter_by: q.filter_by,
simplification: q.simplification
})
for (const v of q.values) params.append("values", v)
return `${API}/v1/geojson?${params}`
}
// GeoParquet publié : un fichier par niveau et disposition, toute la France
function parquetUrl(q) {
const layout = q.filter_by == "FRANCE_ENTIERE_DROM_RAPPROCHES"
? "FRANCE_ENTIERE_DROM_RAPPROCHES" : "FRANCE_ENTIERE"
return `${STORAGE}/provider=IGN/dataset_family=ADMINEXPRESS` +
`/source=EXPRESS-COG-CARTO-TERRITOIRE/year=${q.year}` +
`/administrative_level=${q.borders}/crs=4326/layout=${layout}` +
`/vectorfile_format=parquet/simplification=${q.simplification}/raw.parquet`
}SOURCES = ({
ign: {court: "IGN", label: "IGN, ADMIN EXPRESS COG CARTO", url: "https://geoservices.ign.fr/adminexpress"},
tagc: {court: "Insee", label: "Insee, appartenance géographique des communes", url: "https://www.insee.fr/fr/information/7671844"},
cartiflette: {court: "cartiflette", label: "construite par cartiflette", url: "https://github.com/InseeFrLab/cartiflette"}
})
COLONNES = ({
ID: ["ign", "Identifiant IGN"],
NOM: ["ign", "Nom officiel"],
STATUT: ["ign", "Statut de la commune"],
POPULATION: ["ign", "Population municipale (somme pour les niveaux agrégés)"],
INSEE_COM: ["ign", "Code commune"],
INSEE_COG: ["cartiflette", "Code commune, ou d'arrondissement municipal à Paris, Lyon et Marseille"],
AREA: ["cartiflette", "Territoire : métropole ou DROM"],
PAYS: ["cartiflette", "Pays"],
SOURCE: ["cartiflette", "Source des contours"],
CTCD: ["tagc", "Collectivité territoriale ayant les compétences départementales"],
EPCI: ["tagc", "Intercommunalité (EPCI)"],
EPT: ["tagc", "Établissement public territorial (Grand Paris)"],
ARR: ["tagc", "Arrondissement départemental"],
CANOV: ["tagc", "Canton-ville"],
ZE2020: ["tagc", "Zone d'emploi 2020"],
UU2020: ["tagc", "Unité urbaine 2020"],
AAV2020: ["tagc", "Aire d'attraction des villes 2020"],
BV2022: ["tagc", "Bassin de vie 2022"],
INSEE_DEP: ["tagc", "Code département"],
INSEE_REG: ["tagc", "Code région"],
LIBELLE_DEPARTEMENT: ["tagc", "Nom du département"],
LIBELLE_REGION: ["tagc", "Nom de la région"]
})
// Tableau des données attributaires : chaque en-tête porte le nom de la
// colonne et une étiquette de sa source (lien, description au survol)
function tableAttributs(rows) {
const columns = Object.keys(rows[0])
const entete = columns.map(c => {
const [source, description] = COLONNES[c] ?? []
const etiquette = source
? html`<a class="source source-${source}" href=${SOURCES[source].url} target="_blank"
title=${`${description} (${SOURCES[source].label})`}>${SOURCES[source].court}</a>`
: ""
return html`<th><span class="nom-colonne">${c}</span>${etiquette}</th>`
})
const cellule = v => typeof v == "number"
? html`<td class="nombre">${v.toLocaleString("fr-FR")}</td>`
: html`<td>${v ?? ""}</td>`
const lignes = rows.map(r => html`<tr>${columns.map(c => cellule(r[c]))}</tr>`)
return html`<div class="attributes-table"><table>
<thead><tr>${entete}</tr></thead><tbody>${lignes}</tbody>
</table></div>`
}
// Carte ou tableau des données attributaires
function visualisation(geojson, mode) {
if (geojson.features.length == 0) {
return html`<p class="viz-meta">Aucun polygone pour cette sélection.</p>`
}
const rows = geojson.features.map(f => f.properties)
const columns = Object.keys(rows[0])
const resume = html`<p class="viz-meta">${rows.length} polygones, ${columns.length} colonnes</p>`
if (mode == "Carte") {
return html`<div>${carte(geojson)}${resume}</div>`
}
return html`<div>${tableAttributs(rows)}${resume}</div>`
}l93 = "+proj=lcc +lat_0=46.5 +lon_0=3 +lat_1=49 +lat_2=44 +x_0=700000 +y_0=6600000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs"
topohelper = import("https://cdn.jsdelivr.net/npm/topohelper@0.4.3/+esm")
// Largeur de la colonne de la carte, d'après la fenêtre (mêmes seuils que
// .explorer dans styles.scss) : la carte est un canvas de taille fixe
largeurCarte = Generators.observe(notify => {
const compute = () => {
const explorer = window.innerWidth - 120
notify(Math.floor(window.innerWidth > 991 ? (explorer - 40) * 2.2 / 4.8 : explorer))
}
compute()
window.addEventListener("resize", compute)
return () => window.removeEventListener("resize", compute)
})
function carte(geojson) {
const w = largeurCarte
return topohelper
.fromGeojson(geojson, {deep: true})
.project({proj: l93})
.view({tooltip: true, zoom: true, size: [w, Math.round(w * 0.95)]})
}// Boutons de téléchargement : GeoJSON par l'API, GeoParquet publié
function telechargements(q, parquetNote) {
const geojson = html`<a class="primary" href=${apiUrl(q)} target="_blank" rel="noopener">
<i class="fa-solid fa-download"></i> GeoJSON</a>`
const parquet = Number(q.year) >= PARQUET_FIRST_YEAR
? html`<a class="secondary" href=${parquetUrl(q)} rel="noopener">
<i class="fa-solid fa-download"></i> GeoParquet</a>
<small>${parquetNote}</small>`
: html`<a class="disabled">GeoParquet</a>
<small>Pas de GeoParquet publié pour ${q.year}</small>`
return html`<div class="downloads">${geojson}${parquet}</div>`
}// Sélection multiple de départements, sans dépendance externe : un champ de
// recherche (code ou nom, sans tenir compte des accents) ouvre une liste de
// suggestions, cliquables ou navigables au clavier ; les départements choisis
// s'affichent en étiquettes, retirées avec ×. Suit le protocole des entrées
// Observable : propriété value (liste de codes) et événement input.
function selecteurDepartements(departements, {value = [], placeholder = ""} = {}) {
const sansAccent = t => t.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase()
const nom = new Map(departements.map(d => [d.code, d.nom]))
const input = html`<input type="text" placeholder=${placeholder} autocomplete="off"
class="form-control form-control-sm" role="combobox" aria-expanded="false">`
const liste = html`<ul class="suggestions" role="listbox" hidden></ul>`
const chips = html`<div class="chips"></div>`
const form = html`<form class="multi-select"><div class="champ">${input}${liste}</div>${chips}</form>`
let selection = [...value]
let suggestions = []
let active = 0
// Suggestions : code commençant par la saisie, puis nom commençant par
// la saisie, puis nom la contenant
function filtrer() {
const t = sansAccent(input.value.trim())
const rang = d => !t ? 0
: d.code.toLowerCase().startsWith(t) ? 0
: sansAccent(d.nom).startsWith(t) ? 1
: sansAccent(d.nom).includes(t) ? 2
: -1
return departements
.filter(d => !selection.includes(d.code) && rang(d) >= 0)
.sort((a, b) => rang(a) - rang(b))
}
function ouvrir() {
suggestions = filtrer()
active = 0
liste.replaceChildren(...suggestions.map((d, i) => {
const li = html`<li role="option" class=${i == active ? "active" : ""}>
<span class="code">${d.code}</span><span class="nom">${d.nom}</span></li>`
// mousedown (avant la perte du focus du champ) plutôt que click
li.addEventListener("mousedown", e => { e.preventDefault(); ajouter(d.code) })
li.addEventListener("mousemove", () => surligner(i))
return li
}))
liste.hidden = suggestions.length == 0
input.setAttribute("aria-expanded", String(!liste.hidden))
}
function fermer() {
liste.hidden = true
input.setAttribute("aria-expanded", "false")
}
function surligner(i) {
active = i
Array.from(liste.children).forEach((li, j) => li.classList.toggle("active", j == i))
liste.children[i]?.scrollIntoView({block: "nearest"})
}
function ajouter(code) {
if (code && !selection.includes(code)) {
selection.push(code)
update()
}
input.value = ""
fermer()
}
function renderChips() {
chips.replaceChildren(...selection.map(code => {
const remove = html`<button type="button" aria-label="Retirer ${nom.get(code)}">×</button>`
remove.onclick = () => { selection = selection.filter(s => s != code); update() }
return html`<span class="chip"><b>${code}</b> ${nom.get(code)}${remove}</span>`
}))
}
function update() {
form.value = [...selection]
renderChips()
form.dispatchEvent(new Event("input", {bubbles: true}))
}
// Les frappes ne remontent pas jusqu'à Observable (sinon l'explorateur est
// redessiné et le champ perd le focus) : seule la sélection met à jour
input.addEventListener("input", e => { e.stopPropagation(); ouvrir() })
input.addEventListener("change", e => e.stopPropagation())
input.addEventListener("focus", ouvrir)
input.addEventListener("blur", fermer)
input.addEventListener("keydown", e => {
if (e.key == "ArrowDown" || e.key == "ArrowUp") {
e.preventDefault()
if (liste.hidden) ouvrir()
const n = suggestions.length
if (n) surligner((active + (e.key == "ArrowDown" ? 1 : n - 1)) % n)
} else if (e.key == "Enter") {
e.preventDefault()
ajouter(suggestions[active]?.code)
} else if (e.key == "Escape") {
fermer()
}
})
form.addEventListener("submit", e => e.preventDefault())
form.value = [...selection]
renderChips()
return form
}function pyValues(values) {
return values.length == 1
? `"${values[0]}"`
: `[${values.map(v => `"${v}"`).join(", ")}]`
}
// Découpe une URL d'API en morceaux lisibles (un paramètre par ligne)
function urlParts(url) {
const [base, query] = url.split("?")
const parts = query.split("&")
return [`${base}?${parts[0]}`, ...parts.slice(1).map(p => `&${p}`)]
}
function programmes(q, variable) {
const url = apiUrl(q)
const parts = urlParts(url)
return {
Python: `from cartiflette import carti_download
${variable} = carti_download(
values=${pyValues(q.values)},
borders="${q.borders}",
filter_by="${q.filter_by}",
year=${q.year},
simplification=${q.simplification},
)`,
R: `library(sf)
url <- paste0(
${parts.map(p => ` "${p}"`).join(",\n")}
)
${variable} <- st_read(url)`,
JavaScript: `const url = [
${parts.map(p => ` "${p}"`).join(",\n")}
].join("")
const ${variable} = await fetch(url).then((r) => r.json())`
}
}function panneauCode(codes, note) {
const code = codes[langage]
const tabs = langages.map(l => {
const b = html`<button type="button" class=${l == langage ? "active" : ""}>${l}</button>`
b.onclick = () => { mutable langage = l }
return b
})
const copy = html`<button type="button" class="copy"><i class="fa-regular fa-copy"></i> Copier</button>`
copy.onclick = async () => {
await navigator.clipboard.writeText(code)
copy.textContent = "Copié"
}
return html`<div class="snippet">
<div class="snippet-tabs">${tabs}${copy}</div>
<pre><code>${code}</code></pre>
</div>
<p class="snippet-note">${note[langage]}</p>`
}
notes = ({
Python: html`<code>pip install cartiflette</code> (version 0.2.1 ou plus). Le client lit le GeoParquet et ne télécharge que les polygones demandés.`,
R: html`${avertissement("R")}L'API renvoie du GeoJSON, que <code>sf</code> lit directement.`,
JavaScript: html`${avertissement("JavaScript")}L'API renvoie du GeoJSON (EPSG:4326), utilisable avec d3, Observable Plot, Leaflet ou MapLibre.`
})
// Les packages R et JavaScript ne suivent pas encore les évolutions du client
// Python (GeoParquet, millésimes depuis 2023) : on passe par l'API en attendant
function avertissement(langage) {
return html`<span class="snippet-warning"><i class="fa-solid fa-person-digging"></i>
Le package <code>cartiflette</code> pour ${langage} n'est pas encore à jour des
dernières évolutions ; il va évoluer. En attendant, ce code interroge
directement l'API.</span>`
}departements = [
{code: "01", nom: "Ain"},
{code: "02", nom: "Aisne"},
{code: "03", nom: "Allier"},
{code: "04", nom: "Alpes-de-Haute-Provence"},
{code: "05", nom: "Hautes-Alpes"},
{code: "06", nom: "Alpes-Maritimes"},
{code: "07", nom: "Ardèche"},
{code: "08", nom: "Ardennes"},
{code: "09", nom: "Ariège"},
{code: "10", nom: "Aube"},
{code: "11", nom: "Aude"},
{code: "12", nom: "Aveyron"},
{code: "13", nom: "Bouches-du-Rhône"},
{code: "14", nom: "Calvados"},
{code: "15", nom: "Cantal"},
{code: "16", nom: "Charente"},
{code: "17", nom: "Charente-Maritime"},
{code: "18", nom: "Cher"},
{code: "19", nom: "Corrèze"},
{code: "21", nom: "Côte-d'Or"},
{code: "22", nom: "Côtes-d'Armor"},
{code: "23", nom: "Creuse"},
{code: "24", nom: "Dordogne"},
{code: "25", nom: "Doubs"},
{code: "26", nom: "Drôme"},
{code: "27", nom: "Eure"},
{code: "28", nom: "Eure-et-Loir"},
{code: "29", nom: "Finistère"},
{code: "2A", nom: "Corse-du-Sud"},
{code: "2B", nom: "Haute-Corse"},
{code: "30", nom: "Gard"},
{code: "31", nom: "Haute-Garonne"},
{code: "32", nom: "Gers"},
{code: "33", nom: "Gironde"},
{code: "34", nom: "Hérault"},
{code: "35", nom: "Ille-et-Vilaine"},
{code: "36", nom: "Indre"},
{code: "37", nom: "Indre-et-Loire"},
{code: "38", nom: "Isère"},
{code: "39", nom: "Jura"},
{code: "40", nom: "Landes"},
{code: "41", nom: "Loir-et-Cher"},
{code: "42", nom: "Loire"},
{code: "43", nom: "Haute-Loire"},
{code: "44", nom: "Loire-Atlantique"},
{code: "45", nom: "Loiret"},
{code: "46", nom: "Lot"},
{code: "47", nom: "Lot-et-Garonne"},
{code: "48", nom: "Lozère"},
{code: "49", nom: "Maine-et-Loire"},
{code: "50", nom: "Manche"},
{code: "51", nom: "Marne"},
{code: "52", nom: "Haute-Marne"},
{code: "53", nom: "Mayenne"},
{code: "54", nom: "Meurthe-et-Moselle"},
{code: "55", nom: "Meuse"},
{code: "56", nom: "Morbihan"},
{code: "57", nom: "Moselle"},
{code: "58", nom: "Nièvre"},
{code: "59", nom: "Nord"},
{code: "60", nom: "Oise"},
{code: "61", nom: "Orne"},
{code: "62", nom: "Pas-de-Calais"},
{code: "63", nom: "Puy-de-Dôme"},
{code: "64", nom: "Pyrénées-Atlantiques"},
{code: "65", nom: "Hautes-Pyrénées"},
{code: "66", nom: "Pyrénées-Orientales"},
{code: "67", nom: "Bas-Rhin"},
{code: "68", nom: "Haut-Rhin"},
{code: "69", nom: "Rhône"},
{code: "70", nom: "Haute-Saône"},
{code: "71", nom: "Saône-et-Loire"},
{code: "72", nom: "Sarthe"},
{code: "73", nom: "Savoie"},
{code: "74", nom: "Haute-Savoie"},
{code: "75", nom: "Paris"},
{code: "76", nom: "Seine-Maritime"},
{code: "77", nom: "Seine-et-Marne"},
{code: "78", nom: "Yvelines"},
{code: "79", nom: "Deux-Sèvres"},
{code: "80", nom: "Somme"},
{code: "81", nom: "Tarn"},
{code: "82", nom: "Tarn-et-Garonne"},
{code: "83", nom: "Var"},
{code: "84", nom: "Vaucluse"},
{code: "85", nom: "Vendée"},
{code: "86", nom: "Vienne"},
{code: "87", nom: "Haute-Vienne"},
{code: "88", nom: "Vosges"},
{code: "89", nom: "Yonne"},
{code: "90", nom: "Territoire de Belfort"},
{code: "91", nom: "Essonne"},
{code: "92", nom: "Hauts-de-Seine"},
{code: "93", nom: "Seine-Saint-Denis"},
{code: "94", nom: "Val-de-Marne"},
{code: "95", nom: "Val-d'Oise"},
{code: "971", nom: "Guadeloupe"},
{code: "972", nom: "Martinique"},
{code: "973", nom: "Guyane"},
{code: "974", nom: "La Réunion"},
{code: "976", nom: "Mayotte"}
]
cartiflette
Les fonds de carte officiels français, prêts à joindre à vos données.
Vous voulez faire une carte mais n’avez que des codes Insee ? cartiflette fournit les contours qui vont avec : les fonds de carte de l’IGN enrichis des métadonnées de l’Insee, prêts à être associés à vos données, en moins de 10 lignes de code.
$pip install cartiflette
Un fond de carte France entière
Découpages administratifs ou zonages d’étude de l’Insee: les fonds de carte sont prêts à l’emploi pour répondre à de nombreux besoins cartographiques.
Voir un exemple de la vraie vie
Les communes d’une sélection de départements
Si on n’a besoin que de quelques départements ou régions, ou si on désire représenter des données mélangeant arrondissements et communes, on va devoir faire des opérations compliquées. Avec cartiflette, ce type de fond est prêt à l’emploi !
Voir un exemple de la vraie vie
html`<div class="explorer">
<div class="explorer-controls">
${viewof affichage_departements}
${viewof choix_departements}
${viewof annee_departements}
${viewof arrondissements}
${viewof simplification_departements}
${telechargements(requete_departements, "Fichier France entière, à filtrer par INSEE_DEP")}
</div>
<div class="explorer-viz">${
requete_departements.values.length == 0
? html`<p class="viz-meta">Choisissez au moins un département.</p>`
: visualisation(donnees_departements, affichage_departements)
}</div>
<div class="explorer-code">${panneauCode(programmes(requete_departements, "communes"), notes)}</div>
</div>`viewof affichage_france = Inputs.radio(["Carte", "Métadonnées"], {value: "Carte"})
viewof niveau_france = Inputs.select(niveaux, {label: "Niveau", key: "Départements"})
viewof annee_france = Inputs.select(annees, {label: "Millésime"})
viewof drom_rapproches = Inputs.toggle({label: "DROM rapprochés et zoom sur l'Île-de-France", value: true})
viewof simplification_france = Inputs.radio(
new Map([["Simplifiés (50 %)", 50], ["Complets", 0]]),
{label: "Contours", value: 50}
)requete_france = ({
borders: niveau_france.code,
filter_by: drom_rapproches ? "FRANCE_ENTIERE_DROM_RAPPROCHES" : "FRANCE_ENTIERE",
values: ["France"],
year: annee_france,
simplification: simplification_france
})
donnees_france = d3.json(apiUrl(requete_france))viewof affichage_departements = Inputs.radio(["Carte", "Métadonnées"], {value: "Carte"})
viewof choix_departements = selecteurDepartements(departements, {
placeholder: "Ajouter un département : code ou nom",
value: ["75", "92", "93", "94"]
})
viewof annee_departements = Inputs.select(annees, {label: "Millésime"})
viewof arrondissements = Inputs.toggle({label: "Arrondissements municipaux (Paris, Lyon, Marseille)", value: true})
viewof simplification_departements = Inputs.radio(
new Map([["Simplifiés (50 %)", 50], ["Complets", 0]]),
{label: "Contours", value: 50}
)requete_departements = ({
borders: arrondissements ? "COMMUNE_ARRONDISSEMENT" : "COMMUNE",
filter_by: "DEPARTEMENT",
values: choix_departements,
year: annee_departements,
simplification: simplification_departements
})
donnees_departements = requete_departements.values.length == 0
? {type: "FeatureCollection", features: []}
: d3.json(apiUrl(requete_departements))