Skip to content

Commit 4049c4d

Browse files
committed
feat(design): overlay min/max on chart + multi-select sensor order
1 parent b06bb3e commit 4049c4d

2 files changed

Lines changed: 120 additions & 34 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ outdoor_pm25_entity: sensor.outdoor_pm25
100100
| `hours_to_show` | number | No | 24 | Hours of history to display (1-168) |
101101
| `temperature_unit` | string | No | "auto" | Temperature unit: "auto" (detect from HA), "F" (Fahrenheit), or "C" (Celsius) |
102102
| `radon_unit` | string | No | "auto" | Radon unit: "auto" (detect from sensor), "pCi/L" (US), or "Bq/m3" (International) |
103-
| `show_min_max` | boolean | No | `false` | Show the min/max value seen over the displayed time window beneath each metric |
103+
| `show_min_max` | boolean | No | `false` | Overlay the min/max values of the displayed time window directly at the data points on the graph |
104104
| `order` | array | No | default | Custom display order for metrics (see [Sensor Order](#sensor-order)) |
105105
| `display` | string | No | "full" | "full" (graphs and details) or "compact" (status badge only, ideal for overview pages) |
106106
| `tap_action` | action | No | - | Standard HA action object (e.g., `{ action: navigate, navigation_path: /air-quality }`). Active in compact mode |
@@ -135,7 +135,7 @@ outdoor_pm25_entity: sensor.outdoor_pm25
135135

136136
### Sensor Order
137137

138-
Customize which sensors come first on the card. Provide a list of metric names — any metric you don't list keeps its default position and stays visible.
138+
Customize which sensors come first on the card. In the visual editor, use the multi-select to tick metrics in the order you want them shown. In YAML, provide a list of metric names. Any metric you don't list keeps its default position and stays visible.
139139

140140
Valid metric names: `co`, `radon`, `co2`, `pm25`, `pm10`, `pm1`, `pm03`, `pm4`, `hcho`, `tvoc`, `nox`, `humidity`, `temperature`.
141141

air-quality-card.js

Lines changed: 118 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -235,23 +235,67 @@ class AirQualityCard extends HTMLElement {
235235
return value.toFixed(1);
236236
}
237237

238-
_updateMinMaxDisplay(graphId, data, unit) {
238+
// Anchor min/max value labels to the actual data points on the line.
239+
// The position percentages are computed against the SVG's 300×50 viewBox;
240+
// because preserveAspectRatio="none" stretches the SVG to fill the wrapper,
241+
// the same percentage maps cleanly to the wrapper's dimensions.
242+
_updateMinMaxDisplay(graphId, data, colorFn) {
239243
const minMax = this._getMinMax(data);
240-
if (!minMax) return;
241-
const container = this.shadowRoot.getElementById(`${graphId}-graph-container`);
242-
if (!container) return;
243-
let minmaxEl = this.shadowRoot.getElementById(`${graphId}-minmax`);
244-
if (!minmaxEl) {
245-
const header = container.querySelector('.graph-header');
246-
if (!header) return;
247-
minmaxEl = document.createElement('div');
248-
minmaxEl.className = 'graph-minmax';
249-
minmaxEl.id = `${graphId}-minmax`;
250-
header.insertAdjacentElement('afterend', minmaxEl);
251-
}
252-
const minStr = this._formatGraphValue(minMax.min, unit);
253-
const maxStr = this._formatGraphValue(minMax.max, unit);
254-
minmaxEl.innerHTML = `<span class="arrow">↓</span>${minStr} · <span class="arrow">↑</span>${maxStr} ${unit}`;
244+
if (!minMax || minMax.min === minMax.max) {
245+
this._clearMinMaxMarkers(graphId);
246+
return;
247+
}
248+
let minIdx = 0, maxIdx = 0;
249+
for (let i = 1; i < data.length; i++) {
250+
if (data[i].value < data[minIdx].value) minIdx = i;
251+
if (data[i].value > data[maxIdx].value) maxIdx = i;
252+
}
253+
const points = this._graphData[graphId] && this._graphData[graphId].points;
254+
if (!points || !points.length) return;
255+
const wrapper = this.shadowRoot.getElementById(`${graphId}-graph`);
256+
if (!wrapper) return;
257+
258+
this._renderMinMaxMarker(graphId, 'max', points[maxIdx], colorFn(minMax.max), this._formatGraphValue(minMax.max, this._graphData[graphId].unit));
259+
this._renderMinMaxMarker(graphId, 'min', points[minIdx], colorFn(minMax.min), this._formatGraphValue(minMax.min, this._graphData[graphId].unit));
260+
}
261+
262+
_renderMinMaxMarker(graphId, kind, point, color, valueStr) {
263+
if (!point) return;
264+
const wrapper = this.shadowRoot.getElementById(`${graphId}-graph`);
265+
if (!wrapper) return;
266+
const id = `${graphId}-minmax-${kind}`;
267+
let marker = this.shadowRoot.getElementById(id);
268+
if (!marker) {
269+
marker = document.createElement('div');
270+
marker.id = id;
271+
marker.className = `minmax-marker minmax-marker--${kind}`;
272+
wrapper.appendChild(marker);
273+
}
274+
// Y position: point.y is in the 0..50 SVG coordinate system; convert to %
275+
const leftPct = (point.x / 300) * 100;
276+
const topPct = (point.y / 50) * 100;
277+
// Flip the label across the chart vertical midline so it can't get
278+
// clipped by the chart's top/bottom edge: anchor the label on the
279+
// opposite side of the data point from where it sits.
280+
const placeBelow = point.y < 25;
281+
// Same idea for horizontal: when very close to an edge, anchor the
282+
// label to that edge instead of centering on the point.
283+
let anchor = 'center';
284+
if (leftPct < 12) anchor = 'left';
285+
else if (leftPct > 88) anchor = 'right';
286+
marker.style.left = `${leftPct}%`;
287+
marker.style.top = `${topPct}%`;
288+
marker.style.color = color;
289+
marker.dataset.place = placeBelow ? 'below' : 'above';
290+
marker.dataset.anchor = anchor;
291+
marker.textContent = valueStr;
292+
}
293+
294+
_clearMinMaxMarkers(graphId) {
295+
['min', 'max'].forEach(kind => {
296+
const el = this.shadowRoot.getElementById(`${graphId}-minmax-${kind}`);
297+
if (el) el.remove();
298+
});
255299
}
256300

257301
_isCompact() {
@@ -1068,19 +1112,45 @@ class AirQualityCard extends HTMLElement {
10681112
border-radius: 3px;
10691113
}
10701114
1071-
.graph-minmax {
1072-
font-size: 0.7em;
1073-
color: var(--secondary-text-color);
1074-
opacity: 0.7;
1075-
text-align: right;
1076-
margin-top: -4px;
1077-
margin-bottom: 4px;
1078-
letter-spacing: 0.3px;
1115+
/* Min/max value labels overlaid on the graph at the actual data points
1116+
where the extremes occurred. The text-shadow halo lets them remain
1117+
legible regardless of where they land on the gradient fill. */
1118+
.minmax-marker {
1119+
position: absolute;
1120+
pointer-events: none;
1121+
font-size: 10px;
1122+
font-weight: 600;
1123+
letter-spacing: 0.2px;
1124+
font-variant-numeric: tabular-nums;
1125+
line-height: 1;
1126+
white-space: nowrap;
1127+
transform: translate(-50%, -100%);
1128+
margin-top: -6px;
1129+
text-shadow:
1130+
0 0 3px var(--ha-card-background, var(--card-background-color, #fff)),
1131+
0 0 6px var(--ha-card-background, var(--card-background-color, #fff)),
1132+
0 1px 2px var(--ha-card-background, var(--card-background-color, #fff));
1133+
opacity: 0.95;
10791134
}
10801135
1081-
.graph-minmax .arrow {
1082-
font-weight: 600;
1083-
margin: 0 1px;
1136+
.minmax-marker[data-place="below"] {
1137+
transform: translate(-50%, 0);
1138+
margin-top: 6px;
1139+
}
1140+
1141+
/* When near a chart edge, anchor the label's side to the point rather
1142+
than centering on it — keeps text inside the chart. */
1143+
.minmax-marker[data-anchor="left"] {
1144+
transform: translate(0, -100%);
1145+
}
1146+
.minmax-marker[data-anchor="left"][data-place="below"] {
1147+
transform: translate(0, 0);
1148+
}
1149+
.minmax-marker[data-anchor="right"] {
1150+
transform: translate(-100%, -100%);
1151+
}
1152+
.minmax-marker[data-anchor="right"][data-place="below"] {
1153+
transform: translate(-100%, 0);
10841154
}
10851155
10861156
.graph-wrapper {
@@ -1897,10 +1967,6 @@ class AirQualityCard extends HTMLElement {
18971967
const timeAxis = this.shadowRoot.getElementById(`${graphId}-time-axis`);
18981968
if (!svg || !data.length) return;
18991969

1900-
if (this._config.show_min_max) {
1901-
this._updateMinMaxDisplay(graphId, data, unit);
1902-
}
1903-
19041970
const width = 300;
19051971
const height = 50;
19061972
const padding = 2;
@@ -1932,6 +1998,12 @@ class AirQualityCard extends HTMLElement {
19321998

19331999
this._graphData[graphId] = { points, outdoorPoints, unit, colorFn, outdoorLabel: outdoorLabel || 'Outdoor' };
19342000

2001+
if (this._config.show_min_max) {
2002+
this._updateMinMaxDisplay(graphId, data, colorFn);
2003+
} else {
2004+
this._clearMinMaxMarkers(graphId);
2005+
}
2006+
19352007
if (points.length < 2) return;
19362008

19372009
const ts = Date.now();
@@ -2371,7 +2443,21 @@ if (LitElement && !customElements.get('air-quality-card-editor')) {
23712443
{ name: 'air_quality_entity', selector: { entity: { domain: 'sensor' } } },
23722444
{ name: 'hours_to_show', selector: { number: { min: 1, max: 168, mode: 'box', unit_of_measurement: 'hours' } } },
23732445
{ name: 'show_min_max', selector: { boolean: {} } },
2374-
{ name: 'order', selector: { object: {} } },
2446+
{ name: 'order', selector: { select: { multiple: true, mode: 'list', options: [
2447+
{ value: 'co', label: 'CO' },
2448+
{ value: 'radon', label: 'Radon' },
2449+
{ value: 'co2', label: 'CO₂' },
2450+
{ value: 'pm25', label: 'PM2.5' },
2451+
{ value: 'pm10', label: 'PM10' },
2452+
{ value: 'pm1', label: 'PM1' },
2453+
{ value: 'pm03', label: 'PM0.3' },
2454+
{ value: 'pm4', label: 'PM4' },
2455+
{ value: 'hcho', label: 'HCHO' },
2456+
{ value: 'tvoc', label: 'tVOC' },
2457+
{ value: 'nox', label: 'NOx' },
2458+
{ value: 'humidity', label: 'Humidity' },
2459+
{ value: 'temperature', label: 'Temperature' }
2460+
] } } },
23752461
{ name: 'display', selector: { select: { options: [{ value: 'full', label: 'Full (graphs and details)' }, { value: 'compact', label: 'Compact (status badge only)' }], mode: 'dropdown' } } },
23762462
{ name: 'tap_action', selector: { ui_action: {} } },
23772463
{ name: 'hold_action', selector: { ui_action: {} } },

0 commit comments

Comments
 (0)