/**
* Air Quality Card v2.1.0
* A custom Home Assistant card for air quality visualization
* Thresholds based on WHO 2021 guidelines and ASHRAE standards
*
* https://github.com/KadenThomp36/air-quality-card
*/
const CARD_VERSION = '2.2.0';
class AirQualityCard extends HTMLElement {
// Visual editor using getConfigForm (preferred modern approach)
static getConfigForm() {
return {
schema: [
{ name: 'name', selector: { text: {} } },
{
type: 'grid',
schema: [
{ name: 'co2_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'pm25_entity', selector: { entity: { domain: 'sensor' } } },
]
},
{
type: 'grid',
schema: [
{ name: 'hcho_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'tvoc_entity', selector: { entity: { domain: 'sensor' } } },
]
},
{
type: 'grid',
schema: [
{ name: 'humidity_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'temperature_entity', selector: { entity: { domain: 'sensor' } } },
]
},
{
type: 'expandable',
title: 'Advanced',
schema: [
{ name: 'air_quality_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'recommendation_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'hours_to_show', selector: { number: { min: 1, max: 168, mode: 'box', unit_of_measurement: 'hours' } } },
{ name: 'temperature_unit', selector: { select: { options: [{ value: 'F', label: 'Fahrenheit (°F)' }, { value: 'C', label: 'Celsius (°C)' }], mode: 'dropdown' } } },
]
}
],
computeLabel: (schema) => {
const labels = {
name: 'Card Name',
co2_entity: 'CO₂ Sensor',
pm25_entity: 'PM2.5 Sensor',
humidity_entity: 'Humidity Sensor (optional)',
temperature_entity: 'Temperature Sensor (optional)',
air_quality_entity: 'Air Quality Index (optional)',
hcho_entity: 'Formaldehyde (HCHO; CH2O) Sensor (optional)',
tvoc_entity: 'Volatile Organic Compounds (tVOC) Sensor (optional)',
recommendation_entity: 'Recommendation Sensor (optional)',
hours_to_show: 'Graph History',
temperature_unit: 'Temperature Unit'
};
return labels[schema.name] || schema.name;
}
};
}
// Fallback for older HA versions - use getConfigElement
static getConfigElement() {
return document.createElement('air-quality-card-editor');
}
static getStubConfig() {
return {
name: 'Air Quality',
hours_to_show: 24
};
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._config = {};
this._hass = null;
this._rendered = false;
this._history = { co2: [], pm25: [], humidity: [], temperature: [] };
this._historyLoaded = false;
this._graphData = {};
this._isDragging = false;
}
setConfig(config) {
if (!config) throw new Error('Invalid configuration');
// Validate required entities
if (!config.co2_entity && !config.pm25_entity) {
throw new Error('Please configure at least a CO₂ or PM2.5 sensor entity');
}
this._config = {
name: 'Air Quality',
hours_to_show: 24,
temperature_unit: 'F',
...config
};
this._rendered = false;
this._historyLoaded = false;
}
set hass(hass) {
this._hass = hass;
if (!this._rendered) {
this._initialRender();
this._rendered = true;
this._loadHistory();
}
this._updateStates();
}
getCardSize() {
let size = 3; // Base size for header and recommendation
if (this._config.co2_entity) size += 1;
if (this._config.pm25_entity) size += 1;
if (this._config.hcho_entity) size += 1;
if (this._config.tvoc_entity) size += 1;
if (this._config.humidity_entity) size += 1;
if (this._config.temperature_entity) size += 1;
return size;
}
async _loadHistory() {
if (!this._hass || this._historyLoaded) return;
const endTime = new Date();
const startTime = new Date(endTime.getTime() - (this._config.hours_to_show * 60 * 60 * 1000));
try {
const promises = [];
const keys = [];
if (this._config.co2_entity) {
promises.push(this._fetchHistory(this._config.co2_entity, startTime, endTime));
keys.push('co2');
}
if (this._config.pm25_entity) {
promises.push(this._fetchHistory(this._config.pm25_entity, startTime, endTime));
keys.push('pm25');
}
if (this._config.hcho_entity) {
promises.push(this._fetchHistory(this._config.hcho_entity, startTime, endTime));
keys.push('hcho');
}
if (this._config.tvoc_entity) {
promises.push(this._fetchHistory(this._config.tvoc_entity, startTime, endTime));
keys.push('voc');
}
if (this._config.humidity_entity) {
promises.push(this._fetchHistory(this._config.humidity_entity, startTime, endTime));
keys.push('humidity');
}
if (this._config.temperature_entity) {
promises.push(this._fetchHistory(this._config.temperature_entity, startTime, endTime));
keys.push('temperature');
}
const results = await Promise.all(promises);
keys.forEach((key, i) => {
this._history[key] = this._processHistory(results[i]);
});
this._historyLoaded = true;
this._renderGraphs();
} catch (e) {
console.warn('Air Quality Card: Failed to load history:', e);
}
}
async _fetchHistory(entityId, startTime, endTime) {
if (!entityId) return [];
const uri = `history/period/${startTime.toISOString()}?filter_entity_id=${entityId}&end_time=${endTime.toISOString()}&minimal_response&no_attributes`;
const response = await this._hass.callApi('GET', uri);
return response?.[0] || [];
}
_processHistory(history) {
return history
.filter(item => item.state && !isNaN(parseFloat(item.state)))
.map(item => ({
time: new Date(item.last_changed).getTime(),
value: parseFloat(item.state)
}));
}
_getState(entityId) {
if (!entityId) return 'unknown';
return this._hass?.states[entityId]?.state ?? 'unknown';
}
_getNumericState(entityId) {
const state = this._getState(entityId);
return parseFloat(state) || 0;
}
_getCO2Color(value) {
if (value < 600) return '#4caf50';
if (value < 800) return '#8bc34a';
if (value < 1000) return '#ffc107';
if (value < 1500) return '#ff9800';
return '#f44336';
}
_getPM25Color(value) {
if (value < 5) return '#4caf50';
if (value < 15) return '#8bc34a';
if (value < 25) return '#ffc107';
if (value < 35) return '#ff9800';
return '#f44336';
}
_getHCHOColor(value) {
if (value < 20) return '#4caf50';
if (value < 50) return '#8bc34a';
if (value < 100) return '#ffc107';
if (value < 200) return '#ff9800';
return '#f44336';
}
_getTVOCColor(value) {
if (value < 100) return '#4caf50';
if (value < 300) return '#8bc34a';
if (value < 500) return '#ffc107';
if (value < 1000) return '#ff9800';
return '#f44336';
}
_getHumidityColor(value) {
if (value < 30) return '#ff9800';
if (value < 40) return '#8bc34a';
if (value < 50) return '#4caf50';
if (value < 60) return '#8bc34a';
return '#ff9800';
}
_isCelsius() {
return this._config.temperature_unit === 'C';
}
_getTempUnit() {
return this._isCelsius() ? '°C' : '°F';
}
_getTempColor(value) {
if (this._isCelsius()) {
if (value < 18) return '#2196f3';
if (value < 20) return '#03a9f4';
if (value < 22) return '#4caf50';
if (value < 24) return '#ff9800';
return '#f44336';
}
if (value < 65) return '#2196f3';
if (value < 68) return '#03a9f4';
if (value < 72) return '#4caf50';
if (value < 76) return '#ff9800';
return '#f44336';
}
_getOverallStatus() {
const co2 = this._config.co2_entity ? this._getNumericState(this._config.co2_entity) : 0;
const pm25 = this._config.pm25_entity ? this._getNumericState(this._config.pm25_entity) : 0;
// If air_quality_entity is configured, use it
if (this._config.air_quality_entity) {
const quality = this._getState(this._config.air_quality_entity);
return { status: quality.replace('_', ' '), color: this._getQualityColor(quality) };
}
// Otherwise calculate from CO2 and PM2.5
if (co2 > 1500 || pm25 > 35) return { status: 'Poor', color: '#f44336' };
if (co2 > 1000 || pm25 > 25) return { status: 'Fair', color: '#ff9800' };
if (co2 > 800 || pm25 > 15) return { status: 'Moderate', color: '#ffc107' };
if (co2 > 600 || pm25 > 5) return { status: 'Good', color: '#8bc34a' };
return { status: 'Excellent', color: '#4caf50' };
}
_getQualityColor(quality) {
const colors = {
'good': '#4caf50',
'excellent': '#4caf50',
'moderate': '#8bc34a',
'fair': '#ffc107',
'poor': '#ff9800',
'very_poor': '#f44336',
'very poor': '#f44336',
'extremely_poor': '#b71c1c'
};
return colors[quality?.toLowerCase()] || '#9e9e9e';
}
_getRecommendation() {
// If recommendation_entity is configured, use it
if (this._config.recommendation_entity) {
const rec = this._getState(this._config.recommendation_entity);
return rec !== 'unknown' ? rec : null;
}
// Otherwise calculate from sensor values
const co2 = this._config.co2_entity ? this._getNumericState(this._config.co2_entity) : 0;
const pm25 = this._config.pm25_entity ? this._getNumericState(this._config.pm25_entity) : 0;
const humidity = this._config.humidity_entity ? this._getNumericState(this._config.humidity_entity) : 45;
if (co2 > 1500) return 'Ventilate Now';
if (pm25 > 35) return 'Run Air Purifier';
if (pm25 > 25 && co2 > 1000) return 'Air Purifier + Ventilate';
if (pm25 > 25) return 'Run Air Purifier';
if (co2 > 1000) return 'Open Window';
if (humidity < 30) return 'Too Dry';
if (humidity > 60) return 'Too Humid';
if (co2 > 800 || pm25 > 15) return 'Consider Ventilating';
return 'All Good';
}
_getRecommendationIcon(rec) {
const icons = {
'All Good': 'mdi:check-circle',
'Consider Ventilating': 'mdi:information',
'Open Window': 'mdi:window-open-variant',
'Run Air Purifier': 'mdi:air-purifier',
'Air Purifier + Ventilate': 'mdi:alert',
'Ventilate Now': 'mdi:alert-circle',
'Too Dry': 'mdi:water-percent',
'Too Humid': 'mdi:water'
};
return icons[rec] || 'mdi:air-filter';
}
_initialRender() {
const showCO2 = !!this._config.co2_entity;
const showPM25 = !!this._config.pm25_entity;
const showHCHO = !!this._config.hcho_entity;
const showTVOC = !!this._config.tvoc_entity;
const showHumidity = !!this._config.humidity_entity;
const showTemp = !!this._config.temperature_entity;
this.shadowRoot.innerHTML = `
All Good
Air quality is within healthy limits
${showCO2 ? `
` : ''}
${showPM25 ? `
` : ''}
${showHCHO ? `
` : ''}
${showTVOC ? `
` : ''}
${showHumidity ? `
` : ''}
${showTemp ? `
` : ''}
`;
}
_updateStates() {
if (!this._hass || !this._rendered) return;
const co2 = this._config.co2_entity ? this._getNumericState(this._config.co2_entity) : null;
const pm25 = this._config.pm25_entity ? this._getNumericState(this._config.pm25_entity) : null;
const hcho = this._config.hcho_entity ? this._getNumericState(this._config.hcho_entity) : null;
const tvoc = this._config.tvoc_entity ? this._getNumericState(this._config.tvoc_entity) : null;
const humidity = this._config.humidity_entity ? this._getNumericState(this._config.humidity_entity) : null;
const temp = this._config.temperature_entity ? this._getNumericState(this._config.temperature_entity) : null;
const recommendation = this._getRecommendation();
const overall = this._getOverallStatus();
// Update status badge
const statusBadge = this.shadowRoot.getElementById('status-badge');
const statusText = this.shadowRoot.getElementById('status-text');
const statusIcon = this.shadowRoot.getElementById('status-icon');
if (statusBadge) {
statusBadge.style.background = overall.color + '22';
statusBadge.style.color = overall.color;
statusText.textContent = overall.status;
statusIcon.style.color = overall.color;
}
// Update recommendation
const recIcon = this.shadowRoot.getElementById('rec-icon');
const recTitle = this.shadowRoot.getElementById('rec-title');
const recSubtitle = this.shadowRoot.getElementById('rec-subtitle');
const recContainer = this.shadowRoot.getElementById('recommendation');
if (recIcon && recommendation) {
recIcon.setAttribute('icon', this._getRecommendationIcon(recommendation));
recTitle.textContent = recommendation;
let subtitle = '';
if (recommendation === 'All Good') {
subtitle = 'Air quality is within healthy limits';
} else if (recommendation === 'Run Air Purifier' && pm25 !== null) {
subtitle = `PM2.5 at ${pm25.toFixed(0)} μg/m³ - filter the air`;
} else if (recommendation === 'Open Window' && co2 !== null) {
subtitle = `CO₂ at ${Math.round(co2)} ppm - fresh air needed`;
} else if (recommendation === 'Air Purifier + Ventilate' && co2 !== null && pm25 !== null) {
subtitle = `CO₂: ${Math.round(co2)} ppm, PM2.5: ${pm25.toFixed(0)} μg/m³`;
} else if (recommendation === 'Ventilate Now' && co2 !== null) {
subtitle = `CO₂ at ${Math.round(co2)} ppm - may affect focus`;
} else if (recommendation === 'Too Dry' && humidity !== null) {
subtitle = `Humidity at ${Math.round(humidity)}% - consider humidifier`;
} else if (recommendation === 'Too Humid' && humidity !== null) {
subtitle = `Humidity at ${Math.round(humidity)}% - ventilate`;
} else if (recommendation === 'Consider Ventilating') {
if (co2 !== null && co2 > 800) subtitle = `CO₂ at ${Math.round(co2)} ppm`;
else if (pm25 !== null && pm25 > 15) subtitle = `PM2.5 at ${pm25.toFixed(0)} μg/m³`;
else subtitle = 'Slightly elevated levels';
}
recSubtitle.textContent = subtitle;
const isGood = recommendation === 'All Good';
const isPoor = ['Run Air Purifier', 'Open Window', 'Ventilate Now', 'Air Purifier + Ventilate'].includes(recommendation);
recIcon.style.color = isGood ? 'var(--aq-excellent)' : (isPoor ? 'var(--aq-poor)' : 'var(--aq-moderate)');
recContainer.style.background = isGood ?
'rgba(76, 175, 80, 0.1)' : (isPoor ? 'rgba(255, 152, 0, 0.15)' : 'rgba(255, 193, 7, 0.1)');
}
// Update CO2
if (co2 !== null) {
const co2Color = this._getCO2Color(co2);
const co2ValueEl = this.shadowRoot.getElementById('co2-value');
const co2StatusEl = this.shadowRoot.getElementById('co2-status');
if (co2ValueEl) {
co2ValueEl.innerHTML = `${Math.round(co2)} ppm`;
const statusEl = co2ValueEl.querySelector('.status');
statusEl.textContent = co2 < 800 ? 'Excellent' : co2 < 1000 ? 'Good' : co2 < 1500 ? 'Elevated' : 'Poor';
statusEl.style.background = co2Color + '22';
statusEl.style.color = co2Color;
co2ValueEl.style.color = co2Color;
}
}
// Update PM2.5
if (pm25 !== null) {
const pm25Color = this._getPM25Color(pm25);
const pm25ValueEl = this.shadowRoot.getElementById('pm25-value');
if (pm25ValueEl) {
pm25ValueEl.innerHTML = `${pm25.toFixed(1)} μg/m³`;
const statusEl = pm25ValueEl.querySelector('.status');
statusEl.textContent = pm25 < 5 ? 'Excellent' : pm25 < 15 ? 'Good' : pm25 < 25 ? 'Moderate' : pm25 < 35 ? 'Elevated' : 'Poor';
statusEl.style.background = pm25Color + '22';
statusEl.style.color = pm25Color;
pm25ValueEl.style.color = pm25Color;
}
}
// Update HCHO
if (hcho !== null) {
const hchoColor = this._getHCHOColor(hcho);
const hchoValueEl = this.shadowRoot.getElementById('hcho-value');
if (hchoValueEl) {
hchoValueEl.innerHTML = `${hcho.toFixed(1)} ppb`;
const statusEl = hchoValueEl.querySelector('.status');
statusEl.textContent = hcho < 20 ? 'Excellent' : hcho < 50 ? 'Good' : hcho < 100 ? 'Moderate' : hcho < 200 ? 'Elevated' : 'Poor';
statusEl.style.background = hchoColor + '22';
statusEl.style.color = hchoColor;
hchoValueEl.style.color = hchoColor;
}
}
// Update tVOC
if (tvoc !== null) {
const tvocColor = this._getTVOCColor(tvoc);
const tvocValueEl = this.shadowRoot.getElementById('tvoc-value');
if (tvocValueEl) {
tvocValueEl.innerHTML = `${tvoc.toFixed(1)} ppb`;
const statusEl = tvocValueEl.querySelector('.status');
statusEl.textContent = tvoc < 100 ? 'Excellent' : tvoc < 300 ? 'Good' : tvoc < 500 ? 'Moderate' : tvoc < 1000 ? 'Elevated' : 'Poor';
statusEl.style.background = tvocColor + '22';
statusEl.style.color = tvocColor;
tvocValueEl.style.color = tvocColor;
}
}
// Update Humidity
if (humidity !== null) {
const humidityColor = this._getHumidityColor(humidity);
const humidityValueEl = this.shadowRoot.getElementById('humidity-value');
if (humidityValueEl) {
humidityValueEl.innerHTML = `${Math.round(humidity)} %`;
const statusEl = humidityValueEl.querySelector('.status');
let humidityStatus = 'Comfortable';
if (humidity < 30) humidityStatus = 'Too Dry';
else if (humidity < 40) humidityStatus = 'Dry';
else if (humidity > 60) humidityStatus = 'Too Humid';
else if (humidity > 50) humidityStatus = 'Humid';
statusEl.textContent = humidityStatus;
statusEl.style.background = humidityColor + '22';
statusEl.style.color = humidityColor;
humidityValueEl.style.color = humidityColor;
}
}
// Update Temperature
if (temp !== null) {
const tempColor = this._getTempColor(temp);
const tempUnit = this._getTempUnit();
const tempValueEl = this.shadowRoot.getElementById('temperature-value');
if (tempValueEl) {
tempValueEl.innerHTML = `${Math.round(temp)} ${tempUnit}`;
const statusEl = tempValueEl.querySelector('.status');
let tempStatus = 'Comfortable';
if (this._isCelsius()) {
if (temp < 18) tempStatus = 'Cold';
else if (temp < 20) tempStatus = 'Cool';
else if (temp > 24) tempStatus = 'Hot';
else if (temp > 22) tempStatus = 'Warm';
} else {
if (temp < 65) tempStatus = 'Cold';
else if (temp < 68) tempStatus = 'Cool';
else if (temp > 76) tempStatus = 'Hot';
else if (temp > 72) tempStatus = 'Warm';
}
statusEl.textContent = tempStatus;
statusEl.style.background = tempColor + '22';
statusEl.style.color = tempColor;
tempValueEl.style.color = tempColor;
}
}
}
_renderGraphs() {
this._graphData = {};
if (this._config.co2_entity && this._history.co2.length) {
this._renderGraph('co2', this._history.co2, this._getCO2Color.bind(this), 400, 2000, 'ppm');
}
if (this._config.pm25_entity && this._history.pm25.length) {
this._renderGraph('pm25', this._history.pm25, this._getPM25Color.bind(this), 0, 60, 'μg/m³');
}
if (this._config.hcho_entity && this._history.hcho.length) {
this._renderGraph('hcho', this._history.hcho, this._getHCHOColor.bind(this), 0, 60, 'ppb');
}
if (this._config.tvoc_entity && this._history.tvoc.length) {
this._renderGraph('tvoc', this._history.tvoc, this._getTVOCColor.bind(this), 0, 60, 'ppb');
}
if (this._config.humidity_entity && this._history.humidity.length) {
this._renderGraph('humidity', this._history.humidity, this._getHumidityColor.bind(this), 0, 100, '%');
}
if (this._config.temperature_entity && this._history.temperature.length) {
const tempUnit = this._getTempUnit();
const tempMin = this._isCelsius() ? 10 : 50;
const tempMax = this._isCelsius() ? 32 : 90;
this._renderGraph('temperature', this._history.temperature, this._getTempColor.bind(this), tempMin, tempMax, tempUnit);
}
this._setupGraphInteractions();
}
_renderGraph(graphId, data, colorFn, minVal, maxVal, unit) {
const svg = this.shadowRoot.getElementById(`${graphId}-svg`);
const timeAxis = this.shadowRoot.getElementById(`${graphId}-time-axis`);
if (!svg || !data.length) return;
const width = 300;
const height = 50;
const padding = 2;
const values = data.map(d => d.value);
const dataMin = Math.min(...values, minVal);
const dataMax = Math.max(...values, maxVal);
const range = dataMax - dataMin || 1;
const points = data.map((d, i) => {
const x = padding + (i / (data.length - 1)) * (width - 2 * padding);
const y = height - padding - ((d.value - dataMin) / range) * (height - 2 * padding);
return { x, y, value: d.value, time: d.time, color: colorFn(d.value) };
});
this._graphData[graphId] = { points, unit, colorFn };
if (points.length < 2) return;
const gradientId = `gradient-${graphId}-${Date.now()}`;
let gradientStops = '';
for (let i = 0; i < points.length; i++) {
const pct = (i / (points.length - 1)) * 100;
gradientStops += ``;
}
let linePath = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length; i++) {
linePath += ` L ${points[i].x} ${points[i].y}`;
}
const areaPath = linePath + ` L ${points[points.length - 1].x} ${height} L ${points[0].x} ${height} Z`;
const fillGradientId = `fill-${graphId}-${Date.now()}`;
svg.innerHTML = `
${gradientStops}
`;
if (timeAxis && points.length > 0) {
const startTime = new Date(points[0].time);
const endTime = new Date(points[points.length - 1].time);
const midTime = new Date((startTime.getTime() + endTime.getTime()) / 2);
const formatTime = (d) => d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
timeAxis.innerHTML = `
${formatTime(startTime)}
${formatTime(midTime)}
${formatTime(endTime)}
`;
}
}
_setupGraphInteractions() {
const graphIds = ['co2', 'pm25', 'humidity', 'temperature'].filter(id => {
return this._config[`${id === 'pm25' ? 'pm25' : id}_entity`];
});
graphIds.forEach(graphId => {
const container = this.shadowRoot.getElementById(`${graphId}-graph-container`);
const graphEl = this.shadowRoot.getElementById(`${graphId}-graph`);
const cursor = this.shadowRoot.getElementById(`${graphId}-cursor`);
const tooltip = this.shadowRoot.getElementById(`${graphId}-tooltip`);
if (!container || !graphEl || !cursor || !tooltip) return;
const entityId = container.dataset.entity;
container.addEventListener('click', (e) => {
if (this._isDragging) {
this._isDragging = false;
return;
}
const event = new CustomEvent('hass-more-info', {
bubbles: true,
composed: true,
detail: { entityId }
});
this.dispatchEvent(event);
});
graphEl.addEventListener('mouseenter', () => this._showCursor(graphId));
graphEl.addEventListener('mouseleave', () => this._hideCursor(graphId));
graphEl.addEventListener('mousemove', (e) => this._updateCursor(graphId, e));
let touchTimeout;
graphEl.addEventListener('touchstart', (e) => {
touchTimeout = setTimeout(() => {
this._isDragging = true;
this._showCursor(graphId);
this._updateCursor(graphId, e.touches[0]);
}, 200);
}, { passive: true });
graphEl.addEventListener('touchmove', (e) => {
if (this._isDragging) {
e.preventDefault();
this._updateCursor(graphId, e.touches[0]);
}
}, { passive: false });
graphEl.addEventListener('touchend', () => {
clearTimeout(touchTimeout);
if (this._isDragging) {
setTimeout(() => this._hideCursor(graphId), 1000);
}
});
});
}
_showCursor(graphId) {
const cursor = this.shadowRoot.getElementById(`${graphId}-cursor`);
const tooltip = this.shadowRoot.getElementById(`${graphId}-tooltip`);
if (cursor) cursor.style.display = 'block';
if (tooltip) tooltip.style.display = 'block';
}
_hideCursor(graphId) {
const cursor = this.shadowRoot.getElementById(`${graphId}-cursor`);
const tooltip = this.shadowRoot.getElementById(`${graphId}-tooltip`);
if (cursor) cursor.style.display = 'none';
if (tooltip) tooltip.style.display = 'none';
}
_updateCursor(graphId, event) {
const graphEl = this.shadowRoot.getElementById(`${graphId}-graph`);
const cursor = this.shadowRoot.getElementById(`${graphId}-cursor`);
const tooltip = this.shadowRoot.getElementById(`${graphId}-tooltip`);
const data = this._graphData[graphId];
if (!graphEl || !cursor || !tooltip || !data || !data.points.length) return;
const rect = graphEl.getBoundingClientRect();
const x = event.clientX - rect.left;
const pct = Math.max(0, Math.min(1, x / rect.width));
const targetX = pct * 300;
let closest = data.points[0];
let minDist = Math.abs(closest.x - targetX);
for (const point of data.points) {
const dist = Math.abs(point.x - targetX);
if (dist < minDist) {
minDist = dist;
closest = point;
}
}
cursor.style.left = `${pct * 100}%`;
cursor.style.background = closest.color;
cursor.style.setProperty('--cursor-color', closest.color);
const valueEl = tooltip.querySelector('.graph-tooltip-value');
const timeEl = tooltip.querySelector('.graph-tooltip-time');
if (valueEl) {
let displayValue;
if (data.unit === 'ppm') displayValue = Math.round(closest.value);
else if (data.unit === '%' || data.unit === '°F' || data.unit === '°C') displayValue = Math.round(closest.value);
else displayValue = closest.value.toFixed(1);
valueEl.textContent = `${displayValue} ${data.unit}`;
valueEl.style.color = closest.color;
}
if (timeEl && closest.time) {
const time = new Date(closest.time);
timeEl.textContent = time.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
let tooltipX = pct * 100;
if (tooltipX < 12) tooltipX = 12;
if (tooltipX > 88) tooltipX = 88;
tooltip.style.left = `${tooltipX}%`;
}
}
// Register the card
customElements.define('air-quality-card', AirQualityCard);
window.customCards = window.customCards || [];
window.customCards.push({
type: 'air-quality-card',
name: 'Air Quality Card',
description: 'A custom card for air quality monitoring with WHO-based thresholds and gradient graphs',
preview: true,
documentationURL: 'https://github.com/KadenThomp36/air-quality-card'
});
console.info(
`%c AIR-QUALITY-CARD %c v${CARD_VERSION} `,
'color: white; background: #4caf50; font-weight: bold;',
'color: #4caf50; background: white; font-weight: bold;'
);
// ============================================
// FALLBACK VISUAL CONFIGURATION EDITOR
// For older Home Assistant versions that don't support getConfigForm
// ============================================
const LitElement = Object.getPrototypeOf(
customElements.get("hui-masonry-view") || customElements.get("hui-view")
);
const html = LitElement?.prototype?.html;
const css = LitElement?.prototype?.css;
if (LitElement && !customElements.get('air-quality-card-editor')) {
class AirQualityCardEditor extends LitElement {
static get properties() {
return {
hass: { type: Object },
_config: { type: Object }
};
}
setConfig(config) {
this._config = {
name: 'Air Quality',
hours_to_show: 24,
temperature_unit: 'F',
...config
};
}
_computeLabel(schema) {
const labels = {
name: 'Card Name',
co2_entity: 'CO₂ Sensor',
pm25_entity: 'PM2.5 Sensor',
hcho_entity: 'HCHO Sensor (optional)',
tvoc_entity: 'tVOC Sensor (optional)',
humidity_entity: 'Humidity Sensor (optional)',
temperature_entity: 'Temperature Sensor (optional)',
air_quality_entity: 'Air Quality Index (optional)',
recommendation_entity: 'Recommendation Sensor (optional)',
hours_to_show: 'Graph History (hours)',
temperature_unit: 'Temperature Unit'
};
return labels[schema.name] || schema.name;
}
_schema() {
return [
{ name: 'name', selector: { text: {} } },
{ name: 'co2_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'pm25_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'hcho_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'tvoc_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'humidity_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'temperature_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'air_quality_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'recommendation_entity', selector: { entity: { domain: 'sensor' } } },
{ name: 'hours_to_show', selector: { number: { min: 1, max: 168, mode: 'box' } } },
{ name: 'temperature_unit', selector: { select: { options: [{ value: 'F', label: 'Fahrenheit (°F)' }, { value: 'C', label: 'Celsius (°C)' }], mode: 'dropdown' } } }
];
}
render() {
if (!this._config) return html``;
return html`
`;
}
_valueChanged(ev) {
const newConfig = { type: 'custom:air-quality-card', ...ev.detail.value };
this.dispatchEvent(new CustomEvent('config-changed', {
detail: { config: newConfig },
bubbles: true,
composed: true
}));
}
static get styles() {
return css`
.card-config {
padding: 16px;
}
`;
}
}
customElements.define('air-quality-card-editor', AirQualityCardEditor);
}