changeset 2081:c57cf4049004

dice up the live/ elements and code into ts files (no conversion yet except auto coffee->ts)
author drewp@bigasterisk.com
date Wed, 25 May 2022 00:06:00 -0700
parents 6eb1fcbad5f6
children b62c78f35380
files light9/web/live/ActiveSettings.ts light9/web/live/GraphToControls.ts light9/web/live/Light9Listbox.ts light9/web/live/Light9LiveControl.ts light9/web/live/Light9LiveControls.ts light9/web/live/Light9LiveDeviceControl.ts light9/web/live/elements.html light9/web/live/index.html light9/web/live/live.coffee light9/web/live/vite.config.ts
diffstat 10 files changed, 936 insertions(+), 771 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/live/ActiveSettings.ts	Wed May 25 00:06:00 2022 -0700
@@ -0,0 +1,98 @@
+class ActiveSettings {
+  graph: any;
+  settings: any;
+  keyForSetting: any;
+  onChanged: any;
+  constructor(graph: any) {
+    // The settings we're showing (or would like to but the widget
+    // isn't registered yet):
+    // dev+attr : {setting: Uri, onChangeFunc: f, jsValue: str_or_float}
+    this.graph = graph;
+    this.settings = new Map();
+    this.keyForSetting = new Map(); // setting uri str -> dev+attr
+
+
+
+    // Registered graphValueChanged funcs, by dev+attr. Kept even when
+    // settings are deleted.
+    this.onChanged = new Map();
+  }
+
+  addSettingsRow(device: { value: string; }, deviceAttr: { value: string; }, setting: { value: string; }, value: any) {
+    const key = device.value + " " + deviceAttr.value;
+    if (this.settings.has(key)) {
+      throw new Error("repeated setting on " + key);
+    }
+    if (this.keyForSetting.has(setting.value)) {
+      throw new Error("repeated keyForSetting on " + setting.value);
+    }
+    this.settings.set(key, {
+      setting,
+      onChangeFunc: this.onChanged[key],
+      jsValue: value
+    });
+    this.keyForSetting.set(setting.value, key);
+    if (this.onChanged[key] != null) {
+      return this.onChanged[key](value);
+    }
+  }
+
+  has(setting: { value: any; }) {
+    return this.keyForSetting.has(setting.value);
+  }
+
+  setValue(setting: { value: any; }, value: any) {
+    const row = this.settings.get(this.keyForSetting.get(setting.value));
+    row.jsValue = value;
+    if (row.onChangeFunc != null) { return row.onChangeFunc(value); }
+  }
+
+  registerWidget(device: { value: string; }, deviceAttr: { value: string; }, graphValueChanged: any) {
+    const key = device.value + " " + deviceAttr.value;
+    this.onChanged[key] = graphValueChanged;
+
+    if (this.settings.has(key)) {
+      const row = this.settings.get(key);
+      row.onChangeFunc = graphValueChanged;
+      return row.onChangeFunc(row.jsValue);
+    }
+  }
+
+  effectSettingLookup(device: { value: string; }, attr: { value: string; }) {
+    const key = device.value + " " + attr.value;
+    if (this.settings.has(key)) {
+      return this.settings.get(key).setting;
+    }
+
+    return null;
+  }
+
+  deleteSetting(setting: { value: string; }) {
+    log('deleteSetting ' + setting.value);
+    const key = this.keyForSetting.get(setting.value);
+    const row = this.settings.get(key);
+    if ((row != null) && !row.setting.equals(setting)) {
+      throw new Error('corrupt row for ' + setting.value);
+    }
+    if ((row != null ? row.onChangeFunc : undefined) != null) { row.onChangeFunc(null); }
+    this.settings.delete(key);
+    return this.keyForSetting.delete(setting);
+  }
+
+  clear() {
+    new Map(this.settings).forEach(function (row: { onChangeFunc: (arg0: any) => any; }, key: any) {
+      if (row.onChangeFunc != null) { return row.onChangeFunc(null); }
+    });
+    this.settings.clear();
+    return this.keyForSetting.clear();
+  }
+
+  forAll(cb: (arg0: any) => any) {
+    const all = Array.from(this.keyForSetting.keys());
+    return Array.from(all).map((s: any) => cb(this.graph.Uri(s)));
+  }
+
+  allSettingsStr() {
+    return this.keyForSetting.keys();
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/live/GraphToControls.ts	Wed May 25 00:06:00 2022 -0700
@@ -0,0 +1,207 @@
+
+const valuePred = function(graph: { Uri: (arg0: any) => any; }, attr: { equals: (arg0: any) => any; }) {
+  const U = (x: string) => graph.Uri(x);
+  const scaledAttributeTypes = [U(':color'), U(':brightness'), U(':uv')];
+  if (_.some(scaledAttributeTypes,
+      (            x: any) => attr.equals(x))) { return U(':scaledValue'); } else { return U(':value'); }
+};
+
+const log = debug('live');
+
+// Like element.set(path, newArray), but minimizes splices.
+// Dotted paths don't work yet.
+const syncArray = function(element: this, path: string, newArray: { length?: any; }, isElementEqual: { (a: any, b: any): boolean; (arg0: any, arg1: any): any; }) {
+  let pos = 0;
+  let newPos = 0;
+
+  while (newPos < newArray.length) {
+    if (pos < element[path].length) {
+      if (isElementEqual(element[path][pos], newArray[newPos])) {
+        pos += 1;
+        newPos += 1;
+      } else {
+        element.splice('devices', pos, 1);
+      }
+    } else {
+      element.push('devices', newArray[newPos]);
+      pos += 1;
+      newPos += 1;
+    }
+  }
+
+  if (pos < element[path].length) {
+    return element.splice('devices', pos, element[path].length - pos);
+  }
+};
+
+
+class GraphToControls {
+  graph: any;
+  activeSettings: ActiveSettings;
+  effect: any;
+  ctx: any;
+  // More efficient bridge between liveControl widgets and graph edits,
+  // as opposed to letting each widget scan the graph and push lots of
+  // tiny patches to it.
+  constructor(graph: any) {
+    this.graph = graph;
+    this.activeSettings = new ActiveSettings(this.graph);
+    this.effect = null;
+  }
+
+  ctxForEffect(effect: { value: { replace: (arg0: string, arg1: string) => any; }; }) {
+    return this.graph.Uri(effect.value.replace(
+      "light9.bigasterisk.com/effect",
+      "light9.bigasterisk.com/show/dance2019/effect"));
+  }
+
+  setEffect(effect: any) {
+    this.clearSettings();
+    this.effect = effect;
+    this.ctx = this.ctxForEffect(this.effect);
+    // are these going to pile up? consider @graph.triggerHandler('GTC sync')
+    return this.graph.runHandler(this.syncFromGraph.bind(this), 'GraphToControls sync');
+  }
+
+  newEffect() {
+    // wrong- this should be our editor's scratch effect, promoted to a
+    // real one when you name it.
+    const U = (x: string) => this.graph.Uri(x);
+    const effect = this.graph.nextNumberedResource(U('http://light9.bigasterisk.com/effect/effect'));
+    const ctx = this.ctxForEffect(effect);
+    const quad = (s: any, p: any, o: any) => this.graph.Quad(s, p, o, ctx);
+
+    const addQuads = [
+      quad(effect, U('rdf:type'), U(':Effect')),
+      quad(effect, U('rdfs:label'), this.graph.Literal(effect.value.replace(/.*\//, ""))),
+      quad(effect, U(':publishAttr'), U(':strength'))
+    ];
+    const patch = { addQuads, delQuads: [] };
+    log('init new effect', patch);
+    this.graph.applyAndSendPatch(patch);
+    return effect;
+  }
+
+  syncFromGraph() {
+    const U = (x: string) => this.graph.Uri(x);
+    if (!this.effect) { return; }
+    log('syncFromGraph', this.effect);
+
+    const toClear = new Set(this.activeSettings.allSettingsStr());
+
+    for (let setting of Array.from(this.graph.objects(this.effect, U(':setting')))) {
+      var value: { id: { match: (arg0: {}) => any; }; };
+      const dev = this.graph.uriValue(setting, U(':device'));
+      const devAttr = this.graph.uriValue(setting, U(':deviceAttr'));
+
+      const pred = valuePred(this.graph, devAttr);
+      try {
+        value = this.graph.uriValue(setting, pred);
+        if (!value.id.match(/^http/)) {
+          throw new Error("not uri");
+        }
+      } catch (error) {
+        try {
+          value = this.graph.floatValue(setting, pred);
+        } catch (error1) {
+          value = this.graph.stringValue(setting, pred);
+        }
+      }
+      //log('change: graph contains', devAttr, value)
+      if (this.activeSettings.has(setting)) {
+        this.activeSettings.setValue(setting, value);
+        toClear.delete(setting.value);
+      } else {
+        this.activeSettings.addSettingsRow(dev, devAttr, setting, value);
+      }
+    }
+
+    return Array.from(Array.from(toClear)).map((settingStr: any) => this.activeSettings.deleteSetting(U(settingStr)));
+  }
+
+  clearSettings() {
+    return this.activeSettings.clear();
+  }
+
+  register(device: any, deviceAttr: any, graphValueChanged: any) {
+    return this.activeSettings.registerWidget(device, deviceAttr, graphValueChanged);
+  }
+
+  shouldBeStored(deviceAttr: any, value: string | number) {
+    // this is a bug for zoom=0, since collector will default it to
+    // stick at the last setting if we don't explicitly send the
+    // 0. rx/ry similar though not the exact same deal because of
+    // their remap.
+    return (value != null) && (value !== 0) && (value !== '#000000');
+  }
+
+  emptyEffect() {
+    return this.activeSettings.forAll(this._removeEffectSetting.bind(this));
+  }
+
+  controlChanged(device: any, deviceAttr: any, value: string) {
+    // todo: controls should be disabled if there's no effect and they won't do anything.
+    if (!this.effect) { return; }
+
+    // value is float or #color or (Uri or null)
+    if ((value === undefined) || ((typeof value === "number") && isNaN(value)) || ((typeof value === "object") && (value !== null) && !value.id)) {
+      throw new Error("controlChanged sent bad value " + value);
+    }
+    const effectSetting = this.activeSettings.effectSettingLookup(device, deviceAttr);
+
+    // sometimes this misses an existing setting, which leads to a mess
+    if (this.shouldBeStored(deviceAttr, value)) {
+      if ((effectSetting == null)) {
+        return this._addEffectSetting(device, deviceAttr, value);
+      } else {
+        return this._patchExistingEffectSetting(effectSetting, deviceAttr, value);
+      }
+    } else {
+      return this._removeEffectSetting(effectSetting);
+    }
+  }
+
+  _nodeForValue(value: { id: any; }) {
+    if (value.id != null) {
+      return value;
+    }
+    return this.graph.prettyLiteral(value);
+  }
+
+  _addEffectSetting(device: any, deviceAttr: { value: any; }, value: any) {
+    log('change: _addEffectSetting', deviceAttr.value, value);
+    const U = (x: string) => this.graph.Uri(x);
+    const quad = (s: any, p: any, o: any) => this.graph.Quad(s, p, o, this.ctx);
+    const effectSetting = this.graph.nextNumberedResource(this.effect.value + '_set');
+    this.activeSettings.addSettingsRow(device, deviceAttr, effectSetting, value);
+    const addQuads = [
+      quad(this.effect, U(':setting'), effectSetting),
+      quad(effectSetting, U(':device'), device),
+      quad(effectSetting, U(':deviceAttr'), deviceAttr),
+      quad(effectSetting, valuePred(this.graph, deviceAttr), this._nodeForValue(value))
+    ];
+    const patch = { addQuads, delQuads: [] };
+    log('save', patch);
+    return this.graph.applyAndSendPatch(patch);
+  }
+
+  _patchExistingEffectSetting(effectSetting: { value: any; }, deviceAttr: any, value: any) {
+    log('change: patch existing', effectSetting.value);
+    this.activeSettings.setValue(effectSetting, value);
+    return this.graph.patchObject(effectSetting, valuePred(this.graph, deviceAttr), this._nodeForValue(value), this.ctx);
+  }
+
+  _removeEffectSetting(effectSetting: { value: any; }) {
+    const U = (x: string) => this.graph.Uri(x);
+    const quad = (s: any, p: any, o: any) => this.graph.Quad(s, p, o, this.ctx);
+    if (effectSetting != null) {
+      log('change: _removeEffectSetting', effectSetting.value);
+      const toDel = [quad(this.effect, U(':setting'), effectSetting, this.ctx)];
+      for (let q of Array.from(this.graph.graph.getQuads(effectSetting))) {
+        toDel.push(q);
+      }
+      this.graph.applyAndSendPatch({ delQuads: toDel, addQuads: [] });
+      return this.activeSettings.deleteSetting(effectSetting);
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/live/Light9Listbox.ts	Wed May 25 00:06:00 2022 -0700
@@ -0,0 +1,72 @@
+<dom-module id="light9-listbox">
+  <template>
+    <style>
+     paper-listbox {
+         --paper-listbox-background-color: none;
+         --paper-listbox-color: white;
+         --paper-listbox: {
+             /* measure biggest item? use flex for columns? */
+             column-width: 9em;
+         }
+     }
+     paper-item {
+         --paper-item-min-height: 0;
+         --paper-item: {
+             display: block;
+             border: 1px outset #0f440f;
+             margin: 0 1px 5px 0;
+             background: #0b1d0b;
+         }
+     }
+     paper-item.iron-selected {
+         background: #7b7b4a;
+     }
+    </style>
+    <paper-listbox id="list"
+                   selected="{{value}}"
+                   attr-for-selected="uri"
+                   on-focus-changed="selectOnFocus"
+    >
+      <paper-item on-focus="selectOnFocus">None</paper-item>
+      <template is="dom-repeat" items="{{choices}}">
+        <paper-item on-focus="selectOnFocus" uri="{{item.uri}}">{{item.label}}</paper-item>
+      </template>
+    </paper-listbox>
+
+  </template>
+  <script>
+   HTMLImports.whenReady(function () {
+     Polymer({
+       is: "light9-listbox",
+       properties: {
+         choices: { type: Array },
+         value: { type: String, notify: true },
+       },
+       observers: ['onValue(value)'],
+       selectOnFocus: function(ev) {
+         if (ev.target.uri === undefined) {
+           // *don't* clear for this, or we can't cycle through all choices (including none) with up/down keys
+           //this.clear();
+           //return;
+         }
+         this.value = ev.target.uri;
+         
+       },
+       onValue: function(value) {
+         if (value === null) {
+           this.clear();
+         }
+       },
+       clear: function() {
+         this.async(function() {
+           this.querySelectorAll('paper-item').forEach(
+             function(item) { item.blur(); });
+           this.value = undefined;
+         }.bind(this));
+
+       },
+       
+     });
+   });
+  </script>
+</dom-module>
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/live/Light9LiveControl.ts	Wed May 25 00:06:00 2022 -0700
@@ -0,0 +1,161 @@
+
+<dom-module id="light9-live-control">
+<template>
+  <style>
+   #colorControls {
+       display: flex;
+       align-items: center;
+   }
+   #colorControls > * {
+       margin: 0 3px;
+   }
+   #colorControls paper-slider {
+
+   }
+   paper-slider { width: 100%; height: 25px; }
+  </style>
+
+  <style is="custom-style">
+   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;
+       }
+   }
+   
+  </style>
+
+  <template is="dom-if" if="{{deviceAttrRow.useSlider}}">
+    <paper-slider min="0"
+                  max="{{deviceAttrRow.max}}"
+                  step=".001"
+                  editable
+                  content-type="application/json"
+                  value="{{sliderWriteValue}}"
+                  immediate-value="{{immediateSlider}}"></paper-slider>
+  </template>
+  <template is="dom-if" if="{{deviceAttrRow.useColor}}">
+    <div id="colorControls">
+      <button on-click="goBlack">0.0</button>
+      <light9-color-picker color="{{value}}"></light9-color-picker>
+      
+    </div>
+  </template>
+  <template is="dom-if" if="{{deviceAttrRow.useChoice}}">
+    <light9-listbox choices="{{deviceAttrRow.choices}}" value="{{choiceValue}}">
+    </light9-listbox>
+  </template>
+
+</template>
+
+</dom-module>
+
+const coffeeElementSetupLight9LiveControl = (function() {
+    class Light9LiveControl extends Polymer.Element {
+      static is: string;
+      static getter_properties: {
+          graph: { type: any; notify: boolean; }; device: { type: any; }; deviceAttrRow: { type: any; }; // object returned from attrRow, below
+          value: { type: any; notify: boolean; }; // null, Uri, float, str
+          choiceValue: { type: any; }; immediateSlider: { notify: boolean; observer: string; }; sliderWriteValue: { ...; }; pickedChoice: { ...; }; graphToControls: { ...; };
+      };
+      static getter_observers: {};
+      enableChange: boolean;
+      value: any;
+      immediateSlider: any;
+      deviceAttrRow: any;
+      sliderWriteValue: { value: any; };
+      choiceValue: any;
+      graphToControls: any;
+      graph: any;
+      pickedChoice: any;
+      static initClass() {
+        this.is = 'light9-live-control';
+        this.getter_properties = {
+          graph: { type: Object, notify: true },
+          device: { type: Object },
+          deviceAttrRow: { type: Object }, // object returned from attrRow, below
+          value: { type: Object, notify: true }, // null, Uri, float, str
+          choiceValue: { type: Object },
+        
+          immediateSlider: { notify: true, observer: 'onSlider' },
+          sliderWriteValue: { type: Number },
+    
+          pickedChoice: { observer: 'onChange' },
+          graphToControls: { type: Object }
+        };
+        this.getter_observers = [
+          'onChange(value)',
+          'onGraphToControls(graphToControls)',
+          'onChoice(choiceValue)'
+          ];
+      }
+      constructor() {
+        super();
+        this.enableChange = false; // until 1st graph read
+      }
+      onSlider() { return this.value = this.immediateSlider; }
+      goBlack() { return this.value = "#000000"; }
+      onGraphToControls(gtc: { register: (arg0: any, arg1: any, arg2: any) => void; }) {
+        gtc.register(this.device, this.deviceAttrRow.uri, this.graphValueChanged.bind(this));
+        return this.enableChange = true;
+      }
+        device(device: any, uri: any, arg2: any) {
+            throw new Error("Method not implemented.");
+        }
+  
+      graphValueChanged(v: { value: any, }) {
+        log('change: control gets', v);
+        this.enableChange = false;
+        if (v === null) {
+          this.clear();
+        } else {
+          this.value = v;
+        }
+        if (this.deviceAttrRow.useSlider) { this.sliderWriteValue = v; }
+        if (this.deviceAttrRow.useChoice) { this.choiceValue = (v === null ? v : v.value); }
+        return this.enableChange = true;
+      }
+  
+      onChoice(value: any) {
+        if ((this.graphToControls == null) || !this.enableChange) { return; }
+        if (value != null) {
+          value = this.graph.Uri(value);
+        } else {
+          value = null;
+        }
+        return this.graphToControls.controlChanged(this.device, this.deviceAttrRow.uri, value);
+      }
+      
+      onChange(value: any) {
+        if ((this.graphToControls == null) || !this.enableChange) { return; }
+        if ((typeof value === "number") && isNaN(value)) { return; } // let onChoice do it
+        //log('change: control tells graph', @deviceAttrRow.uri.value, value)
+        if (value === undefined) {
+          value = null;
+        }
+        return this.graphToControls.controlChanged(this.device, this.deviceAttrRow.uri, value);
+      }
+  
+      clear() {
+        this.pickedChoice = null;
+        this.sliderWriteValue = 0;
+        if (this.deviceAttrRow.useColor) {
+          return this.value = '#000000';
+        } else if (this.deviceAttrRow.useChoice) {
+          return this.value = (this.pickedChoice = null);
+        } else {
+          return this.value = (this.immediateSlider = 0);
+        }
+      }
+    }
+    
+    Light9LiveControl.initClass();
+    return Light9LiveControl;
+  })();
+  
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/live/Light9LiveControls.ts	Wed May 25 00:06:00 2022 -0700
@@ -0,0 +1,178 @@
+
+<dom-module id="light9-live-controls">
+<template>
+  <style>
+   :host {
+       display: flex;
+       flex-direction: column;
+   }
+   #preview {
+       width: 100%;
+   }
+   #deviceControls {
+       flex-grow: 1;
+       position: relative;
+       width: 100%;
+       overflow-y: auto;
+   }
+   
+   light9-live-device-control > div {
+       break-inside: avoid-column;
+   }
+   light9-live-device-control {
+
+   }
+  </style>
+  <rdfdb-synced-graph graph="{{graph}}"></rdfdb-synced-graph>
+
+  <h1>device control</h1>
+
+  <div id="save">
+    <div>
+      <button on-click="newEffect">New effect</button>
+      <edit-choice graph="{{graph}}" uri="{{effectChoice}}"></edit-choice>
+      <button on-click="clearAll">clear settings in this effect</button>
+    </div>
+  </div>
+
+  <div id="deviceControls">
+    <template is="dom-repeat" items="{{devices}}" as="device">
+      <light9-live-device-control
+        graph="{{graph}}"
+        uri="{{device.uri}}"
+        effect="{{effect}}"
+        graph-to-controls="{{graphToControls}}"
+      ></light9-live-device-control>
+    </template>
+  </div>
+  
+</template>
+</dom-module>
+
+const coffeeElementSetupLight9LiveControls = (function() {
+    class Light9LiveControls extends Polymer.Element {
+      static is: string;
+      static getter_properties: {
+          graph: { type: any; notify: boolean; }; devices: { type: any; notify: boolean; value: {}; };
+          // string uri of the effect being edited, or null. This is the
+          // master value; GraphToControls follows.
+          effectChoice: { type: any; notify: boolean; value: any; }; graphToControls: { type: any; };
+      };
+      static getter_observers: {};
+      graphToControls: any;
+      okToWriteUrl: boolean;
+      currentSettings: {};
+      graph: any;
+      effectChoice: any;
+      static initClass() {
+        this.is = "light9-live-controls";
+        this.getter_properties = {
+          graph: { type: Object, notify: true },
+          devices: { type: Array, notify: true, value: [] },
+          // string uri of the effect being edited, or null. This is the
+          // master value; GraphToControls follows.
+          effectChoice: { type: String, notify: true, value: null },
+          graphToControls: { type: Object }
+        };
+        this.getter_observers = [
+          'onGraph(graph)',
+          'onEffectChoice(effectChoice)'
+          ];
+      }
+  
+      constructor() {
+        super();
+        this.graphToControls = null;
+        this.okToWriteUrl = false;
+      }
+  
+      ready() {
+        super.ready(...arguments).ready();
+        return this.currentSettings = {};
+      }
+  
+      onGraph() {
+        this.graphToControls = new GraphToControls(this.graph);
+        this.graph.runHandler(this.update.bind(this), 'Light9LiveControls update');
+  
+        // need graph to be loaded, so we don't make double settings? not sure.
+        return setTimeout(this.setFromUrl.bind(this), 1);
+      }
+  
+      setFromUrl() {
+        // not a continuous bidi link between url and effect; it only reads
+        // the url when the page loads.
+        const effect = new URL(window.location.href).searchParams.get('effect');
+        if (effect != null) {
+          log('found url', effect);
+          this.effectChoice = effect;
+        }
+        return this.okToWriteUrl = true;
+      }
+  
+      writeToUrl(effectStr: any) {
+        if (!this.okToWriteUrl) { return; }
+        const u = new URL(window.location.href);
+        if (u.searchParams.get('effect') === effectStr) {
+          return;        
+        }
+        u.searchParams.set('effect', effectStr);
+        window.history.replaceState({}, "", u.href);
+        return log('wrote new url', u.href);
+      }
+  
+      newEffect() {
+        return this.effectChoice = this.graphToControls.newEffect().value;
+      }
+        
+      onEffectChoice() {
+        const U = (x: any) => this.graph.Uri(x);
+        if ((this.effectChoice == null)) {
+          // unlink
+          if (this.graphToControls != null) { this.graphToControls.setEffect(null); }
+        } else {
+          log('load', this.effectChoice);
+          if (this.graphToControls != null) { this.graphToControls.setEffect(this.graph.Uri(this.effectChoice)); }
+        }
+        return this.writeToUrl(this.effectChoice);
+      }
+   
+      clearAll() {
+        // clears the effect!
+        return this.graphToControls.emptyEffect();
+      }
+      
+      update() {
+        const U = (x: string) => this.graph.Uri(x);
+  
+        const newDevs = [];
+        for (let dc of Array.from(this.graph.sortedUris(this.graph.subjects(U('rdf:type'), U(':DeviceClass'))))) {
+          for (let dev of Array.from(this.graph.sortedUris(this.graph.subjects(U('rdf:type'), dc)))) {
+            if (this.graph.contains(dev, U(':hideInLiveUi'), null)) {
+              continue;
+            }
+            newDevs.push({uri: dev});
+          }
+        }
+  
+        //log("controls update now has #{newDevs.length} devices")
+        syncArray(this, 'devices', newDevs, (a: { uri: { value: any; }; }, b: { uri: { value: any; }; }) => a.uri.value === b.uri.value);
+  
+        return;
+  
+        // Tried css columns- big slowdown from relayout as I'm scrolling.
+        // Tried isotope- seems to only scroll to the right.
+        // Tried columnize- fails in jquery maybe from weird elements.
+      
+        // not sure how to get this run after the children are created
+        return setTimeout((() => $('#deviceControls').isotope({
+          // fitColumns would be nice, but it doesn't scroll vertically
+          layoutMode: 'masonry',
+          containerStyle: null
+          })), 2000);
+      }
+    }
+    
+    Light9LiveControls.initClass();
+    return Light9LiveControls;
+  })();
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/live/Light9LiveDeviceControl.ts	Wed May 25 00:06:00 2022 -0700
@@ -0,0 +1,200 @@
+
+<dom-module id="light9-live-device-control">
+<template>
+  <style>
+   :host {
+       display: inline-block;
+   }
+   .device {
+       border: 2px solid #151e2d;
+       margin: 4px;
+       padding: 1px;
+       background: #171717;  /* deviceClass gradient added later */
+       break-inside: avoid-column;
+       width: 335px;
+       
+   }
+   .deviceAttr {
+       border-top: 1px solid #272727;
+       padding-bottom: 2px;
+       display: flex;
+   }
+   .deviceAttr > span {
+
+   }
+   .deviceAttr > light9-live-control {
+       flex-grow: 1;
+   }
+   h2 {
+       font-size: 110%;
+       padding: 4px;
+       margin-top: 0;
+       margin-bottom: 0;
+   }
+   .device, h2 {
+       border-top-right-radius: 15px;
+   }
+
+   #mainLabel {
+       font-size: 120%; 
+       color: #9ab8fd;
+       text-decoration: initial;
+   }
+   .device.selected h2 {
+       outline: 3px solid #ffff0047;
+   }
+   .deviceAttr.selected {
+       background: #cada1829;
+   }
+  </style>
+  <div class$="device {{devClasses}}">
+    <h2 style$="[[bgStyle]]" xon-click="onClick">
+      <resource-display id="mainLabel" graph="{{graph}}" uri="{{uri}}"></resource-display>
+      a <resource-display minor graph="{{graph}}" uri="{{deviceClass}}"></resource-display>
+    </h2>
+    <template is="dom-repeat" items="{{deviceAttrs}}" as="dattr">
+      <div xon-click="onAttrClick" class$="deviceAttr {{dattr.attrClasses}}">
+        <span>attr <resource-display minor graph="{{graph}}" uri="{{dattr.uri}}"></resource-display></span>
+        <light9-live-control
+          graph="{{graph}}"
+          device="{{uri}}"
+          device-attr-row="{{dattr}}"
+          effect="{{effect}}"
+          graph-to-controls="{{graphToControls}}"
+        ></light9-live-control>
+      </div>
+    </template>
+  </div>
+</template>
+</dom-module>
+
+const coffeeElementSetupLight9LiveDeviceControl = (function() {
+    class Light9LiveDeviceControl extends Polymer.Element {
+      static is: string;
+      static getter_properties: {
+          graph: { type: any; notify: boolean; }; uri: { type: any; notify: boolean; }; effect: { type: any; }; deviceClass: { type: any; notify: boolean; }; // the uri str
+          deviceAttrs: { type: any; notify: boolean; }; graphToControls: { ...; }; bgStyle: { ...; }; devClasses: { ...; }; // the css kind
+      };
+      static getter_observers: {};
+      selectedAttrs: any;
+      graph: any;
+      uri: any;
+      devClasses: string;
+      deviceClass: any;
+      deviceAttrs: {};
+      shadowRoot: any;
+      static initClass() {
+        this.is = "light9-live-device-control";
+        this.getter_properties = {
+          graph: { type: Object, notify: true },
+          uri: { type: String, notify: true },
+          effect: { type: String },
+          deviceClass: { type: String, notify: true }, // the uri str
+          deviceAttrs: { type: Array, notify: true },
+          graphToControls: { type: Object },
+          bgStyle: { type: String, computed: '_bgStyle(deviceClass)' },
+          devClasses: { type: String, value: '' } // the css kind
+        };
+        this.getter_observers = [
+          'onGraph(graph)'
+          ];
+      }
+      constructor() {
+        super();
+        this.selectedAttrs = new Set(); // uri strings
+      }
+      _bgStyle(deviceClass: { value: any, length: number, charCodeAt: (arg0: number) => number, }) {
+        let hash = 0;
+        deviceClass = deviceClass.value;
+        for (let start = deviceClass.length-10, i = start, end = deviceClass.length, asc = start <= end; asc ? i < end : i > end; asc ? i++ : i--) {
+          hash += deviceClass.charCodeAt(i);
+        }
+        const hue = (hash * 8) % 360;
+        const accent = `hsl(${hue}, 49%, 22%)`;
+        return `background: linear-gradient(to right, rgba(31,31,31,0) 50%, ${accent} 100%);`;
+      }
+      
+      onGraph() {
+        return this.graph.runHandler(this.update.bind(this), `${this.uri.value} update`);
+      }
+  
+      setDeviceSelected(isSel: any) {
+        return this.devClasses = isSel ? 'selected' : '';
+      }
+  
+      setAttrSelected(devAttr: { value: any, }, isSel: any) {
+        if (isSel) {
+          this.selectedAttrs.add(devAttr.value);
+        } else {
+          this.selectedAttrs.delete(devAttr.value);
+        }
+        return this.update();
+      }
+      
+      update(patch: null) {
+        const U = (x: string) => this.graph.Uri(x);
+        if ((patch != null) && !SyncedGraph.patchContainsPreds(
+          patch, [U('rdf:type'), U(':deviceAttr'), U(':dataType'), U(':choice')])) { return; }
+        this.deviceClass = this.graph.uriValue(this.uri, U('rdf:type'));
+        this.deviceAttrs = [];
+        return Array.from(_.unique(this.graph.sortedUris(this.graph.objects(this.deviceClass, U(':deviceAttr'))))).map((da: any) =>
+          this.push('deviceAttrs', this.attrRow(da)));
+      }
+        push(arg0: string, arg1: { uri: { value: any, }, dataType: any, showColorPicker: any, attrClasses: string, }) {
+            throw new Error("Method not implemented.");
+        }
+  
+      attrRow(devAttr: { value: any, }) {
+        let x: { value: any; };
+        const U = (x: string) => this.graph.Uri(x);
+        const dataType = this.graph.uriValue(devAttr, U(':dataType'));
+        const daRow = {
+          uri: devAttr,
+          dataType,
+          showColorPicker: dataType.equals(U(':color')),
+          attrClasses: this.selectedAttrs.has(devAttr.value) ? 'selected' : ''
+          };
+        if (dataType.equals(U(':color'))) {
+          daRow.useColor = true;
+        } else if (dataType.equals(U(':choice'))) {
+          daRow.useChoice = true;
+          const choiceUris = this.graph.sortedUris(this.graph.objects(devAttr, U(':choice')));
+          daRow.choices = ((() => {
+            const result = [];
+            for (x of Array.from(choiceUris)) {             result.push({uri: x.value, label: this.graph.labelOrTail(x)});
+            }
+            return result;
+          })());
+          daRow.choiceSize = Math.min(choiceUris.length + 1, 10);
+        } else {
+          daRow.useSlider = true;
+          daRow.max = 1;
+          if (dataType.equals(U(':angle'))) {
+            // varies
+            daRow.max = 1;
+          }
+        }
+        return daRow;
+      }
+        
+      clear() {
+        return Array.from(this.shadowRoot.querySelectorAll("light9-live-control")).map((lc: { clear: () => any; }) =>
+          lc.clear());
+      }
+  
+      onClick(ev: any) {
+        return log('click', this.uri);
+      }
+        // select, etc
+  
+      onAttrClick(ev: { model: { dattr: { uri: any, }, }, }) {
+        return log('attr click', this.uri, ev.model.dattr.uri);
+      }
+    }
+        // select
+    
+    Light9LiveDeviceControl.initClass();
+    return Light9LiveDeviceControl;
+  })();
+  
+  
\ No newline at end of file
--- a/light9/web/live/elements.html	Tue May 24 23:32:19 2022 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,279 +0,0 @@
-<script src="/lib/debug/debug-build.js"></script>
-<script>
- debug.enable('*');
-</script>
-<link rel="import" href="/lib/polymer/polymer.html">
-
-<link rel="import" href="/lib/paper-slider/paper-slider.html">
-<link rel="import" href="/lib/paper-listbox/paper-listbox.html">
-<link rel="import" href="/lib/paper-item/paper-item.html">
-<link rel="import" href="/lib/iron-ajax/iron-ajax.html">
-
-<link rel="import" href="../rdfdb-synced-graph.html">
-<link rel="import" href="../resource-display.html">
-<link rel="import" href="../light9-color-picker.html">
-<link rel="import" href="../edit-choice.html">
-<dom-module id="light9-listbox">
-  <template>
-    <style>
-     paper-listbox {
-         --paper-listbox-background-color: none;
-         --paper-listbox-color: white;
-         --paper-listbox: {
-             /* measure biggest item? use flex for columns? */
-             column-width: 9em;
-         }
-     }
-     paper-item {
-         --paper-item-min-height: 0;
-         --paper-item: {
-             display: block;
-             border: 1px outset #0f440f;
-             margin: 0 1px 5px 0;
-             background: #0b1d0b;
-         }
-     }
-     paper-item.iron-selected {
-         background: #7b7b4a;
-     }
-    </style>
-    <paper-listbox id="list"
-                   selected="{{value}}"
-                   attr-for-selected="uri"
-                   on-focus-changed="selectOnFocus"
-    >
-      <paper-item on-focus="selectOnFocus">None</paper-item>
-      <template is="dom-repeat" items="{{choices}}">
-        <paper-item on-focus="selectOnFocus" uri="{{item.uri}}">{{item.label}}</paper-item>
-      </template>
-    </paper-listbox>
-
-  </template>
-  <script>
-   HTMLImports.whenReady(function () {
-     Polymer({
-       is: "light9-listbox",
-       properties: {
-         choices: { type: Array },
-         value: { type: String, notify: true },
-       },
-       observers: ['onValue(value)'],
-       selectOnFocus: function(ev) {
-         if (ev.target.uri === undefined) {
-           // *don't* clear for this, or we can't cycle through all choices (including none) with up/down keys
-           //this.clear();
-           //return;
-         }
-         this.value = ev.target.uri;
-         
-       },
-       onValue: function(value) {
-         if (value === null) {
-           this.clear();
-         }
-       },
-       clear: function() {
-         this.async(function() {
-           this.querySelectorAll('paper-item').forEach(
-             function(item) { item.blur(); });
-           this.value = undefined;
-         }.bind(this));
-
-       },
-       
-     });
-   });
-  </script>
-</dom-module>
-
-<dom-module id="light9-live-control">
-  <template>
-    <style>
-     #colorControls {
-         display: flex;
-         align-items: center;
-     }
-     #colorControls > * {
-         margin: 0 3px;
-     }
-     #colorControls paper-slider {
-
-     }
-     paper-slider { width: 100%; height: 25px; }
-    </style>
-
-    <style is="custom-style">
-     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;
-         }
-     }
-     
-    </style>
-
-    <template is="dom-if" if="{{deviceAttrRow.useSlider}}">
-      <paper-slider min="0"
-                    max="{{deviceAttrRow.max}}"
-                    step=".001"
-                    editable
-                    content-type="application/json"
-                    value="{{sliderWriteValue}}"
-                    immediate-value="{{immediateSlider}}"></paper-slider>
-    </template>
-    <template is="dom-if" if="{{deviceAttrRow.useColor}}">
-      <div id="colorControls">
-        <button on-click="goBlack">0.0</button>
-        <light9-color-picker color="{{value}}"></light9-color-picker>
-        
-      </div>
-    </template>
-    <template is="dom-if" if="{{deviceAttrRow.useChoice}}">
-      <light9-listbox choices="{{deviceAttrRow.choices}}" value="{{choiceValue}}">
-      </light9-listbox>
-    </template>
-
-  </template>
-  
-</dom-module>
-
-<dom-module id="light9-live-device-control">
-  <template>
-    <style>
-     :host {
-         display: inline-block;
-     }
-     .device {
-         border: 2px solid #151e2d;
-         margin: 4px;
-         padding: 1px;
-         background: #171717;  /* deviceClass gradient added later */
-         break-inside: avoid-column;
-         width: 335px;
-         
-     }
-     .deviceAttr {
-         border-top: 1px solid #272727;
-         padding-bottom: 2px;
-         display: flex;
-     }
-     .deviceAttr > span {
-
-     }
-     .deviceAttr > light9-live-control {
-         flex-grow: 1;
-     }
-     h2 {
-         font-size: 110%;
-         padding: 4px;
-         margin-top: 0;
-         margin-bottom: 0;
-     }
-     .device, h2 {
-         border-top-right-radius: 15px;
-     }
-
-     #mainLabel {
-         font-size: 120%; 
-         color: #9ab8fd;
-         text-decoration: initial;
-     }
-     .device.selected h2 {
-         outline: 3px solid #ffff0047;
-     }
-     .deviceAttr.selected {
-         background: #cada1829;
-     }
-    </style>
-    <div class$="device {{devClasses}}">
-      <h2 style$="[[bgStyle]]" xon-click="onClick">
-        <resource-display id="mainLabel" graph="{{graph}}" uri="{{uri}}"></resource-display>
-        a <resource-display minor graph="{{graph}}" uri="{{deviceClass}}"></resource-display>
-      </h2>
-      <template is="dom-repeat" items="{{deviceAttrs}}" as="dattr">
-        <div xon-click="onAttrClick" class$="deviceAttr {{dattr.attrClasses}}">
-          <span>attr <resource-display minor graph="{{graph}}" uri="{{dattr.uri}}"></resource-display></span>
-          <light9-live-control
-            graph="{{graph}}"
-            device="{{uri}}"
-            device-attr-row="{{dattr}}"
-            effect="{{effect}}"
-            graph-to-controls="{{graphToControls}}"
-          ></light9-live-control>
-        </div>
-      </template>
-    </div>
-  </template>
-</dom-module>
-
-<dom-module id="light9-live-controls">
-  <template>
-    <style>
-     :host {
-         display: flex;
-         flex-direction: column;
-     }
-     #preview {
-         width: 100%;
-     }
-     #deviceControls {
-         flex-grow: 1;
-         position: relative;
-         width: 100%;
-         overflow-y: auto;
-     }
-     
-     light9-live-device-control > div {
-         break-inside: avoid-column;
-     }
-     light9-live-device-control {
-
-     }
-    </style>
-    <rdfdb-synced-graph graph="{{graph}}"></rdfdb-synced-graph>
-
-    <h1>device control</h1>
-
-    <div id="save">
-      <div>
-        <button on-click="newEffect">New effect</button>
-        <edit-choice graph="{{graph}}" uri="{{effectChoice}}"></edit-choice>
-        <button on-click="clearAll">clear settings in this effect</button>
-      </div>
-    </div>
-
-    <div id="deviceControls">
-      <template is="dom-repeat" items="{{devices}}" as="device">
-        <light9-live-device-control
-          graph="{{graph}}"
-          uri="{{device.uri}}"
-          effect="{{effect}}"
-          graph-to-controls="{{graphToControls}}"
-        ></light9-live-device-control>
-      </template>
-    </div>
-    
-  </template>
-</dom-module>
-
-
-<script src="/node_modules/d3/dist/d3.min.js"></script>
-<script src="/node_modules/n3/n3-browser.js"></script>
-<script src="/lib/async/dist/async.js"></script>
-<script src="/lib/underscore/underscore-min.js"></script>
-
-<script src="../coffee_element.js"></script>
-<!-- see live.coffee
-     <script src="/lib/jquery/dist/jquery.js"></script>
-     <script src="/lib/isotope/dist/isotope.pkgd.min.js"></script>
-     <script src="/lib/isotope-fit-columns/fit-columns.js"></script>
-     <script src="/lib/jquery.columnizer/src/jquery.columnizer.js"></script>
-   -->
-
-<script src="live.js"></script>   
--- a/light9/web/live/index.html	Tue May 24 23:32:19 2022 -0700
+++ b/light9/web/live/index.html	Wed May 25 00:06:00 2022 -0700
@@ -4,8 +4,6 @@
     <title>device control</title>
     <meta charset="utf-8" />
     <link rel="stylesheet" href="/style.css">
-    <script src="/node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js"></script>
-    <link rel="import" href="elements.html">
   </head>
   <body>
     <style>
--- a/light9/web/live/live.coffee	Tue May 24 23:32:19 2022 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,490 +0,0 @@
-log = debug('live')
-
-# Like element.set(path, newArray), but minimizes splices.
-# Dotted paths don't work yet.
-syncArray = (element, path, newArray, isElementEqual) ->
-  pos = 0
-  newPos = 0
-
-  while newPos < newArray.length
-    if pos < element[path].length
-      if isElementEqual(element[path][pos], newArray[newPos])
-        pos += 1
-        newPos += 1
-      else
-        element.splice('devices', pos, 1)
-    else
-      element.push('devices', newArray[newPos])
-      pos += 1
-      newPos += 1
-
-  if pos < element[path].length
-    element.splice('devices', pos, element[path].length - pos)
-
-valuePred = (graph, attr) ->
-  U = (x) -> graph.Uri(x)
-  scaledAttributeTypes = [U(':color'), U(':brightness'), U(':uv')]
-  if _.some(scaledAttributeTypes,
-            (x) -> attr.equals(x)) then U(':scaledValue') else U(':value')
-
-coffeeElementSetup(class Light9LiveControl extends Polymer.Element
-  @is: 'light9-live-control'
-  @getter_properties:
-    graph: { type: Object, notify: true }
-    device: { type: Object }
-    deviceAttrRow: { type: Object } # object returned from attrRow, below
-    value: { type: Object, notify: true } # null, Uri, float, str
-    choiceValue: { type: Object }
-    
-    immediateSlider: { notify: true, observer: 'onSlider' }
-    sliderWriteValue: { type: Number }
-
-    pickedChoice: { observer: 'onChange' }
-    graphToControls: { type: Object }
-  @getter_observers: [
-    'onChange(value)'
-    'onGraphToControls(graphToControls)'
-    'onChoice(choiceValue)'
-    ]
-  constructor: ->
-    super()
-    @enableChange = false # until 1st graph read
-  onSlider: -> @value = @immediateSlider
-  goBlack: -> @value = "#000000"
-  onGraphToControls: (gtc) ->
-    gtc.register(@device, @deviceAttrRow.uri, @graphValueChanged.bind(@))
-    @enableChange = true
-
-  graphValueChanged: (v) ->
-    log('change: control gets', v)
-    @enableChange = false
-    if v == null
-      @clear()
-    else
-      @value = v
-    @sliderWriteValue = v if @deviceAttrRow.useSlider
-    @choiceValue = (if v == null then v else v.value) if @deviceAttrRow.useChoice
-    @enableChange = true
-
-  onChoice: (value) ->
-    return unless @graphToControls? and @enableChange
-    if value?
-      value = @graph.Uri(value)
-    else
-      value = null
-    @graphToControls.controlChanged(@device, @deviceAttrRow.uri, value)
-    
-  onChange: (value) ->
-    return unless @graphToControls? and @enableChange
-    return if typeof value == "number" and isNaN(value) # let onChoice do it
-    #log('change: control tells graph', @deviceAttrRow.uri.value, value)
-    if value == undefined
-      value = null
-    @graphToControls.controlChanged(@device, @deviceAttrRow.uri, value)
-
-  clear: ->
-    @pickedChoice = null
-    @sliderWriteValue = 0
-    if @deviceAttrRow.useColor
-      @value = '#000000'
-    else if @deviceAttrRow.useChoice
-      @value = @pickedChoice = null
-    else
-      @value = @immediateSlider = 0
-)
-
-coffeeElementSetup(class Light9LiveDeviceControl extends Polymer.Element
-  @is: "light9-live-device-control"
-  @getter_properties:
-    graph: { type: Object, notify: true }
-    uri: { type: String, notify: true }
-    effect: { type: String }
-    deviceClass: { type: String, notify: true } # the uri str
-    deviceAttrs: { type: Array, notify: true }
-    graphToControls: { type: Object }
-    bgStyle: { type: String, computed: '_bgStyle(deviceClass)' }
-    devClasses: { type: String, value: '' } # the css kind
-  @getter_observers: [
-    'onGraph(graph)'
-    ]
-  constructor: ->
-    super()
-    @selectedAttrs = new Set() # uri strings
-  _bgStyle: (deviceClass) ->
-    hash = 0
-    deviceClass = deviceClass.value
-    for i in [(deviceClass.length-10)...deviceClass.length]
-      hash += deviceClass.charCodeAt(i)
-    hue = (hash * 8) % 360
-    accent = "hsl(#{hue}, 49%, 22%)"
-    "background: linear-gradient(to right, rgba(31,31,31,0) 50%, #{accent} 100%);"
-    
-  onGraph: ->
-    @graph.runHandler(@update.bind(@), "#{@uri.value} update")
-
-  setDeviceSelected: (isSel) ->
-    @devClasses = if isSel then 'selected' else ''
-
-  setAttrSelected: (devAttr, isSel) ->
-    if isSel
-      @selectedAttrs.add(devAttr.value)
-    else
-      @selectedAttrs.delete(devAttr.value)
-    @update()
-    
-  update: (patch) ->
-    U = (x) => @graph.Uri(x)
-    return if patch? and not SyncedGraph.patchContainsPreds(
-      patch, [U('rdf:type'), U(':deviceAttr'), U(':dataType'), U(':choice')])
-    @deviceClass = @graph.uriValue(@uri, U('rdf:type'))
-    @deviceAttrs = []
-    for da in _.unique(@graph.sortedUris(@graph.objects(@deviceClass, U(':deviceAttr'))))
-      @push('deviceAttrs', @attrRow(da))
-
-  attrRow: (devAttr) ->
-    U = (x) => @graph.Uri(x)
-    dataType = @graph.uriValue(devAttr, U(':dataType'))
-    daRow = {
-      uri: devAttr
-      dataType: dataType
-      showColorPicker: dataType.equals(U(':color'))
-      attrClasses: if @selectedAttrs.has(devAttr.value) then 'selected' else ''
-      }
-    if dataType.equals(U(':color'))
-      daRow.useColor = true
-    else if dataType.equals(U(':choice'))
-      daRow.useChoice = true
-      choiceUris = @graph.sortedUris(@graph.objects(devAttr, U(':choice')))
-      daRow.choices = ({uri: x.value, label: @graph.labelOrTail(x)} for x in choiceUris)
-      daRow.choiceSize = Math.min(choiceUris.length + 1, 10)
-    else
-      daRow.useSlider = true
-      daRow.max = 1
-      if dataType.equals(U(':angle'))
-        # varies
-        daRow.max = 1
-    return daRow
-      
-  clear: ->
-    for lc in @shadowRoot.querySelectorAll("light9-live-control")
-      lc.clear()
-
-  onClick: (ev) ->
-    log('click', @uri)
-    # select, etc
-
-  onAttrClick: (ev) ->
-    log('attr click', @uri, ev.model.dattr.uri)
-    # select
-)
-
-
-class ActiveSettings
-  constructor: (@graph) ->
-    # The settings we're showing (or would like to but the widget
-    # isn't registered yet):
-    # dev+attr : {setting: Uri, onChangeFunc: f, jsValue: str_or_float}
-    @settings = new Map()
-    @keyForSetting = new Map() # setting uri str -> dev+attr
-
-    # Registered graphValueChanged funcs, by dev+attr. Kept even when
-    # settings are deleted.
-    @onChanged = new Map()
-
-  addSettingsRow: (device, deviceAttr, setting, value) ->
-    key = device.value + " " + deviceAttr.value
-    if @settings.has(key)
-      throw new Error("repeated setting on "+key)
-    if @keyForSetting.has(setting.value)
-      throw new Error("repeated keyForSetting on "+setting.value)
-    @settings.set(key, {
-      setting: setting,
-      onChangeFunc: @onChanged[key],
-      jsValue: value
-    })
-    @keyForSetting.set(setting.value, key)
-    if @onChanged[key]?
-      @onChanged[key](value)
-
-  has: (setting) ->
-    @keyForSetting.has(setting.value)
-
-  setValue: (setting, value) ->
-    row = @settings.get(@keyForSetting.get(setting.value))
-    row.jsValue = value
-    row.onChangeFunc(value) if row.onChangeFunc?
-
-  registerWidget: (device, deviceAttr, graphValueChanged) ->
-    key = device.value + " " + deviceAttr.value
-    @onChanged[key] = graphValueChanged
-    
-    if @settings.has(key)
-      row = @settings.get(key)
-      row.onChangeFunc = graphValueChanged
-      row.onChangeFunc(row.jsValue)
-
-  effectSettingLookup: (device, attr) ->
-    key = device.value + " " + attr.value
-    if @settings.has(key)
-      return @settings.get(key).setting
-
-    return null
-
-  deleteSetting: (setting) ->
-    log('deleteSetting ' + setting.value)
-    key = @keyForSetting.get(setting.value)
-    row = @settings.get(key)
-    if row? and not row.setting.equals(setting)
-      throw new Error('corrupt row for ' + setting.value)
-    row.onChangeFunc(null) if row?.onChangeFunc?
-    @settings.delete(key)
-    @keyForSetting.delete(setting)
-
-  clear: ->
-    new Map(@settings).forEach (row, key) ->
-      row.onChangeFunc(null) if row.onChangeFunc?
-    @settings.clear()
-    @keyForSetting.clear()
-
-  forAll: (cb) ->
-    all = Array.from(@keyForSetting.keys())
-    for s in all
-      cb(@graph.Uri(s))
-
-  allSettingsStr: ->
-    @keyForSetting.keys()
-
-class GraphToControls
-  # More efficient bridge between liveControl widgets and graph edits,
-  # as opposed to letting each widget scan the graph and push lots of
-  # tiny patches to it.
-  constructor: (@graph) ->
-    @activeSettings = new ActiveSettings(@graph)
-    @effect = null
-
-  ctxForEffect: (effect) ->
-    @graph.Uri(effect.value.replace(
-      "light9.bigasterisk.com/effect",
-      "light9.bigasterisk.com/show/dance2019/effect"))
-      
-  setEffect: (effect) ->
-    @clearSettings()
-    @effect = effect
-    @ctx = @ctxForEffect(@effect)
-    # are these going to pile up? consider @graph.triggerHandler('GTC sync')
-    @graph.runHandler(@syncFromGraph.bind(@), 'GraphToControls sync')
-
-  newEffect: ->
-    # wrong- this should be our editor's scratch effect, promoted to a
-    # real one when you name it.
-    U = (x) => @graph.Uri(x)
-    effect = @graph.nextNumberedResource(U('http://light9.bigasterisk.com/effect/effect'))
-    ctx = @ctxForEffect(effect)
-    quad = (s, p, o) => @graph.Quad(s, p, o, ctx)
-
-    addQuads = [
-      quad(effect, U('rdf:type'), U(':Effect'))
-      quad(effect, U('rdfs:label'), @graph.Literal(effect.value.replace(/.*\//, "")))
-      quad(effect, U(':publishAttr'), U(':strength'))
-    ]
-    patch = {addQuads: addQuads, delQuads: []}
-    log('init new effect', patch)
-    @graph.applyAndSendPatch(patch)
-    return effect
-
-  syncFromGraph: ->
-    U = (x) => @graph.Uri(x)
-    return if not @effect
-    log('syncFromGraph', @effect)
-    
-    toClear = new Set(@activeSettings.allSettingsStr())
-    
-    for setting in @graph.objects(@effect, U(':setting'))
-      dev = @graph.uriValue(setting, U(':device'))
-      devAttr = @graph.uriValue(setting, U(':deviceAttr'))
-
-      pred = valuePred(@graph, devAttr)
-      try
-        value = @graph.uriValue(setting, pred)
-        if not value.id.match(/^http/)
-          throw new Error("not uri")
-      catch
-        try
-          value = @graph.floatValue(setting, pred)
-        catch
-          value = @graph.stringValue(setting, pred)
-      #log('change: graph contains', devAttr, value)
-
-      if @activeSettings.has(setting)
-        @activeSettings.setValue(setting, value)
-        toClear.delete(setting.value)
-      else
-        @activeSettings.addSettingsRow(dev, devAttr, setting, value)
-          
-    for settingStr in Array.from(toClear)
-      @activeSettings.deleteSetting(U(settingStr))
-
-  clearSettings: ->
-    @activeSettings.clear()
-
-  register: (device, deviceAttr, graphValueChanged) ->
-    @activeSettings.registerWidget(device, deviceAttr, graphValueChanged)
-
-  shouldBeStored: (deviceAttr, value) ->
-    # this is a bug for zoom=0, since collector will default it to
-    # stick at the last setting if we don't explicitly send the
-    # 0. rx/ry similar though not the exact same deal because of
-    # their remap.
-    return value? and value != 0 and value != '#000000'
-
-  emptyEffect: ->
-    @activeSettings.forAll(@_removeEffectSetting.bind(@))
-
-  controlChanged: (device, deviceAttr, value) ->
-    # todo: controls should be disabled if there's no effect and they won't do anything.
-    return if not @effect
-    
-    # value is float or #color or (Uri or null)
-    if (value == undefined or (typeof value == "number" and isNaN(value)) or (typeof value == "object" and value != null and not value.id))
-      throw new Error("controlChanged sent bad value " + value)
-    effectSetting = @activeSettings.effectSettingLookup(device, deviceAttr)
-
-    # sometimes this misses an existing setting, which leads to a mess
-    
-    if @shouldBeStored(deviceAttr, value)
-      if not effectSetting?
-        @_addEffectSetting(device, deviceAttr, value)
-      else
-        @_patchExistingEffectSetting(effectSetting, deviceAttr, value)
-    else
-      @_removeEffectSetting(effectSetting)
-
-  _nodeForValue: (value) ->
-    if value.id?
-      return value
-    return @graph.prettyLiteral(value)
-
-  _addEffectSetting: (device, deviceAttr, value) ->
-    log('change: _addEffectSetting', deviceAttr.value, value)
-    U = (x) => @graph.Uri(x)
-    quad = (s, p, o) => @graph.Quad(s, p, o, @ctx)
-    effectSetting = @graph.nextNumberedResource(@effect.value + '_set')
-    @activeSettings.addSettingsRow(device, deviceAttr, effectSetting, value)
-    addQuads = [
-      quad(@effect, U(':setting'), effectSetting),
-      quad(effectSetting, U(':device'),  device),
-      quad(effectSetting, U(':deviceAttr'), deviceAttr),
-      quad(effectSetting, valuePred(@graph, deviceAttr), @_nodeForValue(value))
-    ]
-    patch = {addQuads: addQuads, delQuads: []}
-    log('save', patch)
-    @graph.applyAndSendPatch(patch)
-
-  _patchExistingEffectSetting: (effectSetting, deviceAttr, value) ->
-    log('change: patch existing', effectSetting.value)
-    @activeSettings.setValue(effectSetting, value)
-    @graph.patchObject(effectSetting, valuePred(@graph, deviceAttr), @_nodeForValue(value), @ctx)
-
-  _removeEffectSetting: (effectSetting) ->
-    U = (x) => @graph.Uri(x)
-    quad = (s, p, o) => @graph.Quad(s, p, o, @ctx)
-    if effectSetting?
-      log('change: _removeEffectSetting', effectSetting.value)
-      toDel = [quad(@effect, U(':setting'), effectSetting, @ctx)]
-      for q in @graph.graph.getQuads(effectSetting)
-        toDel.push(q)
-      @graph.applyAndSendPatch({delQuads: toDel, addQuads: []})
-      @activeSettings.deleteSetting(effectSetting)
-    
-    
-coffeeElementSetup(class Light9LiveControls extends Polymer.Element
-  @is: "light9-live-controls"
-  @getter_properties:
-    graph: { type: Object, notify: true }
-    devices: { type: Array, notify: true, value: [] }
-    # string uri of the effect being edited, or null. This is the
-    # master value; GraphToControls follows.
-    effectChoice: { type: String, notify: true, value: null }
-    graphToControls: { type: Object }
-  @getter_observers: [
-    'onGraph(graph)'
-    'onEffectChoice(effectChoice)'
-    ]
-
-  constructor: ->
-    super()
-    @graphToControls = null
-    @okToWriteUrl = false
-
-  ready: ->
-    super.ready()
-    @currentSettings = {}
-
-  onGraph: ->
-    @graphToControls = new GraphToControls(@graph)
-    @graph.runHandler(@update.bind(@), 'Light9LiveControls update')
-
-    # need graph to be loaded, so we don't make double settings? not sure.
-    setTimeout(@setFromUrl.bind(@), 1)
-
-  setFromUrl: ->
-    # not a continuous bidi link between url and effect; it only reads
-    # the url when the page loads.
-    effect = new URL(window.location.href).searchParams.get('effect')
-    if effect?
-      log('found url', effect)
-      @effectChoice = effect
-    @okToWriteUrl = true
-
-  writeToUrl: (effectStr) ->
-    return unless @okToWriteUrl
-    u = new URL(window.location.href)
-    if u.searchParams.get('effect') == effectStr
-      return        
-    u.searchParams.set('effect', effectStr)
-    window.history.replaceState({}, "", u.href)
-    log('wrote new url', u.href)
-
-  newEffect: ->
-    @effectChoice = @graphToControls.newEffect().value
-      
-  onEffectChoice: ->
-    U = (x) => @graph.Uri(x)
-    if not @effectChoice?
-      # unlink
-      @graphToControls.setEffect(null) if @graphToControls?
-    else
-      log('load', @effectChoice)
-      @graphToControls.setEffect(@graph.Uri(@effectChoice)) if @graphToControls?
-    @writeToUrl(@effectChoice)
- 
-  clearAll: ->
-    # clears the effect!
-    @graphToControls.emptyEffect()
-    
-  update: ->
-    U = (x) => @graph.Uri(x)
-
-    newDevs = []
-    for dc in @graph.sortedUris(@graph.subjects(U('rdf:type'), U(':DeviceClass')))
-      for dev in @graph.sortedUris(@graph.subjects(U('rdf:type'), dc))
-        if @graph.contains(dev, U(':hideInLiveUi'), null)
-          continue
-        newDevs.push({uri: dev})
-
-    #log("controls update now has #{newDevs.length} devices")
-    syncArray(@, 'devices', newDevs, (a, b) -> a.uri.value == b.uri.value)
-
-    return
-
-    # Tried css columns- big slowdown from relayout as I'm scrolling.
-    # Tried isotope- seems to only scroll to the right.
-    # Tried columnize- fails in jquery maybe from weird elements.
-    
-    # not sure how to get this run after the children are created
-    setTimeout((() -> $('#deviceControls').isotope({
-      # fitColumns would be nice, but it doesn't scroll vertically
-      layoutMode: 'masonry',
-      containerStyle: null
-      })), 2000)
-)
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/live/vite.config.ts	Wed May 25 00:06:00 2022 -0700
@@ -0,0 +1,20 @@
+import { defineConfig } from "vite";
+
+const servicePort = 8217;
+export default defineConfig({
+  base: "/live/",
+  root: "./light9/web/live",
+  publicDir: "../web",
+  server: {
+    host: "0.0.0.0",
+    strictPort: true,
+    port: servicePort + 100,
+    hmr: {
+      port: servicePort + 200,
+    },
+  },
+  clearScreen: false,
+  define: {
+    global: {},
+  },
+});