Skip to content

Commit 3ce80a3

Browse files
committed
fix: update isochrone data fetching logic and improve transport mode options
1 parent 2bde7a2 commit 3ce80a3

File tree

4 files changed

+164
-150
lines changed

4 files changed

+164
-150
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ node_modules
1111
dist
1212
dist-ssr
1313
*.local
14+
.env
1415

1516
# Editor directories and files
1617
.vscode/*

src/App.tsx

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { MapContainer, TileLayer, Polygon, Marker } from "react-leaflet";
22
import './App.css'
3-
import { fetchAllEtablissementsData, fetchIsochroneData } from './dataGouvFetcher';
3+
import { fetchAllEtablissementsData } from './dataGouvFetcher';
4+
import { fetchIsochroneData } from './mapboxFetcher';
45
import { useEffect, useState, Fragment } from 'react';
56

67
function App() {
7-
const [timeInMinutes, setTimeInMinutes] = useState<number>(5);
8-
const [transportMode, setTransportMode] = useState<'pedestrian' | 'car'>('pedestrian');
8+
const [timeInMinutes, setTimeInMinutes] = useState<number>(15);
9+
const [transportMode, setTransportMode] = useState<'walking' | 'cycling' | 'driving-traffic' | 'driving'>('walking');
910

1011
// Etablissements GeoJSON data
1112
const [etablissementsGeoJSON, setEtablissementsGeoJSON] = useState<any>();
@@ -61,10 +62,12 @@ function App() {
6162
<header className="App-header">
6263
<h1>Universities or schools in France</h1>
6364
<p>Displaying isochrones of universities and schools in France. {total > 0 && (
64-
<span>
65-
Isochrones resolved: {resolved} / {total} ({percent}%)
66-
</span>
67-
)}</p>
65+
<span>
66+
{resolved === total
67+
? "All isochrones resolved!"
68+
: `Isochrones resolved: ${resolved} / ${total} (${percent}%)`}
69+
</span>
70+
)}</p>
6871
</header>
6972
<label>Time in minutes:</label>
7073
<input
@@ -77,10 +80,12 @@ function App() {
7780
<label>Transport mode:</label>
7881
<select
7982
value={transportMode}
80-
onChange={(e) => setTransportMode(e.target.value as 'pedestrian' | 'car')}
83+
onChange={(e) => setTransportMode(e.target.value as 'walking' | 'cycling' | 'driving-traffic' | 'driving')}
8184
>
82-
<option value="car">Car</option>
83-
<option value="pedestrian">Pedestrian</option>
85+
<option value="driving">Driving</option>
86+
<option value="walking">Walking</option>
87+
<option value="cycling">Cycling</option>
88+
<option value="driving-traffic">Driving (Traffic)</option>
8489
</select>
8590
</div>
8691
<MapContainer

src/dataGouvFetcher.tsx

Lines changed: 1 addition & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -1,135 +1,5 @@
1-
const geoPortailBaseURL = "https://data.geopf.fr/navigation/isochrone"
2-
3-
// Simple token-bucket rate limiter to enforce max 5 requests per second
4-
// Allows bursts up to `bucketSize` but refills `maxTokens` every `refillIntervalMs`.
5-
class RateLimiter {
6-
private maxTokens: number
7-
private tokens: number
8-
private refillIntervalMs: number
9-
private queue: Array<() => void>
10-
private refillTimer: any
11-
12-
constructor(maxTokens = 5, refillIntervalMs = 1000) {
13-
this.maxTokens = maxTokens
14-
this.tokens = maxTokens
15-
this.refillIntervalMs = refillIntervalMs
16-
this.queue = []
17-
this.refillTimer = setInterval(() => this.refill(), this.refillIntervalMs)
18-
}
19-
20-
private refill() {
21-
this.tokens = this.maxTokens
22-
this.drainQueue()
23-
}
24-
25-
private drainQueue() {
26-
while (this.tokens > 0 && this.queue.length > 0) {
27-
const job = this.queue.shift()
28-
if (job) {
29-
this.tokens -= 1
30-
job()
31-
}
32-
}
33-
}
34-
35-
// schedule returns a promise resolved by running `fn` when token available
36-
public schedule<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
37-
return new Promise<T>((resolve, reject) => {
38-
if (signal?.aborted) {
39-
return reject(new DOMException('Aborted', 'AbortError'))
40-
}
41-
42-
const run = () => {
43-
if (signal?.aborted) {
44-
return reject(new DOMException('Aborted', 'AbortError'))
45-
}
46-
47-
fn().then(resolve).catch(reject)
48-
}
49-
50-
// If token available, consume and run immediately
51-
if (this.tokens > 0) {
52-
this.tokens -= 1
53-
run()
54-
} else {
55-
// otherwise enqueue and attach abort listener to remove if aborted
56-
const wrappedJob = () => run()
57-
this.queue.push(wrappedJob)
58-
59-
const onAbort = () => {
60-
// try to remove from queue
61-
const idx = this.queue.indexOf(wrappedJob)
62-
if (idx !== -1) this.queue.splice(idx, 1)
63-
signal?.removeEventListener('abort', onAbort)
64-
reject(new DOMException('Aborted', 'AbortError'))
65-
}
66-
67-
signal?.addEventListener('abort', onAbort)
68-
}
69-
})
70-
}
71-
72-
public stop() {
73-
clearInterval(this.refillTimer)
74-
this.queue = []
75-
}
76-
}
77-
78-
const isochroneRateLimiter = new RateLimiter(5, 1000)
79-
80-
// Helper: perform fetch through rate limiter and retry on failures
81-
const fetchWithRetries = (url: string, signal?: AbortSignal, maxRetries = 3): Promise<any> => {
82-
let attempt = 0
83-
84-
return new Promise((resolve, reject) => {
85-
const tryOnce = () => {
86-
if (signal?.aborted) {
87-
return reject(new DOMException('Aborted', 'AbortError'))
88-
}
89-
90-
attempt += 1
91-
92-
isochroneRateLimiter.schedule(async () => {
93-
const response = await fetch(url, { signal })
94-
if (!response.ok) {
95-
throw new Error(`HTTP ${response.status}`)
96-
}
97-
const data = await response.json()
98-
return data
99-
}, signal).then(resolve).catch((err) => {
100-
// If aborted, reject immediately
101-
if (signal?.aborted) {
102-
return reject(new DOMException('Aborted', 'AbortError'))
103-
}
104-
105-
if (attempt <= maxRetries) {
106-
const delay = 500 * Math.pow(2, attempt - 1) // 500ms, 1s, 2s, ...
107-
setTimeout(() => {
108-
tryOnce()
109-
}, delay)
110-
} else {
111-
reject(err)
112-
}
113-
})
114-
}
115-
116-
tryOnce()
117-
})
118-
}
119-
120-
export const fetchIsochroneData = async (lat: number, lon: number, time: number = 1800, transportMode: string, signal?: AbortSignal) => {
121-
if (lat < -63.28125 || lat > 55.8984375 || lon < -63.28125 || lon > 55.8984375) {
122-
return null;
123-
}
124-
125-
const url = geoPortailBaseURL + `?gp-access-lib=3.4.2&resource=bdtopo-valhalla&point=${lon},${lat}&direction=departure&costType=time&costValue=${time}&profile=${transportMode}&timeUnit=second&distanceUnit=meter&crs=EPSG:4326&constraints=`
126-
127-
// Use the fetch-with-retries helper so failures are retried (re-queued) a few times
128-
const data = await fetchWithRetries(url, signal, 3)
129-
return formatIsochrone(data)
130-
}
131-
1321
const dataGouvBaseURL = "https://data.enseignementsup-recherche.gouv.fr"
2+
1333
const etablissements = (offset: number) =>
1344
dataGouvBaseURL +
1355
`/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`
@@ -140,15 +10,6 @@ const fetchEtablissementsData = async (offset: number) => {
14010
return data
14111
}
14212

143-
const formatIsochrone = (isochroneData: any) => {
144-
if (!isochroneData) {
145-
return []
146-
}
147-
148-
return isochroneData?.geometry?.coordinates[0]
149-
.map((coord: any) => [coord[1], coord[0]])
150-
}
151-
15213
export const fetchAllEtablissementsData = async () => {
15314
let offset = 0
15415
let etablissements = []

src/mapboxFetcher.tsx

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
const mapBoxBaseURL = "https://api.mapbox.com/isochrone/v1/mapbox";
2+
3+
// Simple token-bucket rate limiter to enforce max 5 requests per second
4+
class RateLimiter {
5+
private maxTokens: number
6+
private tokens: number
7+
private refillIntervalMs: number
8+
private queue: Array<() => void>
9+
private refillTimer: any
10+
11+
constructor(maxTokens = 5, refillIntervalMs = 1000) {
12+
this.maxTokens = maxTokens
13+
this.tokens = maxTokens
14+
this.refillIntervalMs = refillIntervalMs
15+
this.queue = []
16+
this.refillTimer = setInterval(() => this.refill(), this.refillIntervalMs)
17+
}
18+
19+
private refill() {
20+
this.tokens = this.maxTokens
21+
this.drainQueue()
22+
}
23+
24+
private drainQueue() {
25+
while (this.tokens > 0 && this.queue.length > 0) {
26+
const job = this.queue.shift()
27+
if (job) {
28+
this.tokens -= 1
29+
job()
30+
}
31+
}
32+
}
33+
34+
public schedule<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
35+
return new Promise<T>((resolve, reject) => {
36+
if (signal?.aborted) {
37+
return reject(new DOMException('Aborted', 'AbortError'))
38+
}
39+
40+
const run = () => {
41+
if (signal?.aborted) {
42+
return reject(new DOMException('Aborted', 'AbortError'))
43+
}
44+
45+
fn().then(resolve).catch(reject)
46+
}
47+
48+
if (this.tokens > 0) {
49+
this.tokens -= 1
50+
run()
51+
} else {
52+
const wrappedJob = () => run()
53+
this.queue.push(wrappedJob)
54+
55+
const onAbort = () => {
56+
const idx = this.queue.indexOf(wrappedJob)
57+
if (idx !== -1) this.queue.splice(idx, 1)
58+
signal?.removeEventListener('abort', onAbort)
59+
reject(new DOMException('Aborted', 'AbortError'))
60+
}
61+
62+
signal?.addEventListener('abort', onAbort)
63+
}
64+
})
65+
}
66+
67+
public stop() {
68+
clearInterval(this.refillTimer)
69+
this.queue = []
70+
}
71+
}
72+
73+
const isochroneRateLimiter = new RateLimiter(20, 1000)
74+
75+
const fetchWithRetries = (url: string, signal?: AbortSignal, maxRetries = 3): Promise<any> => {
76+
let attempt = 0
77+
78+
return new Promise((resolve, reject) => {
79+
const tryOnce = () => {
80+
if (signal?.aborted) {
81+
return reject(new DOMException('Aborted', 'AbortError'))
82+
}
83+
84+
attempt += 1
85+
86+
isochroneRateLimiter.schedule(async () => {
87+
const response = await fetch(url, { signal })
88+
if (!response.ok) {
89+
throw new Error(`HTTP ${response.status}`)
90+
}
91+
const data = await response.json()
92+
return data
93+
}, signal).then(resolve).catch((err) => {
94+
if (signal?.aborted) {
95+
return reject(new DOMException('Aborted', 'AbortError'))
96+
}
97+
98+
if (attempt <= maxRetries) {
99+
const delay = 500 * Math.pow(2, attempt - 1)
100+
setTimeout(() => {
101+
tryOnce()
102+
}, delay)
103+
} else {
104+
reject(err)
105+
}
106+
})
107+
}
108+
109+
tryOnce()
110+
})
111+
}
112+
113+
// You must provide your Mapbox access token here
114+
const MAPBOX_ACCESS_TOKEN = import.meta.env.VITE_MAPBOX_API_KEY || '';
115+
116+
export const fetchIsochroneData = async (
117+
lat: number,
118+
lon: number,
119+
time: number = 30, // in minutes for Mapbox API
120+
transportMode: string = "driving", // "driving", "walking", "cycling"
121+
signal?: AbortSignal
122+
) => {
123+
// Mapbox supports only certain profiles
124+
const supportedProfiles = ["driving", "walking", "cycling"]
125+
const profile = supportedProfiles.includes(transportMode) ? transportMode : "driving"
126+
127+
// Mapbox expects lon,lat
128+
const coordinates = `${lon},${lat}`
129+
130+
// Mapbox API: time is in minutes, not seconds
131+
const contours_minutes = Math.round(time / 60) || 1
132+
133+
const url = `${mapBoxBaseURL}/${profile}/${coordinates}?contours_minutes=${contours_minutes}&polygons=true&access_token=${MAPBOX_ACCESS_TOKEN}`
134+
135+
const data = await fetchWithRetries(url, signal, 3)
136+
return formatIsochrone(data)
137+
}
138+
139+
const formatIsochrone = (isochroneData: any) => {
140+
if (!isochroneData || !isochroneData.features || !isochroneData.features.length) {
141+
return []
142+
}
143+
// Return the coordinates of the first polygon (most common use)
144+
return isochroneData.features[0]?.geometry?.coordinates[0]?.map(
145+
(coord: any) => [coord[1], coord[0]]
146+
)
147+
}

0 commit comments

Comments
 (0)