# HG changeset patch # User drewp@bigasterisk.com # Date 2023-05-26 22:46:16 # Node ID 9645581bff2402536cc788190678d64e24fedf35 # Parent 8d6792a6ffdb425b7e61641c673d9f79f44e8231 redo color picker code in lit diff --git a/light9/live/Light9LiveControl.ts b/light9/live/Light9LiveControl.ts --- a/light9/live/Light9LiveControl.ts +++ b/light9/live/Light9LiveControl.ts @@ -2,15 +2,20 @@ import debug from "debug"; import { css, html, LitElement, PropertyValues } from "lit"; import { customElement, property } from "lit/decorators.js"; import { Literal, NamedNode } from "n3"; +import { SubEvent } from "sub-events"; import { SyncedGraph } from "../web/SyncedGraph"; import { ControlValue } from "./Effect"; import { GraphToControls } from "./GraphToControls"; import { DeviceAttrRow } from "./Light9DeviceControl"; import { Choice } from "./Light9Listbox"; +export { Slider } from "@material/mwc-slider"; +export { Light9ColorPicker } from "../web/light9-color-picker"; const log = debug("control"); -export { Slider } from "@material/mwc-slider"; + +const makeType = (d: "scalar" | "color" | "choice") => new NamedNode(`http://light9.bigasterisk.com/${d}`); + // UI for one device attr (of any type). @customElement("light9-live-control") export class Light9LiveControl extends LitElement { @@ -25,45 +30,12 @@ export class Light9LiveControl extends L #colorControls > * { margin: 0 3px; } - #colorControls paper-slider { - } - paper-slider { - width: 100%; - height: 25px; - } - - paper-slider { - --paper-slider-knob-color: var(--paper-red-500); - --paper-slider-active-color: var(--paper-red-500); - - --paper-slider-font-color: white; - --paper-slider-input: { - width: 75px; - - background: black; - display: inline-block; - } - } + :host { + border: 2px solid white; + } `, ]; - render() { - if (this.dataType.value === "http://light9.bigasterisk.com/scalar") { - return html` `; - } else if (this.dataType.value === "http://light9.bigasterisk.com/color") { - return html` -
- - -
- `; - } else if (this.dataType.value === "http://light9.bigasterisk.com/choice") { - return html` `; - } else { - throw new Error(`${this.dataType} unknown`); - } - } - // passed from parent @property() device!: NamedNode; @property() dataType: NamedNode; @@ -83,15 +55,34 @@ export class Light9LiveControl extends L @property() pickedChoice: Choice | null = null; @property() choiceValue: Choice | null = null; + valueChanged: SubEvent = new SubEvent(); + constructor() { super(); - this.dataType = new NamedNode("http://light9.bigasterisk.com/scalar"); + this.dataType = makeType("color"); // getTopGraph().then((g) => { // this.graph = g; // // this.graph.runHandler(this.graphReads.bind(this), `${this.device} ${this.deviceAttrRow.uri} reads`); // }); } + render() { + const dbg=html` + ]` + if (this.dataType.equals(makeType("scalar"))) { + return html`${dbg} `; + } else if (this.dataType.equals(makeType("color"))) { + return html` ${dbg} +
+ + +
+ `; + } else if (this.dataType.equals(makeType("choice"))) { + return html`${dbg} `; + } + } + // graphReads() { // const U = this.graph.U(); // } @@ -115,7 +106,7 @@ export class Light9LiveControl extends L onGraphValueChanged(v: ControlValue | null) { // log("change: control must display", v); // this.enableChange = false; - if (this.dataType.value == "http://light9.bigasterisk.com/scalar") { + if (this.dataType.equals(makeType("scalar"))) { if (v !== null) { setTimeout(() => { // only needed once per page layout diff --git a/light9/web/floating_color_picker.ts b/light9/web/floating_color_picker.ts new file mode 100644 --- /dev/null +++ b/light9/web/floating_color_picker.ts @@ -0,0 +1,311 @@ +// Note that this file deals only with hue+sat. See Light9ColorPicker for the value component. + +import debug from "debug"; +import { css, html, LitElement } from "lit"; +import { customElement, query } from "lit/decorators.js"; +import color from "onecolor"; +import { SubEvent } from "sub-events"; + +const log = debug("control"); + +function clamp(x: number, lo: number, hi: number) { + return Math.max(lo, Math.min(hi, x)); +} + +class RainbowCoord { + // origin is rainbow top-lefft + constructor(public x: number, public y: number) {} +} + +export class ClientCoord { + // origin is top-left of client viewport (regardless of page scroll) + constructor(public x: number, public y: number) {} +} + +// Load the rainbow, and map between colors and pixels. +class RainbowCanvas { + ctx: CanvasRenderingContext2D; + colorPos: { [color: string]: RainbowCoord } = {}; + _loaded = false; + _loadWatchers: (() => void)[] = []; + constructor(url: string, public size: RainbowCoord) { + var elem = document.createElement("canvas"); + elem.width = size.x; + elem.height = size.y; + this.ctx = elem.getContext("2d")!; + + var img = new Image(); + img.onload = () => { + this.ctx.drawImage(img, 0, 0); + this._readImage(); + this._loaded = true; + this._loadWatchers.forEach(function (cb) { + cb(); + }); + this._loadWatchers = []; + }; + img.src = url; + } + + onLoad(cb: () => void) { + // we'll call this when posFor is available + if (this._loaded) { + cb(); + return; + } + this._loadWatchers.push(cb); + } + + _readImage() { + var data = this.ctx.getImageData(0, 0, this.size.x, this.size.y).data; + for (var y = 0; y < this.size.y; y += 1) { + for (var x = 0; x < this.size.x; x += 1) { + var base = (y * this.size.x + x) * 4; + let px = [data[base + 0], data[base + 1], data[base + 2], 255]; + if (px[0] == 0 && px[1] == 0 && px[2] == 0) { + // (there's no black on the rainbow images) + throw new Error(`color picker canvas (${this.size.x}) returns 0,0,0`); + } + var c = color(px).hex(); + this.colorPos[c] = new RainbowCoord(x, y); + } + } + } + + colorAt(pos: RainbowCoord) { + var data = this.ctx.getImageData(pos.x, pos.y, 1, 1).data; + return color([data[0], data[1], data[2], 255]).hex(); + } + + posFor(col: string): RainbowCoord { + if (col == "#000000") { + throw new Error("no match"); + } + + let bright = color(col).value(1).hex(); + let r = parseInt(bright.slice(1, 3), 16), + g = parseInt(bright.slice(3, 5), 16), + b = parseInt(bright.slice(5, 7), 16); + + // We may not have a match for this color exactly (e.g. on + // the small image), so we have to search for a near one. + + // 0, 1, -1, 2, -2, ... + let walk = function (x: number): number { + return -x + (x > 0 ? 0 : 1); + }; + + var radius = 8; + for (var dr = 0; dr < radius; dr = walk(dr)) { + for (var dg = 0; dg < radius; dg = walk(dg)) { + for (var db = 0; db < radius; db = walk(db)) { + // Don't need bounds check- out of range + // corrupt colors just won't match. + const color2 = color([r + dr, g + dg, b + db, 255]); + const pos = this.colorPos[color2.hex()]; + if (pos !== undefined) { + return pos; + } + } + } + } + throw new Error("no match"); + } +} + +// One-per-page element that floats above everything. Plus the scrim element, which is also per-page. +@customElement("light9-color-picker-float") +class Light9ColorPickerFloat extends LitElement { + static styles = [ + css` + :host { + z-index: 10; + position: fixed; /* host coords are the same as client coords /* + left: 0; + top: 0; + width: 100%; + height: 100%; + + /* Updated later. */ + display: none; + } + #largeCrosshair { + position: absolute; + left: -60px; + top: -62px; + pointer-events: none; + } + #largeCrosshair { + background: url(/colorpick_crosshair_large.svg); + width: 1000px; + height: 1000px; + } + #largeRainbowComp { + z-index: 2; + position: relative; + width: 400px; + height: 200px; + border: 10px solid #000; + box-shadow: 8px 11px 40px 0px rgba(0, 0, 0, 0.74); + overflow: hidden; + } + #largeRainbow { + background: url(/colorpick_rainbow_large.png); + width: 400px; + height: 200px; + user-select: none; + } + #outOfBounds { + user-select: none; + z-index: 1; + background: #00000060; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + } + `, + ]; + + @query("#largeCrosshair") largeCrosshairEl!: HTMLElement; + @query("#largeRainbow") largeRainbowEl!: HTMLElement; + + canvasMove: SubEvent = new SubEvent(); + outsideMove: SubEvent = new SubEvent(); + mouseUp: SubEvent = new SubEvent(); + + render() { + return html` + +
+
+
+
+
+ `; + } + + // make top-left of rainbow image be at this pos + placeRainbow(pos: ClientCoord) { + const el = this.shadowRoot?.querySelector("#largeRainbowComp")! as HTMLElement; + const cssBorder = 10; + el.style.left = pos.x - cssBorder + "px"; + el.style.top = pos.y - cssBorder + "px"; + } + + moveLargeCrosshair(pos: RainbowCoord) { + const ch = this.largeCrosshairEl; + ch.style.left = pos.x - ch.offsetWidth / 2 + "px"; + ch.style.top = pos.y - ch.offsetHeight / 2 + "px"; + } + + private onCanvasMove(ev: MouseEvent) { + this.canvasMove.emit(new RainbowCoord(ev.offsetX, ev.offsetY)); + } + + private onMouseUp(ev: MouseEvent) { + this.mouseUp.emit(); + } + + private onOutOfBoundsMove(ev: MouseEvent) { + this.outsideMove.emit(new ClientCoord(ev.clientX, ev.clientY)); + } +} + +class PickerFloat { + private rainbow?: RainbowCanvas; + private currentListener?: (hsc: string) => void; + private rainbowOrigin: ClientCoord = new ClientCoord(0, 0); + private floatEl?: Light9ColorPickerFloat; + + pageInit() { + this.getFloatEl(); + this.getRainbow(); + } + + private getFloatEl(): Light9ColorPickerFloat { + if (!this.floatEl) { + this.floatEl = document.createElement("light9-color-picker-float") as Light9ColorPickerFloat; + this.subscribeToFloatElement(this.floatEl); + document.body.appendChild(this.floatEl); + } + return this.floatEl; + } + + private subscribeToFloatElement(el: Light9ColorPickerFloat) { + el.canvasMove.subscribe(this.onCanvasMove.bind(this)); + el.outsideMove.subscribe(this.onOutsideMove.bind(this)); + el.mouseUp.subscribe(() => { + this.hide(); + }); + } + + private onCanvasMove(pos: RainbowCoord) { + pos = new RainbowCoord( // + clamp(pos.x, 0, 400 - 1), // + clamp(pos.y, 0, 200 - 1) + ); + this.getFloatEl().moveLargeCrosshair(pos); + if (this.currentListener) { + this.currentListener(this.getRainbow().colorAt(pos)); + } + } + + private onOutsideMove(pos: ClientCoord) { + const rp = this.toRainbow(pos); + log("rp", rp); + this.onCanvasMove(rp); + } + + private getRainbow(): RainbowCanvas { + if (!this.rainbow) { + this.rainbow = new RainbowCanvas("/colorpick_rainbow_large.png", new RainbowCoord(400, 200)); + } + return this.rainbow; + } + + startPick(clickPoint: ClientCoord, startColor: string, onNewHueSatColor: (hsc: string) => void) { + log("start pick", clickPoint); + const el = this.getFloatEl(); + + let pos: RainbowCoord; + try { + pos = this.getRainbow().posFor(startColor); + } catch (e) { + pos = new RainbowCoord(-999, -999); + } + + this.rainbowOrigin = new ClientCoord( // + clickPoint.x - clamp(pos.x, 0, 400), // + clickPoint.y - clamp(pos.y, 0, 200) + ); + log("rainbow goes to", this.rainbowOrigin); + + el.placeRainbow(this.rainbowOrigin); + setTimeout(() => { + this.getFloatEl().moveLargeCrosshair(pos); + }, 1); + + el.style.display = "block"; + log("set listener"); + this.currentListener = onNewHueSatColor; + } + + private hide() { + const el = this.getFloatEl(); + el.style.display = "none"; + this.currentListener = undefined; + } + + private toRainbow(pos: ClientCoord): RainbowCoord { + return new RainbowCoord( // + pos.x - this.rainbowOrigin.x, // + pos.y - this.rainbowOrigin.y + ); + } +} + +export const pickerFloat = new PickerFloat(); diff --git a/light9/web/light9-color-picker.ts b/light9/web/light9-color-picker.ts --- a/light9/web/light9-color-picker.ts +++ b/light9/web/light9-color-picker.ts @@ -1,157 +1,13 @@ import debug from "debug"; -import { css, html, LitElement, PropertyValues } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import { Literal, NamedNode } from "n3"; -import { SyncedGraph } from "../web/SyncedGraph"; -import { ControlValue } from "./Effect"; -import { GraphToControls } from "./GraphToControls"; -import { DeviceAttrRow } from "./Light9DeviceControl"; -import { Choice } from "./Light9Listbox"; +import { css, html, LitElement, PropertyValueMap } from "lit"; +import { customElement, property, query } from "lit/decorators.js"; +import color from "onecolor"; import { SubEvent } from "sub-events"; -import color from "onecolor"; +import { ClientCoord, pickerFloat } from "./floating_color_picker"; +import { Slider } from "@material/mwc-slider"; const log = debug("control"); - -@customElement("light9-color-picker-float") -export class Light9ColorPickerFloat extends LitElement { - static styles = [ - css` - :host { - z-index: 10; - position: fixed; - width: 400px; - height: 200px; - border: 10px solid #000; - box-shadow: 8px 11px 40px 0px rgba(0, 0, 0, 0.74); - /* This display (and border-color) are replaced later. */ - display: none; - } - #largeCrosshair { - position: absolute; - left: -60px; - top: -62px; - pointer-events: none; - } - #largeCrosshair { - background: url(/colorpick_crosshair_large.svg); - width: 1000px; - height: 1000px; - } - #largeRainbowComp { - display: inline-block; - overflow: hidden; - position: relative; - } - #largeRainbowComp { - position: absolute; - left: 0x; - top: 0; - } - #largeRainbow { - background: url(/colorpick_rainbow_large.png); - width: 400px; - height: 200px; - user-select: none; - } - `, - ]; - render() { - return html` -
-
-
-
- `; - } - // more methods get added by Light9ColorPicker -} - -class RainbowCanvas { - constructor(url, size) { - this.size = size; - var elem = document.createElement("canvas"); - elem.width = size[0]; - elem.height = size[1]; - this.ctx = elem.getContext("2d"); - - this.colorPos = {}; // color: pos - this._loaded = false; - this._loadWatchers = []; // callbacks - - var img = new Image(); - img.onload = function () { - this.ctx.drawImage(img, 0, 0); - this._readImage(); - this._loaded = true; - this._loadWatchers.forEach(function (cb) { - cb(); - }); - this._loadWatchers = []; - }.bind(this); - img.src = url; - } - onLoad(cb) { - // we'll call this when posFor is available - if (this._loaded) { - cb(); - return; - } - this._loadWatchers.push(cb); - } - _readImage() { - var data = this.ctx.getImageData(0, 0, this.size[0], this.size[1]).data; - for (var y = 0; y < this.size[1]; y += 1) { - for (var x = 0; x < this.size[0]; x += 1) { - var base = (y * this.size[0] + x) * 4; - let px = [data[base + 0], data[base + 1], data[base + 2], 255]; - if (px[0] == 0 && px[1] == 0 && px[2] == 0) { - // (there's no black on the rainbow images) - throw new Error(`color picker canvas (${this.size[0]}) returns 0,0,0`); - } - var c = one.color(px).hex(); - this.colorPos[c] = [x, y]; - } - } - } - colorAt(pos) { - var data = this.ctx.getImageData(pos[0], pos[1], 1, 1).data; - return one.color([data[0], data[1], data[2], 255]).hex(); - } - posFor(color) { - if (color == "#000000") { - throw new Error("no match"); - } - - let bright = one.color(color).value(1).hex(); - let r = parseInt(bright.substr(1, 2), 16), - g = parseInt(bright.substr(3, 2), 16), - b = parseInt(bright.substr(5, 2), 16); - - // We may not have a match for this color exactly (e.g. on - // the small image), so we have to search for a near one. - - // 0, 1, -1, 2, -2, ... - let walk = function (x) { - return -x + (x > 0 ? 0 : 1); - }; - - var radius = 8; - for (var dr = 0; dr < radius; dr = walk(dr)) { - for (var dg = 0; dg < radius; dg = walk(dg)) { - for (var db = 0; db < radius; db = walk(db)) { - // Don't need bounds check- out of range - // corrupt colors just won't match. - color = one.color([r + dr, g + dg, b + db, 255]).hex(); - var pos = this.colorPos[color]; - if (pos !== undefined) { - return pos; - } - } - } - } - throw new Error("no match"); - } -} +type int8 = number; @customElement("light9-color-picker") export class Light9ColorPicker extends LitElement { @@ -173,7 +29,7 @@ export class Light9ColorPicker extends L border: 1px solid #333; } - paper-slider { + mwc-slider { width: 160px; } @@ -181,158 +37,65 @@ export class Light9ColorPicker extends L display: flex; align-items: center; } - - #outOfBounds { - user-select: none; - z-index: 1; - background: #00000060; - position: fixed; - left: 0; - top: 0; - width: 100%; - height: 100%; - display: none; /* Toggledlater. */ - } `, ]; render() { return html` -
- - V: - - - -
- - +
+ V: `; } - static get properties() { - return { - color: { type: String, notify: true }, - hueSatColor: { type: String, notify: true, value: null }, - value: { type: Number, notify: true }, // 0..255 - sliderWriteValue: { type: Number, notify: true }, - }; - } - static get observers() { - return ["readColor(color)", "onValue(value)", "writeColor(hueSatColor, value)"]; - } - ready() { - super.ready(); - if (!window.pickerCanvases) { - window.pickerCanvases = { - large: new RainbowCanvas("/colorpick_rainbow_large.png", [400, 200]), - }; - } - this.large = window.pickerCanvases.large; - this.$.large.onCanvasMove = this.onCanvasMove.bind(this); - this.$.large.hideLarge = this.hideLarge.bind(this); - document.body.append(this.$.large); - } - disconnectedCallback() { - super.disconnectedCallback(); - document.body.removeChild(this.$.large); + @property() color: string = "#000"; // actual output color, computed by value*hueSatColor + @property() hueSatColor: string = "#fff"; // always V=1, to be scaled down + @property() value: int8 = 0; + + @query("#swatch") swatchEl!: HTMLElement; + @query("#outOfBounds") outOfBoundsEl!: HTMLElement; + + connectedCallback(): void { + super.connectedCallback(); + pickerFloat.pageInit(); } - onValue(value) { - if (this.hueSatColor === null) { - this.hueSatColor = "#ffffff"; + update(changedProperties: PropertyValueMap) { + if (changedProperties.has("color")) { + this.setColor(this.color); } - let neverBlack = 0.1 + (0.9 * value) / 255; - this.$.swatch.style.filter = `brightness(${neverBlack})`; - } - writeColor(hueSatColor, value) { - if (hueSatColor === null || this.pauseWrites) { - return; + if (changedProperties.has("value") || changedProperties.has("hueSatColor")) { + this.color = color(this.hueSatColor) + .value(this.value / 255) + .hex(); + const sl = this.shadowRoot?.querySelector("#value") as Slider; + if (sl) { + sl.value = this.value; + } } - this.color = one - .color(hueSatColor) - .value(value / 255) - .hex(); - this.$.large.style.borderColor = this.color; + super.update(changedProperties); + } + + private onVSliderChange(ev: CustomEvent) { + this.value = ev.detail.value; + this.swatchEl.style.borderColor = this.hueSatColor; } - readColor(color) { - if (this.$.large.style.display == "block") { - // for performance, don't do color searches on covered widget - return; + + // for outside users of the component + setColor(col: string) { + log(`set color pick to color ${col}`); + if (col == "") { + col = "#000"; } - - this.pauseWrites = true; - var colorValue = one.color(color).value() * 255; - // writing back to immediate-value doesn't work on paper-slider - this.sliderWriteValue = colorValue; + this.value = color(col).value() * 255; // don't update this if only the value changed, or we desaturate - this.hueSatColor = one.color(color).value(1).hex(); - - this.pauseWrites = false; - } - showLarge(x, y) { - this.$.large.style.display = "block"; - this.$.outOfBounds.style.display = "block"; - try { - let pos; - try { - pos = this.large.posFor(this.color); - } catch (e) { - pos = [-999, -999]; - } - this.moveLargeCrosshair(pos); - this.$.large.style.left = x - this.clamp(pos[0], 0, 400) + "px"; - this.$.large.style.top = y - this.clamp(pos[1], 0, 200) + "px"; - } catch (e) { - this.moveLargeCrosshair([-999, -999]); - this.$.large.style.left = 400 / 2 + "px"; - this.$.large.style.top = 200 / 2 + "px"; - return; - } - } - hideLarge() { - this.$.large.style.display = "none"; - this.$.outOfBounds.style.display = "none"; - - if (this.color !== undefined) { - this.readColor(this.color); - } - this.closeTime = Date.now(); + this.hueSatColor = color(col).value(1).hex(); } - onDownSmall(ev) { - this.showLarge(ev.pageX, ev.pageY); - } - moveLargeCrosshair(pos) { - const ch = this.$.large.shadowRoot.querySelector("#largeCrosshair"); - ch.style.left = pos[0] - ch.offsetWidth / 2 + "px"; - ch.style.top = pos[1] - ch.offsetHeight / 2 + "px"; - } - onCanvasMove(ev) { - if (ev.buttons != 1) { - this.hideLarge(); - return; + + private startFloatingPick(ev: MouseEvent) { + if (this.value < (20 as int8)) { + log("boost"); + this.value = 255 as int8; } - var canvas = this.$.large.shadowRoot.querySelector("#largeRainbow"); - var pos = [ev.offsetX - canvas.offsetLeft, ev.offsetY - canvas.offsetTop]; - this.setLargePoint(pos); - } - setLargePoint(pos) { - this.moveLargeCrosshair(pos); - this.hueSatColor = this.large.colorAt(pos); - - // special case: it's useless to adjust the hue/sat of black - if (this.value == 0) { - this.value = 255; - } - } - onOutOfBoundsMove(ev) { - const largeX = ev.offsetX - this.$.large.offsetLeft; - const largeY = ev.offsetY - this.$.large.offsetTop; - this.setLargePoint([this.clamp(largeX, 0, 400 - 1), this.clamp(largeY, 0, 200 - 1)]); - } - clamp(x, lo, hi) { - return Math.max(lo, Math.min(hi, x)); + pickerFloat.startPick(new ClientCoord(ev.clientX, ev.clientY), this.color, (hsc: string) => { + this.hueSatColor = hsc; + }); } }