Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/components/CigaretteEquivalent.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="cigarette-equivalent-card" data-testid="cigarette-equivalent">
<span className="cigarette-equivalent-icon" aria-hidden="true">
🚬
</span>
<span className="cigarette-equivalent-text">
≈ <strong>{cigarettes}</strong> {label}/day
</span>
</div>
);
}
22 changes: 22 additions & 0 deletions src/components/CigaretteEquivalent.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<CigaretteEquivalent aqi={100} />);
expect(screen.getByTestId("cigarette-equivalent")).toBeInTheDocument();
expect(screen.getByText("1.6")).toBeInTheDocument();
});

it("accepts a raw pm25 value", () => {
render(<CigaretteEquivalent pm25={22} />);
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText(/cigarette\/day/)).toBeInTheDocument();
});

it("renders nothing for clean air (AQI 0)", () => {
const { container } = render(<CigaretteEquivalent aqi={0} />);
expect(container).toBeEmptyDOMElement();
});
});
36 changes: 36 additions & 0 deletions src/utils/cigaretteEquivalent.js
Original file line number Diff line number Diff line change
@@ -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));
}
36 changes: 36 additions & 0 deletions src/utils/cigaretteEquivalent.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading