changeset 2242:9645581bff24

redo color picker code in lit
author drewp@bigasterisk.com
date Fri, 26 May 2023 15:46:16 -0700
parents 8d6792a6ffdb
children ba9aca728d65
files light9/live/Light9LiveControl.ts light9/web/floating_color_picker.ts light9/web/light9-color-picker.ts
diffstat 3 files changed, 393 insertions(+), 328 deletions(-) [+]
line wrap: on
line diff
--- a/light9/live/Light9LiveControl.ts	Thu May 25 16:28:04 2023 -0700
+++ b/light9/live/Light9LiveControl.ts	Fri May 26 15:46:16 2023 -0700
@@ -2,15 +2,20 @@
 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 @@
       #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`<mwc-slider .value=${this.sliderValue} step=${1 / 255} min="0" max="1" @input=${this.onSliderInput}></mwc-slider> `;
-    } else if (this.dataType.value === "http://light9.bigasterisk.com/color") {
-      return html`
-        <div id="colorControls">
-          <button on-click="goBlack">0.0</button>
-          <light9-color-picker color="{{value}}"></light9-color-picker>
-        </div>
-      `;
-    } else if (this.dataType.value === "http://light9.bigasterisk.com/choice") {
-      return html` <light9-listbox choices="{{deviceAttrRow.choices}}" value="{{choiceValue}}"> </light9-listbox> `;
-    } else {
-      throw new Error(`${this.dataType} unknown`);
-    }
-  }
-
   // passed from parent
   @property() device!: NamedNode;
   @property() dataType: NamedNode;
@@ -83,15 +55,34 @@
   @property() pickedChoice: Choice | null = null;
   @property() choiceValue: Choice | null = null;
 
+  valueChanged: SubEvent<Literal> = 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} <mwc-slider .value=${this.sliderValue} step=${1 / 255} min="0" max="1" @input=${this.onSliderInput}></mwc-slider> `;
+    } else if (this.dataType.equals(makeType("color"))) {
+      return html` ${dbg}
+        <div id="colorControls">
+          <button on-click="goBlack">0.0</button>
+          <light9-color-picker color="${this.value}"></light9-color-picker>
+        </div>
+      `;
+    } else if (this.dataType.equals(makeType("choice"))) {
+      return html`${dbg} <light9-listbox choices="{{deviceAttrRow.choices}}" value="{{choiceValue}}"> </light9-listbox> `;
+    }
+  }
+
   // graphReads() {
   //   const U = this.graph.U();
   // }
@@ -115,7 +106,7 @@
   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
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/floating_color_picker.ts	Fri May 26 15:46:16 2023 -0700
@@ -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<RainbowCoord> = new SubEvent();
+  outsideMove: SubEvent<ClientCoord> = new SubEvent();
+  mouseUp: SubEvent<void> = new SubEvent();
+
+  render() {
+    return html`
+      <!-- Temporary scrim on the rest of the page. It looks like we're dimming
+            the page to look pretty, but really this is so we can track the mouse
+            when it's outside the large canvas. -->
+      <div id="outOfBounds" @mousemove=${this.onOutOfBoundsMove} @mouseup=${this.onMouseUp}></div>
+      <div id="largeRainbowComp">
+        <div id="largeRainbow" @mousemove=${this.onCanvasMove} @mouseup=${this.onMouseUp}></div>
+        <div id="largeCrosshair"></div>
+      </div>
+    `;
+  }
+
+  // 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();
--- a/light9/web/light9-color-picker.ts	Thu May 25 16:28:04 2023 -0700
+++ b/light9/web/light9-color-picker.ts	Fri May 26 15:46:16 2023 -0700
@@ -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`
-      <div id="largeRainbowComp">
-        <div id="largeRainbow" on-mousemove="onCanvasMove" on-mouseup="hideLarge"></div>
-        <div id="largeCrosshair"></div>
-      </div>
-    `;
-  }
-  // 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 @@
         border: 1px solid #333;
       }
 
-      paper-slider {
+      mwc-slider {
         width: 160px;
       }
 
@@ -181,158 +37,65 @@
         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`
-      <div id="swatch" style="background-color: {{color}}" on-mousedown="onDownSmall"></div>
-      <span id="vee">
-        V:
-        <paper-slider min="0" max="255" step="1" value="{{sliderWriteValue}}" immediate-value="{{value}}"></paper-slider>
-      </span>
-      <!-- Temporary scrim on the rest of the page. It looks like we're dimming
-        the page to look pretty, but really this is so we can track the mouse
-        when it's outside the large canvas. -->
-      <div id="outOfBounds" on-mousemove="onOutOfBoundsMove" on-mouseup="hideLarge"></div>
-      <!--  Large might span multiple columns, and chrome won't
-        send events for those parts. Workaround: take it out of
-        the columns. -->
-      <light9-color-picker-float id="large"></light9-color-picker-float>
+      <div id="swatch" style="background-color: ${this.color}; border-color: ${this.hueSatColor}" @mousedown=${this.startFloatingPick}></div>
+      <span id="vee"> V: <mwc-slider id="value" step="1" min="0" max="255" @input=${this.onVSliderChange}></mwc-slider> </span>
     `;
   }
-  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<this>) {
+    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;
+    });
   }
 }