diff --git a/gallery/src/pages/components/ha-control-circular-slider.markdown b/gallery/src/pages/components/ha-control-circular-slider.markdown new file mode 100644 index 0000000000..7a5a234cf2 --- /dev/null +++ b/gallery/src/pages/components/ha-control-circular-slider.markdown @@ -0,0 +1,3 @@ +--- +title: Control Circular Slider +--- diff --git a/gallery/src/pages/components/ha-control-circular-slider.ts b/gallery/src/pages/components/ha-control-circular-slider.ts new file mode 100644 index 0000000000..791fff7829 --- /dev/null +++ b/gallery/src/pages/components/ha-control-circular-slider.ts @@ -0,0 +1,153 @@ +import { css, html, LitElement, TemplateResult } from "lit"; +import { customElement, state } from "lit/decorators"; +import "../../../../src/components/ha-card"; +import "../../../../src/components/ha-control-circular-slider"; +import "../../../../src/components/ha-slider"; + +@customElement("demo-components-ha-control-circular-slider") +export class DemoHaCircularSlider extends LitElement { + @state() + private current = 22; + + @state() + private value = 19; + + @state() + private high = 25; + + @state() + private changingValue?: number; + + @state() + private changingHigh?: number; + + private _valueChanged(ev) { + this.value = ev.detail.value; + } + + private _valueChanging(ev) { + this.changingValue = ev.detail.value; + } + + private _highChanged(ev) { + this.high = ev.detail.value; + } + + private _highChanging(ev) { + this.changingHigh = ev.detail.value; + } + + private _currentChanged(ev) { + this.current = ev.currentTarget.value; + } + + protected render(): TemplateResult { + return html` + + + Config + + Current + + ${this.current} °C + + + + + + Single + + + Value: ${this.value} °C + + Changing: + ${this.changingValue != null ? `${this.changingValue} °C` : "-"} + + + + + + Dual + + + Low value: ${this.value} °C + + Low changing: + ${this.changingValue != null ? `${this.changingValue} °C` : "-"} + + High value: ${this.high} °C + + High changing: + ${this.changingHigh != null ? `${this.changingHigh} °C` : "-"} + + + + `; + } + + static get styles() { + return css` + ha-card { + max-width: 600px; + margin: 24px auto; + } + pre { + margin-top: 0; + margin-bottom: 8px; + } + p { + margin: 0; + } + p.title { + margin-bottom: 12px; + } + ha-control-circular-slider { + --control-circular-slider-color: #ff9800; + --control-circular-slider-background: #ff9800; + --control-circular-slider-background-opacity: 0.3; + } + ha-control-circular-slider[dual] { + --control-circular-slider-high-color: #2196f3; + --control-circular-slider-low-color: #ff9800; + --control-circular-slider-background: var(--disabled-color); + } + .field { + display: flex; + flex-direction: row; + align-items: center; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "demo-components-ha-control-circular-slider": DemoHaCircularSlider; + } +} diff --git a/src/components/ha-control-circular-slider.ts b/src/components/ha-control-circular-slider.ts new file mode 100644 index 0000000000..7b7472ddbc --- /dev/null +++ b/src/components/ha-control-circular-slider.ts @@ -0,0 +1,546 @@ +import { + DIRECTION_ALL, + Manager, + Pan, + Tap, + TouchMouseInput, +} from "@egjs/hammerjs"; +import { + CSSResultGroup, + LitElement, + PropertyValues, + TemplateResult, + css, + html, + nothing, + svg, +} from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { ifDefined } from "lit/directives/if-defined"; +import { styleMap } from "lit/directives/style-map"; +import { fireEvent } from "../common/dom/fire_event"; +import { clamp } from "../common/number/clamp"; +import { arc } from "../resources/svg-arc"; + +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", +]); + +@customElement("ha-control-circular-slider") +export class HaControlCircularSlider extends LitElement { + @property({ type: Boolean, reflect: true }) + public disabled = false; + + @property({ type: Boolean }) + public dual?: 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 current?: number; + + @property({ type: Number }) + public low?: number; + + @property({ type: Number }) + public high?: number; + + @property({ type: Number }) + public step = 1; + + @property({ type: Number }) + public min = 0; + + @property({ type: Number }) + public max = 100; + + @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.low ?? this.max) : this.min; + const max = + this._activeSlider === "low" ? Math.max(this.high ?? this.min) : this.max; + return Math.min(Math.max(value, min), max); + } + + protected firstUpdated(changedProperties: PropertyValues): void { + super.firstUpdated(changedProperties); + this._setupListeners(); + } + + connectedCallback(): void { + super.connectedCallback(); + this._setupListeners(); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + } + + private _mc?: 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; + + @query("#interaction") + private _interaction; + + private _findActiveSlider(value: number): ActiveSlider { + if (!this.dual) return "value"; + const low = Math.max(this.low ?? this.min, this.min); + const high = Math.min(this.high ?? 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) { + if (!this._activeSlider) return; + this[this._activeSlider] = value; + } + + private _getActiveValue(): number | undefined { + if (!this._activeSlider) return undefined; + return this[this._activeSlider]; + } + + _setupListeners() { + if (this._interaction && !this._mc) { + this._mc = new Manager(this._interaction, { + inputClass: TouchMouseInput, + }); + this._mc.add( + new Pan({ + direction: DIRECTION_ALL, + enable: true, + threshold: 0, + }) + ); + + this._mc.add(new Tap({ event: "singletap" })); + + this._mc.on("pan", (e) => { + e.srcEvent.stopPropagation(); + e.srcEvent.preventDefault(); + }); + this._mc.on("panstart", (e) => { + if (this.disabled) 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(); + }); + this._mc.on("pancancel", () => { + if (this.disabled) return; + this._activeSlider = undefined; + }); + this._mc.on("panmove", (e) => { + if (this.disabled) 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 }); + } + }); + this._mc.on("panend", (e) => { + if (this.disabled) return; + const percentage = this._getPercentageFromEvent(e); + const raw = this._percentageToValue(percentage); + const bounded = this._boundedValue(raw); + const stepped = this._steppedValue(bounded); + if (this._activeSlider) { + fireEvent(this, `${this._activeSlider}-changing`, { + value: undefined, + }); + fireEvent(this, `${this._activeSlider}-changed`, { value: stepped }); + } + this._activeSlider = undefined; + }); + this._mc.on("singletap", (e) => { + if (this.disabled) 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; + }); + } + } + + 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; + } + + destroyListeners() { + if (this._mc) { + this._mc.destroy(); + this._mc = undefined; + } + } + + protected render(): TemplateResult { + const trackPath = arc({ x: 0, y: 0, start: 0, end: MAX_ANGLE, r: RADIUS }); + + const maxRatio = MAX_ANGLE / 360; + + const f = RADIUS * 2 * Math.PI; + const lowValue = this.dual ? this.low : this.value; + const highValue = this.high; + const lowPercentage = this._valueToPercentage(lowValue ?? this.min); + const highPercentage = this._valueToPercentage(highValue ?? this.max); + + const lowArcLength = lowPercentage * f * maxRatio; + const lowStrokeDasharray = `${lowArcLength} ${f - lowArcLength}`; + + const highArcLength = (1 - highPercentage) * f * maxRatio; + const highStrokeDasharray = `${highArcLength} ${f - highArcLength}`; + const highStrokeDashOffset = `${highArcLength + f * (1 - maxRatio)}`; + + const currentPercentage = this._valueToPercentage(this.current ?? 0); + const currentAngle = currentPercentage * MAX_ANGLE; + + return html` + + + + + + + + + ${this.dual + ? svg` + + ` + : nothing} + ${this.current != null + ? svg` + + + + + ` + : nothing} + + + + `; + } + + static get styles(): CSSResultGroup { + return css` + :host { + --control-circular-slider-color: var(--primary-color); + --control-circular-slider-background: #8b97a3; + --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 + ); + } + svg { + width: 320px; + display: block; + } + #slider { + outline: none; + } + #interaction { + display: flex; + fill: none; + stroke: transparent; + stroke-linecap: round; + stroke-width: 48px; + cursor: pointer; + } + #display { + pointer-events: none; + } + :host([disabled]) #interaction { + cursor: initial; + } + + .background { + fill: none; + stroke: var(--control-circular-slider-background); + opacity: var(--control-circular-slider-background-opacity); + stroke-linecap: round; + stroke-width: 24px; + } + + .track { + outline: none; + 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; + } + + .track:focus-visible { + stroke-width: 28px; + } + + .pressed .track { + transition: stroke-width 300ms ease-in-out; + } + + .current { + stroke: var(--primary-text-color); + transform: rotate(var(--current-angle, 0)); + transition: transform 300ms ease-in-out; + } + + #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; + } +} diff --git a/src/components/ha-control-slider.ts b/src/components/ha-control-slider.ts index 2cc172969a..3f4abcf4e3 100644 --- a/src/components/ha-control-slider.ts +++ b/src/components/ha-control-slider.ts @@ -176,7 +176,7 @@ export class HaControlSlider extends LitElement { this._mc = undefined; } this.removeEventListener("keydown", this._handleKeyDown); - this.removeEventListener("keyup", this._handleKeyDown); + this.removeEventListener("keyup", this._handleKeyUp); } private get _tenPercentStep() { diff --git a/src/resources/svg-arc.ts b/src/resources/svg-arc.ts new file mode 100644 index 0000000000..67e29b5157 --- /dev/null +++ b/src/resources/svg-arc.ts @@ -0,0 +1,67 @@ +type Vector = [number, number]; +type Matrix = [Vector, Vector]; + +const rotateVector = ([[a, b], [c, d]]: Matrix, [x, y]: Vector): Vector => [ + a * x + b * y, + c * x + d * y, +]; +const createRotateMatrix = (x: number): Matrix => [ + [Math.cos(x), -Math.sin(x)], + [Math.sin(x), Math.cos(x)], +]; +const addVector = ([a1, a2]: Vector, [b1, b2]: Vector): Vector => [ + a1 + b1, + a2 + b2, +]; + +export const toRadian = (angle: number) => (angle / 180) * Math.PI; + +type ArcOptions = { + x: number; + y: number; + r: number; + start: number; + end: number; + rotate?: number; +}; + +export const arc = (options: ArcOptions) => { + const { x, y, r, start, end, rotate = 0 } = options; + const cx = x; + const cy = y; + const rx = r; + const ry = r; + const t1 = toRadian(start); + const t2 = toRadian(end); + const delta = (t2 - t1) % (2 * Math.PI); + const phi = toRadian(rotate); + + const rotMatrix = createRotateMatrix(phi); + const [sX, sY] = addVector( + rotateVector(rotMatrix, [rx * Math.cos(t1), ry * Math.sin(t1)]), + [cx, cy] + ); + const [eX, eY] = addVector( + rotateVector(rotMatrix, [ + rx * Math.cos(t1 + delta), + ry * Math.sin(t1 + delta), + ]), + [cx, cy] + ); + const fA = delta > Math.PI ? 1 : 0; + const fS = delta > 0 ? 1 : 0; + + return [ + "M", + sX, + sY, + "A", + rx, + ry, + (phi / (2 * Math.PI)) * 360, + fA, + fS, + eX, + eY, + ].join(" "); +};
Config
Current
${this.current} °C
Single
Dual