Air Quality Monitoring Simulator
Step 1: Project Setup
// setup
// In your terminal, run:
npm create vite@latest air-quality-monitor -- --template react
cd air-quality-monitor
npm install
2. Clean up the boilerplate
// Delete unnecessary files in 'src' and keep 'main.jsx', 'App.jsx', and 'App.css'.
Add style.css file (external stylesheet)
// Create a new file in 'src/style.css'. We'll move common styles here for better structure.
Step 2: Setup App.jsx (you already have this, we will improve a bit)
// File: src/App.jsx
import React, { useState, useEffect, useMemo } from 'react';
import './App.css';
import './style.css';
export default function App() {
const [cities, setCities] = useState([]);
const [selectedCountry, setSelectedCountry] = useState('');
const [selectedCity, setSelectedCity] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setTimeout(() => {
setCities([
{ id: '1', name: 'New York', country: 'USA', airQualityIndex: 42, coordinates: [40.7128, -74.0060] },
{ id: '2', name: 'Los Angeles', country: 'USA', airQualityIndex: 58, coordinates: [34.0522, -118.2437] },
{ id: '3', name: 'London', country: 'UK', airQualityIndex: 35, coordinates: [51.5074, -0.1278] },
{ id: '4', name: 'Tokyo', country: 'Japan', airQualityIndex: 22, coordinates: [35.6895, 139.6917] },
]);
setIsLoading(false);
}, 1000);
}, []);
const countries = useMemo(() => Array.from(new Set(cities.map(city => city.country))), [cities]);
const filteredCities = useMemo(() => cities.filter(city =>
(selectedCountry ? city.country === selectedCountry : true) &&
(selectedCity ? city.name === selectedCity : true)
), [cities, selectedCountry, selectedCity]);
if (isLoading) return <div className="loading">Loading...</div>;
if (error) return <div className="error">Error: {error.message}</div>;
return (
<div className="app-container">
<h1 className="heading">Air Quality Dashboard</h1>
<div className="filters">
<select className="input" value={selectedCountry} onChange={e => setSelectedCountry(e.target.value)}>
<option value="">Select Country</option>
{countries.map(country => (
<option key={country} value={country}>{country}</option>
))}
</select>
<select className="input" value={selectedCity} onChange={e => setSelectedCity(e.target.value)}>
<option value="">Select City</option>
{filteredCities.map(city => (
<option key={city.id} value={city.name}>{city.name}</option>
))}
</select>
</div>
<div className="map">
<div className="map-placeholder">Map will be displayed here</div>
</div>
</div>
);
}
Step 3: Improve Styling (style.css)
Create src/style.css at src foler.
// style.css
body {
margin: 0;
padding: 0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f8fafc;
color: #333;
}
.loading {
font-size: 1.5rem;
color: #0369a1;
}
.error {
color: red;
font-size: 1.2rem;
}
button, select {
border-radius: 8px;
border: 1px solid #ccc;
padding: 10px;
font-size: 1rem;
transition: background-color 0.3s ease;
}
button:hover, select:hover {
background-color: #e0f2fe;
cursor: pointer;
}
We would like to expand our app to include the following functionality:
π― Integrate Leaflet.js for real maps.
π Use OpenAQ API for real-time air quality data.
π¨ Add dynamic map markers and city info popup.
π Add search box for better UX.
Step 4. Install Leaflet packages and Include Leaflet CSS
// leaflet module install
npm install leaflet react-leaflet
In your index.html
, inside <head>
, add:
// Sindex.html
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
Step 5: Update App.jsx
OurApp.jsx
is now fully set up to:
Import Leaflet components
Dynamically place markers based on city data
Show popup with city name, AQI, and country
// updated App.jsx
import React, { useState, useEffect, useMemo } from 'react';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import './App.css';
import './style.css';
// Fix default icon issue in Leaflet
import iconUrl from 'leaflet/dist/images/marker-icon.png';
import iconShadow from 'leaflet/dist/images/marker-shadow.png';
const DefaultIcon = L.icon({ iconUrl, shadowUrl: iconShadow });
L.Marker.prototype.options.icon = DefaultIcon;
export default function App() {
const [cities, setCities] = useState([]);
const [selectedCountry, setSelectedCountry] = useState('');
const [selectedCity, setSelectedCity] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setTimeout(() => {
setCities([
{ id: '1', name: 'New York', country: 'USA', airQualityIndex: 42, coordinates: [40.7128, -74.0060] },
{ id: '2', name: 'Los Angeles', country: 'USA', airQualityIndex: 58, coordinates: [34.0522, -118.2437] },
{ id: '3', name: 'London', country: 'UK', airQualityIndex: 35, coordinates: [51.5074, -0.1278] },
{ id: '4', name: 'Tokyo', country: 'Japan', airQualityIndex: 22, coordinates: [35.6895, 139.6917] },
]);
setIsLoading(false);
}, 1000);
}, []);
const countries = useMemo(() => Array.from(new Set(cities.map(city => city.country))), [cities]);
const filteredCities = useMemo(() => cities.filter(city =>
(selectedCountry ? city.country === selectedCountry : true) &&
(selectedCity ? city.name === selectedCity : true)
), [cities, selectedCountry, selectedCity]);
const mapCenter = filteredCities.length > 0 ? filteredCities[0].coordinates : [20, 0]; // Default global view
if (isLoading) return <div className="loading">Loading...</div>;
if (error) return <div className="error">Error: {error.message}</div>;
return (
<div className="app-container">
<h1 className="heading">Air Quality Dashboard</h1>
<div className="filters">
<select className="input" value={selectedCountry} onChange={e => setSelectedCountry(e.target.value)}>
<option value="">Select Country</option>
{countries.map(country => (
<option key={country} value={country}>{country}</option>
))}
</select>
<select className="input" value={selectedCity} onChange={e => setSelectedCity(e.target.value)}>
<option value="">Select City</option>
{filteredCities.map(city => (
<option key={city.id} value={city.name}>{city.name}</option>
))}
</select>
</div>
<MapContainer center={mapCenter} zoom={2} className="map">
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution="© OpenStreetMap contributors"
/>
{filteredCities.map(city => (
<Marker key={city.id} position={city.coordinates}>
<Popup>
<strong>{city.name}</strong><br />
AQI: {city.airQualityIndex}<br />
Country: {city.country}
</Popup>
</Marker>
))}
</MapContainer>
</div>
);
}
Step 6: Improve style.css for Map
Our external CSS now ensures:
Map height and width are controlled
Inputs and buttons remain pretty
Responsive layout is prepared
// update style.css
body {
margin: 0;
padding: 0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f8fafc;
color: #333;
}
.loading {
font-size: 1.5rem;
color: #0369a1;
}
.error {
color: red;
font-size: 1.2rem;
}
button, select {
border-radius: 8px;
border: 1px solid #ccc;
padding: 10px;
font-size: 1rem;
transition: background-color 0.3s ease;
}
button:hover, select:hover {
background-color: #e0f2fe;
cursor: pointer;
}
.map {
height: 500px;
width: 100%;
border-radius: 8px;
margin-top: 20px;
}
Challenge: Next Enhancements (Highly Recommended!)
π Auto-refresh AQI data (Simulate or connect to real API)
π¨ Custom map styling (e.g., dark mode tiles)
π Search bar to quickly find cities
π Mobile responsiveness
π§© Cluster markers for crowded areas
Step 6. Install dependencies and Add Leaflet CSS
// Some code
npm install leaflet react-leaflet axios
In 'index.html' inside , add: Add CDN link in index.html
.
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
// Updated style.css
body {
margin: 0;
padding: 0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #0f172a; /* Dark mode background */
color: #f8fafc; /* Light text for dark mode */
}
.loading {
font-size: 1.5rem;
color: #38bdf8;
}
.error {
color: red;
font-size: 1.2rem;
}
button, select {
border-radius: 8px;
border: 1px solid #ccc;
padding: 10px;
font-size: 1rem;
transition: background-color 0.3s ease;
background-color: #1e293b;
color: #f8fafc;
}
button:hover, select:hover {
background-color: #334155;
cursor: pointer;
}
.map {
height: 500px;
width: 100%;
border-radius: 8px;
margin-top: 20px;
}
Step 7: Update App.jsx with Real-Time AQI Simulation & API Integration
// Some code
import React, { useState, useEffect, useMemo } from 'react';
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import L from 'leaflet';
import axios from 'axios';
import 'leaflet/dist/leaflet.css';
import './App.css';
import './style.css';
// Fix default icon issue in Leaflet
import iconUrl from 'leaflet/dist/images/marker-icon.png';
import iconShadow from 'leaflet/dist/images/marker-shadow.png';
const DefaultIcon = L.icon({ iconUrl, shadowUrl: iconShadow });
L.Marker.prototype.options.icon = DefaultIcon;
export default function App() {
const [cities, setCities] = useState([]);
const [selectedCountry, setSelectedCountry] = useState('');
const [selectedCity, setSelectedCity] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
// Fetch initial data from OpenAQ API (optional)
useEffect(() => {
const fetchData = async () => {
try {
const response = await axios.get('https://api.openaq.org/v2/latest?limit=10');
const cityData = response.data.results.map((location, index) => ({
id: String(index + 1),
name: location.city || 'Unknown',
country: location.country || 'Unknown',
airQualityIndex: Math.floor(Math.random() * 100), // Simulate AQI since API gives measurements not AQI directly
coordinates: [location.coordinates.latitude, location.coordinates.longitude]
}));
setCities(cityData);
setIsLoading(false);
} catch (err) {
console.error(err);
setError(err);
setIsLoading(false);
}
};
fetchData();
}, []);
// Simulate real-time AQI updates every 10 seconds
useEffect(() => {
const interval = setInterval(() => {
setCities(prevCities =>
prevCities.map(city => ({
...city,
airQualityIndex: Math.max(0, Math.min(500, city.airQualityIndex + Math.floor(Math.random() * 20 - 10))) // Clamp between 0 and 500
}))
);
}, 10000); // 10 seconds
return () => clearInterval(interval);
}, []);
const countries = useMemo(() => Array.from(new Set(cities.map(city => city.country))), [cities]);
const filteredCities = useMemo(() => cities.filter(city =>
(selectedCountry ? city.country === selectedCountry : true) &&
(selectedCity ? city.name === selectedCity : true)
), [cities, selectedCountry, selectedCity]);
const mapCenter = filteredCities.length > 0 ? filteredCities[0].coordinates : [20, 0];
if (isLoading) return <div className="loading">Loading...</div>;
if (error) return <div className="error">Error: {error.message}</div>;
return (
<div className="app-container">
<h1 className="heading">Air Quality Dashboard</h1>
<div className="filters">
<select className="input" value={selectedCountry} onChange={e => setSelectedCountry(e.target.value)}>
<option value="">Select Country</option>
{countries.map(country => (
<option key={country} value={country}>{country}</option>
))}
</select>
<select className="input" value={selectedCity} onChange={e => setSelectedCity(e.target.value)}>
<option value="">Select City</option>
{filteredCities.map(city => (
<option key={city.id} value={city.name}>{city.name}</option>
))}
</select>
</div>
<MapContainer center={mapCenter} zoom={2} className="map">
{/* Dark mode tile layer */}
<TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
attribution="© OpenStreetMap contributors & CartoDB"
/>
{filteredCities.map(city => (
<Marker key={city.id} position={city.coordinates}>
<Popup>
<strong>{city.name}</strong><br />
AQI: {city.airQualityIndex}<br />
Country: {city.country}
</Popup>
</Marker>
))}
</MapContainer>
</div>
);
}
When we run app, we encounter the following issues:
// Some code
AppAQIRealTime.jsx:46
GET https://corsproxy.io/?https://api.openaq.org/v2/latest?limit=10 410 (Gone)
fetchData @ AppAQIRealTime.jsx:46
(anonymous) @ AppAQIRealTime.jsx:65
<App>
(anonymous) @ main.jsx:12
// Some code
AppAQIRealTime.jsx:60
AxiosError {message: 'Request failed with status code 410', name: 'AxiosError', code: 'ERR_BAD_REQUEST', config: {β¦}, request: XMLHttpRequest, β¦}
fetchData @ AppAQIRealTime.jsx:60
await in fetchData
(anonymous) @ AppAQIRealTime.jsx:65
<App>
(anonymous) @ main.jsx:12
Solution: Create Your Own Local Backend Proxy (Best Practice)
If you want to practice backend/frontend separation (good for your portfolio), do this:
Step 1: Create a simple Express server
In your project root, run:
npm install express cors axios
Create a new file at root directory: server.js
// server.js
import express from 'express'
import cors from 'cors'
import axios from 'axios'
const app = express()
app.use(cors())
app.get('/api/airquality', async (req, res) => {
try {
const response = await axios.get('https://api.openaq.org/v2/locations', {
params: {
limit: 20,
page: 1,
sort: 'desc',
order_by: 'lastUpdated',
},
headers: {
Accept: 'application/json',
'User-Agent': 'air-quality-monitoring-app',
},
})
res.json(response.data)
} catch (error) {
res.status(500).json({ error: error.message })
}
})
const PORT = 4000
app.listen(PORT, () =>
console.log(`Server running on http://localhost:${PORT}`),
)
Step 2: Update your React App.jsx
Change:
// old App.jsx
const response = await axios.get('https://api.openaq.org/v2/latest?limit=10');
To:
// updated App.jsx
const response = await axios.get('http://localhost:4000/api/airquality');
Step 3: Run your backend and frontend
In one terminal, run:
In another terminal, run your React app:
Solution 2: Local Mock Data (For Safe Classroom Project)
We're already simulating AQI, and to avoid unstable external APIs, letβs mock the data locally.
Step 1: Update your serverMock.js
like this:
// serverMock.js
import express from 'express'
import cors from 'cors'
import axios from 'axios'
const app = express()
app.use(cors())
app.get('/api/airquality', (req, res) => {
// Local mock data
const mockData = {
results: [
{
city: 'New York',
country: 'USA',
coordinates: { latitude: 40.7128, longitude: -74.006 },
},
{
city: 'Los Angeles',
country: 'USA',
coordinates: { latitude: 34.0522, longitude: -118.2437 },
},
{
city: 'London',
country: 'UK',
coordinates: { latitude: 51.5074, longitude: -0.1278 },
},
{
city: 'Tokyo',
country: 'Japan',
coordinates: { latitude: 35.6895, longitude: 139.6917 },
},
{
city: 'Paris',
country: 'France',
coordinates: { latitude: 48.8566, longitude: 2.3522 },
},
],
}
res.json(mockData)
})
const PORT = 4000
app.listen(PORT, () =>
console.log(`β
Backend server running at http://localhost:${PORT}`),
)
Switch map mode between Dark Mode and Light Mode
We will:
Add a toggle button to switch between dark and light map styles.
Update your <TileLayer />
component to dynamically use the selected mode.
Step 1: Add State for Map Mode
In your App.jsx
, at the top inside your component, add state for map mode:
// App.jsx snippet
const [mapMode, setMapMode] = useState('dark');
Above your map (maybe below filters), add a button to switch modes:
// App.jsx snippet
<div className="filters">
<select className="input" value={selectedCountry} onChange={e => setSelectedCountry(e.target.value)}>
<option value="">Select Country</option>
{countries.map(country => (
<option key={country} value={country}>{country}</option>
))}
</select>
<select className="input" value={selectedCity} onChange={e => setSelectedCity(e.target.value)}>
<option value="">Select City</option>
{filteredCities.map(city => (
<option key={city.id} value={city.name}>{city.name}</option>
))}
</select>
{/* Add New toggle button */}
<button onClick={() => setMapMode(prevMode => prevMode === 'dark' ? 'light' : 'dark')}>
Switch to {mapMode === 'dark' ? 'Light' : 'Dark'} Mode
</button>
</div>
Step 3: Update TileLayer to Use Dynamic Map Style
Now go to your <TileLayer />
component and replace the static URL with a dynamic one:
// App.jsx snippet
<TileLayer
url={
mapMode === 'dark'
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
}
attribution="© OpenStreetMap contributors & CartoDB"
/>
Now when you click the button:
π Dark mode: CartoDB dark tiles.
π Light mode: OpenStreetMap standard tiles.
β
Works even while map is active.
Output: