ha-frontend/src/components/ha-gauge.ts

213 lines
6.2 KiB
TypeScript

import { css, LitElement, PropertyValues, svg, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { formatNumber } from "../common/number/format_number";
import { afterNextRender } from "../common/util/render-status";
import { FrontendLocaleData } from "../data/translation";
import { getValueInPercentage, normalize } from "../util/calculate";
import { isSafari } from "../util/is_safari";
const getAngle = (value: number, min: number, max: number) => {
const percentage = getValueInPercentage(normalize(value, min, max), min, max);
return (percentage * 180) / 100;
};
export interface LevelDefinition {
level: number;
stroke: string;
}
@customElement("ha-gauge")
export class Gauge extends LitElement {
@property({ type: Number }) public min = 0;
@property({ type: Number }) public max = 100;
@property({ type: Number }) public value = 0;
@property({ type: String }) public valueText?: string;
@property() public locale!: FrontendLocaleData;
@property({ type: Boolean }) public needle?: boolean;
@property() public levels?: LevelDefinition[];
@property() public label = "";
@state() private _angle = 0;
@state() private _updated = false;
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
// Wait for the first render for the initial animation to work
afterNextRender(() => {
this._updated = true;
this._angle = getAngle(this.value, this.min, this.max);
this._rescale_svg();
});
}
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (!this._updated || !changedProperties.has("value")) {
return;
}
this._angle = getAngle(this.value, this.min, this.max);
this._rescale_svg();
}
protected render() {
return svg`
<svg viewBox="0 0 100 50" class="gauge">
${
!this.needle || !this.levels
? svg`<path
class="dial"
d="M 10 50 A 40 40 0 0 1 90 50"
></path>`
: ""
}
${
this.levels
? this.levels
.sort((a, b) => a.level - b.level)
.map((level, idx) => {
let firstPath: TemplateResult | undefined;
if (idx === 0 && level.level !== this.min) {
const angle = getAngle(this.min, this.min, this.max);
firstPath = svg`<path
stroke="var(--info-color)"
class="level"
d="M
${50 - 40 * Math.cos((angle * Math.PI) / 180)}
${50 - 40 * Math.sin((angle * Math.PI) / 180)}
A 40 40 0 0 1 90 50
"
></path>`;
}
const angle = getAngle(level.level, this.min, this.max);
return svg`${firstPath}<path
stroke="${level.stroke}"
class="level"
d="M
${50 - 40 * Math.cos((angle * Math.PI) / 180)}
${50 - 40 * Math.sin((angle * Math.PI) / 180)}
A 40 40 0 0 1 90 50
"
></path>`;
})
: ""
}
${
this.needle
? svg`<path
class="needle"
d="M 25 47.5 L 2.5 50 L 25 52.5 z"
style=${ifDefined(
!isSafari
? styleMap({ transform: `rotate(${this._angle}deg)` })
: undefined
)}
transform=${ifDefined(
isSafari ? `rotate(${this._angle} 50 50)` : undefined
)}
>
`
: svg`<path
class="value"
d="M 90 50.001 A 40 40 0 0 1 10 50"
style=${ifDefined(
!isSafari
? styleMap({ transform: `rotate(${this._angle}deg)` })
: undefined
)}
transform=${ifDefined(
isSafari ? `rotate(${this._angle} 50 50)` : undefined
)}
>`
}
${
// Workaround for https://github.com/home-assistant/frontend/issues/6467
isSafari
? svg`<animateTransform
attributeName="transform"
type="rotate"
from="0 50 50"
to="${this._angle} 50 50"
dur="1s"
/>`
: ""
}
</path>
</svg>
<svg class="text">
<text class="value-text">
${this.valueText || formatNumber(this.value, this.locale)} ${
this.label
}
</text>
</svg>`;
}
private _rescale_svg() {
// Set the viewbox of the SVG containing the value to perfectly
// fit the text
// That way it will auto-scale correctly
const svgRoot = this.shadowRoot!.querySelector(".text")!;
const box = svgRoot.querySelector("text")!.getBBox()!;
svgRoot.setAttribute(
"viewBox",
`${box.x} ${box!.y} ${box.width} ${box.height}`
);
}
static get styles() {
return css`
:host {
position: relative;
}
.dial {
fill: none;
stroke: var(--primary-background-color);
stroke-width: 15;
}
.value {
fill: none;
stroke-width: 15;
stroke: var(--gauge-color);
transform-origin: 50% 100%;
transition: all 1s ease 0s;
}
.needle {
fill: var(--primary-text-color);
transform-origin: 50% 100%;
transition: all 1s ease 0s;
}
.level {
fill: none;
stroke-width: 15;
}
.gauge {
display: block;
}
.text {
position: absolute;
max-height: 40%;
max-width: 55%;
left: 50%;
bottom: -6%;
transform: translate(-50%, 0%);
}
.value-text {
font-size: 50px;
fill: var(--primary-text-color);
text-anchor: middle;
}
`;
}
}