From e4602db517015b484ab28bd418955e8d637215db Mon Sep 17 00:00:00 2001 From: vedant7007 Date: Sat, 29 Aug 2026 21:13:59 +0530 Subject: [PATCH] feat: add PM2.5/AQI cigarettes-per-day equivalent pm25ToCigarettes / aqiToCigarettes (Berkeley Earth: ~22 ug/m3 PM2.5 per day per cigarette); the AQI path reuses exposureModel's aqiToPm25. Adds a small CigaretteEquivalent card that hides itself for clean air. Closes #1131 --- src/components/CigaretteEquivalent.jsx | 29 +++++++++++++++++ src/components/CigaretteEquivalent.test.jsx | 22 +++++++++++++ src/utils/cigaretteEquivalent.js | 36 +++++++++++++++++++++ src/utils/cigaretteEquivalent.test.js | 36 +++++++++++++++++++++ 4 files changed, 123 insertions(+) create mode 100644 src/components/CigaretteEquivalent.jsx create mode 100644 src/components/CigaretteEquivalent.test.jsx create mode 100644 src/utils/cigaretteEquivalent.js create mode 100644 src/utils/cigaretteEquivalent.test.js diff --git a/src/components/CigaretteEquivalent.jsx b/src/components/CigaretteEquivalent.jsx new file mode 100644 index 0000000..79090cb --- /dev/null +++ b/src/components/CigaretteEquivalent.jsx @@ -0,0 +1,29 @@ +import { aqiToCigarettes, pm25ToCigarettes } from "../utils/cigaretteEquivalent"; + +/** + * Surface the "cigarettes/day" equivalent of the current air quality — a + * visceral way to read an AQI number. Pass either `aqi` or a raw `pm25` + * concentration. Renders nothing when the equivalent rounds to zero (clean + * air) so it never shows a meaningless "0 cigarettes". + * + * @param {{ aqi?: number, pm25?: number }} params + */ +export default function CigaretteEquivalent({ aqi, pm25 }) { + const cigarettes = + typeof pm25 === "number" ? pm25ToCigarettes(pm25) : aqiToCigarettes(aqi); + + if (!cigarettes) return null; + + const label = cigarettes === 1 ? "cigarette" : "cigarettes"; + + return ( +
+ + + ≈ {cigarettes} {label}/day + +
+ ); +} diff --git a/src/components/CigaretteEquivalent.test.jsx b/src/components/CigaretteEquivalent.test.jsx new file mode 100644 index 0000000..dc30f29 --- /dev/null +++ b/src/components/CigaretteEquivalent.test.jsx @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import CigaretteEquivalent from "./CigaretteEquivalent"; + +describe("CigaretteEquivalent", () => { + it("renders the equivalent for a polluted AQI", () => { + render(); + expect(screen.getByTestId("cigarette-equivalent")).toBeInTheDocument(); + expect(screen.getByText("1.6")).toBeInTheDocument(); + }); + + it("accepts a raw pm25 value", () => { + render(); + expect(screen.getByText("1")).toBeInTheDocument(); + expect(screen.getByText(/cigarette\/day/)).toBeInTheDocument(); + }); + + it("renders nothing for clean air (AQI 0)", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/utils/cigaretteEquivalent.js b/src/utils/cigaretteEquivalent.js new file mode 100644 index 0000000..0a78026 --- /dev/null +++ b/src/utils/cigaretteEquivalent.js @@ -0,0 +1,36 @@ +import { aqiToPm25 } from "./exposureModel"; + +/** + * Berkeley Earth rule of thumb: breathing ~22 µg/m³ of PM2.5 for a day is + * roughly equivalent, in particulate intake, to smoking one cigarette. + */ +export const PM25_PER_CIGARETTE = 22; + +/** + * Convert a PM2.5 concentration to an equivalent number of cigarettes per day. + * + * @param {number} pm25 - PM2.5 concentration in µg/m³. + * @returns {number} Equivalent cigarettes/day, rounded to 1 decimal. Non-finite + * or non-positive input returns 0. + * @example + * pm25ToCigarettes(22); // 1 + * pm25ToCigarettes(220); // 10 + */ +export function pm25ToCigarettes(pm25) { + if (typeof pm25 !== "number" || !Number.isFinite(pm25) || pm25 <= 0) return 0; + return Math.round((pm25 / PM25_PER_CIGARETTE) * 10) / 10; +} + +/** + * Convert a US AQI value to an equivalent number of cigarettes per day, by first + * mapping AQI back to a PM2.5 concentration. + * + * @param {number} aqi - US AQI value. + * @returns {number} Equivalent cigarettes/day, rounded to 1 decimal. + * @example + * aqiToCigarettes(50); // 0.5 (12 µg/m³) + * aqiToCigarettes(100); // 1.6 (35.4 µg/m³) + */ +export function aqiToCigarettes(aqi) { + return pm25ToCigarettes(aqiToPm25(aqi)); +} diff --git a/src/utils/cigaretteEquivalent.test.js b/src/utils/cigaretteEquivalent.test.js new file mode 100644 index 0000000..a3f5068 --- /dev/null +++ b/src/utils/cigaretteEquivalent.test.js @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { pm25ToCigarettes, aqiToCigarettes } from "./cigaretteEquivalent"; + +describe("pm25ToCigarettes", () => { + it("maps the reference concentration to one cigarette", () => { + expect(pm25ToCigarettes(22)).toBe(1); + }); + + it("scales linearly", () => { + expect(pm25ToCigarettes(11)).toBe(0.5); + expect(pm25ToCigarettes(220)).toBe(10); + }); + + it("rounds to one decimal", () => { + expect(pm25ToCigarettes(35.4)).toBe(1.6); + }); + + it("returns 0 for non-positive, non-finite, or non-number input", () => { + expect(pm25ToCigarettes(0)).toBe(0); + expect(pm25ToCigarettes(-5)).toBe(0); + expect(pm25ToCigarettes(NaN)).toBe(0); + expect(pm25ToCigarettes("12")).toBe(0); + expect(pm25ToCigarettes(undefined)).toBe(0); + }); +}); + +describe("aqiToCigarettes", () => { + it("converts AQI via PM2.5", () => { + expect(aqiToCigarettes(50)).toBe(0.5); // 12 µg/m³ + expect(aqiToCigarettes(100)).toBe(1.6); // 35.4 µg/m³ + }); + + it("returns 0 at AQI 0", () => { + expect(aqiToCigarettes(0)).toBe(0); + }); +});