diff --git a/gallery/src/pages/components/ha-hs-color-picker.markdown b/gallery/src/pages/components/ha-hs-color-picker.markdown new file mode 100644 index 0000000000..ec37f2ad97 --- /dev/null +++ b/gallery/src/pages/components/ha-hs-color-picker.markdown @@ -0,0 +1,3 @@ +--- +title: HS Color Picker +--- diff --git a/gallery/src/pages/components/ha-hs-color-picker.ts b/gallery/src/pages/components/ha-hs-color-picker.ts new file mode 100644 index 0000000000..53eaf21876 --- /dev/null +++ b/gallery/src/pages/components/ha-hs-color-picker.ts @@ -0,0 +1,120 @@ +import "../../../../src/components/ha-hs-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"; +import { hsv2rgb } from "../../../../src/common/color/convert-color"; + +@customElement("demo-components-ha-hs-color-picker") +export class DemoHaHsColorPicker extends LitElement { + @state() + brightness = 255; + + @state() + value: [number, number] = [0, 0]; + + @state() + liveValue?: [number, number]; + + private _brightnessChanged(ev) { + this.brightness = Number(ev.target.value); + } + + private _hsColorCursor(ev) { + this.liveValue = ev.detail.value; + } + + private _hsColorChanged(ev) { + this.value = ev.detail.value; + } + + private _hueChanged(ev) { + this.value = [ev.target.value, this.value[1]]; + } + + private _saturationChanged(ev) { + this.value = [this.value[0], ev.target.value]; + } + + protected render(): TemplateResult { + const h = (this.liveValue ?? this.value)[0]; + const s = (this.liveValue ?? this.value)[1]; + + const rgb = hsv2rgb([h, s, this.brightness]); + + return html` + +
+

${h}° - ${Math.round(s * 100)}%

+

${rgb.map((v) => Math.round(v)).join(", ")}

+ +

Hue : ${this.value[0]}

+ + +

Saturation : ${this.value[1]}

+ + +

Color Brighness : ${this.brightness}

+ + +
+
+ `; + } + + static get styles() { + return css` + ha-card { + max-width: 600px; + margin: 24px auto; + } + .card-content { + display: flex; + align-items: center; + flex-direction: column; + } + ha-hs-color-picker { + width: 400px; + } + .value { + font-size: 22px; + font-weight: bold; + margin: 0 0 12px 0; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "demo-components-ha-hs-color-picker": DemoHaHsColorPicker; + } +} diff --git a/gallery/src/pages/components/ha-temp-color-picker.markdown b/gallery/src/pages/components/ha-temp-color-picker.markdown new file mode 100644 index 0000000000..3221e55ee8 --- /dev/null +++ b/gallery/src/pages/components/ha-temp-color-picker.markdown @@ -0,0 +1,3 @@ +--- +title: Temp Color Picker +--- diff --git a/gallery/src/pages/components/ha-temp-color-picker.ts b/gallery/src/pages/components/ha-temp-color-picker.ts new file mode 100644 index 0000000000..d924e745bf --- /dev/null +++ b/gallery/src/pages/components/ha-temp-color-picker.ts @@ -0,0 +1,117 @@ +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` + +
+

${this.liveValue ?? this.value} K

+ +

Min temp : ${this.min} K

+ + +

Max temp : ${this.max} K

+ + +

Value : ${this.value} K

+ + +
+
+ `; + } + + 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; + } +} diff --git a/src/components/ha-color-picker.js b/src/components/ha-color-picker.js deleted file mode 100644 index f94018ab71..0000000000 --- a/src/components/ha-color-picker.js +++ /dev/null @@ -1,644 +0,0 @@ -import { html } from "@polymer/polymer/lib/utils/html-tag"; -/* eslint-plugin-disable lit */ -import { PolymerElement } from "@polymer/polymer/polymer-element"; -import { hs2rgb, rgb2hs } from "../common/color/convert-color"; -import { EventsMixin } from "../mixins/events-mixin"; -/** - * Color-picker custom element - * - * @appliesMixin EventsMixin - */ -class HaColorPicker extends EventsMixin(PolymerElement) { - static get template() { - return html` - -
- - - - - - - - - - - - - -
- `; - } - - static get properties() { - return { - hsColor: { - type: Object, - }, - - // use these properties to update the state via attributes - desiredHsColor: { - type: Object, - observer: "applyHsColor", - }, - - // use these properties to update the state via attributes - desiredRgbColor: { - type: Object, - observer: "applyRgbColor", - }, - - // width, height and radius apply to the coordinates of - // of the canvas. - // border width are relative to these numbers - // the onscreen displayed size should be controlled with css - // and should be the same or smaller - width: { - type: Number, - value: 500, - }, - - height: { - type: Number, - value: 500, - }, - - radius: { - type: Number, - value: 225, - }, - - // the amount segments for the hue - // 0 = continuous gradient - // other than 0 gives 'pie-pieces' - hueSegments: { - type: Number, - value: 0, - observer: "segmentationChange", - }, - - // the amount segments for the hue - // 0 = continuous gradient - // 1 = only fully saturated - // > 1 = segments from white to fully saturated - saturationSegments: { - type: Number, - value: 0, - observer: "segmentationChange", - }, - - // set to true to make the segments purely esthetical - // this allows selection off all collors, also - // interpolated between the segments - ignoreSegments: { - type: Boolean, - value: false, - }, - - // throttle te amount of 'colorselected' events fired - // value is timeout in milliseconds - throttle: { - type: Number, - value: 500, - }, - }; - } - - ready() { - super.ready(); - this.setupLayers(); - this.drawColorWheel(); - this.drawMarker(); - - if (this.desiredHsColor) { - this.applyHsColor(this.desiredHsColor); - } - - if (this.desiredRgbColor) { - this.applyRgbColor(this.desiredRgbColor); - } - - this.interactionLayer.addEventListener("mousedown", (ev) => - this.onMouseDown(ev) - ); - this.interactionLayer.addEventListener("touchstart", (ev) => - this.onTouchStart(ev) - ); - } - - // converts browser coordinates to canvas canvas coordinates - // origin is wheel center - // returns {x: X, y: Y} object - convertToCanvasCoordinates(clientX, clientY) { - const svgPoint = this.interactionLayer.createSVGPoint(); - svgPoint.x = clientX; - svgPoint.y = clientY; - const cc = svgPoint.matrixTransform( - this.interactionLayer.getScreenCTM().inverse() - ); - return { x: cc.x, y: cc.y }; - } - - // Mouse events - - onMouseDown(ev) { - const cc = this.convertToCanvasCoordinates(ev.clientX, ev.clientY); - // return if we're not on the wheel - if (!this.isInWheel(cc.x, cc.y)) { - return; - } - // a mousedown in wheel is always a color select action - this.onMouseSelect(ev); - // allow dragging - this.canvas.classList.add("mouse", "dragging"); - this.addEventListener("mousemove", this.onMouseSelect); - this.addEventListener("mouseup", this.onMouseUp); - } - - onMouseUp() { - this.canvas.classList.remove("mouse", "dragging"); - this.removeEventListener("mousemove", this.onMouseSelect); - } - - onMouseSelect(ev) { - requestAnimationFrame(() => this.processUserSelect(ev)); - } - - // Touch events - - onTouchStart(ev) { - const touch = ev.changedTouches[0]; - const cc = this.convertToCanvasCoordinates(touch.clientX, touch.clientY); - // return if we're not on the wheel - if (!this.isInWheel(cc.x, cc.y)) { - return; - } - if (ev.target === this.marker) { - // drag marker - ev.preventDefault(); - this.canvas.classList.add("touch", "dragging"); - this.addEventListener("touchmove", this.onTouchSelect); - this.addEventListener("touchend", this.onTouchEnd); - return; - } - // don't fire color selection immediately, - // wait for touchend and invalidate when we scroll - this.tapBecameScroll = false; - this.addEventListener("touchend", this.onTap); - this.addEventListener( - "touchmove", - () => { - this.tapBecameScroll = true; - }, - { passive: true } - ); - } - - onTap(ev) { - if (this.tapBecameScroll) { - return; - } - ev.preventDefault(); - this.onTouchSelect(ev); - } - - onTouchEnd() { - this.canvas.classList.remove("touch", "dragging"); - this.removeEventListener("touchmove", this.onTouchSelect); - } - - onTouchSelect(ev) { - requestAnimationFrame(() => this.processUserSelect(ev.changedTouches[0])); - } - - /* - * General event/selection handling - */ - - // Process user input to color - processUserSelect(ev) { - const canvasXY = this.convertToCanvasCoordinates(ev.clientX, ev.clientY); - const hs = this.getColor(canvasXY.x, canvasXY.y); - let rgb; - if (!this.isInWheel(canvasXY.x, canvasXY.y)) { - const [r, g, b] = hs2rgb([hs.h, hs.s]); - rgb = { r, g, b }; - } else { - rgb = this.getRgbColor(canvasXY.x, canvasXY.y); - } - this.onColorSelect(hs, rgb); - } - - // apply color to marker position and canvas - onColorSelect(hs, rgb) { - this.setMarkerOnColor(hs); // marker always follows mouse 'raw' hs value (= mouse position) - if (!this.ignoreSegments) { - // apply segments if needed - hs = this.applySegmentFilter(hs); - } - // always apply the new color to the interface / canvas - this.applyColorToCanvas(hs); - // throttling is applied to updating the exposed colors (properties) - // and firing of events - if (this.colorSelectIsThrottled) { - // make sure we apply the last selected color - // eventually after throttle limit has passed - clearTimeout(this.ensureFinalSelect); - this.ensureFinalSelect = setTimeout(() => { - this.fireColorSelected(hs, rgb); // do it for the final time - }, this.throttle); - return; - } - this.fireColorSelected(hs, rgb); // do it - this.colorSelectIsThrottled = true; - setTimeout(() => { - this.colorSelectIsThrottled = false; - }, this.throttle); - } - - // set color values and fire colorselected event - fireColorSelected(hs, rgb) { - this.hsColor = hs; - this.fire("colorselected", { hs, rgb }); - } - - /* - * Interface updating - */ - - // set marker position to the given color - setMarkerOnColor(hs) { - if (!this.marker || !this.tooltip) { - return; - } - const dist = hs.s * this.radius; - const theta = ((hs.h - 180) / 180) * Math.PI; - const markerdX = -dist * Math.cos(theta); - const markerdY = -dist * Math.sin(theta); - const translateString = `translate(${markerdX},${markerdY})`; - this.marker.setAttribute("transform", translateString); - this.tooltip.setAttribute("transform", translateString); - } - - // apply given color to interface elements - applyColorToCanvas(hs) { - if (!this.interactionLayer) { - return; - } - // we're not really converting hs to hsl here, but we keep it cheap - // setting the color on the interactionLayer, the svg elements can inherit - this.interactionLayer.style.color = `hsl(${hs.h}, 100%, ${ - 100 - hs.s * 50 - }%)`; - } - - applyHsColor(hs) { - // do nothing is we already have the same color - if (this.hsColor && this.hsColor.h === hs.h && this.hsColor.s === hs.s) { - return; - } - this.setMarkerOnColor(hs); // marker is always set on 'raw' hs position - if (!this.ignoreSegments) { - // apply segments if needed - hs = this.applySegmentFilter(hs); - } - this.hsColor = hs; - // always apply the new color to the interface / canvas - this.applyColorToCanvas(hs); - } - - applyRgbColor(rgb) { - const [h, s] = rgb2hs(rgb); - this.applyHsColor({ h, s }); - } - - /* - * input processing helpers - */ - - // get angle (degrees) - getAngle(dX, dY) { - const theta = Math.atan2(-dY, -dX); // radians from the left edge, clockwise = positive - const angle = (theta / Math.PI) * 180 + 180; // degrees, clockwise from right - return angle; - } - - // returns true when coordinates are in the colorwheel - isInWheel(x, y) { - return this.getDistance(x, y) <= 1; - } - - // returns distance from wheel center, 0 = center, 1 = edge, >1 = outside - getDistance(dX, dY) { - return Math.sqrt(dX * dX + dY * dY) / this.radius; - } - - /* - * Getting colors - */ - - getColor(x, y) { - const hue = this.getAngle(x, y); // degrees, clockwise from right - const relativeDistance = this.getDistance(x, y); // edge of radius = 1 - const sat = Math.min(relativeDistance, 1); // Distance from center - return { h: hue, s: sat }; - } - - getRgbColor(x, y) { - // get current pixel - const imageData = this.backgroundLayer - .getContext("2d") - .getImageData(x + 250, y + 250, 1, 1); - const pixel = imageData.data; - return { r: pixel[0], g: pixel[1], b: pixel[2] }; - } - - applySegmentFilter(hs) { - // apply hue segment steps - if (this.hueSegments) { - const angleStep = 360 / this.hueSegments; - const halfAngleStep = angleStep / 2; - hs.h -= halfAngleStep; // take the 'centered segemnts' into account - if (hs.h < 0) { - hs.h += 360; - } // don't end up below 0 - const rest = hs.h % angleStep; - hs.h -= rest - angleStep; - } - - // apply saturation segment steps - if (this.saturationSegments) { - if (this.saturationSegments === 1) { - hs.s = 1; - } else { - const segmentSize = 1 / this.saturationSegments; - const saturationStep = 1 / (this.saturationSegments - 1); - const calculatedSat = Math.floor(hs.s / segmentSize) * saturationStep; - hs.s = Math.min(calculatedSat, 1); - } - } - return hs; - } - - /* - * Drawing related stuff - */ - - setupLayers() { - this.canvas = this.$.canvas; - this.backgroundLayer = this.$.backgroundLayer; - this.interactionLayer = this.$.interactionLayer; - - // coordinate origin position (center of the wheel) - this.originX = this.width / 2; - this.originY = this.originX; - - // synchronise width/height coordinates - this.backgroundLayer.width = this.width; - this.backgroundLayer.height = this.height; - this.interactionLayer.setAttribute( - "viewBox", - `${-this.originX} ${-this.originY} ${this.width} ${this.height}` - ); - } - - drawColorWheel() { - /* - * Setting up all paremeters - */ - let shadowColor; - let shadowOffsetX; - let shadowOffsetY; - let shadowBlur; - const context = this.backgroundLayer.getContext("2d"); - // postioning and sizing - const cX = this.originX; - const cY = this.originY; - const radius = this.radius; - const counterClockwise = false; - // styling of the wheel - const wheelStyle = window.getComputedStyle(this.backgroundLayer, null); - const borderWidth = parseInt( - wheelStyle.getPropertyValue("--wheel-borderwidth"), - 10 - ); - const borderColor = wheelStyle - .getPropertyValue("--wheel-bordercolor") - .trim(); - const wheelShadow = wheelStyle.getPropertyValue("--wheel-shadow").trim(); - // extract shadow properties from CSS variable - // the shadow should be defined as: "10px 5px 5px 0px COLOR" - if (wheelShadow !== "none") { - const values = wheelShadow.split("px "); - shadowColor = values.pop(); - shadowOffsetX = parseInt(values[0], 10); - shadowOffsetY = parseInt(values[1], 10); - shadowBlur = parseInt(values[2], 10) || 0; - } - const borderRadius = radius + borderWidth / 2; - const wheelRadius = radius; - const shadowRadius = radius + borderWidth; - - /* - * Drawing functions - */ - function drawCircle(hueSegments, saturationSegments) { - hueSegments = hueSegments || 360; // reset 0 segments to 360 - const angleStep = 360 / hueSegments; - const halfAngleStep = angleStep / 2; // center segments on color - for (let angle = 0; angle <= 360; angle += angleStep) { - const startAngle = (angle - halfAngleStep) * (Math.PI / 180); - const endAngle = (angle + halfAngleStep + 1) * (Math.PI / 180); - context.beginPath(); - context.moveTo(cX, cY); - context.arc( - cX, - cY, - wheelRadius, - startAngle, - endAngle, - counterClockwise - ); - context.closePath(); - // gradient - const gradient = context.createRadialGradient( - cX, - cY, - 0, - cX, - cY, - wheelRadius - ); - let lightness = 100; - // first gradient stop - gradient.addColorStop(0, `hsl(${angle}, 100%, ${lightness}%)`); - // segment gradient stops - if (saturationSegments > 0) { - const ratioStep = 1 / saturationSegments; - let ratio = 0; - for (let stop = 1; stop < saturationSegments; stop += 1) { - const prevLighness = lightness; - ratio = stop * ratioStep; - lightness = 100 - 50 * ratio; - gradient.addColorStop( - ratio, - `hsl(${angle}, 100%, ${prevLighness}%)` - ); - gradient.addColorStop(ratio, `hsl(${angle}, 100%, ${lightness}%)`); - } - gradient.addColorStop(ratio, `hsl(${angle}, 100%, 50%)`); - } - // last gradient stop - gradient.addColorStop(1, `hsl(${angle}, 100%, 50%)`); - - context.fillStyle = gradient; - context.fill(); - } - } - - function drawShadow() { - context.save(); - context.beginPath(); - context.arc(cX, cY, shadowRadius, 0, 2 * Math.PI, false); - context.shadowColor = shadowColor; - context.shadowOffsetX = shadowOffsetX; - context.shadowOffsetY = shadowOffsetY; - context.shadowBlur = shadowBlur; - context.fillStyle = "white"; - context.fill(); - context.restore(); - } - - function drawBorder() { - context.beginPath(); - context.arc(cX, cY, borderRadius, 0, 2 * Math.PI, false); - context.lineWidth = borderWidth; - context.strokeStyle = borderColor; - context.stroke(); - } - - /* - * Call the drawing functions - * draws the shadow, wheel and border - */ - if (wheelStyle.shadow !== "none") { - drawShadow(); - } - drawCircle(this.hueSegments, this.saturationSegments); - if (borderWidth > 0) { - drawBorder(); - } - } - - /* - * Draw the (draggable) marker and tooltip - * on the interactionLayer) - */ - - drawMarker() { - const svgElement = this.interactionLayer; - const markerradius = this.radius * 0.08; - const tooltipradius = this.radius * 0.15; - const TooltipOffsetY = -(tooltipradius * 3); - const TooltipOffsetX = 0; - - svgElement.marker = document.createElementNS( - "http://www.w3.org/2000/svg", - "circle" - ); - svgElement.marker.setAttribute("id", "marker"); - svgElement.marker.setAttribute("r", markerradius); - this.marker = svgElement.marker; - svgElement.appendChild(svgElement.marker); - - svgElement.tooltip = document.createElementNS( - "http://www.w3.org/2000/svg", - "circle" - ); - svgElement.tooltip.setAttribute("id", "colorTooltip"); - svgElement.tooltip.setAttribute("r", tooltipradius); - svgElement.tooltip.setAttribute("cx", TooltipOffsetX); - svgElement.tooltip.setAttribute("cy", TooltipOffsetY); - this.tooltip = svgElement.tooltip; - svgElement.appendChild(svgElement.tooltip); - } - - segmentationChange() { - if (this.backgroundLayer) { - this.drawColorWheel(); - } - } -} -customElements.define("ha-color-picker", HaColorPicker); diff --git a/src/components/ha-hs-color-picker.ts b/src/components/ha-hs-color-picker.ts new file mode 100644 index 0000000000..33fa5977f2 --- /dev/null +++ b/src/components/ha-hs-color-picker.ts @@ -0,0 +1,329 @@ +import { DIRECTION_ALL, Manager, Pan, Tap } from "@egjs/hammerjs"; +import { css, html, LitElement, PropertyValues, 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 { hsv2rgb, rgb2hex } from "../common/color/convert-color"; +import { fireEvent } from "../common/dom/fire_event"; + +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 rad2deg(rad: number) { + return (rad / (2 * Math.PI)) * 360; +} + +function deg2rad(deg: number) { + return (deg / 360) * 2 * Math.PI; +} + +function drawColorWheel(ctx: CanvasRenderingContext2D, colorBrightness = 255) { + const radius = ctx.canvas.width / 2; + + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + ctx.beginPath(); + + const cX = ctx.canvas.width / 2; + const cY = ctx.canvas.width / 2; + for (let angle = 0; angle < 360; angle += 1) { + const startAngle = deg2rad(angle - 0.5); + const endAngle = deg2rad(angle + 1.5); + + ctx.beginPath(); + ctx.moveTo(cX, cY); + ctx.arc(cX, cY, radius, startAngle, endAngle); + 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])); + gradient.addColorStop(0, start); + gradient.addColorStop(1, end); + ctx.fillStyle = gradient; + ctx.fill(); + } +} + +@customElement("ha-hs-color-picker") +class HaHsColorPicker extends LitElement { + @property({ type: Boolean, reflect: true }) + public disabled = false; + + @property({ type: Number, attribute: false }) + public renderSize?: number; + + @property({ type: Number }) + public value?: [number, number]; + + @property({ type: Number }) + public colorBrightness?: number; + + @query("#canvas") private _canvas!: HTMLCanvasElement; + + private _mc?: HammerManager; + + @state() + private _pressed?: string; + + @state() + private _cursorPosition?: [number, number]; + + @state() + private _localValue?: [number, number]; + + protected firstUpdated(changedProps: PropertyValues): void { + super.firstUpdated(changedProps); + this._setupListeners(); + this._generateColorWheel(); + } + + private _generateColorWheel() { + const ctx = this._canvas.getContext("2d")!; + drawColorWheel(ctx, this.colorBrightness); + } + + connectedCallback(): void { + super.connectedCallback(); + this._setupListeners(); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this._destroyListeners(); + } + + protected updated(changedProps: PropertyValues): void { + super.updated(changedProps); + if (changedProps.has("colorBrightness")) { + this._generateColorWheel(); + } + if (changedProps.has("value")) { + if ( + this.value !== undefined && + (this._localValue?.[0] !== this.value[0] || + this._localValue?.[1] !== this.value[1]) + ) { + this._resetPosition(); + } + } + } + + _setupListeners() { + if (this._canvas && !this._mc) { + this._mc = new Manager(this._canvas); + this._mc.add( + new Pan({ + direction: DIRECTION_ALL, + enable: true, + }) + ); + + 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 }); + }); + } + } + + private _resetPosition() { + if (this.value === undefined) return; + this._cursorPosition = this._getCoordsFromValue(this.value); + this._localValue = this.value; + } + + private _getCoordsFromValue = (value: [number, number]): [number, number] => { + const phi = deg2rad(value[0]); + + const r = Math.min(value[1], 1); + + const [x, y] = polar2xy(r, phi); + + return [x, y]; + }; + + private _getValueFromCoord = (x: number, y: number): [number, number] => { + const [r, phi] = xy2polar(x, y); + + const deg = Math.round(rad2deg(phi)) % 360; + + const hue = (deg + 360) % 360; + const saturation = Math.round(Math.min(r, 1) * 100) / 100; + + return [hue, saturation]; + }; + + 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]; + }; + + _destroyListeners() { + if (this._mc) { + this._mc.destroy(); + this._mc = undefined; + } + } + + render() { + const size = this.renderSize || 400; + const canvasSize = size * window.devicePixelRatio; + + const rgb = + this._localValue !== undefined + ? hsv2rgb([ + this._localValue[0], + this._localValue[1], + this.colorBrightness ?? 255, + ]) + : ([255, 255, 255] as [number, number, number]); + + 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 ? "1.5" : "1"; + const markerOffset = + this._pressed === "touch" ? `0px, -${size / 8}px` : "0px, 0px"; + + return html` +
+ + + ${this.renderSVGFilter()} + + + + +
+ `; + } + + renderSVGFilter() { + return svg` + + + + + `; + } + + static get styles() { + return css` + :host { + display: block; + } + .container { + position: relative; + width: 100%; + height: 100%; + cursor: pointer; + display: flex; + } + canvas { + width: 100%; + height: 100%; + } + 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-hs-color-picker": HaHsColorPicker; + } +} diff --git a/src/components/ha-temp-color-picker.ts b/src/components/ha-temp-color-picker.ts new file mode 100644 index 0000000000..ddc97b53ed --- /dev/null +++ b/src/components/ha-temp-color-picker.ts @@ -0,0 +1,354 @@ +import { DIRECTION_ALL, Manager, Pan, Tap } from "@egjs/hammerjs"; +import { css, html, LitElement, PropertyValues, 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 { 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); + 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 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, + 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 + 1) / 2; + + const temperature = min + fraction * (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() min = 2000; + + @property() max = 10000; + + @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(); + } + + 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("min") || changedProps.has("max")) { + this._generateColorWheel(); + this._resetPosition(); + } + if (changedProps.has("value")) { + if (this.value !== undefined && this._localValue !== this.value) { + this._resetPosition(); + } + } + } + + _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 }); + }); + } + } + + private _resetPosition() { + if (this.value === 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] => { + const fraction = (temperature - this.min) / (this.max - this.min); + const y = 2 * fraction - 1; + return [0, y]; + }; + + private _getValueFromCoord = (_x: number, y: number): number => { + const fraction = (y + 1) / 2; + const temperature = this.min + fraction * (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]; + }; + + _destroyListeners() { + if (this._mc) { + this._mc.destroy(); + this._mc = undefined; + } + } + + 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 [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 ? "1.5" : "1"; + const markerOffset = + this._pressed === "touch" ? `0px, -${size / 8}px` : "0px, 0px"; + + return html` +
+ + + ${this.renderSVGFilter()} + + + + +
+ `; + } + + renderSVGFilter() { + return svg` + + + + + `; + } + + static get styles() { + return css` + :host { + display: block; + } + .container { + position: relative; + width: 100%; + height: 100%; + cursor: pointer; + display: flex; + } + canvas { + width: 100%; + height: 100%; + } + 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; + } +} diff --git a/src/dialogs/more-info/components/lights/ha-more-info-view-light-color-picker.ts b/src/dialogs/more-info/components/lights/ha-more-info-view-light-color-picker.ts index 2fac5f0bdb..c17e116536 100644 --- a/src/dialogs/more-info/components/lights/ha-more-info-view-light-color-picker.ts +++ b/src/dialogs/more-info/components/lights/ha-more-info-view-light-color-picker.ts @@ -1,7 +1,6 @@ import "@material/mwc-button"; import "@material/mwc-tab-bar/mwc-tab-bar"; import "@material/mwc-tab/mwc-tab"; -import { mdiPalette } from "@mdi/js"; import { css, CSSResultGroup, @@ -11,12 +10,13 @@ import { PropertyValues, } from "lit"; import { customElement, property, state } from "lit/decorators"; +import { hs2rgb, rgb2hs } from "../../../../common/color/convert-color"; import { throttle } from "../../../../common/util/throttle"; import "../../../../components/ha-button-toggle-group"; -import "../../../../components/ha-color-picker"; -import "../../../../components/ha-control-slider"; +import "../../../../components/ha-hs-color-picker"; import "../../../../components/ha-icon-button-prev"; import "../../../../components/ha-labeled-slider"; +import "../../../../components/ha-temp-color-picker"; import { getLightCurrentModeRgbColor, LightColorMode, @@ -35,8 +35,6 @@ class MoreInfoViewLightColorPicker extends LitElement { @property() public params?: LightColorPickerViewParams; - @state() private _ctSliderValue?: number; - @state() private _cwSliderValue?: number; @state() private _wwSliderValue?: number; @@ -47,11 +45,9 @@ class MoreInfoViewLightColorPicker extends LitElement { @state() private _brightnessAdjusted?: number; - @state() private _hueSegments = 24; + @state() private _hsPickerValue?: [number, number]; - @state() private _saturationSegments = 8; - - @state() private _colorPickerColor?: [number, number, number]; + @state() private _ctPickerValue?: number; @state() private _mode?: Mode; @@ -94,47 +90,34 @@ class MoreInfoViewLightColorPicker extends LitElement { )} ` - : ""} + : nothing}
${this._mode === LightColorMode.COLOR_TEMP ? html`

- ${this._ctSliderValue ? `${this._ctSliderValue} K` : nothing} + ${this._ctPickerValue ? `${this._ctPickerValue} K` : nothing}

- - + ` - : ""} + : nothing} ${this._mode === "color" ? html` -
- - - -
+ + ${supportsRgbw || supportsRgbww ? html` ` - : ""} + : nothing} ` - : ""} + : nothing}
`; } @@ -212,7 +195,7 @@ class MoreInfoViewLightColorPicker extends LitElement { this._brightnessAdjusted = maxVal; } } - this._ctSliderValue = + this._ctPickerValue = stateObj.attributes.color_mode === LightColorMode.COLOR_TEMP ? stateObj.attributes.color_temp_kelvin : undefined; @@ -239,14 +222,12 @@ class MoreInfoViewLightColorPicker extends LitElement { ? Math.round((Math.max(...currentRgbColor.slice(0, 3)) * 100) / 255) : undefined; - this._colorPickerColor = currentRgbColor?.slice(0, 3) as [ - number, - number, - number - ]; + this._hsPickerValue = currentRgbColor + ? rgb2hs(currentRgbColor.slice(0, 3) as [number, number, number]) + : undefined; } else { - this._colorPickerColor = [0, 0, 0]; - this._ctSliderValue = undefined; + this._hsPickerValue = [0, 0]; + this._ctPickerValue = undefined; this._wvSliderValue = undefined; this._cwSliderValue = undefined; this._wwSliderValue = undefined; @@ -295,14 +276,79 @@ class MoreInfoViewLightColorPicker extends LitElement { this._mode = newMode; } - private _ctSliderMoved(ev: CustomEvent) { + private _hsColorCursorMoved(ev: CustomEvent) { + if (!ev.detail.value) { + return; + } + this._hsPickerValue = ev.detail.value; + + this._throttleUpdateColor(); + } + + private _throttleUpdateColor = throttle(() => this._updateColor(), 500); + + private _updateColor() { + const hs_color = this._hsPickerValue!; + const rgb_color = hs2rgb(hs_color); + + if ( + lightSupportsColorMode(this.stateObj!, LightColorMode.RGBWW) || + lightSupportsColorMode(this.stateObj!, LightColorMode.RGBW) + ) { + this._setRgbWColor( + this._colorBrightnessSliderValue + ? this._adjustColorBrightness( + rgb_color, + (this._colorBrightnessSliderValue * 255) / 100 + ) + : rgb_color + ); + } else if (lightSupportsColorMode(this.stateObj!, LightColorMode.RGB)) { + if (this._brightnessAdjusted) { + const brightnessAdjust = (this._brightnessAdjusted / 255) * 100; + const brightnessPercentage = Math.round( + ((this.stateObj!.attributes.brightness || 0) * brightnessAdjust) / 255 + ); + this.hass.callService("light", "turn_on", { + entity_id: this.stateObj!.entity_id, + brightness_pct: brightnessPercentage, + rgb_color: this._adjustColorBrightness( + rgb_color, + this._brightnessAdjusted, + true + ), + }); + } else { + this.hass.callService("light", "turn_on", { + entity_id: this.stateObj!.entity_id, + rgb_color, + }); + } + } else { + this.hass.callService("light", "turn_on", { + entity_id: this.stateObj!.entity_id, + hs_color: [hs_color[0], hs_color[1] * 100], + }); + } + } + + private _hsColorChanged(ev: CustomEvent) { + if (!ev.detail.value) { + return; + } + this._hsPickerValue = ev.detail.value; + + this._updateColor(); + } + + private _ctColorCursorMoved(ev: CustomEvent) { const ct = ev.detail.value; - if (isNaN(ct) || this._ctSliderValue === ct) { + if (isNaN(ct) || this._ctPickerValue === ct) { return; } - this._ctSliderValue = ct; + this._ctPickerValue = ct; this._throttleUpdateColorTemp(); } @@ -310,18 +356,18 @@ class MoreInfoViewLightColorPicker extends LitElement { private _throttleUpdateColorTemp = throttle(() => { this.hass.callService("light", "turn_on", { entity_id: this.stateObj!.entity_id, - color_temp_kelvin: this._ctSliderValue, + color_temp_kelvin: this._ctPickerValue, }); }, 500); - private _ctSliderChanged(ev: CustomEvent) { + private _ctColorChanged(ev: CustomEvent) { const ct = ev.detail.value; - if (isNaN(ct) || this._ctSliderValue === ct) { + if (isNaN(ct) || this._ctPickerValue === ct) { return; } - this._ctSliderValue = ct; + this._ctPickerValue = ct; this.hass.callService("light", "turn_on", { entity_id: this.stateObj!.entity_id, @@ -399,16 +445,6 @@ class MoreInfoViewLightColorPicker extends LitElement { ); } - private _segmentClick() { - if (this._hueSegments === 24 && this._saturationSegments === 8) { - this._hueSegments = 0; - this._saturationSegments = 0; - } else { - this._hueSegments = 24; - this._saturationSegments = 8; - } - } - private _adjustColorBrightness( rgbColor: [number, number, number], value?: number, @@ -448,68 +484,6 @@ class MoreInfoViewLightColorPicker extends LitElement { } } - /** - * Called when a new color has been picked. - * should be throttled with the 'throttle=' attribute of the color picker - */ - private _colorPicked( - ev: CustomEvent<{ - hs: { h: number; s: number }; - rgb: { r: number; g: number; b: number }; - }> - ) { - this._colorPickerColor = [ - ev.detail.rgb.r, - ev.detail.rgb.g, - ev.detail.rgb.b, - ]; - - if ( - lightSupportsColorMode(this.stateObj!, LightColorMode.RGBWW) || - lightSupportsColorMode(this.stateObj!, LightColorMode.RGBW) - ) { - this._setRgbWColor( - this._colorBrightnessSliderValue - ? this._adjustColorBrightness( - [ev.detail.rgb.r, ev.detail.rgb.g, ev.detail.rgb.b], - (this._colorBrightnessSliderValue * 255) / 100 - ) - : [ev.detail.rgb.r, ev.detail.rgb.g, ev.detail.rgb.b] - ); - } else if (lightSupportsColorMode(this.stateObj!, LightColorMode.RGB)) { - const rgb_color: [number, number, number] = [ - ev.detail.rgb.r, - ev.detail.rgb.g, - ev.detail.rgb.b, - ]; - if (this._brightnessAdjusted) { - const brightnessAdjust = (this._brightnessAdjusted / 255) * 100; - const brightnessPercentage = Math.round( - ((this.stateObj!.attributes.brightness || 0) * brightnessAdjust) / 255 - ); - this.hass.callService("light", "turn_on", { - entity_id: this.stateObj!.entity_id, - brightness_pct: brightnessPercentage, - rgb_color: this._adjustColorBrightness( - rgb_color, - this._brightnessAdjusted, - true - ), - }); - } else { - this.hass.callService("light", "turn_on", { - entity_id: this.stateObj!.entity_id, - rgb_color, - }); - } - } else { - this.hass.callService("light", "turn_on", { - entity_id: this.stateObj!.entity_id, - hs_color: [ev.detail.hs.h, ev.detail.hs.s * 100], - }); - } - } - static get styles(): CSSResultGroup { return [ css` @@ -526,35 +500,16 @@ class MoreInfoViewLightColorPicker extends LitElement { flex: 1; } - .segmentation-container { - position: relative; - max-height: 500px; - display: flex; - justify-content: center; + ha-hs-color-picker { + max-width: 320px; + min-width: 200px; + margin: 44px 0 44px 0; } - .segmentation-button { - position: absolute; - top: 5%; - left: 0; - color: var(--secondary-text-color); - } - - ha-color-picker { - --ha-color-picker-wheel-borderwidth: 5; - --ha-color-picker-wheel-bordercolor: white; - --ha-color-picker-wheel-shadow: none; - --ha-color-picker-marker-borderwidth: 2; - --ha-color-picker-marker-bordercolor: white; - } - - ha-control-slider { - height: 45vh; - max-height: 320px; - min-height: 200px; - margin: 20px 0; - --control-slider-thickness: 100px; - --control-slider-border-radius: 24px; + ha-temp-color-picker { + max-width: 320px; + min-width: 200px; + margin: 20px 0 44px 0; } ha-labeled-slider { @@ -572,17 +527,6 @@ class MoreInfoViewLightColorPicker extends LitElement { direction: ltr; } - .color-temp { - --control-slider-background: -webkit-linear-gradient( - top, - rgb(166, 209, 255) 0%, - white 50%, - rgb(255, 160, 0) 100% - ); - --control-slider-background-opacity: 1; - margin-bottom: 44px; - } - hr { border-color: var(--divider-color); border-bottom: none;