@mytil67/school-map
v0.12.0
Published
Référentiel d'écoles et carte réutilisables — écoles géocodées de Strasbourg, circonscriptions, territoires, annuaire des élus, et une carte dont les marqueurs sont pilotés par les données.
Maintainers
Readme
@mytil67/school-map
Référentiel des écoles de Strasbourg et carte réutilisable, pour les applications qui parlent du parc scolaire.
npm install @mytil67/school-map leaflet react-leafletPublié sur npm public : ni jeton, ni .npmrc, ni variable
d'environnement — en local comme en déploiement.
Ce que le paquet contient
| | | | --- | --- | | 116 écoles | nom, type, adresse, coordonnées, UAI, téléphone | | les anciens noms | résolus vers l'école actuelle, plutôt que perdus | | 6 territoires | les secteurs de la ville, avec leur couleur d'identité | | 7 circonscriptions | la grille pédagogique de l'Éducation nationale | | 13 adjoint·es de quartier | quartiers, écoles couvertes, assistantes | | 38 quartiers | et l'adjoint·e qui en répond | | éducation prioritaire | REP, REP+, QPV, et l'IEN de chaque école | | la carte scolaire | 50 secteurs élémentaires, 59 maternelles, chacun rattaché à ses écoles | | les zones | périmètres des adjoint·es, QPV, territoires et circonscriptions tracés | | un composant de carte | marqueurs et zones pilotés par les données, popup libre |
Les données se resynchronisent depuis deux portails d'open data : lieux_education de la Ville de Strasbourg et l'annuaire de l'éducation.
Pourquoi un paquet
La même carte avait été réécrite dans chaque application, chaque fois
soudée à son hôte : useNavigate de react-router, les types du domaine
local, les couleurs du système de design en dur. Ici, rien de tout ça :
- aucune dépendance à un routeur —
onSelectremonte le point cliqué ; - aucune dépendance à un système de design — la popup est un render prop, l'application la met en forme comme elle l'entend ;
- aucune couleur imposée — couleur et rayon viennent de chaque point.
La carte
import 'leaflet/dist/leaflet.css'; // ← à la racine de l'application
import { SchoolMap, STRASBOURG_SCHOOLS } from '@mytil67/school-map';
<SchoolMap
points={STRASBOURG_SCHOOLS.map(ecole => ({
id: ecole.slug ?? ecole.name,
name: ecole.name,
lat: ecole.lat,
lon: ecole.lon,
color: '#5b5bd6',
radius: 8,
}))}
height={500}
fitToPoints
legend={[{ label: 'Écoles', color: '#5b5bd6' }]}
renderPopup={point => <Fiche ecole={point.data} />}
onSelect={point => ouvrir(point.data)}
/>La feuille de style de Leaflet est à votre charge. Le paquet ne
l'importe pas : un import de CSS depuis le JS le rendrait inutilisable
hors bundler (Node, rendu serveur, scripts). En développement, la carte
vous avertit en console si elle manque — sans elle, les tuiles
s'empilent n'importe où, sans erreur.
leaflet, react-leaflet, react et react-dom sont des
peerDependencies. Deux copies de Leaflet dans un même bundle cassent
les icônes sans message : npm ls leaflet ne doit en montrer qu'une.
Les trois découpages
Une école appartient à trois grilles qui ne se recouvrent pas : sa circonscription d'inspection, son territoire ville, et ses adjoint·es de quartier. Tout est accessible depuis la racine :
import {
findCirconscription, findElusForSchool, findTerritory,
} from '@mytil67/school-map';
findTerritory('Sturm II Élémentaire'); // { id: 'ESCRO', label: 'Esplanade…', color: '#10b981' }
findCirconscription('Sturm II Élémentaire'); // { id: '7', … }
findElusForSchool('Sturm II Élémentaire'); // [{ name: 'MORITZ Hervé', assistant: … }]La couleur suit le territoire, jamais sa position dans un tri : un même secteur ne change pas de teinte selon le filtre actif.
Les adjoint·es de quartier
Les 13 adjoint·es de quartier de l'annuaire municipal, avec leurs quartiers, leurs écoles et leurs assistantes. L'annuaire mêle deux rôles : les 18 élu·es qui siègent en conseil d'école sans porter ce rôle ne sont pas des référent·es territoriaux, et ne sont pas au paquet. Les confondre donnait à une école cinq « référents » dont un seul en était un.
L'annuaire se lit dans les deux sens :
import {
DISTRICTS, ELUS, SCHOOLS_WITH_ELUS,
findEluByName, findElusByDistrict, findElusForSchool, findSchoolsOfElu,
} from '@mytil67/school-map';
findElusForSchool('Sturm II Élémentaire'); // qui est référent de cette école ?
findSchoolsOfElu('Hervé Moritz'); // → SchoolLocation[], géocodées
findElusByDistrict('Grande île'); // → Elu[]findEluByName et findElusByDistrict tolèrent la casse, les accents et
l'ordre prénom/nom — « herve moritz » trouve « MORITZ Hervé ». En cas
d'ambiguïté ils renvoient null plutôt que de deviner.
Pour un tableau ou une carte, partez de SCHOOLS_WITH_ELUS : les 116
écoles, chacune avec ses référent·es, son territoire et sa
circonscription, jointes une fois pour toutes.
<SchoolMap
points={SCHOOLS_WITH_ELUS.map(({ school, elus, territory }) => ({
id: school.slug ?? school.name,
name: school.name,
lat: school.lat,
lon: school.lon,
color: territory?.color ?? TERRITORY_FALLBACK_COLOR,
data: { school, elus },
}))}
renderPopup={({ data }) => <Referents elus={data.elus} />}
fitToPoints
/>Boucler sur findElusForSchool à la place relancerait 116 rapprochements
flous sur des noms qui sont déjà ceux du référentiel.
elus est souvent vide, et c'est le cas courant : l'annuaire ne
rattache que 65 des 116 écoles à un·e adjoint·e. Cela veut dire que
l'annuaire ne la mentionne pas, jamais qu'elle n'a pas de référent·e —
ne l'affichez pas comme un manquement. La liste peut aussi porter
plusieurs noms : les quartiers se chevauchent.
DISTRICTS donne les 38 quartiers cités, chacun avec ses adjoint·es —
mais sans liste d'écoles : l'annuaire rattache les écoles à une
personne, pas à un quartier. Une adjointe couvrant six quartiers et
quatre écoles, répartir les secondes sur les premiers demanderait
d'inventer un rattachement que la source ne donne pas.
ELUS_SOURCE_DATE porte la date de l'annuaire embarqué.
Les zones
Des tracés qui découpent la ville, à poser sous les écoles. Ils vivent dans leur propre sous-chemin — ~290 Ko, ~70 Ko compressés — que la racine ne réexporte pas : qui n'affiche que des points ne les paie pas.
import { SchoolMap } from '@mytil67/school-map';
import { SCHOOL_SECTORS, colorZones } from '@mytil67/school-map/zones';
<SchoolMap
points={points}
zones={colorZones(SCHOOL_SECTORS.elementaire)}
renderZonePopup={zone => <>{zone.name}</>}
fitToPoints
/>| Export | Zones | Source |
| --- | --- | --- |
| SCHOOL_SECTORS.elementaire | 50 | carte scolaire de la Ville |
| SCHOOL_SECTORS.maternelle | 59 | carte scolaire de la Ville |
| ELU_ZONES | 13 | périmètres des adjoint·es de quartier 2026 |
| QPV_ZONES | 15 | quartiers prioritaires 2024-2030, à Strasbourg |
| TERRITORY_ZONES | 6 | reconstruits |
| CIRCONSCRIPTION_ZONES | 7 | reconstruites |
Chaque secteur nomme ses écoles (schools, noms du référentiel) : le
SIG municipal écrit « ERC.CHATRIAN » ou « ST JEAN », le rapprochement est
fait une fois pour toutes. Un secteur de site en couvre plusieurs —
« REUSS » dessert Reuss 1 et Reuss 2. Les écoles européenne et
internationale n'ont pas de secteur : elles recrutent hors carte scolaire.
Territoires et circonscriptions n'ont pas de contour publié. Ils sont
reconstruits comme réunion des secteurs élémentaires de leurs écoles :
c'est « ce que desservent ses écoles », pas une limite administrative.
Ils portent derived: true — montrez-les en pointillés (dashed). Le
jeu « directions de territoire » de la Ville n'est pas utilisé : il
compte 7 zones et ne découpe pas la ville comme les 6 territoires.
colorZones colore une mosaïque pour que deux zones voisines n'aient
jamais la même teinte — avec une palette qui tourne, deux secteurs
mitoyens finissent tôt ou tard de la même couleur et leur limite
disparaît. Cinq teintes suffisent aux cartes scolaires ; les territoires,
eux, gardent leur couleur d'identité.
Les tracés servent aussi hors carte :
import { findSectorAt, findSectorOfSchool } from '@mytil67/school-map/zones';
findSectorAt(48.5712, 7.7521, 'elementaire'); // de quelle école dépend cette adresse ?
findSectorOfSchool('EE Guynemer 1'); // → secteur « GUYNEMER 1 & 2 »pointInZone(lat, lon, geometry) et findZoneAt(lat, lon, zones)
marchent sur n'importe quelles zones, celles de l'application comprises.
Le périmètre d'un groupe d'écoles
zoneOfSchools dessine ce que couvre un ensemble d'écoles : la réunion
de leurs secteurs, d'un seul tenant, sans les frontières intérieures. Le
territoire d'un·e RTT, d'un·e RET, d'un·e IEN — tout rattachement
d'écoles à une personne, calculé à la demande depuis vos propres
données :
import { zoneOfSchools } from '@mytil67/school-map/zones';
const { geometry, sectors, unplaced } = zoneOfSchools(slugsDesEcolesDuRTT);
<SchoolMap points={points} zones={[{ id: 'rtt', name: 'Périmètre', geometry }]} />Les écoles se désignent par slug, par nom, ou par n'importe quelle
écriture d'usage. unplaced rend celles qu'aucun secteur ne couvre —
nom inconnu, école hors carte scolaire : les taire dessinerait un
périmètre plus petit sans le dire.
Un seul découpage à la fois : grid: 'elementaire' par défaut, ou
'maternelle'. Une école de l'autre cycle compte pour le secteur de la
grille où elle se trouve.
mergeZones(zones) fait la même fusion sur n'importe quelle liste de
zones d'un même découpage — secteurs, périmètres d'élu·es. Elle ne
calcule pas d'union générale : deux zones voisines partagent exactement
leur frontière, et une arête présente dans les deux sens est
intérieure. C'est rapide (quelques millisecondes pour une vingtaine
d'écoles) et sans dépendance — mais deux découpages différents,
maternelle et élémentaire, ne se fusionnent pas entre eux.
La géométrie est en GeoJSON : longitude d'abord, l'inverse de
MapPoint. Les fonctions, elles, prennent toujours la latitude en premier.
Trois écoles sont posées hors du secteur qui les nomme sur les tracés mêmes de la Ville : Canardière (11 m) et Marguerite Perey (5 m), dont le point tombe de l'autre côté de la rue limite, et Conseil des XV cycle 3 (112 m).
findSectorAtà leur adresse répond donc le secteur voisin — c'est ce que dit la carte scolaire, pas une erreur du paquet.
Le portrait environnemental
Ce que l'open data sait de l'entourage de chaque école, calculé une fois à la synchronisation pour que personne n'ait à le saisir :
import { findEnvironment } from '@mytil67/school-map/environment';
findEnvironment('ecole-elementaire-du-conseil-des-xv-cycle-2');
// {
// roadNoise: '55-60', // dB(A) Lden, carte 2022
// flood: { overflow: null, groundwater: 'Zone jaune' }, // PPRi
// tram: { stop: "PLACE D'ISLANDE", lines: 'F', meters: 536 },
// gym: { name: 'Salle omnisports du Centre Sportif Bon Pasteur', meters: 329 },
// pool: { name: 'piscine de la Kibitzenau', meters: … },
// canopy: 17.9, // % d'arbres ≥ 3 m dans 100 m
// }| Champ | Source | À savoir |
| --- | --- | --- |
| roadNoise | carte de bruit routier 2022 | classe Lden en dB(A) ; au-delà de 68, point noir du bruit |
| flood.overflow | PPRi, débordement | 2 écoles concernées |
| flood.groundwater | PPRi, remontée de nappe | presque toute la ville : 112 écoles en « Zone jaune » |
| tram | stations de tram | la plus proche, et ses lignes |
| gym | équipements sportifs | salle omnisports la plus proche |
| pool | équipements sportifs | piscine couverte la plus proche — bassins d'été écartés |
| canopy | LIDAR 2025, 0,5 m | part du disque de 100 m couverte d'arbres de 3 m ou plus |
Rien n'est interprété : une zone de PPRi garde son nom réglementaire,
une classe de bruit sa plage. null pour une zone de PPRi veut dire
« hors zone réglementée pour cet aléa », jamais « sans risque ». Les
distances sont à vol d'oiseau. canopy mesure les arbres du quartier,
pas ceux de la cour seule.
Les jeux sources pèsent 50 Mo pour le bruit et 1,85 Go pour le raster ; seuls les attributs calculés, ~40 Ko, entrent dans le paquet. Le raster n'est jamais téléchargé : on n'en lit que le carré de 200 m autour de chaque école.
Les données, sans la carte
Deux sous-chemins tirent les données seules — ni Leaflet, ni DOM. Un script Node, un traitement serveur ou un rendu côté serveur doivent passer par là ; la racine, elle, importe Leaflet :
import { findSchoolLocation } from '@mytil67/school-map/referential';
import { findElusForSchool } from '@mytil67/school-map/territories';Brancher ses propres données
Votre application tient un tableau qui parle des écoles — effectifs,
équipements, contacts — nommées à sa façon. joinToSchools le rapproche
du référentiel :
import { joinToSchools } from '@mytil67/school-map/referential';
import lignes from './data/mon-tableau.json'; // reste chez vous
const jointure = joinToSchools(lignes, { name: 'Nom école', type: 'Type' });
console.log(jointure.report());
jointure.get('EE Sturm II'); // les lignes de cette école
jointure.unmatched; // ce qui n'a PAS été rapproché
jointure.schoolsWithoutRow; // les écoles sans aucune ligneLe paquet ne voit jamais le contenu de vos lignes. T reste le
vôtre, les objets sont rendus intacts, rien n'est copié dans le paquet.
C'est le rapprochement de noms qui est mutualisé, pas la donnée.
Passez la colonne de type si vous l'avez. C'est le même piège qu'ailleurs : « Saint Thomas » existe en maternelle et en élémentaire, à la même adresse ; sans indice, une ligne sur deux tombe sur la mauvaise.
report(), unmatched et schoolsWithoutRow existent pour que rien ne
disparaisse en silence — une ligne non rapprochée est une école absente
du tableau final, sans le moindre message.
Pour un tableau qui parle de sites et non d'écoles — où
« Schoepflin » désigne les deux — passez spread: true : une ligne peut
alors couvrir plusieurs écoles.
Rapprocher un nom
Chaque application nomme ses écoles à sa façon. findSchoolLocation
ramène ces écritures sur la même entrée :
| Écriture | Source |
| --- | --- |
| École élémentaire Jacques Sturm | le référentiel, d'après l'open data de la Ville |
| Sturm II Élémentaire | ZAPI, reconstruit depuis les groupes JAMF |
| EE Sturm II | exports bruts |
| Elémentaire Jacques Sturm | annuaire municipal des élus |
Quatre passes, de la plus sûre à la plus permissive : égalité stricte,
égalité des noms propres une fois retirés qualificatifs (école,
maternelle, EE…) et rattachements (et ALM, d'application),
comparaison mot à mot, puis inclusion. Les numéros de bâtiment
restent bloquants — Guynemer 1 n'est pas Guynemer 2 — mais un libellé
sans numéro couvre tout le site.
Les noms d'usage retirés du référentiel continuent de résoudre :
Ecole Neuhof A maternelle et ALM ramène sur Alice Mosnier, dont elle
était un doublon. Ils sont listés dans src/data/aliases.json et
consultés après l'égalité stricte — une vraie entrée l'emporte toujours.
Passez le type quand vous le connaissez. Une dizaine d'écoles existent en maternelle et en élémentaire sous le même nom propre, souvent à la même adresse ; sans indice, une fois sur deux c'est la mauvaise qui répond :
findSchoolLocation('Saint Thomas', { type: 'maternelle' });API
| Export | Rôle |
| --- | --- |
| SchoolMap | le composant de carte |
| STRASBOURG_SCHOOLS | les 116 écoles |
| findSchoolLocation(nom, opts) | rapproche un nom d'une entrée |
| findAllSchoolLocations(nom, opts) | toutes les entrées qu'un nom peut désigner — « Schoepflin » en couvre deux |
| normalizeSchoolName(nom) | clé de comparaison (minuscules, sans accent ni séparateur) |
| inferSchoolType(nom) | maternelle | elementaire | unknown |
| createMarkerIcon(couleur, rayon) | la pastille, pour monter sa propre carte |
| joinToSchools(lignes, opts) | rapproche votre tableau du référentiel, et rend compte des deux bords |
Les zones, depuis /zones seulement : SCHOOL_SECTORS, ELU_ZONES,
QPV_ZONES, TERRITORY_ZONES, CIRCONSCRIPTION_ZONES,
ZONES_SOURCE_DATE, ZONES_SOURCES, ZONE_PALETTE, colorZones,
pointInZone, findZoneAt, findSectorAt, findSectorOfSchool,
zoneOfSchools, mergeZones.
Le portrait, depuis /environment : SCHOOL_ENVIRONMENT,
findEnvironment, ENVIRONMENT_SOURCE_DATE, ENVIRONMENT_SOURCES.
Les trois découpages, depuis la racine ou depuis /territories :
CIRCONSCRIPTIONS, TERRITORIES, DISTRICTS, ELUS,
SCHOOLS_WITH_ELUS, ELUS_SOURCE_DATE, TERRITORY_COLORS,
TERRITORY_FALLBACK_COLOR, findCirconscription, findTerritory,
findElusForSchool, findEluByName, findSchoolsOfElu,
findElusByDistrict.
Propriétés d'une école : name, type, address, lat, lon,
territory, circonscription, uai, slug, officialName, phone,
normalized, source, rep, qpv, ien.
repvaut'REP','REP+'ounull, etqpvtrueounull.nulln'est pasfalse: il dit « hors REP » ou « non déclaré », jamais « renseigné comme non ».undefinedsignale les 2 écoles que le classeur municipal ne liste pas.
slugest la clé de rendu à privilégier. Contrairement à l'UAI — que 13 groupes scolaires partagent entre leur maternelle et leur élémentaire — il désigne une école et une seule.
Props de SchoolMap : points, zones, center, zoom, height,
fitToPoints, fitTo, renderPopup, onSelect, renderZonePopup,
onSelectZone, zoneTooltips, legend, tileUrl, tileAttribution,
exportSafe, ariaLabel, className, id.
Une zone (MapZone) : id, name, geometry (Polygon ou
MultiPolygon GeoJSON), color, fillOpacity, dashed, data. Une
entrée de légende prend shape: 'area' pour une zone.
exportSafe prépare la carte pour une capture html2canvas : rendu
canvas et tuiles en cross-origin, au prix d'un peu de fluidité.
La carte expose aussi la liste de ses points en texte masqué : une carte Leaflet est un mur pour un lecteur d'écran.
Quand ça ne marche pas
| Symptôme | Cause la plus probable |
| --- | --- |
| Carte grise, tuiles empilées n'importe où | import 'leaflet/dist/leaflet.css' oublié |
| Carte de hauteur nulle | le conteneur parent n'a pas de hauteur : passez height en pixels, pas en % |
| Icônes de marqueurs cassées | deux copies de Leaflet dans le bundle — npm ls leaflet ne doit en montrer qu'une |
| window is not defined | vous importez la racine côté serveur : elle tire Leaflet. Passez par /referential ou /territories, qui n'ont besoin ni de DOM ni de Leaflet |
| Une école n'apparaît pas | elle n'est pas au référentiel : comptez et affichez les non-localisées plutôt que de les laisser disparaître |
| Une école n'a aucun·e adjoint·e | normal pour 51 des 116 : l'annuaire ne les mentionne pas. Ce n'est pas un manquement |
Le journal des versions est livré avec le paquet :
node_modules/@mytil67/school-map/CHANGELOG.md.
Développement
npm install
npm run build # tsup : ESM + CJS + types
npm run check # node --test : 107 tests, dont les 351 écritures de noms
npm run data # resynchronise les deux open data et rejoue tout