ha-frontend/src/components/ha-control-circular-slider.ts

877 lines
24 KiB
TypeScript

import {
DIRECTION_ALL,
Manager,
Pan,
Press,
Tap,
TouchMouseInput,
} from "@egjs/hammerjs";
import {
CSSResultGroup,
LitElement,
PropertyValues,
TemplateResult,
css,
html,
nothing,
svg,
} from "lit";
import {
customElement,
property,
query,
queryAll,
state,
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import { clamp } from "../common/number/clamp";
import { svgArc } from "../resources/svg-arc";
import { isTouch } from "../util/is_touch";
const MAX_ANGLE = 270;
const ROTATE_ANGLE = 360 - MAX_ANGLE / 2 - 90;
const RADIUS = 145;
function xy2polar(x: number, y: number) {
const r = Math.sqrt(x * x + y * y);
const phi = Math.atan2(y, x);
return [r, phi];
}
function rad2deg(rad: number) {
return (rad / (2 * Math.PI)) * 360;
}
type ActiveSlider = "low" | "high" | "value";
declare global {
interface HASSDomEvents {
"value-changing": { value: unknown };
"low-changing": { value: unknown };
"low-changed": { value: unknown };
"high-changing": { value: unknown };
"high-changed": { value: unknown };
}
}
const A11Y_KEY_CODES = new Set([
"ArrowRight",
"ArrowUp",
"ArrowLeft",
"ArrowDown",
"PageUp",
"PageDown",
"Home",
"End",
]);
export type ControlCircularSliderMode = "start" | "end" | "full";
@customElement("ha-control-circular-slider")
export class HaControlCircularSlider extends LitElement {
@property({ type: Boolean, reflect: true })
public disabled = false;
@property({ type: Boolean, reflect: true })
public readonly = false;
@property({ type: Boolean })
public dual?: boolean;
@property({ type: String })
public mode?: ControlCircularSliderMode;
@property({ type: Boolean })
public inactive?: boolean;
@property({ type: String })
public label?: string;
@property({ type: String, attribute: "low-label" })
public lowLabel?: string;
@property({ type: String, attribute: "high-label" })
public highLabel?: string;
@property({ type: Number })
public value?: number;
@property({ type: Number })
public low?: number;
@property({ type: Number })
public high?: number;
@property({ type: Number })
public current?: number;
@property({ type: Number })
public step = 1;
@property({ type: Number })
public min = 0;
@property({ type: Number })
public max = 100;
@property({ type: Boolean, attribute: "prevent-interaction-on-scroll" })
public preventInteractionOnScroll?: boolean;
@state()
public _localValue?: number = this.value;
@state()
public _localLow?: number = this.low;
@state()
public _localHigh?: number = this.high;
@state()
public _activeSlider?: ActiveSlider;
@state()
public _lastSlider?: ActiveSlider;
private _valueToPercentage(value: number) {
return (
(clamp(value, this.min, this.max) - this.min) / (this.max - this.min)
);
}
private _percentageToValue(value: number) {
return (this.max - this.min) * value + this.min;
}
private _steppedValue(value: number) {
return Math.round(value / this.step) * this.step;
}
private _boundedValue(value: number) {
const min =
this._activeSlider === "high"
? Math.min(this._localLow ?? this.max)
: this.min;
const max =
this._activeSlider === "low"
? Math.max(this._localHigh ?? this.min)
: this.max;
return Math.min(Math.max(value, min), max);
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (!this._activeSlider) {
if (changedProps.has("value")) {
this._localValue = this.value;
}
if (changedProps.has("low")) {
this._localLow = this.low;
}
if (changedProps.has("high")) {
this._localHigh = this.high;
}
}
if (
(changedProps.has("_localValue") &&
changedProps.get("_localValue") == null) ||
(changedProps.has("_localLow") &&
changedProps.get("_localLow") == null) ||
(changedProps.has("_localHigh") &&
changedProps.get("_localHigh") == null) ||
changedProps.has("preventInteractionOnScroll")
) {
this._destroyListeners();
this._setupListeners();
}
}
connectedCallback(): void {
super.connectedCallback();
this._setupListeners();
}
disconnectedCallback(): void {
super.disconnectedCallback();
}
private _managers: HammerManager[] = [];
private _getPercentageFromEvent = (e: HammerInput) => {
const bound = this._slider.getBoundingClientRect();
const x = (2 * (e.center.x - bound.left - bound.width / 2)) / bound.width;
const y = (2 * (e.center.y - bound.top - bound.height / 2)) / bound.height;
const [, phi] = xy2polar(x, y);
const offset = (360 - MAX_ANGLE) / 2;
const angle = ((rad2deg(phi) + offset - ROTATE_ANGLE + 360) % 360) - offset;
return Math.max(Math.min(angle / MAX_ANGLE, 1), 0);
};
@query("#slider")
private _slider;
@queryAll("[data-interaction]")
private _interactions?: HTMLElement[];
private _findActiveSlider(value: number): ActiveSlider {
if (!this.dual) return "value";
const low = Math.max(this._localLow ?? this.min, this.min);
const high = Math.min(this._localHigh ?? this.max, this.max);
if (low >= value) {
return "low";
}
if (high <= value) {
return "high";
}
const lowDistance = Math.abs(value - low);
const highDistance = Math.abs(value - high);
return lowDistance <= highDistance ? "low" : "high";
}
private _setActiveValue(value: number) {
switch (this._activeSlider) {
case "high":
this._localHigh = value;
break;
case "low":
this._localLow = value;
break;
case "value":
this._localValue = value;
break;
}
}
private _getActiveValue(): number | undefined {
switch (this._activeSlider) {
case "high":
return this._localHigh;
case "low":
return this._localLow;
case "value":
return this._localValue;
}
return undefined;
}
private _setupListeners() {
if (this._interactions && this._managers.length === 0) {
this._interactions.forEach((interaction) => {
const mc = new Manager(interaction, {
inputClass: TouchMouseInput,
});
this._managers.push(mc);
const pressToActivate = this.preventInteractionOnScroll && isTouch;
// If press to activate is true, a 50ms press is required to activate the slider
mc.add(
new Press({
enable: pressToActivate,
pointers: 1,
time: 50,
})
);
const panRecognizer = new Pan({
direction: DIRECTION_ALL,
enable: !pressToActivate,
threshold: 0,
});
mc.add(panRecognizer);
mc.add(new Tap({ event: "singletap" }));
mc.on("press", (e) => {
e.srcEvent.stopPropagation();
e.srcEvent.preventDefault();
if (this.disabled || this.readonly) return;
const percentage = this._getPercentageFromEvent(e);
const raw = this._percentageToValue(percentage);
this._activeSlider = this._findActiveSlider(raw);
const bounded = this._boundedValue(raw);
this._setActiveValue(bounded);
const stepped = this._steppedValue(bounded);
if (this._activeSlider) {
fireEvent(this, `${this._activeSlider}-changing`, {
value: stepped,
});
}
panRecognizer.set({ enable: true });
});
mc.on("pressup", (e) => {
e.srcEvent.stopPropagation();
e.srcEvent.preventDefault();
const percentage = this._getPercentageFromEvent(e);
const raw = this._percentageToValue(percentage);
const bounded = this._boundedValue(raw);
const stepped = this._steppedValue(bounded);
this._setActiveValue(stepped);
if (this._activeSlider) {
fireEvent(this, `${this._activeSlider}-changing`, {
value: undefined,
});
fireEvent(this, `${this._activeSlider}-changed`, {
value: stepped,
});
}
this._activeSlider = undefined;
});
mc.on("pan", (e) => {
e.srcEvent.stopPropagation();
e.srcEvent.preventDefault();
});
mc.on("panstart", (e) => {
if (this.disabled || this.readonly) return;
const percentage = this._getPercentageFromEvent(e);
const raw = this._percentageToValue(percentage);
this._activeSlider = this._findActiveSlider(raw);
this._lastSlider = this._activeSlider;
this.shadowRoot?.getElementById("#slider")?.focus();
});
mc.on("pancancel", () => {
if (this.disabled || this.readonly) return;
this._activeSlider = undefined;
if (pressToActivate) {
panRecognizer.set({ enable: false });
}
});
mc.on("panmove", (e) => {
if (this.disabled || this.readonly) return;
const percentage = this._getPercentageFromEvent(e);
const raw = this._percentageToValue(percentage);
const bounded = this._boundedValue(raw);
this._setActiveValue(bounded);
const stepped = this._steppedValue(bounded);
if (this._activeSlider) {
fireEvent(this, `${this._activeSlider}-changing`, {
value: stepped,
});
}
});
mc.on("panend", (e) => {
if (this.disabled || this.readonly) return;
const percentage = this._getPercentageFromEvent(e);
const raw = this._percentageToValue(percentage);
const bounded = this._boundedValue(raw);
const stepped = this._steppedValue(bounded);
this._setActiveValue(stepped);
if (this._activeSlider) {
fireEvent(this, `${this._activeSlider}-changing`, {
value: undefined,
});
fireEvent(this, `${this._activeSlider}-changed`, {
value: stepped,
});
}
this._activeSlider = undefined;
if (pressToActivate) {
panRecognizer.set({ enable: false });
}
});
mc.on("singletap", (e) => {
if (this.disabled || this.readonly) return;
const percentage = this._getPercentageFromEvent(e);
const raw = this._percentageToValue(percentage);
this._activeSlider = this._findActiveSlider(raw);
const bounded = this._boundedValue(raw);
const stepped = this._steppedValue(bounded);
this._setActiveValue(stepped);
if (this._activeSlider) {
fireEvent(this, `${this._activeSlider}-changing`, {
value: undefined,
});
fireEvent(this, `${this._activeSlider}-changed`, {
value: stepped,
});
}
this._lastSlider = this._activeSlider;
this.shadowRoot?.getElementById("#slider")?.focus();
this._activeSlider = undefined;
if (pressToActivate) {
panRecognizer.set({ enable: false });
}
});
});
}
}
private get _tenPercentStep() {
return Math.max(this.step, (this.max - this.min) / 10);
}
private _handleKeyDown(e: KeyboardEvent) {
if (!A11Y_KEY_CODES.has(e.code)) return;
e.preventDefault();
if (this._lastSlider) {
this.shadowRoot?.getElementById(this._lastSlider)?.focus();
}
this._activeSlider =
this._lastSlider ?? ((e.currentTarget as any).id as ActiveSlider);
this._lastSlider = undefined;
const value = this._getActiveValue();
switch (e.code) {
case "ArrowRight":
case "ArrowUp":
this._setActiveValue(
this._boundedValue((value ?? this.min) + this.step)
);
break;
case "ArrowLeft":
case "ArrowDown":
this._setActiveValue(
this._boundedValue((value ?? this.min) - this.step)
);
break;
case "PageUp":
this._setActiveValue(
this._steppedValue(
this._boundedValue((value ?? this.min) + this._tenPercentStep)
)
);
break;
case "PageDown":
this._setActiveValue(
this._steppedValue(
this._boundedValue((value ?? this.min) - this._tenPercentStep)
)
);
break;
case "Home":
this._setActiveValue(this._boundedValue(this.min));
break;
case "End":
this._setActiveValue(this._boundedValue(this.max));
break;
}
fireEvent(this, `${this._activeSlider}-changing`, {
value: this._getActiveValue(),
});
this._activeSlider = undefined;
}
_handleKeyUp(e: KeyboardEvent) {
if (!A11Y_KEY_CODES.has(e.code)) return;
this._activeSlider = (e.currentTarget as any).id as ActiveSlider;
e.preventDefault();
fireEvent(this, `${this._activeSlider}-changing`, {
value: undefined,
});
fireEvent(this, `${this._activeSlider}-changed`, {
value: this._getActiveValue(),
});
this._activeSlider = undefined;
}
private _destroyListeners() {
if (this._managers.length > 0) {
this._managers.forEach((manager) => manager.destroy());
this._managers = [];
}
}
private _strokeCircleDashArc(value: number): [string, string] {
return this._strokeDashArc(value, value);
}
private _strokeDashArc(from: number, to: number): [string, string] {
const start = this._valueToPercentage(from);
const end = this._valueToPercentage(to);
const track = (RADIUS * 2 * Math.PI * MAX_ANGLE) / 360;
const arc = Math.max((end - start) * track, 0);
const arcOffset = start * track - 0.5;
const strokeDasharray = `${arc} ${track - arc}`;
const strokeDashOffset = `-${arcOffset}`;
return [strokeDasharray, strokeDashOffset];
}
protected renderArc(
id: string,
value: number | undefined,
mode: ControlCircularSliderMode
) {
if (this.disabled) return nothing;
const path = svgArc({
x: 0,
y: 0,
start: 0,
end: MAX_ANGLE,
r: RADIUS,
});
const angle =
value != null ? this._valueToPercentage(value) * MAX_ANGLE : undefined;
const limit = mode === "end" ? this.max : this.min;
const current = this.current ?? limit;
const target = value ?? limit;
const showActive =
mode === "end"
? target <= current
: mode === "start"
? current <= target
: false;
const showTarget = value != null;
const activeArc = showTarget
? showActive
? mode === "end"
? this._strokeDashArc(target, current)
: this._strokeDashArc(current, target)
: this._strokeCircleDashArc(target)
: undefined;
const coloredArc =
mode === "full"
? this._strokeDashArc(this.min, this.max)
: mode === "end"
? this._strokeDashArc(target, limit)
: this._strokeDashArc(limit, target);
const targetCircle = showTarget
? this._strokeCircleDashArc(target)
: undefined;
const currentCircle =
this.current != null &&
this.current <= this.max &&
this.current >= this.min &&
(showActive || this.mode === "full")
? this._strokeCircleDashArc(this.current)
: undefined;
const onlyDotInteraction =
(this.preventInteractionOnScroll && isTouch) || false;
return svg`
<g class=${classMap({ inactive: Boolean(this.inactive) })}>
<path
class="arc arc-clear"
d=${path}
stroke-dasharray=${coloredArc[0]}
stroke-dashoffset=${coloredArc[1]}
/>
<path
class="arc arc-colored ${classMap({ [id]: true })}"
d=${path}
stroke-dasharray=${coloredArc[0]}
stroke-dashoffset=${coloredArc[1]}
/>
${
activeArc
? svg`
<path
.id=${id}
d=${path}
class="arc arc-active ${classMap({ [id]: true })}"
stroke-dasharray=${activeArc[0]}
stroke-dashoffset=${activeArc[1]}
role="slider"
tabindex="0"
aria-valuemin=${this.min}
aria-valuemax=${this.max}
aria-valuenow=${
this._localValue != null
? this._steppedValue(this._localValue)
: undefined
}
aria-disabled=${this.disabled}
aria-readonly=${this.readonly}
aria-label=${ifDefined(this.lowLabel ?? this.label)}
@keydown=${this._handleKeyDown}
@keyup=${this._handleKeyUp}
/>
`
: nothing
}
${
currentCircle
? svg`
<path
class="current arc-current"
d=${path}
stroke-dasharray=${currentCircle[0]}
stroke-dashoffset=${currentCircle[1]}
/>
`
: nothing
}
${
targetCircle
? svg`
<!-- Use circle instead of path for interaction (Safari doesn't support well pointer-events with stroke-dasharray) -->
<circle
transform="rotate(${angle} 0 0)"
?data-interaction=${onlyDotInteraction}
cx=${RADIUS}
cy="0"
/>
<path
d=${path}
stroke-dasharray=${targetCircle[0]}
stroke-dashoffset=${targetCircle[1]}
/>
<path
class="target-border ${classMap({ [id]: true })}"
d=${path}
stroke-dasharray=${targetCircle[0]}
stroke-dashoffset=${targetCircle[1]}
/>
<path
class="target"
d=${path}
stroke-dasharray=${targetCircle[0]}
stroke-dashoffset=${targetCircle[1]}
/>
`
: nothing
}
</g>
`;
}
protected render(): TemplateResult {
const trackPath = svgArc({
x: 0,
y: 0,
start: 0,
end: MAX_ANGLE,
r: RADIUS,
});
const lowValue = this.dual ? this._localLow : this._localValue;
const highValue = this._localHigh;
const current = this.current;
const currentStroke = current
? this._strokeCircleDashArc(current)
: undefined;
const onlyDotInteraction =
(this.preventInteractionOnScroll && isTouch) || false;
return html`
<svg
id="slider"
viewBox="0 0 320 320"
overflow="visible"
class=${classMap({
pressed: Boolean(this._activeSlider),
})}
@keydown=${this._handleKeyDown}
tabindex=${this._lastSlider ? "0" : "-1"}
>
<g
id="container"
transform="translate(160 160) rotate(${ROTATE_ANGLE})"
>
<path d=${trackPath} ?data-interaction=${!onlyDotInteraction} />
<path class="background" d=${trackPath} />
${currentStroke
? svg`
<path
class="current"
d=${trackPath}
stroke-dasharray=${currentStroke[0]}
stroke-dashoffset=${currentStroke[1]}
/>
`
: nothing}
${lowValue != null || this.mode === "full"
? this.renderArc(
this.dual ? "low" : "value",
lowValue,
(!this.dual && this.mode) || "start"
)
: nothing}
${this.dual && highValue != null
? this.renderArc("high", highValue, "end")
: nothing}
</g>
</svg>
`;
}
static get styles(): CSSResultGroup {
return css`
:host {
--control-circular-slider-color: var(--primary-color);
--control-circular-slider-background: var(--disabled-color);
--control-circular-slider-background-opacity: 0.3;
--control-circular-slider-low-color: var(
--control-circular-slider-color
);
--control-circular-slider-high-color: var(
--control-circular-slider-color
);
--control-circular-slider-interaction-margin: 12px;
width: 320px;
display: block;
}
svg {
width: 100%;
display: block;
pointer-events: none;
}
g {
fill: none;
}
#slider {
outline: none;
}
path[data-interaction] {
fill: none;
cursor: pointer;
pointer-events: auto;
stroke: transparent;
stroke-linecap: round;
stroke-width: calc(
24px + 2 * var(--control-circular-slider-interaction-margin)
);
}
circle[data-interaction] {
r: calc(12px + var(--control-circular-slider-interaction-margin));
fill: transparent;
cursor: pointer;
pointer-events: auto;
}
:host([disabled]) [data-interaction],
:host([readonly]) [data-interaction] {
cursor: initial;
pointer-events: none;
}
.background {
fill: none;
stroke: var(--control-circular-slider-background);
opacity: var(--control-circular-slider-background-opacity);
transition:
stroke 180ms ease-in-out,
opacity 180ms ease-in-out;
stroke-linecap: round;
stroke-width: 24px;
}
.arc {
fill: none;
stroke-linecap: round;
stroke-width: 24px;
transition:
stroke-width 300ms ease-in-out,
stroke-dasharray 300ms ease-in-out,
stroke-dashoffset 300ms ease-in-out,
stroke 180ms ease-in-out,
opacity 180ms ease-in-out;
}
.target {
fill: none;
stroke-linecap: round;
stroke-width: 18px;
stroke: white;
transition:
stroke-width 300ms ease-in-out,
stroke-dasharray 300ms ease-in-out,
stroke-dashoffset 300ms ease-in-out,
stroke 180ms ease-in-out,
opacity 180ms ease-in-out;
}
.target-border {
fill: none;
stroke-linecap: round;
stroke-width: 24px;
stroke: white;
transition:
stroke-width 300ms ease-in-out,
stroke-dasharray 300ms ease-in-out,
stroke-dashoffset 300ms ease-in-out,
stroke 180ms ease-in-out,
opacity 180ms ease-in-out;
}
.current {
fill: none;
stroke-linecap: round;
stroke-width: 8px;
stroke: var(--primary-text-color);
opacity: 0.5;
transition:
stroke-width 300ms ease-in-out,
stroke-dasharray 300ms ease-in-out,
stroke-dashoffset 300ms ease-in-out,
stroke 180ms ease-in-out,
opacity 180ms ease-in-out;
}
.arc-current {
stroke: var(--clear-background-color);
}
.arc-clear {
stroke: var(--clear-background-color);
}
.arc-colored {
opacity: 0.5;
}
.arc-active {
outline: none;
}
.arc-active:focus-visible {
stroke-width: 28px;
}
.pressed .arc,
.pressed .target,
.pressed .target-border,
.pressed .current {
transition:
stroke-width 300ms ease-in-out,
stroke 180ms ease-in-out,
opacity 180ms ease-in-out;
}
.inactive .arc,
.inactive .arc-current {
opacity: 0;
}
.value {
stroke: var(--control-circular-slider-color);
}
.low {
stroke: var(--control-circular-slider-low-color);
}
.high {
stroke: var(--control-circular-slider-high-color);
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-control-circular-slider": HaControlCircularSlider;
}
}