🍁

WBGT EXPLORER

DOCUMENTATION
◀ BACK TO MAP
◉ INTRODUCTION

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.

◉ ARCHITECTURE

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.

◉ FILE STRUCTURE
wbgtview/ ├── index.html — main map page ├── docs.html — this documentation page ├── config.php — DB credentials & PDO factory ├── api/ │ ├── sites.php — GET all sensor sites │ ├── measurements.php— GET stats + recent readings │ ├── hourly.php — GET hourly WBGT distribution │ └── export.php — full CSV download (no row limit) ├── css/ │ └── style.css — Theme Park visual styles └── js/ ├── thresholds.js — WBGT risk preset definitions (edit to customise) └── app.js — map, charts, UI logic
◉ REQUIREMENTS
ComponentVersionNotes
PHP8.0 +PDO + PDO_MySQL extension required
MariaDB / MySQL10.3 +Window functions used for stats
Web serverApache / NginxMust execute .php files
Leaflet.js1.9.4Loaded from unpkg CDN
Leaflet MarkerCluster1.5.3Loaded from unpkg CDN
Chart.js4.4.0Loaded from jsDelivr CDN
Press Start 2P fontLoaded from Google Fonts CDN

All JavaScript libraries are loaded from public CDNs — no local installation needed.

◉ WHAT IS WBGT?

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:

WBGT = 0.7 × WB + 0.2 × BG + 0.1 × AT
SymbolMeaningDB ColumnUnit
WBWet Bulb Temperature — accounts for humidity and evaporative coolingWB°C
BGBlack Globe Temperature — measures radiant heat from the sunBG°C
ATAir Temperature (dry bulb)AT°C
RHRelative Humidity (displayed separately)RH%

The 0.7 weighting on wet bulb reflects that humidity and evaporative cooling contribute most to heat stress.

◉ WBGT RISK SCALE

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:

No Risk < 18 °C Normal activity. No heat precautions required.
Low 18–23 °C Be aware. Light heat precautions for strenuous activity.
Moderate 23–28 °C Be careful. Rest breaks, hydration recommended.
High 28–32 °C Danger. Limit exposure; monitor for heat illness.
Very High 32–35 °C Extreme danger. Cancel or postpone outdoor activity.
Extreme ≥ 35 °C Halt all outdoor work/activity immediately.

For all preset definitions and their sources, see the Threshold Presets section.

◉ WBGT THRESHOLD PRESETS

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

KeyNameSource / Use-caseLevels
generalGeneral (Default)General-purpose scale, suitable for most uses6
militaryUS Military — Flag SystemUS Army/Marine Corps TB MED 507 / NAVMED P-5052-55
acsmACSM Sports & AthleticsAmerican College of Sports Medicine — outdoor events5
niosh_lightNIOSH/ACGIH — Light WorkNIOSH Criteria Document (2016) — ≤ 200 W metabolic rate4
niosh_heavyNIOSH/ACGIH — Heavy WorkNIOSH Criteria Document (2016) — > 400 W metabolic rate5
canada_ccohsCanada — CCOHS Outdoor WorkersCanadian Centre for Occupational Health & Safety5

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

FieldTypeDescription
maxnumberUpper bound of this level (exclusive). Use Infinity for the last level.
colorstringCSS colour used for markers, chart bars, and legend dots.
labelstringHuman-readable risk label shown in tooltips, legend, and stats.
rangestringDisplay string shown in the legend (e.g. "18 – 23 °C").
clsstringCSS 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:

  1. Replaces the active THRESHOLDS array with the new preset's levels
  2. Calls marker.setStyle() on every stored marker to update fill colours
  3. Updates all marker tooltips to reflect the new risk label
  4. Re-renders the hourly chart bar colours
  5. Re-renders the legend list and source description

No page reload or API call is required.

◉ DATABASE SCHEMA

Database: weather  |  Table: wbgtprod

ColumnTypeDescription
timestampint(11)Unix timestamp (seconds since epoch). Indexed with UNIQUE.
WBdecimal(10,1)Wet Bulb temperature (°C)
BGdecimal(10,1)Black Globe temperature (°C)
ATdecimal(10,1)Air Temperature / dry bulb (°C)
RHdecimal(10,1)Relative Humidity (%)
latitudedecimal(9,6)GPS latitude in decimal degrees (e.g. 43.140462)
longitudedecimal(9,6)GPS longitude in decimal degrees (e.g. -79.270350)
device_nametextSensor identifier string (e.g. "wbgt1")
raw_linetextOriginal 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);
◉ CONFIGURATION

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';
◉ API — GET /api/sites.php

GET JSON Returns all unique sensor sites with location and summary statistics.

PARAMETERS

None.

RESPONSE

JSON array. Each element:

FieldTypeDescription
device_namestringSensor identifier
latitudefloatAverage GPS latitude
longitudefloatAverage GPS longitude
reading_countintTotal number of rows for this device
avg_wbgtfloatMean WBGT across all readings (°C)
last_readingintUnix timestamp of most recent reading
first_readingintUnix 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
  }
]
◉ API — GET /api/measurements.php

GET JSON Returns statistics and up to 500 recent readings for a single sensor within a date range.

PARAMETERS

ParamTypeRequiredDescription
devicestringREQUIREDSensor identifier (e.g. wbgt1)
fromintoptionalRange start as Unix timestamp (default: 30 days ago)
tointoptionalRange 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).

◉ API — GET /api/hourly.php

GET JSON Returns WBGT aggregated by hour-of-day (0–23) for the diurnal distribution chart.

PARAMETERS

ParamTypeRequiredDescription
devicestringREQUIREDSensor identifier
fromintoptionalRange start Unix timestamp
tointoptionalRange 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().

◉ API — GET /api/export.php

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

ParamTypeRequiredDescription
devicestringREQUIREDSensor identifier (e.g. wbgt1)
fromintoptionalRange start Unix timestamp (default: 30 days ago)
tointoptionalRange 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

ColumnDescription
datetimeHuman-readable timestamp (YYYY-MM-DD HH:MM:SS)
timestampOriginal Unix timestamp
WB_CWet Bulb temperature (°C)
BG_CBlack Globe temperature (°C)
AT_CAmbient (dry-bulb) temperature (°C)
RH_pctRelative humidity (%)
WBGT_CCalculated WBGT (°C) = 0.7×WB + 0.2×BG + 0.1×AT
latitudeSensor GPS latitude
longitudeSensor GPS longitude
device_nameSensor identifier string
◉ FRONTEND — MAP (LEAFLET.JS)

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

StateAppearance
DefaultRadius 7 px, fill = WBGT risk colour, 1.5 px black border
SelectedRadius 11 px, 4 px white border
◉ FRONTEND — CHART (CHART.JS)

The hourly distribution uses Chart.js 4.4.0 with a mixed bar + line chart:

DatasetTypeDescription
Avg WBGTBarAverage WBGT per hour, bars coloured by risk level
Max WBGTLine (dashed)Maximum WBGT per hour — shows peak exposure
Min WBGTLine (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.

◉ FRONTEND — JS FUNCTION REFERENCE (app.js)
FunctionDescription
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.
◉ FRONTEND — UI BEHAVIOUR

MAP RESIZE ON SITE SELECTION

When a sensor marker is clicked the layout transitions from a full-width map to a split view:

StateMap widthSidebar width
No site selected (default)flex: 1 (fills remaining space)370 px fixed
Site selected (.has-selection)40% of layout60% 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.

◉ FRONTEND — DATA EXPORT

Four export buttons appear in the site panel once a location is selected. All exports respect the currently selected date range.

ButtonTypeWhat it exportsRow 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.

◉ FRONTEND — THRESHOLD CONFIGURATION

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.

◉ ADDING NEW SENSORS

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.

◉ PERFORMANCE NOTES

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.

◉ TROUBLESHOOTING
SymptomLikely CauseFix
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