Use slider for color temp instead of wheel (#17771)

This commit is contained in:
Paul Bottein 2023-09-01 15:25:27 +02:00 committed by GitHub
parent fb69deb617
commit 0d0fe75f4e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 76 additions and 598 deletions

View File

@ -1,3 +0,0 @@
---
title: Temp Color Picker
---

View File

@ -1,117 +0,0 @@
import "../../../../src/components/ha-temp-color-picker";
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-slider";
@customElement("demo-components-ha-temp-color-picker")
export class DemoHaTempColorPicker extends LitElement {
@state()
min = 3000;
@state()
max = 7000;
@state()
value = 4000;
@state()
liveValue?: number;
private _minChanged(ev) {
this.min = Number(ev.target.value);
}
private _maxChanged(ev) {
this.max = Number(ev.target.value);
}
private _valueChanged(ev) {
this.value = Number(ev.target.value);
}
private _tempColorCursor(ev) {
this.liveValue = ev.detail.value;
}
private _tempColorChanged(ev) {
this.value = ev.detail.value;
}
protected render(): TemplateResult {
return html`
<ha-card>
<div class="card-content">
<p class="value">${this.liveValue ?? this.value} K</p>
<ha-temp-color-picker
.min=${this.min}
.max=${this.max}
.value=${this.value}
@value-changed=${this._tempColorChanged}
@cursor-moved=${this._tempColorCursor}
></ha-temp-color-picker>
<p>Min temp : ${this.min} K</p>
<ha-slider
step="1"
pin
min="2000"
max="10000"
.value=${this.min}
@change=${this._minChanged}
>
</ha-slider>
<p>Max temp : ${this.max} K</p>
<ha-slider
step="1"
pin
min="2000"
max="10000"
.value=${this.max}
@change=${this._maxChanged}
>
</ha-slider>
<p>Value : ${this.value} K</p>
<ha-slider
step="1"
pin
min=${this.min}
max=${this.max}
.value=${this.value}
@change=${this._valueChanged}
>
</ha-slider>
</div>
</ha-card>
`;
}
static get styles() {
return css`
ha-card {
max-width: 600px;
margin: 24px auto;
}
.card-content {
display: flex;
align-items: center;
flex-direction: column;
}
ha-temp-color-picker {
width: 400px;
}
.value {
font-size: 22px;
font-weight: bold;
margin: 0 0 12px 0;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-temp-color-picker": DemoHaTempColorPicker;
}
}

View File

@ -7,6 +7,12 @@ import { hsv2rgb, rgb2hex } from "../common/color/convert-color";
import { rgbw2rgb, rgbww2rgb } from "../common/color/convert-light-color";
import { fireEvent } from "../common/dom/fire_event";
declare global {
interface HASSDomEvents {
"cursor-moved": { value?: any };
}
}
function xy2polar(x: number, y: number) {
const r = Math.sqrt(x * x + y * y);
const phi = Math.atan2(y, x);

View File

@ -1,440 +0,0 @@
import { DIRECTION_ALL, Manager, Pan, Tap } from "@egjs/hammerjs";
import { LitElement, PropertyValues, css, html, svg } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { rgb2hex } from "../common/color/convert-color";
import {
DEFAULT_MAX_KELVIN,
DEFAULT_MIN_KELVIN,
temperature2rgb,
} from "../common/color/convert-light-color";
import { fireEvent } from "../common/dom/fire_event";
const SAFE_ZONE_FACTOR = 0.9;
declare global {
interface HASSDomEvents {
"cursor-moved": { value?: any };
}
}
const A11Y_KEY_CODES = new Set([
"ArrowRight",
"ArrowUp",
"ArrowLeft",
"ArrowDown",
"PageUp",
"PageDown",
"Home",
"End",
]);
function xy2polar(x: number, y: number) {
const r = Math.sqrt(x * x + y * y);
const phi = Math.atan2(y, x);
return [r, phi];
}
function polar2xy(r: number, phi: number) {
const x = Math.cos(phi) * r;
const y = Math.sin(phi) * r;
return [x, y];
}
function drawColorWheel(
ctx: CanvasRenderingContext2D,
minTemp: number,
maxTemp: number
) {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
const radius = ctx.canvas.width / 2;
const min = Math.max(minTemp, 2000);
const max = Math.min(maxTemp, 40000);
for (let y = -radius; y < radius; y += 1) {
const x = radius * Math.sqrt(1 - (y / radius) ** 2);
const fraction = (y / (radius * SAFE_ZONE_FACTOR) + 1) / 2;
const temperature = Math.max(
Math.min(min + fraction * (max - min), max),
min
);
const color = rgb2hex(temperature2rgb(temperature));
ctx.fillStyle = color;
ctx.fillRect(radius - x, radius + y - 0.5, 2 * x, 2);
ctx.fill();
}
}
@customElement("ha-temp-color-picker")
class HaTempColorPicker extends LitElement {
@property({ type: Boolean, reflect: true })
public disabled = false;
@property({ type: Number, attribute: false })
public renderSize?: number;
@property({ type: Number })
public value?: number;
@property({ type: Number })
public min = DEFAULT_MIN_KELVIN;
@property({ type: Number })
public max = DEFAULT_MAX_KELVIN;
@query("#canvas") private _canvas!: HTMLCanvasElement;
private _mc?: HammerManager;
@state()
private _pressed?: string;
@state()
private _cursorPosition?: [number, number];
@state()
private _localValue?: number;
protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps);
this._setupListeners();
this._generateColorWheel();
this.setAttribute("role", "slider");
this.setAttribute("aria-orientation", "vertical");
if (!this.hasAttribute("tabindex")) {
this.setAttribute("tabindex", "0");
}
}
private _generateColorWheel() {
const ctx = this._canvas.getContext("2d")!;
drawColorWheel(ctx, this.min, this.max);
}
connectedCallback(): void {
super.connectedCallback();
this._setupListeners();
}
disconnectedCallback(): void {
super.disconnectedCallback();
this._destroyListeners();
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("_localValue")) {
this.setAttribute("aria-valuenow", this._localValue?.toString() ?? "");
}
if (changedProps.has("min") || changedProps.has("max")) {
this._generateColorWheel();
this._resetPosition();
}
if (changedProps.has("min")) {
this.setAttribute("aria-valuemin", this.min.toString());
}
if (changedProps.has("max")) {
this.setAttribute("aria-valuemax", this.max.toString());
}
if (changedProps.has("value")) {
if (this._localValue !== this.value) {
this._resetPosition();
}
}
}
private _setupListeners() {
if (this._canvas && !this._mc) {
this._mc = new Manager(this._canvas);
this._mc.add(
new Pan({
direction: DIRECTION_ALL,
enable: true,
threshold: 0,
})
);
this._mc.add(new Tap({ event: "singletap" }));
let savedPosition;
this._mc.on("panstart", (e) => {
if (this.disabled) return;
this._pressed = e.pointerType;
savedPosition = this._cursorPosition;
});
this._mc.on("pancancel", () => {
if (this.disabled) return;
this._pressed = undefined;
this._cursorPosition = savedPosition;
});
this._mc.on("panmove", (e) => {
if (this.disabled) return;
this._cursorPosition = this._getPositionFromEvent(e);
this._localValue = this._getValueFromCoord(...this._cursorPosition);
fireEvent(this, "cursor-moved", { value: this._localValue });
});
this._mc.on("panend", (e) => {
if (this.disabled) return;
this._pressed = undefined;
this._cursorPosition = this._getPositionFromEvent(e);
this._localValue = this._getValueFromCoord(...this._cursorPosition);
fireEvent(this, "cursor-moved", { value: undefined });
fireEvent(this, "value-changed", { value: this._localValue });
});
this._mc.on("singletap", (e) => {
if (this.disabled) return;
this._cursorPosition = this._getPositionFromEvent(e);
this._localValue = this._getValueFromCoord(...this._cursorPosition);
fireEvent(this, "value-changed", { value: this._localValue });
});
this.addEventListener("keydown", this._handleKeyDown);
this.addEventListener("keyup", this._handleKeyUp);
}
}
private _resetPosition() {
if (this.value === undefined) {
this._cursorPosition = undefined;
this._localValue = undefined;
return;
}
const [, y] = this._getCoordsFromValue(this.value);
const currentX = this._cursorPosition?.[0] ?? 0;
const x =
Math.sign(currentX) * Math.min(Math.sqrt(1 - y ** 2), Math.abs(currentX));
this._cursorPosition = [x, y];
this._localValue = this.value;
}
private _getCoordsFromValue = (temperature: number): [number, number] => {
if (this.value === this.min) {
return [0, -1];
}
if (this.value === this.max) {
return [0, 1];
}
const fraction = (temperature - this.min) / (this.max - this.min);
const y = (2 * fraction - 1) * SAFE_ZONE_FACTOR;
return [0, y];
};
private _getValueFromCoord = (_x: number, y: number): number => {
const fraction = (y / SAFE_ZONE_FACTOR + 1) / 2;
const temperature = Math.max(
Math.min(this.min + fraction * (this.max - this.min), this.max),
this.min
);
return Math.round(temperature);
};
private _getPositionFromEvent = (e: HammerInput): [number, number] => {
const x = e.center.x;
const y = e.center.y;
const boundingRect = e.target.getBoundingClientRect();
const offsetX = boundingRect.left;
const offsetY = boundingRect.top;
const maxX = e.target.clientWidth;
const maxY = e.target.clientHeight;
const _x = (2 * (x - offsetX)) / maxX - 1;
const _y = (2 * (y - offsetY)) / maxY - 1;
const [r, phi] = xy2polar(_x, _y);
const [__x, __y] = polar2xy(Math.min(1, r), phi);
return [__x, __y];
};
private _destroyListeners() {
if (this._mc) {
this._mc.destroy();
this._mc = undefined;
}
this.removeEventListener("keydown", this._handleKeyDown);
this.removeEventListener("keyup", this._handleKeyDown);
}
_handleKeyDown(e: KeyboardEvent) {
if (!A11Y_KEY_CODES.has(e.code)) return;
e.preventDefault();
const step = 1;
const tenPercentStep = Math.max(step, (this.max - this.min) / 10);
const currentValue =
this._localValue ?? Math.round((this.max + this.min) / 2);
switch (e.code) {
case "ArrowRight":
case "ArrowUp":
this._localValue = Math.round(Math.min(currentValue + step, this.max));
break;
case "ArrowLeft":
case "ArrowDown":
this._localValue = Math.round(Math.max(currentValue - step, this.min));
break;
case "PageUp":
this._localValue = Math.round(
Math.min(currentValue + tenPercentStep, this.max)
);
break;
case "PageDown":
this._localValue = Math.round(
Math.max(currentValue - tenPercentStep, this.min)
);
break;
case "Home":
this._localValue = this.min;
break;
case "End":
this._localValue = this.max;
break;
}
if (this._localValue != null) {
const [_, y] = this._getCoordsFromValue(this._localValue);
const currentX = this._cursorPosition?.[0] ?? 0;
const x =
Math.sign(currentX) *
Math.min(Math.sqrt(1 - y ** 2), Math.abs(currentX));
this._cursorPosition = [x, y];
fireEvent(this, "cursor-moved", { value: this._localValue });
}
}
_handleKeyUp(e: KeyboardEvent) {
if (!A11Y_KEY_CODES.has(e.code)) return;
e.preventDefault();
this.value = this._localValue;
fireEvent(this, "value-changed", { value: this._localValue });
}
render() {
const size = this.renderSize || 400;
const canvasSize = size * window.devicePixelRatio;
const rgb = temperature2rgb(
this._localValue ?? Math.round((this.max + this.min) / 2)
);
const [x, y] = this._cursorPosition ?? [0, 0];
const cx = ((x + 1) * size) / 2;
const cy = ((y + 1) * size) / 2;
const markerPosition = `${cx}px, ${cy}px`;
const markerScale = this._pressed
? this._pressed === "touch"
? "2.5"
: "1.5"
: "1";
const markerOffset =
this._pressed === "touch" ? `0px, -${size / 16}px` : "0px, 0px";
return html`
<div class="container ${classMap({ pressed: Boolean(this._pressed) })}">
<canvas id="canvas" .width=${canvasSize} .height=${canvasSize}></canvas>
<svg
id="interaction"
viewBox="0 0 ${size} ${size}"
overflow="visible"
aria-hidden="true"
>
<defs>${this.renderSVGFilter()}</defs>
<g
style=${styleMap({
fill: rgb2hex(rgb),
transform: `translate(${markerPosition})`,
})}
class="cursor"
>
<circle
cx="0"
cy="0"
r="16"
style=${styleMap({
fill: rgb2hex(rgb),
transform: `translate(${markerOffset}) scale(${markerScale})`,
visibility: this._cursorPosition ? undefined : "hidden",
})}
></circle>
</g>
</svg>
</div>
`;
}
renderSVGFilter() {
return svg`
<filter
id="marker-shadow"
x="-50%"
y="-50%"
width="200%"
height="200%"
filterUnits="objectBoundingBox"
>
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.3" flood-color="rgba(0, 0, 0, 1)"/>
<feDropShadow dx="0" dy="1" stdDeviation="3" flood-opacity="0.15" flood-color="rgba(0, 0, 0, 1)"/>
</filter>
`;
}
static get styles() {
return css`
:host {
display: block;
outline: none;
}
.container {
position: relative;
width: 100%;
height: 100%;
display: flex;
}
canvas {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 50%;
transition: box-shadow 180ms ease-in-out;
cursor: pointer;
}
:host(:focus-visible) canvas {
box-shadow: 0 0 0 2px rgb(255, 160, 0);
}
svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
circle {
fill: black;
stroke: white;
stroke-width: 2;
filter: url(#marker-shadow);
}
.container:not(.pressed) circle {
transition:
transform 100ms ease-in-out,
fill 100ms ease-in-out;
}
.container:not(.pressed) .cursor {
transition: transform 200ms ease-in-out;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-temp-color-picker": HaTempColorPicker;
}
}

View File

@ -26,7 +26,6 @@ import "../../../../components/ha-hs-color-picker";
import "../../../../components/ha-icon";
import "../../../../components/ha-icon-button-prev";
import "../../../../components/ha-labeled-slider";
import "../../../../components/ha-temp-color-picker";
import {
getLightCurrentModeRgbColor,
LightColor,

View File

@ -1,25 +1,31 @@
import {
css,
CSSResultGroup,
html,
LitElement,
nothing,
PropertyValues,
css,
html,
nothing,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { rgb2hex } from "../../../../common/color/convert-color";
import {
DEFAULT_MAX_KELVIN,
DEFAULT_MIN_KELVIN,
temperature2rgb,
} from "../../../../common/color/convert-light-color";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stateColorCss } from "../../../../common/entity/state_color";
import { throttle } from "../../../../common/util/throttle";
import "../../../../components/ha-temp-color-picker";
import "../../../../components/ha-control-slider";
import { UNAVAILABLE } from "../../../../data/entity";
import {
LightColor,
LightColorMode,
LightEntity,
} from "../../../../data/light";
import { HomeAssistant } from "../../../../types";
import {
DEFAULT_MAX_KELVIN,
DEFAULT_MIN_KELVIN,
} from "../../../../common/color/convert-light-color";
declare global {
interface HASSDomEvents {
@ -28,6 +34,26 @@ declare global {
}
}
export const generateColorTemperatureGradient = (min: number, max: number) => {
const count = 10;
const gradient: [number, string][] = [];
const step = (max - min) / count;
const percentageStep = 1 / count;
for (let i = 0; i < count + 1; i++) {
const value = min + step * i;
const hex = rgb2hex(temperature2rgb(value));
gradient.push([percentageStep * i, hex]);
}
return gradient
.map(([stop, color]) => `${color} ${(stop as number) * 100}%`)
.join(", ");
};
@customElement("light-color-temp-picker")
class LightColorTempPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@ -46,18 +72,36 @@ class LightColorTempPicker extends LitElement {
const maxKelvin =
this.stateObj.attributes.max_color_temp_kelvin ?? DEFAULT_MAX_KELVIN;
const gradient = this._generateTemperatureGradient(minKelvin!, maxKelvin);
const color = stateColorCss(this.stateObj);
return html`
<ha-temp-color-picker
@value-changed=${this._ctColorChanged}
@cursor-moved=${this._ctColorCursorMoved}
<ha-control-slider
inverted
vertical
.value=${this._ctPickerValue}
.min=${minKelvin}
.max=${maxKelvin}
.value=${this._ctPickerValue}
mode="cursor"
@value-changed=${this._ctColorChanged}
@slider-moved=${this._ctColorCursorMoved}
.ariaLabel=${this.hass.localize(
"ui.dialogs.more_info_control.light.color_temp"
)}
style=${styleMap({
"--control-slider-color": color,
"--gradient": gradient,
})}
.disabled=${this.stateObj.state === UNAVAILABLE}
>
</ha-temp-color-picker>
</ha-control-slider>
`;
}
private _generateTemperatureGradient = memoizeOne(
(min: number, max: number) => generateColorTemperatureGradient(min, max)
);
public _updateSliderValues() {
const stateObj = this.stateObj;
@ -138,10 +182,18 @@ class LightColorTempPicker extends LitElement {
flex-direction: column;
}
ha-temp-color-picker {
ha-control-slider {
height: 45vh;
max-height: 320px;
min-height: 200px;
--control-slider-thickness: 100px;
--control-slider-border-radius: 24px;
--control-slider-color: var(--primary-color);
--control-slider-background: -webkit-linear-gradient(
top,
var(--gradient)
);
--control-slider-background-opacity: 1;
}
`,
];

View File

@ -3,17 +3,16 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { rgb2hex } from "../../../common/color/convert-color";
import {
DEFAULT_MAX_KELVIN,
DEFAULT_MIN_KELVIN,
temperature2rgb,
} from "../../../common/color/convert-light-color";
import { computeDomain } from "../../../common/entity/compute_domain";
import { stateActive } from "../../../common/entity/state_active";
import "../../../components/ha-control-slider";
import { UNAVAILABLE } from "../../../data/entity";
import { LightColorMode, lightSupportsColorMode } from "../../../data/light";
import { generateColorTemperatureGradient } from "../../../dialogs/more-info/components/lights/light-color-temp-picker";
import { HomeAssistant } from "../../../types";
import { LovelaceTileFeature } from "../types";
import { LightColorTempTileFeatureConfig } from "./types";
@ -70,7 +69,7 @@ class HuiLightColorTempTileFeature
const maxKelvin =
this.stateObj.attributes.max_color_temp_kelvin ?? DEFAULT_MAX_KELVIN;
const gradient = this.generateTemperatureGradient(minKelvin!, maxKelvin);
const gradient = this._generateTemperatureGradient(minKelvin!, maxKelvin);
return html`
<div class="container">
@ -91,26 +90,8 @@ class HuiLightColorTempTileFeature
`;
}
private generateTemperatureGradient = memoizeOne(
(min: number, max: number) => {
const count = 10;
const gradient: [number, string][] = [];
const step = (max - min) / count;
const percentageStep = 1 / count;
for (let i = 0; i < count + 1; i++) {
const value = min + step * i;
const hex = rgb2hex(temperature2rgb(value));
gradient.push([percentageStep * i, hex]);
}
return gradient
.map(([stop, color]) => `${color} ${(stop as number) * 100}%`)
.join(", ");
}
private _generateTemperatureGradient = memoizeOne(
(min: number, max: number) => generateColorTemperatureGradient(min, max)
);
private _valueChanged(ev: CustomEvent) {