mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-24 09:46:36 +00:00
Add circular slider (#16981)
* WIP Create round slider * Fix interaction on iOS * Add dual and simple gauge * Add events * Rename events * Use low and high * Improve dual slider selection * Add min and max * Rename component * Prevents setting a high value lower than low and vice versa * Add keyboard support * Fix typings * Use html * Update current indicator * Improve doc * Fix keyboard focus after mouse interaction * Don't fallback to value
This commit is contained in:
parent
bbdcc021d4
commit
e9961b93f9
@ -0,0 +1,3 @@
|
||||
---
|
||||
title: Control Circular Slider
|
||||
---
|
153
gallery/src/pages/components/ha-control-circular-slider.ts
Normal file
153
gallery/src/pages/components/ha-control-circular-slider.ts
Normal file
@ -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`
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<p class="title"><b>Config</b></p>
|
||||
<div class="field">
|
||||
<p>Current</p>
|
||||
<ha-slider
|
||||
min="10"
|
||||
max="30"
|
||||
.value=${this.current}
|
||||
@change=${this._currentChanged}
|
||||
pin
|
||||
></ha-slider>
|
||||
<p>${this.current} °C</p>
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<p class="title"><b>Single</b></p>
|
||||
<ha-control-circular-slider
|
||||
@value-changed=${this._valueChanged}
|
||||
@value-changing=${this._valueChanging}
|
||||
.value=${this.value}
|
||||
.current=${this.current}
|
||||
step="1"
|
||||
min="10"
|
||||
max="30"
|
||||
></ha-control-circular-slider>
|
||||
<div>
|
||||
Value: ${this.value} °C
|
||||
<br />
|
||||
Changing:
|
||||
${this.changingValue != null ? `${this.changingValue} °C` : "-"}
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<p class="title"><b>Dual</b></p>
|
||||
<ha-control-circular-slider
|
||||
dual
|
||||
@low-changed=${this._valueChanged}
|
||||
@low-changing=${this._valueChanging}
|
||||
@high-changed=${this._highChanged}
|
||||
@high-changing=${this._highChanging}
|
||||
.low=${this.value}
|
||||
.high=${this.high}
|
||||
.current=${this.current}
|
||||
step="1"
|
||||
min="10"
|
||||
max="30"
|
||||
></ha-control-circular-slider>
|
||||
<div>
|
||||
Low value: ${this.value} °C
|
||||
<br />
|
||||
Low changing:
|
||||
${this.changingValue != null ? `${this.changingValue} °C` : "-"}
|
||||
<br />
|
||||
High value: ${this.high} °C
|
||||
<br />
|
||||
High changing:
|
||||
${this.changingHigh != null ? `${this.changingHigh} °C` : "-"}
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
546
src/components/ha-control-circular-slider.ts
Normal file
546
src/components/ha-control-circular-slider.ts
Normal file
@ -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`
|
||||
<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})"
|
||||
>
|
||||
<g id="interaction">
|
||||
<path d=${trackPath} />
|
||||
</g>
|
||||
<g id="display">
|
||||
<path class="background" d=${trackPath} />
|
||||
<circle
|
||||
.id=${this.dual ? "low" : "value"}
|
||||
class="track"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r=${RADIUS}
|
||||
stroke-dasharray=${lowStrokeDasharray}
|
||||
stroke-dashoffset="0"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-valuemin=${this.min}
|
||||
aria-valuemax=${this.max}
|
||||
aria-valuenow=${lowValue != null
|
||||
? this._steppedValue(lowValue)
|
||||
: undefined}
|
||||
aria-disabled=${this.disabled}
|
||||
aria-label=${ifDefined(this.lowLabel ?? this.label)}
|
||||
@keydown=${this._handleKeyDown}
|
||||
@keyup=${this._handleKeyUp}
|
||||
/>
|
||||
${this.dual
|
||||
? svg`
|
||||
<circle
|
||||
id="high"
|
||||
class="track"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r=${RADIUS}
|
||||
stroke-dasharray=${highStrokeDasharray}
|
||||
stroke-dashoffset=${highStrokeDashOffset}
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-valuemin=${this.min}
|
||||
aria-valuemax=${this.max}
|
||||
aria-valuenow=${
|
||||
highValue != null
|
||||
? this._steppedValue(highValue)
|
||||
: undefined
|
||||
}
|
||||
aria-disabled=${this.disabled}
|
||||
aria-label=${ifDefined(this.highLabel)}
|
||||
@keydown=${this._handleKeyDown}
|
||||
@keyup=${this._handleKeyUp}
|
||||
/>
|
||||
`
|
||||
: nothing}
|
||||
${this.current != null
|
||||
? svg`
|
||||
<g
|
||||
style=${styleMap({ "--current-angle": `${currentAngle}deg` })}
|
||||
class="current"
|
||||
>
|
||||
<line
|
||||
x1=${RADIUS - 12}
|
||||
y1="0"
|
||||
x2=${RADIUS - 15}
|
||||
y2="0"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<line
|
||||
x1=${RADIUS - 15}
|
||||
y1="0"
|
||||
x2=${RADIUS - 20}
|
||||
y2="0"
|
||||
stroke-linecap="round"
|
||||
stroke-width="4"
|
||||
/>
|
||||
</g>
|
||||
`
|
||||
: nothing}
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
@ -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() {
|
||||
|
67
src/resources/svg-arc.ts
Normal file
67
src/resources/svg-arc.ts
Normal file
@ -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(" ");
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user