/** * 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 = `
${this._config.name}
Good
All Good
Air quality is within healthy limits
${showCO2 ? `
CO₂ -- ppm
` : ''} ${showPM25 ? `
PM2.5 -- μg/m³
` : ''} ${showHCHO ? `
HCHO / CH₂O -- ppm
` : ''} ${showTVOC ? `
tVOC -- μg/m³
` : ''} ${showHumidity ? `
Humidity -- %
` : ''} ${showTemp ? `
Temperature -- ${this._getTempUnit()}
` : ''}
`; } _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); }