WBGT Explorer is a web-based dashboard for visualising Wet Bulb Globe Temperature (WBGT) measurements collected by field sensors across Canada.
Sensors report readings to a MariaDB database. The dashboard reads that database in real time and presents an interactive map, statistical summaries, and a diurnal (hourly) distribution chart for each sensor site.
The interface is styled after the retro SEGA Genesis game Theme Park, using pixel fonts, saturated greens and golds, and a game-HUD panel layout.
The application is a classic three-tier web app running on a single server:
[ Browser ]
│ HTML + CSS + JavaScript (Leaflet, Chart.js)
│ Fetches JSON from PHP API endpoints
▼
[ Apache / PHP 8.4 ]
│ api/sites.php — sensor index
│ api/measurements.php — stats + readings
│ api/hourly.php — hourly distribution
▼
[ MariaDB 11.8 ]
database: weather
table: wbgtprod
There is no build step, no framework, and no Node.js dependency — just PHP files served directly by Apache.
| Component | Version | Notes |
|---|---|---|
| PHP | 8.0 + | PDO + PDO_MySQL extension required |
| MariaDB / MySQL | 10.3 + | Window functions used for stats |
| Web server | Apache / Nginx | Must execute .php files |
| Leaflet.js | 1.9.4 | Loaded from unpkg CDN |
| Leaflet MarkerCluster | 1.5.3 | Loaded from unpkg CDN |
| Chart.js | 4.4.0 | Loaded from jsDelivr CDN |
| Press Start 2P font | — | Loaded from Google Fonts CDN |
All JavaScript libraries are loaded from public CDNs — no local installation needed.
The Wet Bulb Globe Temperature (WBGT) is a composite index used to estimate heat stress on the human body, accounting for temperature, humidity, wind, and solar radiation.
This system uses the outdoor WBGT formula:
| Symbol | Meaning | DB Column | Unit |
|---|---|---|---|
| WB | Wet Bulb Temperature — accounts for humidity and evaporative cooling | WB | °C |
| BG | Black Globe Temperature — measures radiant heat from the sun | BG | °C |
| AT | Air Temperature (dry bulb) | AT | °C |
| RH | Relative Humidity (displayed separately) | RH | % |
The 0.7 weighting on wet bulb reflects that humidity and evaporative cooling contribute most to heat stress.
Sensor dots and WBGT values throughout the UI are colour-coded by risk level. The active preset is selected from the dropdown in the WBGT RISK SCALE panel on the map page. Changing the preset instantly recolours all markers, tooltips, and chart bars.
The General (Default) preset uses the following levels:
For all preset definitions and their sources, see the Threshold Presets section.
Threshold presets are defined in js/thresholds.js. This file is the single place to edit, add, or remove presets — no changes to app.js or index.html are needed.
AVAILABLE PRESETS
| Key | Name | Source / Use-case | Levels |
|---|---|---|---|
general | General (Default) | General-purpose scale, suitable for most uses | 6 |
military | US Military — Flag System | US Army/Marine Corps TB MED 507 / NAVMED P-5052-5 | 5 |
acsm | ACSM Sports & Athletics | American College of Sports Medicine — outdoor events | 5 |
niosh_light | NIOSH/ACGIH — Light Work | NIOSH Criteria Document (2016) — ≤ 200 W metabolic rate | 4 |
niosh_heavy | NIOSH/ACGIH — Heavy Work | NIOSH Criteria Document (2016) — > 400 W metabolic rate | 5 |
canada_ccohs | Canada — CCOHS Outdoor Workers | Canadian Centre for Occupational Health & Safety | 5 |
ADDING A CUSTOM PRESET
Open js/thresholds.js and add a new key to the THRESHOLD_PRESETS object:
my_custom: {
name: 'My Custom Scale',
source: 'Internal company guideline',
levels: [
{ max: 24, color: '#22cc44', label: 'Safe', range: '< 24 °C', cls: 'wbgt-low' },
{ max: 30, color: '#f5c518', label: 'Caution', range: '24–30 °C', cls: 'wbgt-mod' },
{ max: Infinity, color: '#ee1100', label: 'Danger', range: '≥ 30 °C', cls: 'wbgt-high' },
],
},
The new option will appear in the dropdown automatically on the next page load — no other changes required.
LEVEL OBJECT FIELDS
| Field | Type | Description |
|---|---|---|
| max | number | Upper bound of this level (exclusive). Use Infinity for the last level. |
| color | string | CSS colour used for markers, chart bars, and legend dots. |
| label | string | Human-readable risk label shown in tooltips, legend, and stats. |
| range | string | Display string shown in the legend (e.g. "18 – 23 °C"). |
| cls | string | CSS class applied to coloured value spans in the readings table. |
HOW SWITCHING WORKS
Selecting a preset from the dropdown calls applyPreset(key) in app.js, which:
- Replaces the active
THRESHOLDSarray with the new preset's levels - Calls
marker.setStyle()on every stored marker to update fill colours - Updates all marker tooltips to reflect the new risk label
- Re-renders the hourly chart bar colours
- Re-renders the legend list and source description
No page reload or API call is required.
Database: weather | Table: wbgtprod
| Column | Type | Description |
|---|---|---|
| timestamp | int(11) | Unix timestamp (seconds since epoch). Indexed with UNIQUE. |
| WB | decimal(10,1) | Wet Bulb temperature (°C) |
| BG | decimal(10,1) | Black Globe temperature (°C) |
| AT | decimal(10,1) | Air Temperature / dry bulb (°C) |
| RH | decimal(10,1) | Relative Humidity (%) |
| latitude | decimal(9,6) | GPS latitude in decimal degrees (e.g. 43.140462) |
| longitude | decimal(9,6) | GPS longitude in decimal degrees (e.g. -79.270350) |
| device_name | text | Sensor identifier string (e.g. "wbgt1") |
| raw_line | text | Original raw data line as received from the sensor |
RECOMMENDED INDEXES
For best query performance with large datasets, the following indexes are recommended:
-- Already present (unique timestamp): ALTER TABLE wbgtprod ADD UNIQUE INDEX idx_ts (timestamp); -- Recommended for fast device queries: ALTER TABLE wbgtprod ADD INDEX idx_device_ts (device_name(32), timestamp); -- Recommended for sites map query: ALTER TABLE wbgtprod ADD INDEX idx_lat_lng (latitude, longitude);
All database settings live in config.php at the project root:
define('DB_HOST', 'localhost'); // MariaDB host
define('DB_NAME', 'weather'); // Database name
define('DB_USER', 'wbgtprod'); // MySQL user
define('DB_PASS', '...'); // MySQL password
The getDB() function returns a lazily-created PDO singleton. All API scripts call this function — credentials are never exposed to the browser since PHP files are never served as plain text.
TIMEZONE
The hourly distribution chart uses FROM_UNIXTIME() which inherits the MariaDB server's timezone. To use a specific timezone, set it in MariaDB:
SET GLOBAL time_zone = 'America/Toronto';
GET JSON Returns all unique sensor sites with location and summary statistics.
PARAMETERS
None.
RESPONSE
JSON array. Each element:
| Field | Type | Description |
|---|---|---|
| device_name | string | Sensor identifier |
| latitude | float | Average GPS latitude |
| longitude | float | Average GPS longitude |
| reading_count | int | Total number of rows for this device |
| avg_wbgt | float | Mean WBGT across all readings (°C) |
| last_reading | int | Unix timestamp of most recent reading |
| first_reading | int | Unix timestamp of oldest reading |
EXAMPLE
[
{
"device_name": "wbgt1",
"latitude": 43.140462,
"longitude": -79.27035,
"reading_count": 195767,
"avg_wbgt": 21.3,
"last_reading": 1760207580,
"first_reading": 1745431613
}
]
GET JSON Returns statistics and up to 500 recent readings for a single sensor within a date range.
PARAMETERS
| Param | Type | Required | Description |
|---|---|---|---|
| device | string | REQUIRED | Sensor identifier (e.g. wbgt1) |
| from | int | optional | Range start as Unix timestamp (default: 30 days ago) |
| to | int | optional | Range end as Unix timestamp (default: now) |
RESPONSE
{
"stats": {
"count": 4320,
"approx_median": false,
"avg": { "WB": 18.5, "BG": 20.1, "AT": 19.2, "RH": 78.4, "WBGT": 18.9 },
"median": { "WB": 18.2, "BG": 19.8, "AT": 18.9, "RH": 77.0, "WBGT": 18.6 },
"min": { "WB": 5.0, "BG": 5.2, "AT": 5.1, "RH": 45.0, "WBGT": 5.1 },
"max": { "WB": 28.1, "BG": 34.0, "AT": 31.0, "RH": 98.0, "WBGT": 28.8 }
},
"data": [
{
"timestamp": 1760207580,
"WB": 17.1, "BG": 17.3, "AT": 17.7,
"RH": 93.1, "WBGT": 17.2
},
... // up to 500 rows, newest first
]
}
approx_median is true when the range contains more than 5 000 rows; in that case the median is calculated on the most recent 5 000 rows only.
WBGT in the data array is pre-calculated server-side: ROUND(0.7×WB + 0.2×BG + 0.1×AT, 1).
GET JSON Returns WBGT aggregated by hour-of-day (0–23) for the diurnal distribution chart.
PARAMETERS
| Param | Type | Required | Description |
|---|---|---|---|
| device | string | REQUIRED | Sensor identifier |
| from | int | optional | Range start Unix timestamp |
| to | int | optional | Range end Unix timestamp |
RESPONSE
{
"hourly": [
null, // hour 0 — no data
{ "hour": 1, "count": 182,
"avg_wbgt": 14.2, "min_wbgt": 9.1, "max_wbgt": 22.3,
"avg_at": 14.8, "avg_rh": 85.1 },
... // 24 elements total; null = no readings that hour
]
}
Hours with no readings in the selected range are returned as null. The frontend chart skips null entries (bars not drawn).
Hour values use the MariaDB server's local timezone via FROM_UNIXTIME().
GET CSV Streams all readings for a sensor in a date range directly to the browser as a downloadable CSV file. No row limit — exports the full dataset.
PARAMETERS
| Param | Type | Required | Description |
|---|---|---|---|
| device | string | REQUIRED | Sensor identifier (e.g. wbgt1) |
| from | int | optional | Range start Unix timestamp (default: 30 days ago) |
| to | int | optional | Range end Unix timestamp (default: now) |
RESPONSE
A Content-Disposition: attachment CSV stream. Rows are ordered oldest-first. A UTF-8 BOM is prepended for Excel compatibility. Filename format:
wbgt_{device}_{from_date}_{to_date}.csv
COLUMNS
| Column | Description |
|---|---|
| datetime | Human-readable timestamp (YYYY-MM-DD HH:MM:SS) |
| timestamp | Original Unix timestamp |
| WB_C | Wet Bulb temperature (°C) |
| BG_C | Black Globe temperature (°C) |
| AT_C | Ambient (dry-bulb) temperature (°C) |
| RH_pct | Relative humidity (%) |
| WBGT_C | Calculated WBGT (°C) = 0.7×WB + 0.2×BG + 0.1×AT |
| latitude | Sensor GPS latitude |
| longitude | Sensor GPS longitude |
| device_name | Sensor identifier string |
The map is rendered by Leaflet 1.9.4 using CartoDB Positron base tiles. A CSS filter on the tile pane gives the tiles their retro green game look:
.leaflet-tile-pane {
filter: hue-rotate(85deg) saturate(1.8) brightness(0.88) contrast(1.05);
}
Sensor markers are L.circleMarker instances grouped inside a Leaflet MarkerCluster layer. Cluster icons are custom L.divIcon elements coloured by the average WBGT of their child markers.
MAP BOUNDS
The map is constrained to Canada's bounding box:
SW corner: 41.5°N, 145.0°W NE corner: 84.0°N, 50.0°W
Panning outside this box is resisted by maxBoundsViscosity: 0.85.
MARKER STATES
| State | Appearance |
|---|---|
| Default | Radius 7 px, fill = WBGT risk colour, 1.5 px black border |
| Selected | Radius 11 px, 4 px white border |
The hourly distribution uses Chart.js 4.4.0 with a mixed bar + line chart:
| Dataset | Type | Description |
|---|---|---|
| Avg WBGT | Bar | Average WBGT per hour, bars coloured by risk level |
| Max WBGT | Line (dashed) | Maximum WBGT per hour — shows peak exposure |
| Min WBGT | Line (dashed) | Minimum WBGT per hour — shows overnight lows |
The chart is destroyed and recreated each time new data loads to avoid Chart.js animation artefacts from stale datasets.
| Function | Description |
|---|---|
| wbgtInfo(val) | Returns {color, label, cls} for a WBGT value based on the risk thresholds array. |
| fmtTs(ts) | Formats a Unix timestamp as a human-readable date/time string (en-CA locale). |
| timeAgo(ts) | Returns a relative time string such as "3h ago" or "12d ago". |
| dateToUnixStart(s) | Converts a YYYY-MM-DD string to the Unix timestamp for midnight that day. |
| dateToUnixEnd(s) | Same, but for 23:59:59 on that day. |
| unixToDateInput(ts) | Formats a Unix timestamp as YYYY-MM-DD for use in <input type="date">. |
| setDefaultDates(site) | Sets the date range inputs to the 30 days ending on the site's last_reading timestamp. |
| initMap() | Creates the Leaflet map, adds the CartoDB tile layer, and initialises the MarkerClusterGroup. |
| loadSites() | Fetches api/sites.php, creates a CircleMarker for each site, and updates the sensor/reading count badges in the header. |
| createClusterIcon(cluster) | Returns a custom L.divIcon for a cluster, sized by child count and coloured by average WBGT. |
| onMarkerClick(site, marker) | Highlights the clicked marker, populates the site info panel, sets default dates, and calls loadSiteData(). |
| loadSiteData() | Fetches measurements and hourly data in parallel, then calls the three render functions. |
| renderStats(stats) | Builds the statistics table (avg / median / min / max for all fields) and injects it into the sidebar. |
| renderChart(hourly) | Destroys any existing Chart.js instance and creates a new bar+line chart from the 24-element hourly array. |
| renderReadings(data) | Builds the scrollable readings table from the data array (up to 500 rows). |
| exportReadingsCSV() | Client-side export: downloads the last-fetched 500-row readings table as a CSV file. |
| exportHourlyCSV() | Client-side export: downloads the 24-element hourly averages as a CSV file. |
| exportStatsCSV() | Client-side export: downloads the summary statistics (avg, median, min, max for all fields) as a CSV file. |
| exportAllCSV() | Server-side export: redirects to api/export.php which streams the full dataset for the selected date range (no row limit). |
| downloadCSV(rows, filename) | Helper: converts a 2-D array to CSV text and triggers a browser download via a temporary Blob URL. |
| applyPreset(key) | Switches the active threshold preset: updates THRESHOLDS, recolours all markers and tooltips, refreshes chart bar colours, and re-renders the legend. No page reload needed. |
| renderLegend() | Rebuilds the legend list and source description from the active preset; repopulates the #threshold-select dropdown. |
| backToWelcome() | Resets the sidebar to the welcome/legend panel and deselects the active marker. |
| init() | Entry point called on DOMContentLoaded — initialises map, loads sites, renders legend, attaches button handlers. |
MAP RESIZE ON SITE SELECTION
When a sensor marker is clicked the layout transitions from a full-width map to a split view:
| State | Map width | Sidebar width |
|---|---|---|
| No site selected (default) | flex: 1 (fills remaining space) | 370 px fixed |
Site selected (.has-selection) | 40% of layout | 60% of layout |
The transition is animated over 380 ms with a cubic-bezier easing. After the animation completes, map.invalidateSize() is called to force Leaflet to recalculate the map viewport, preventing blank tile areas.
Clicking the ◀ BACK button removes the .has-selection class and returns the map to full width.
DATE RANGE DEFAULT
When a site is selected the date range inputs are pre-filled to the 30 days ending on the site's last_reading timestamp (not the current date). This ensures data is always visible even when the sensor has been offline for an extended period.
READING COUNT BADGE
The header shows a live count of unique sensors and total readings sourced from the api/sites.php response.
Four export buttons appear in the site panel once a location is selected. All exports respect the currently selected date range.
| Button | Type | What it exports | Row limit |
|---|---|---|---|
| 📋 READINGS CSV | Client-side | The readings currently shown in the table — datetime, WB, BG, AT, RH, WBGT, lat, lon | 500 rows (newest first) |
| ⏰ HOURLY CSV | Client-side | 24-hour diurnal averages — hour, count, avg/min/max WBGT, avg AT, avg RH | 24 rows (one per hour) |
| 📊 STATS CSV | Client-side | Summary statistics — reading count, avg/median/min/max for WBGT, WB, BG, AT, RH | 1 row |
| 💾 ALL DATA CSV | Server-side | Every row in the selected date range via api/export.php — all sensor columns + calculated WBGT |
No limit |
Client-side exports use the Blob API to generate the download entirely in the browser from cached data — no additional network request. The ALL DATA CSV export triggers a direct browser download from the server via fputcsv() streaming and includes a UTF-8 BOM for Excel compatibility.
The risk threshold dropdown in the sidebar is driven entirely by js/thresholds.js. The active preset colours markers, chart bars, tooltips, and the legend simultaneously with no page reload.
See the Threshold Presets science section for the full preset table, field reference, and instructions for adding a custom preset.
DROPDOWN BEHAVIOUR
- Dropdown is populated automatically from all keys in
THRESHOLD_PRESETS - Selecting an option calls
applyPreset(key)immediately (no submit button) - The source description below the dropdown updates to show the selected standard
- The selected preset persists for the lifetime of the page session (resets on reload)
LIVE RECOLOURING
applyPreset() iterates state.markers — an array populated during loadSites() — and calls marker.setStyle({ fillColor }) and marker.setTooltipContent() on each entry. The cluster layer automatically redraws cluster icons with the new colours.
To add a new sensor, simply insert rows into wbgtprod with a unique device_name and valid latitude/longitude values. The map will include the new site automatically on the next page load.
INSERT INTO wbgtprod (timestamp, WB, BG, AT, RH, latitude, longitude, device_name, raw_line) VALUES (UNIX_TIMESTAMP(), 22.1, 25.4, 23.0, 68.5, 45.4215, -75.6972, 'wbgt2', 'raw data here');
Rows where latitude = 0 or longitude = 0 are excluded from the map query — ensure valid coordinates are always inserted.
SITES QUERY
The sites.php query runs a full GROUP BY device_name scan on every page load. With a composite index on (device_name, timestamp) this is fast even at millions of rows. Without the index, response time will grow with table size.
MEDIAN CALCULATION
Medians are calculated in PHP on a maximum sample of 5 000 rows per request. For very long date ranges this is an approximation. stats.approx_median = true in the API response signals this condition to the UI.
MAP MARKERS
Markers are rendered as L.circleMarker using Leaflet's canvas renderer, which handles thousands of points efficiently. The MarkerClusterGroup further reduces visual clutter at low zoom levels.
READINGS TABLE
Individual readings are capped at 500 rows per request (newest first). This is a display limit only — statistics are always calculated over the full selected date range.
| Symptom | Likely Cause | Fix |
|---|---|---|
| Map shows no sensor dots | All rows have latitude = 0 or NULL |
Check that sensor firmware is writing valid GPS coords |
API returns {"error":"…"} |
DB credentials wrong or user lacks SELECT privilege | Verify config.php; run GRANT SELECT ON weather.* TO 'wbgtprod'@'localhost'; |
| Stats show all nulls | Date range contains no data | The default range anchors to the site's last reading. Widen the range or check the data timestamps. |
| Hourly chart is mostly empty | Narrow date range has few readings per hour | Widen the date range to at least a few weeks for a meaningful diurnal pattern |
| White page / PHP errors | PHP PDO_MySQL extension not enabled | Enable extension=pdo_mysql in php.ini |