Skip to content

Commit 95cae99

Browse files
committed
Add filter panel and enhance institution filtering
Introduces a FilterPanel component to allow filtering institutions by type, region, department, commune, and name. Updates the App to use these filters for both markers and isochrones, and extends the Etablissement and Isochrone types to support richer filtering and linking. Also updates the README with project-specific information and improves ESLint config for type-aware linting.
1 parent ddb7bac commit 95cae99

File tree

8 files changed

+187
-75
lines changed

8 files changed

+187
-75
lines changed

README.md

Lines changed: 40 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,40 @@
1-
# React + TypeScript + Vite
2-
3-
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4-
5-
Currently, two official plugins are available:
6-
7-
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
8-
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9-
10-
## Expanding the ESLint configuration
11-
12-
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
13-
14-
- Configure the top-level `parserOptions` property like this:
15-
16-
```js
17-
export default tseslint.config({
18-
languageOptions: {
19-
// other options...
20-
parserOptions: {
21-
project: ['./tsconfig.node.json', './tsconfig.app.json'],
22-
tsconfigRootDir: import.meta.dirname,
23-
},
24-
},
25-
})
26-
```
27-
28-
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
29-
- Optionally add `...tseslint.configs.stylisticTypeChecked`
30-
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
31-
32-
```js
33-
// eslint.config.js
34-
import react from 'eslint-plugin-react'
35-
36-
export default tseslint.config({
37-
// Set the react version
38-
settings: { react: { version: '18.3' } },
39-
plugins: {
40-
// Add the react plugin
41-
react,
42-
},
43-
rules: {
44-
// other rules...
45-
// Enable its recommended rules
46-
...react.configs.recommended.rules,
47-
...react.configs['jsx-runtime'].rules,
48-
},
49-
})
50-
```
1+
## Uni-Chrono
2+
3+
Uni-Chrono is a web application for visualizing isochrones for higher-education institutions in France. It displays universities/schools on a map, and for each one a polygon representing the area reachable in X minutes for a given mode of transport.
4+
5+
### What the application does
6+
7+
- Fetches the list of higher-education institutions (France) from the MESR open data platform.
8+
- Displays each institution as a marker on a map (OpenStreetMap via Leaflet).
9+
- Computes and draws, for each institution, an isochrone (reachable area) based on:
10+
- the selected time (in minutes),
11+
- the mode of transport (walking, cycling, driving).
12+
- Updates the map live when you change the time or transport mode.
13+
- Shows a progress bar indicating how many isochrones have been computed out of the total.
14+
- Caches institutions and isochrones in local storage (localStorage) to speed up subsequent loads.
15+
- Provides a filter panel to search institutions by name, type, region, department, or commune.
16+
17+
### Data and services used
18+
19+
- Institutions data: dataset "fr-esr-principaux-etablissements-enseignement-superieur" (data.enseignementsup-recherche.gouv.fr), filtered to France.
20+
- Isochrone computation: Mapbox Isochrone API (supported profiles: walking, cycling, driving). A Mapbox token (VITE_MAPBOX_API_KEY) is required.
21+
- Basemap: OpenStreetMap tiles, rendered via React-Leaflet.
22+
23+
## Start the project (How to run)
24+
25+
Prerequisites:
26+
- Node.js LTS (18+ recommended)
27+
- A valid Mapbox token for the Isochrone API
28+
29+
Environment setup:
30+
1. Create a `.env` file at the project root (or `.env.local`) and add your key: `VITE_MAPBOX_API_KEY=pk.XXXXXXXXXXXX`
31+
2. Check `vite.config.ts`: the field `base: '/Uni-Chrono/'` is suited for GitHub Pages deployment under the `Uni-Chrono` repo. Locally, it works as is.
32+
33+
Install and run locally:
34+
1. Install dependencies: `npm install`
35+
2. Start the development server: `npm run dev`
36+
3. Open the URL printed by Vite (typically http://localhost:5173).
37+
38+
Production build and preview:
39+
1. Build the project: `npm run build`
40+
2. Preview the build: `npm run preview`

eslint.config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ export default tseslint.config(
1212
languageOptions: {
1313
ecmaVersion: 2020,
1414
globals: globals.browser,
15+
parserOptions: {
16+
project: ['./tsconfig.node.json', './tsconfig.app.json'],
17+
tsconfigRootDir: import.meta.dirname,
18+
},
1519
},
1620
plugins: {
1721
'react-hooks': reactHooks,

index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<meta charset="UTF-8" />
55
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7-
<title>Vite + React + TS</title>
7+
<title>uni-labs-project</title>
88
</head>
99
<body>
1010
<div id="root"></div>

src/App.tsx

Lines changed: 55 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Isochrone } from './types/isochrone';
1212
import { Etablissement } from './types/etablissement';
1313

1414
import LocalStorageCache from './LocalStorageCache';
15+
import FilterPanel, { Filters } from './components/FilterPanel';
1516

1617
function App() {
1718
// TTL par défaut pour le cache: 24 heures
@@ -51,8 +52,10 @@ function App() {
5152
return cached || [];
5253
});
5354
const [controller, setController] = useState(new AbortController());
54-
// Track which marker is hovered (by index)
55-
const [hoveredMarkerIndex, setHoveredMarkerIndex] = useState<number | null>(null);
55+
// Track which marker is hovered (by etablissementId)
56+
const [hoveredEtabId, setHoveredEtabId] = useState<string | null>(null);
57+
// Filters state
58+
const [filters, setFilters] = useState<Filters>({});
5659

5760
useEffect(() => {
5861
// Si déjà en cache, ne pas refetch
@@ -126,6 +129,7 @@ function App() {
126129
const isochrone: Isochrone = {
127130
coordinates,
128131
color: etablissement.color || '#000000',
132+
etablissementId: etablissement.uai || `${etablissement.coordonnees.lat},${etablissement.coordonnees.lon}`,
129133
};
130134
setIsochrones((prevIsochrones) => {
131135
const updated = [...prevIsochrones, isochrone];
@@ -143,7 +147,17 @@ function App() {
143147
// eslint-disable-next-line react-hooks/exhaustive-deps
144148
}, [etablissementsGeoJSON, timeInMinutes, transportMode]);
145149

146-
// Compute percentage of resolved isochrones
150+
// Apply filters to etablissements
151+
const filteredEtab = (etablissementsGeoJSON || []).filter(e => {
152+
if (filters.type && !(e.type_d_etablissement || []).includes(filters.type)) return false;
153+
if (filters.region && e.reg_nom !== filters.region) return false;
154+
if (filters.departement && e.dep_nom !== filters.departement) return false;
155+
if (filters.commune && e.com_nom !== filters.commune) return false;
156+
if (filters.name && !e.uo_lib?.toLowerCase().includes(filters.name.toLowerCase())) return false;
157+
return true;
158+
});
159+
160+
// Compute percentage of resolved isochrones (based on all vs fetched count)
147161
const total = etablissementsGeoJSON?.length || 0;
148162
const resolved = isochrones?.length || 0;
149163
const percent = total > 0 ? Math.round((resolved / total) * 100) : 0;
@@ -170,9 +184,15 @@ function App() {
170184
</span>
171185
)}
172186
</div>
173-
<div style={{ display: 'flex', flexDirection: 'row', gap: 4, alignItems: 'center' }}>
187+
<div style={{ display: 'flex', flexDirection: 'row', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
174188
<TimeSelector value={timeInMinutes} onChange={setTimeInMinutes} />
175189
<TransportModeSelector value={transportMode} onChange={v => setTransportMode(v as typeof transportMode)} />
190+
<FilterPanel
191+
etablissements={etablissementsGeoJSON}
192+
values={filters}
193+
onChange={setFilters}
194+
onReset={() => setFilters({})}
195+
/>
176196
</div>
177197
</div>
178198
</div>
@@ -184,31 +204,43 @@ function App() {
184204
style={{ flexGrow: 1 }}
185205
>
186206
{
187-
etablissementsGeoJSON && etablissementsGeoJSON
188-
.map((etablissement, index: number) =>
189-
<Marker
190-
key={index}
191-
position={[etablissement.coordonnees.lat, etablissement.coordonnees.lon]}
192-
title={etablissement.uo_lib}
193-
alt={etablissement.uo_lib}
194-
eventHandlers={{
195-
mouseover: () => setHoveredMarkerIndex(index),
196-
mouseout: () => setHoveredMarkerIndex(null),
197-
}}
198-
/>
199-
)
207+
filteredEtab
208+
.map((etablissement) => {
209+
const etabId = etablissement.uai || `${etablissement.coordonnees.lat},${etablissement.coordonnees.lon}`;
210+
return (
211+
<Marker
212+
key={etabId}
213+
position={[etablissement.coordonnees.lat, etablissement.coordonnees.lon]}
214+
title={etablissement.uo_lib}
215+
alt={etablissement.uo_lib}
216+
eventHandlers={{
217+
mouseover: () => setHoveredEtabId(etabId),
218+
mouseout: () => setHoveredEtabId(null),
219+
}}
220+
/>
221+
);
222+
})
200223
}
201224
{
202225
isochrones && isochrones
203-
.filter((_isochrone, index: number) => {
204-
// If hovering, only show the linked isochrone
205-
if (hoveredMarkerIndex !== null) {
206-
return index === hoveredMarkerIndex;
207-
}
226+
.filter((iso) => {
227+
// Lier aux filtres via l'identifiant d'établissement
228+
const matchHover = hoveredEtabId ? iso.etablissementId === hoveredEtabId : true;
229+
// Si un hover est actif, ne montrer que celui-ci
230+
if (!matchHover) return false;
231+
// Filtrer aussi sur les critères si possible
232+
if (!etablissementsGeoJSON || !iso.etablissementId) return matchHover;
233+
const e = etablissementsGeoJSON.find(x => (x.uai || `${x.coordonnees.lat},${x.coordonnees.lon}`) === iso.etablissementId);
234+
if (!e) return false;
235+
if (filters.type && !(e.type_d_etablissement || []).includes(filters.type)) return false;
236+
if (filters.region && e.reg_nom !== filters.region) return false;
237+
if (filters.departement && e.dep_nom !== filters.departement) return false;
238+
if (filters.commune && e.com_nom !== filters.commune) return false;
239+
if (filters.name && !e.uo_lib?.toLowerCase().includes(filters.name.toLowerCase())) return false;
208240
return true;
209241
})
210242
.map((isochrone: Isochrone, index: number) =>
211-
<Polygon key={index} positions={isochrone.coordinates} color={isochrone.color} />
243+
<Polygon key={`${isochrone.etablissementId || index}`} positions={isochrone.coordinates} color={isochrone.color} />
212244
)
213245
}
214246
<TileLayer

src/components/FilterPanel.tsx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import React from 'react';
2+
import Field from '../design-system/Field';
3+
import Select from '../design-system/Select';
4+
import Button from '../design-system/Button';
5+
import { Etablissement } from '../types/etablissement';
6+
7+
export type Filters = {
8+
type?: string;
9+
region?: string;
10+
departement?: string;
11+
commune?: string;
12+
name?: string;
13+
};
14+
15+
interface FilterPanelProps {
16+
etablissements: Etablissement[] | undefined;
17+
values: Filters;
18+
onChange: (next: Filters) => void;
19+
onReset?: () => void;
20+
}
21+
22+
const uniqueSorted = (arr: (string | undefined)[]) =>
23+
Array.from(new Set(arr.filter(Boolean) as string[])).sort((a, b) => a.localeCompare(b));
24+
25+
const FilterPanel: React.FC<FilterPanelProps> = ({ etablissements, values, onChange, onReset }) => {
26+
const types = uniqueSorted((etablissements || []).flatMap(e => e.type_d_etablissement || []));
27+
const regions = uniqueSorted((etablissements || []).map(e => e.reg_nom));
28+
const deps = uniqueSorted((etablissements || []).map(e => e.dep_nom));
29+
const communes = uniqueSorted((etablissements || []).map(e => e.com_nom));
30+
31+
const set = (patch: Partial<Filters>) => onChange({ ...values, ...patch });
32+
33+
return (
34+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'flex-end' }}>
35+
<Select label="Type d’établissement" value={values.type || ''} onChange={(e) => set({ type: e.target.value || undefined })} style={{ minWidth: 220 }}>
36+
<option value="">Tous</option>
37+
{types.map(t => (
38+
<option key={t} value={t}>{t}</option>
39+
))}
40+
</Select>
41+
<Select label="Région" value={values.region || ''} onChange={(e) => set({ region: e.target.value || undefined })} style={{ minWidth: 180 }}>
42+
<option value="">Toutes</option>
43+
{regions.map(r => (
44+
<option key={r} value={r}>{r}</option>
45+
))}
46+
</Select>
47+
<Select label="Département" value={values.departement || ''} onChange={(e) => set({ departement: e.target.value || undefined })} style={{ minWidth: 180 }}>
48+
<option value="">Tous</option>
49+
{deps.map(d => (
50+
<option key={d} value={d}>{d}</option>
51+
))}
52+
</Select>
53+
<Select label="Commune" value={values.commune || ''} onChange={(e) => set({ commune: e.target.value || undefined })} style={{ minWidth: 200 }}>
54+
<option value="">Toutes</option>
55+
{communes.map(c => (
56+
<option key={c} value={c}>{c}</option>
57+
))}
58+
</Select>
59+
<Field label="Nom (contient)">
60+
<input
61+
type="text"
62+
value={values.name || ''}
63+
onChange={(e) => set({ name: e.target.value || undefined })}
64+
placeholder="Ex: Sorbonne"
65+
style={{ padding: 8, minWidth: 220 }}
66+
/>
67+
</Field>
68+
<Button variant="secondary" onClick={onReset} compact>Réinitialiser</Button>
69+
</div>
70+
);
71+
};
72+
73+
export default FilterPanel;

src/dataGouvFetcher.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const dataGouvBaseURL = "https://data.enseignementsup-recherche.gouv.fr"
44

55
const etablissements = (offset: number) =>
66
dataGouvBaseURL +
7-
`/api/explore/v2.1/catalog/datasets/fr-esr-principaux-etablissements-enseignement-superieur/records?order_by=uai&select=coordonnees%2Ctype_d_etablissement%2Cuo_lib&limit=100&offset=${offset}&lang=fr&refine=pays_etranger_acheminement%3A%22France%22`
7+
`/api/explore/v2.1/catalog/datasets/fr-esr-principaux-etablissements-enseignement-superieur/records?order_by=uai&select=coordonnees%2Ctype_d_etablissement%2Cuo_lib%2Cuai%2Creg_nom%2Cdep_nom%2Ccom_nom&limit=100&offset=${offset}&lang=fr&refine=pays_etranger_acheminement%3A%22France%22`
88

99
const fetchEtablissementsData = async (offset: number): Promise<Etablissements> => {
1010
const response = await fetch(etablissements(offset))

src/types/etablissement.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,13 @@ export interface Etablissement {
1010
},
1111
"type_d_etablissement": string[],
1212
"uo_lib": string,
13+
/** Identifiant UAI de l'établissement */
14+
"uai"?: string,
15+
/** Région (nom) */
16+
"reg_nom"?: string,
17+
/** Département (nom) */
18+
"dep_nom"?: string,
19+
/** Commune (nom) */
20+
"com_nom"?: string,
1321
color?: string
1422
}

src/types/isochrone.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,9 @@ export interface Isochrone {
1616
* Color to display the isochrone on the map
1717
*/
1818
color: string;
19+
20+
/**
21+
* Identifiant pour relier l'isochrone à un établissement (UAI ou fallback)
22+
*/
23+
etablissementId?: string;
1924
}

0 commit comments

Comments
 (0)