/**
* Utilities for mapping PM2.5 values to Air Quality Categories (AQC),
* AQI colors, y-axis limits, and visual elements in Highcharts plots.
*/
import { requireLuxonDateTimeArray } from './helpers.js';
// ------------------------------------------------------------------
// Constants
// ------------------------------------------------------------------
/**
* 2024 NAAQS PM2.5 thresholds in µg/m³ for AQI categories.
*/
const AQI_THRESHOLDS = [0, 9, 35.4, 55.4, 125.4, 225.4];
/**
* RGB colors corresponding to AQI categories 1 through 6.
*/
const AQI_COLORS = [
"rgb(0,255,0)", // Green
"rgb(255,255,0)", // Yellow
"rgb(255,126,0)", // Orange
"rgb(255,0,0)", // Red
"rgb(143,63,151)", // Purple
"rgb(126,0,35)", // Maroon
];
/**
* Returns true if the given value is a finite number.
* @param {*} value
* @returns {boolean}
*/
function isValidPM25(value) {
return typeof value === 'number' && isFinite(value);
}
// ------------------------------------------------------------------
// Core Utilities
// ------------------------------------------------------------------
/**
* Returns the Air Quality Category (AQC) level associated with a PM2.5 measurement.
* Categories range from 1 (Good) to 6 (Hazardous).
*
* @param {number} pm25 - PM2.5 concentration in µg/m³.
* @returns {number|null} AQC level (1–6), or null if input is invalid.
*/
export function pm25ToAQC(pm25) {
if (!isValidPM25(pm25)) return null;
if (pm25 <= 9) return 1;
if (pm25 <= 35.4) return 2;
if (pm25 <= 55.4) return 3;
if (pm25 <= 125.4) return 4;
if (pm25 <= 225.4) return 5;
return 6;
}
/**
* Returns the AQI color associated with a PM2.5 level.
*
* @param {number} pm25 - PM2.5 concentration in µg/m³.
* @returns {string} RGB color string.
*/
export function pm25ToColor(pm25) {
const AQC = pm25ToAQC(pm25);
return AQC == null ? "rgb(187,187,187)" : AQI_COLORS[AQC - 1];
}
/**
* Returns the ymax value appropriate for a maximum PM2.5 level.
* Uses fixed breakpoints to prevent charts from autoscaling too wildly.
*
* @param {number} pm25 - Maximum PM2.5 value in µg/m³.
* @returns {number} Suggested y-axis maximum.
*/
export function pm25ToYMax(pm25) {
if (!isValidPM25(pm25)) return 50;
if (pm25 <= 50) return 50;
if (pm25 <= 100) return 100;
if (pm25 <= 200) return 200;
if (pm25 <= 400) return 500;
if (pm25 <= 600) return 600;
if (pm25 <= 1000) return 1000;
if (pm25 <= 1500) return 1500;
return 1.05 * pm25;
}
/**
* Returns an array of plotLine objects for overlaying AQI thresholds on a Highcharts yAxis.
*
* @param {number} [width=2] - Line width in pixels.
* @returns {Array<Object>} Highcharts-compatible `plotLines` array.
*/
export function pm25_AQILines(width = 2) {
return AQI_THRESHOLDS.slice(1).map((value, i) => ({
color: AQI_COLORS[i + 1], // start from yellow
width,
value,
}));
}
/**
* Adds a colored AQI stacked bar to the left side of an existing Highcharts chart.
* Intended to provide a visual reference for AQI zones.
*
* @param {Highcharts.Chart} chart - A fully rendered Highcharts chart.
* @param {number} [width=6] - Width of the AQI bar in pixels.
*/
export function pm25_addAQIStackedBar(chart, width = 6) {
if (!chart?.xAxis?.[0] || !chart?.yAxis?.[0] || !chart?.renderer) {
console.warn("Invalid chart object passed to pm25_addAQIStackedBar.");
return;
}
width = Math.max(1, Number(width) || 6);
const yAxis = chart.yAxis[0];
const xlo = chart.xAxis[0].left;
const ymax_px = yAxis.toPixels(yAxis.max);
for (let i = 0; i < AQI_THRESHOLDS.length - 1; i++) {
const yhi = yAxis.toPixels(AQI_THRESHOLDS[i]);
const nextThreshold = AQI_THRESHOLDS[i + 1] ?? 5000;
if (yhi > ymax_px) {
const ylo = Math.max(yAxis.toPixels(nextThreshold), ymax_px);
const height = Math.abs(yhi - ylo);
chart.renderer
.rect(xlo, ylo, width, height, 1)
.attr({ fill: AQI_COLORS[i], stroke: "transparent" })
.add();
}
}
}
/**
* Validate the input data arrays for hourly or time series plots.
*
* @param {DateTime[]} datetime - Array of Luxon DateTime objects (assumed UTC).
* @param {Array<number|null>} pm25 - Array of PM2.5 values (finite or null).
* @param {Array<number|null>} nowcast - Array of NowCast values (finite or null).
* @throws {Error} If input is invalid in structure or type.
*/
export function validatePlotArrays(datetime, pm25, nowcast) {
// Presence and type check
if (!Array.isArray(datetime) || !Array.isArray(pm25) || !Array.isArray(nowcast)) {
throw new Error("Input arrays must be defined and of type Array");
}
// Length check
const len = datetime.length;
if (pm25.length !== len || nowcast.length !== len) {
throw new Error(`All arrays must have the same length. Got: datetime(${len}), pm25(${pm25.length}), nowcast(${nowcast.length})`);
}
// Validate DateTime objects
requireLuxonDateTimeArray(datetime, 'datetime');
// Check if datetimes are increasing
let warned = false;
for (let i = 1; i < len; i++) {
const prev = datetime[i - 1];
const curr = datetime[i];
if (curr.toMillis() <= prev.toMillis()) {
if (!warned) {
console.warn("⚠️ Warning: datetime array is not strictly increasing");
warned = true;
}
console.warn(`↳ Non-increasing at index ${i - 1} → ${i}: ${prev.toISO()} >= ${curr.toISO()}`);
}
}
// pm25 and nowcast must be finite numbers or null
const isValidValue = (v) => v === null || (typeof v === "number" && isFinite(v));
for (let i = 0; i < len; i++) {
if (!isValidValue(pm25[i])) {
throw new Error(`Invalid pm25 value at index ${i}: ${pm25[i]}`);
}
if (!isValidValue(nowcast[i])) {
throw new Error(`Invalid nowcast value at index ${i}: ${nowcast[i]}`);
}
}
}