← Public packages
@reng/pollen
Pollen index / levels for a city or lat/lon via Open-Meteo Air Quality API (zero OAuth).
src/getPollen.ts
271 lines · 7.0 KB · TypeScriptexport type GetPollenInput = {
/** Place name to geocode via Open-Meteo (e.g. "Berlin"). Ignored when latitude+longitude are set. */
location?: string
latitude?: number
longitude?: number
/** Forecast hours to scan for peak (1–96, default 24). Current/next hour is always included. */
hours?: number
}
export type PollenType =
| 'alder'
| 'birch'
| 'grass'
| 'mugwort'
| 'olive'
| 'ragweed'
export type PollenLevelLabel = 'none' | 'low' | 'moderate' | 'high' | 'very_high'
export type PollenLevel = {
value: number
unit: 'grains/m³'
label: PollenLevelLabel
}
export type GetPollenResult = {
place: {
name: string
country?: string
admin1?: string
timezone?: string
} | null
coords: { latitude: number; longitude: number }
time: string
timezone: string
/** Open-Meteo CAMS pollen is Europe-focused; outside Europe values are typically null. */
coverage: 'available' | 'unavailable'
levels: Partial<Record<PollenType, PollenLevel>>
peak?: {
time: string
type: PollenType
value: number
label: PollenLevelLabel
} | null
summary: string
attribution: string
}
const POLLEN_KEYS = [
'alder_pollen',
'birch_pollen',
'grass_pollen',
'mugwort_pollen',
'olive_pollen',
'ragweed_pollen',
] as const
type PollenHourlyKey = (typeof POLLEN_KEYS)[number]
const KEY_TO_TYPE: Record<PollenHourlyKey, PollenType> = {
alder_pollen: 'alder',
birch_pollen: 'birch',
grass_pollen: 'grass',
mugwort_pollen: 'mugwort',
olive_pollen: 'olive',
ragweed_pollen: 'ragweed',
}
/** Simplified grain/m³ bands for a brief human label (not clinical advice). */
function labelFor(value: number): PollenLevelLabel {
if (value <= 0) return 'none'
if (value < 10) return 'low'
if (value < 50) return 'moderate'
if (value < 100) return 'high'
return 'very_high'
}
type GeoResult = {
name: string
latitude: number
longitude: number
country?: string
admin1?: string
timezone?: string
}
async function geocode(name: string): Promise<GeoResult> {
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(name)}&count=1&language=en&format=json`
const res = await fetch(url)
if (!res.ok) {
throw new Error(`Geocoding failed: HTTP ${res.status}`)
}
const data = (await res.json()) as {
results?: Array<{
name: string
latitude: number
longitude: number
country?: string
admin1?: string
timezone?: string
}>
}
const hit = data.results?.[0]
if (!hit) {
throw new Error(`Place not found: ${name}`)
}
return {
name: hit.name,
latitude: hit.latitude,
longitude: hit.longitude,
country: hit.country,
admin1: hit.admin1,
timezone: hit.timezone,
}
}
type AirQualityResponse = {
latitude: number
longitude: number
timezone?: string
hourly_units?: Record<string, string>
hourly?: {
time: string[]
} & Partial<Record<PollenHourlyKey, Array<number | null>>>
error?: boolean
reason?: string
}
/**
* Fetch pollen grain levels for a city or lat/lon via Open-Meteo Air Quality API.
* Use when you need a slim allergy/pollen pulse. Coverage is Europe-focused
* (CAMS); outside Europe hourly pollen fields are usually null.
*
* @param input - `location` and/or `latitude`+`longitude`, optional `hours`
* @returns Place, coords, time, levels per pollen type present, brief summary
*
* @example
* import { getPollen } from 'kody:@reng/pollen/getPollen'
*
* const berlin = await getPollen({ location: 'Berlin', hours: 24 })
* // { place: { name: 'Berlin', … }, levels: { grass: { value, label }, … }, summary: '…' }
*/
export async function getPollen(input: GetPollenInput = {}): Promise<GetPollenResult> {
const hours = Math.min(96, Math.max(1, Math.floor(input.hours ?? 24)))
let place: GetPollenResult['place'] = null
let latitude = input.latitude
let longitude = input.longitude
const hasCoords =
typeof latitude === 'number' &&
Number.isFinite(latitude) &&
typeof longitude === 'number' &&
Number.isFinite(longitude)
if (!hasCoords) {
const loc = input.location?.trim()
if (!loc) {
throw new Error('Provide location (place name) or latitude and longitude')
}
const geo = await geocode(loc)
latitude = geo.latitude
longitude = geo.longitude
place = {
name: geo.name,
country: geo.country,
admin1: geo.admin1,
timezone: geo.timezone,
}
} else if (input.location?.trim()) {
place = { name: input.location.trim() }
}
const hourlyParam = POLLEN_KEYS.join(',')
const aqUrl =
`https://air-quality-api.open-meteo.com/v1/air-quality` +
`?latitude=${latitude}` +
`&longitude=${longitude}` +
`&hourly=${hourlyParam}` +
`&forecast_hours=${hours}` +
`&timezone=auto`
const res = await fetch(aqUrl)
if (!res.ok) {
throw new Error(`Air quality API failed: HTTP ${res.status}`)
}
const data = (await res.json()) as AirQualityResponse
if (data.error) {
throw new Error(data.reason || 'Air quality API error')
}
const times = data.hourly?.time ?? []
if (!times.length) {
throw new Error('No hourly pollen data returned')
}
// forecast_hours starts at the current hour — use the first timestep.
const idx = 0
const levels: GetPollenResult['levels'] = {}
let anyNumeric = false
for (const key of POLLEN_KEYS) {
const series = data.hourly?.[key]
const raw = series?.[idx]
if (typeof raw !== 'number' || !Number.isFinite(raw)) continue
anyNumeric = true
const type = KEY_TO_TYPE[key]
levels[type] = {
value: raw,
unit: 'grains/m³',
label: labelFor(raw),
}
}
let peak: GetPollenResult['peak'] = null
if (anyNumeric) {
for (let i = 0; i < times.length; i++) {
for (const key of POLLEN_KEYS) {
const raw = data.hourly?.[key]?.[i]
if (typeof raw !== 'number' || !Number.isFinite(raw)) continue
if (!peak || raw > peak.value) {
peak = {
time: times[i]!,
type: KEY_TO_TYPE[key],
value: raw,
label: labelFor(raw),
}
}
}
}
}
const coverage: GetPollenResult['coverage'] = anyNumeric ? 'available' : 'unavailable'
const time = times[idx]!
const timezone = data.timezone || place?.timezone || 'GMT'
let summary: string
if (!anyNumeric) {
summary =
'No pollen data for this location. Open-Meteo CAMS pollen covers Europe (and limited areas) during pollen season; values elsewhere are typically null.'
} else {
const parts = (Object.entries(levels) as Array<[PollenType, PollenLevel]>)
.filter(([, lv]) => lv.value > 0)
.sort((a, b) => b[1].value - a[1].value)
.map(([type, lv]) => `${type} ${lv.label} (${lv.value} grains/m³)`)
if (parts.length === 0) {
summary = `Pollen near none at ${time} (${timezone}).`
} else {
summary = `At ${time} (${timezone}): ${parts.join('; ')}.`
if (peak && peak.time !== time) {
summary += ` Peak in window: ${peak.type} ${peak.label} (${peak.value}) at ${peak.time}.`
}
}
}
return {
place,
coords: { latitude: latitude!, longitude: longitude! },
time,
timezone,
coverage,
levels,
peak: anyNumeric ? peak : null,
summary,
attribution:
'Data: Open-Meteo Air Quality API / CAMS European air quality forecasts. Not medical advice.',
}
}
export default getPollen