diff --git a/package.json b/package.json
index 97b87ee55f..06242b1c75 100644
--- a/package.json
+++ b/package.json
@@ -77,7 +77,7 @@
"@material/mwc-top-app-bar": "0.27.0",
"@material/mwc-top-app-bar-fixed": "0.27.0",
"@material/top-app-bar": "=14.0.0-canary.53b3cad2f.0",
- "@material/web": "=1.0.0-pre.8",
+ "@material/web": "=1.0.0-pre.9",
"@mdi/js": "7.2.96",
"@mdi/svg": "7.2.96",
"@polymer/app-layout": "3.1.0",
diff --git a/src/common/color/convert-light-color.ts b/src/common/color/convert-light-color.ts
new file mode 100644
index 0000000000..3e67b00fcb
--- /dev/null
+++ b/src/common/color/convert-light-color.ts
@@ -0,0 +1,106 @@
+import { clamp } from "../number/clamp";
+
+const DEFAULT_MIN_KELVIN = 2700;
+const DEFAULT_MAX_KELVIN = 6500;
+
+export const temperature2rgb = (
+ temperature: number
+): [number, number, number] => {
+ const value = temperature / 100;
+ return [
+ temperatureRed(value),
+ temperatureGreen(value),
+ temperatureBlue(value),
+ ];
+};
+
+const temperatureRed = (temperature: number): number => {
+ if (temperature <= 66) {
+ return 255;
+ }
+ const red = 329.698727446 * (temperature - 60) ** -0.1332047592;
+ return clamp(red, 0, 255);
+};
+
+const temperatureGreen = (temperature: number): number => {
+ let green: number;
+ if (temperature <= 66) {
+ green = 99.4708025861 * Math.log(temperature) - 161.1195681661;
+ } else {
+ green = 288.1221695283 * (temperature - 60) ** -0.0755148492;
+ }
+ return clamp(green, 0, 255);
+};
+
+const temperatureBlue = (temperature: number): number => {
+ if (temperature >= 66) {
+ return 255;
+ }
+ if (temperature <= 19) {
+ return 0;
+ }
+ const blue = 138.5177312231 * Math.log(temperature - 10) - 305.0447927307;
+ return clamp(blue, 0, 255);
+};
+
+const matchMaxScale = (
+ inputColors: number[],
+ outputColors: number[]
+): number[] => {
+ const maxIn: number = Math.max(...inputColors);
+ const maxOut: number = Math.max(...outputColors);
+ let factor: number;
+ if (maxOut === 0) {
+ factor = 0.0;
+ } else {
+ factor = maxIn / maxOut;
+ }
+ return outputColors.map((value) => Math.round(value * factor));
+};
+
+const mired2kelvin = (miredTemperature: number) =>
+ Math.floor(1000000 / miredTemperature);
+
+const kelvin2mired = (kelvintTemperature: number) =>
+ Math.floor(1000000 / kelvintTemperature);
+
+export const rgbww2rgb = (
+ rgbww: [number, number, number, number, number],
+ minKelvin?: number,
+ maxKelvin?: number
+): [number, number, number] => {
+ const [r, g, b, cw, ww] = rgbww;
+ // Calculate color temperature of the white channels
+ const maxMireds: number = kelvin2mired(minKelvin ?? DEFAULT_MIN_KELVIN);
+ const minMireds: number = kelvin2mired(maxKelvin ?? DEFAULT_MAX_KELVIN);
+ const miredRange: number = maxMireds - minMireds;
+ let ctRatio: number;
+ try {
+ ctRatio = ww / (cw + ww);
+ } catch (_error) {
+ ctRatio = 0.5;
+ }
+ const colorTempMired = minMireds + ctRatio * miredRange;
+ const colorTempKelvin = colorTempMired ? mired2kelvin(colorTempMired) : 0;
+ const [wR, wG, wB] = temperature2rgb(colorTempKelvin);
+ const whiteLevel = Math.max(cw, ww) / 255;
+
+ // Add the white channels to the rgb channels.
+ const rgb = [
+ r + wR * whiteLevel,
+ g + wG * whiteLevel,
+ b + wB * whiteLevel,
+ ] as [number, number, number];
+
+ // Match the output maximum value to the input. This ensures the
+ // output doesn't overflow.
+ return matchMaxScale([r, g, b, cw, ww], rgb) as [number, number, number];
+};
+
+export const rgbw2rgb = (
+ rgbw: [number, number, number, number]
+): [number, number, number] => {
+ const [r, g, b, w] = rgbw;
+ const rgb = [r + w, g + w, b + w] as [number, number, number];
+ return matchMaxScale([r, g, b, w], rgb) as [number, number, number];
+};
diff --git a/src/components/ha-hs-color-picker.ts b/src/components/ha-hs-color-picker.ts
index 33fa5977f2..a0b86fa6a5 100644
--- a/src/components/ha-hs-color-picker.ts
+++ b/src/components/ha-hs-color-picker.ts
@@ -4,6 +4,7 @@ import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { hsv2rgb, rgb2hex } from "../common/color/convert-color";
+import { rgbw2rgb, rgbww2rgb } from "../common/color/convert-light-color";
import { fireEvent } from "../common/dom/fire_event";
function xy2polar(x: number, y: number) {
@@ -26,7 +27,36 @@ function deg2rad(deg: number) {
return (deg / 360) * 2 * Math.PI;
}
-function drawColorWheel(ctx: CanvasRenderingContext2D, colorBrightness = 255) {
+function adjustRgb(
+ rgb: [number, number, number],
+ wv?: number,
+ cw?: number,
+ ww?: number,
+ minKelvin?: number,
+ maxKelvin?: number
+) {
+ if (wv != null) {
+ return rgbw2rgb([...rgb, wv] as [number, number, number, number]);
+ }
+ if (cw != null && ww !== null) {
+ return rgbww2rgb(
+ [...rgb, cw, ww] as [number, number, number, number, number],
+ minKelvin,
+ maxKelvin
+ );
+ }
+ return rgb;
+}
+
+function drawColorWheel(
+ ctx: CanvasRenderingContext2D,
+ colorBrightness = 255,
+ wv?: number,
+ cw?: number,
+ ww?: number,
+ minKelvin?: number,
+ maxKelvin?: number
+) {
const radius = ctx.canvas.width / 2;
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
@@ -44,8 +74,26 @@ function drawColorWheel(ctx: CanvasRenderingContext2D, colorBrightness = 255) {
ctx.closePath();
const gradient = ctx.createRadialGradient(cX, cY, 0, cX, cY, radius);
- const start = rgb2hex(hsv2rgb([angle, 0, colorBrightness]));
- const end = rgb2hex(hsv2rgb([angle, 1, colorBrightness]));
+ const start = rgb2hex(
+ adjustRgb(
+ hsv2rgb([angle, 0, colorBrightness]),
+ wv,
+ cw,
+ ww,
+ minKelvin,
+ maxKelvin
+ )
+ );
+ const end = rgb2hex(
+ adjustRgb(
+ hsv2rgb([angle, 1, colorBrightness]),
+ wv,
+ cw,
+ ww,
+ minKelvin,
+ maxKelvin
+ )
+ );
gradient.addColorStop(0, start);
gradient.addColorStop(1, end);
ctx.fillStyle = gradient;
@@ -67,6 +115,21 @@ class HaHsColorPicker extends LitElement {
@property({ type: Number })
public colorBrightness?: number;
+ @property({ type: Number })
+ public wv?: number;
+
+ @property({ type: Number })
+ public cw?: number;
+
+ @property({ type: Number })
+ public ww?: number;
+
+ @property({ type: Number })
+ public minKelvin?: number;
+
+ @property({ type: Number })
+ public maxKelvin?: number;
+
@query("#canvas") private _canvas!: HTMLCanvasElement;
private _mc?: HammerManager;
@@ -88,7 +151,15 @@ class HaHsColorPicker extends LitElement {
private _generateColorWheel() {
const ctx = this._canvas.getContext("2d")!;
- drawColorWheel(ctx, this.colorBrightness);
+ drawColorWheel(
+ ctx,
+ this.colorBrightness,
+ this.wv,
+ this.cw,
+ this.ww,
+ this.minKelvin,
+ this.maxKelvin
+ );
}
connectedCallback(): void {
@@ -103,7 +174,14 @@ class HaHsColorPicker extends LitElement {
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
- if (changedProps.has("colorBrightness")) {
+ if (
+ changedProps.has("colorBrightness") ||
+ changedProps.has("vw") ||
+ changedProps.has("ww") ||
+ changedProps.has("cw") ||
+ changedProps.has("minKelvin") ||
+ changedProps.has("maxKelvin")
+ ) {
this._generateColorWheel();
}
if (changedProps.has("value")) {
@@ -221,11 +299,16 @@ class HaHsColorPicker extends LitElement {
const rgb =
this._localValue !== undefined
- ? hsv2rgb([
- this._localValue[0],
- this._localValue[1],
- this.colorBrightness ?? 255,
- ])
+ ? adjustRgb(
+ hsv2rgb([
+ this._localValue[0],
+ this._localValue[1],
+ this.colorBrightness ?? 255,
+ ]),
+ this.wv,
+ this.cw,
+ this.ww
+ )
: ([255, 255, 255] as [number, number, number]);
const [x, y] = this._cursorPosition ?? [0, 0];
diff --git a/src/components/ha-temp-color-picker.ts b/src/components/ha-temp-color-picker.ts
index ddc97b53ed..8e1309b0a9 100644
--- a/src/components/ha-temp-color-picker.ts
+++ b/src/components/ha-temp-color-picker.ts
@@ -4,6 +4,7 @@ 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 { temperature2rgb } from "../common/color/convert-light-color";
import { fireEvent } from "../common/dom/fire_event";
declare global {
@@ -12,6 +13,17 @@ declare global {
}
}
+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);
@@ -24,44 +36,6 @@ function polar2xy(r: number, phi: number) {
return [x, y];
}
-function temperature2rgb(temperature: number): [number, number, number] {
- const value = temperature / 100;
- return [getRed(value), getGreen(value), getBlue(value)];
-}
-
-function getRed(temperature: number): number {
- if (temperature <= 66) {
- return 255;
- }
- const tmp_red = 329.698727446 * (temperature - 60) ** -0.1332047592;
- return clamp(tmp_red);
-}
-
-function getGreen(temperature: number): number {
- let green: number;
- if (temperature <= 66) {
- green = 99.4708025861 * Math.log(temperature) - 161.1195681661;
- } else {
- green = 288.1221695283 * (temperature - 60) ** -0.0755148492;
- }
- return clamp(green);
-}
-
-function getBlue(temperature: number): number {
- if (temperature >= 66) {
- return 255;
- }
- if (temperature <= 19) {
- return 0;
- }
- const blue = 138.5177312231 * Math.log(temperature - 10) - 305.0447927307;
- return clamp(blue);
-}
-
-function clamp(value: number): number {
- return Math.max(0, Math.min(255, value));
-}
-
function drawColorWheel(
ctx: CanvasRenderingContext2D,
minTemp: number,
@@ -99,9 +73,11 @@ class HaTempColorPicker extends LitElement {
@property({ type: Number })
public value?: number;
- @property() min = 2000;
+ @property({ type: Number })
+ public min = 2000;
- @property() max = 10000;
+ @property({ type: Number })
+ public max = 10000;
@query("#canvas") private _canvas!: HTMLCanvasElement;
@@ -120,6 +96,11 @@ class HaTempColorPicker extends LitElement {
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() {
@@ -139,18 +120,27 @@ class HaTempColorPicker extends LitElement {
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.value !== undefined && this._localValue !== this.value) {
+ if (this.value != null && this._localValue !== this.value) {
this._resetPosition();
}
}
}
- _setupListeners() {
+ private _setupListeners() {
if (this._canvas && !this._mc) {
this._mc = new Manager(this._canvas);
this._mc.add(
@@ -195,6 +185,9 @@ class HaTempColorPicker extends LitElement {
this._localValue = this._getValueFromCoord(...this._cursorPosition);
fireEvent(this, "value-changed", { value: this._localValue });
});
+
+ this.addEventListener("keydown", this._handleKeyDown);
+ this.addEventListener("keyup", this._handleKeyUp);
}
}
@@ -237,21 +230,74 @@ class HaTempColorPicker extends LitElement {
return [__x, __y];
};
- _destroyListeners() {
+ 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 =
- this._localValue !== undefined
- ? temperature2rgb(this._localValue)
- : ([255, 255, 255] as [number, number, number]);
+ const rgb = temperature2rgb(
+ this._localValue ?? Math.round((this.max + this.min) / 2)
+ );
const [x, y] = this._cursorPosition ?? [0, 0];
@@ -266,7 +312,12 @@ class HaTempColorPicker extends LitElement {
return html`
-