changeset 2087:1b6e7016e3de

rewrite state mgmt in live/
author drewp@bigasterisk.com
date Sun, 29 May 2022 01:49:34 -0700
parents fe807af851c8
children 0617b6006ec4
files light9/web/RdfdbSyncedGraph.ts light9/web/SyncedGraph.ts light9/web/live/ActiveSettings.ts light9/web/live/Effect.ts light9/web/live/GraphToControls.ts light9/web/live/Light9DeviceControl.ts light9/web/live/Light9LiveControl.ts light9/web/live/Light9LiveControls.ts
diffstat 8 files changed, 424 insertions(+), 473 deletions(-) [+]
line wrap: on
line diff
--- a/light9/web/RdfdbSyncedGraph.ts	Sun May 29 01:43:11 2022 -0700
+++ b/light9/web/RdfdbSyncedGraph.ts	Sun May 29 01:49:34 2022 -0700
@@ -1,12 +1,14 @@
 import debug from "debug";
 import { html, LitElement, css } from "lit";
 import { customElement, property } from "lit/decorators.js";
+import { NamedNode } from "n3";
 import { Patch } from "./patch";
 import { SyncedGraph } from "./SyncedGraph";
 
 const log = debug("syncedgraph-el");
 
-
+// todo: consider if this has anything to contribute:
+// https://github.com/webcomponents-cg/community-protocols/blob/main/proposals/context.md
 let setTopGraph: (sg: SyncedGraph) => void;
 (window as any).topSyncedGraph = new Promise<SyncedGraph>((res, rej) => {
   setTopGraph = res;
@@ -42,23 +44,23 @@
   constructor() {
     super();
     this.status = "startup";
+    const prefixes = new Map<string, string>([
+      ["", "http://light9.bigasterisk.com/"],
+      ["dev", "http://light9.bigasterisk.com/device/"],
+      ["rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"],
+      ["rdfs", "http://www.w3.org/2000/01/rdf-schema#"],
+      ["xsd", "http://www.w3.org/2001/XMLSchema#"],
+    ]);
     this.graph = new SyncedGraph(
       this.testGraph ? null : "/rdfdb/api/syncedGraph",
-      {
-        "": "http://light9.bigasterisk.com/",
-        dev: "http://light9.bigasterisk.com/device/",
-        rdf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
-        rdfs: "http://www.w3.org/2000/01/rdf-schema#",
-        xsd: "http://www.w3.org/2001/XMLSchema#",
-      },
+      prefixes,
       (s: string) => {
         this.status = s;
       },
-      this.onClear.bind(this),
+      this.onClear.bind(this)
     );
     setTopGraph(this.graph);
   }
-
 }
 
 export async function getTopGraph(): Promise<SyncedGraph> {
--- a/light9/web/SyncedGraph.ts	Sun May 29 01:43:11 2022 -0700
+++ b/light9/web/SyncedGraph.ts	Sun May 29 01:49:34 2022 -0700
@@ -1,4 +1,3 @@
-import * as d3 from "d3";
 import debug from "debug";
 import * as N3 from "n3";
 import { Quad, Quad_Object, Quad_Predicate, Quad_Subject } from "n3";
@@ -16,7 +15,7 @@
   private graph: N3.Store;
   cachedFloatValues: any;
   cachedUriValues: any;
-  prefixFuncs: (x: string) => string = (x) => x;
+  private prefixFuncs: (prefix: string) => N3.PrefixedToIri;
   serial: any;
   _nextNumber: any;
   // Main graph object for a browser to use. Consider using RdfdbSyncedGraph element to create & own
@@ -29,12 +28,13 @@
   constructor(
     // url is the /syncedGraph path of an rdfdb server.
     public url: any,
-    // prefixes can be used in Uri(curie) calls.
-    public prefixes: { [short: string]: string },
+    // prefixes can be used in Uri(curie) calls. This mapping may grow during loadTrig calls.
+    public prefixes: Map<string, string>,
     private setStatus: any,
     // called if we clear the graph
     private clearCb: any
   ) {
+    this.prefixFuncs = this.rebuildPrefixFuncs(prefixes);
     this.graph = new N3.Store();
     this._autoDeps = new AutoDependencies();
     this.clearGraph();
@@ -50,7 +50,7 @@
     this._applyPatch({ adds: [], dels: this.graph.getQuads(null, null, null, null) });
     // if we had a Store already, this lets N3.Store free all its indices/etc
     this.graph = new N3.Store();
-    this._addPrefixes(this.prefixes);
+    this.rebuildPrefixFuncs(this.prefixes);
   }
 
   _clearGraphOnNewConnection() {
@@ -63,14 +63,16 @@
     }
   }
 
-  _addPrefixes(prefixes: { [x: string]: string }) {
-    for (let k of Array.from(prefixes || {})) {
-      this.prefixes[k] = prefixes[k];
-    }
-    this.prefixFuncs = N3.Util.prefixes(this.prefixes);
+  private rebuildPrefixFuncs(prefixes: Map<string, string>) {
+    const p = Object.create(null);
+    prefixes.forEach((v: string, k: string) => (p[k] = v));
+
+    this.prefixFuncs = N3.Util.prefixes(p);
+    return this.prefixFuncs;
   }
 
-  U() { // just a shorthand
+  U() {
+    // just a shorthand
     return this.Uri.bind(this);
   }
 
@@ -85,12 +87,21 @@
     return this.prefixFuncs(part[0])(part[1]);
   }
 
-  Literal(jsValue: any) {
+  // Uri(shorten(u)).value==u
+  shorten(uri: N3.NamedNode): string {
+    const prefix = "http://light9.bigasterisk.com/";
+    if (uri.value.startsWith(prefix)) {
+      return ":" + uri.value.substring(prefix.length);
+    }
+    return uri.value;
+  }
+
+  Literal(jsValue: string | number) {
     return N3.DataFactory.literal(jsValue);
   }
 
   LiteralRoundedFloat(f: number) {
-    return N3.DataFactory.literal(d3.format(".3f")(f), this.Uri("http://www.w3.org/2001/XMLSchema#double"));
+    return N3.DataFactory.literal(f.toPrecision(3), this.Uri("http://www.w3.org/2001/XMLSchema#double"));
   }
 
   Quad(s: any, p: any, o: any, g: any) {
@@ -114,7 +125,7 @@
         return patch.adds.push(quad);
       } else {
         this._applyPatch(patch);
-        this._addPrefixes(prefixes);
+        // todo: here, add those prefixes to our known set
         if (cb) {
           return cb();
         }
@@ -145,7 +156,7 @@
     if (this._client) {
       this._client.sendPatch(patch);
     }
-    return console.timeEnd("applyAndSendPatch");
+    console.timeEnd("applyAndSendPatch");
   }
 
   _validatePatch(patch: Patch) {
@@ -171,7 +182,7 @@
     // In most cases you want applyAndSendPatch.
     //
     // This is the only method that writes to this.graph!
-    log("patch from server [1]")
+    log("patch from server [1]");
     this.cachedFloatValues.clear();
     this.cachedUriValues.clear();
     for (let quad of Array.from(patch.dels)) {
@@ -299,6 +310,12 @@
     return Array.from(quads).map((q: { subject: any }) => q.subject);
   }
 
+  subjectStatements(s: Quad_Subject): Quad[] {
+    this._autoDeps.askedFor(s, null, null, null);
+    const quads = this.graph.getQuads(s, null, null, null);
+    return quads;
+  }
+
   items(list: any) {
     const out = [];
     let current = list;
--- a/light9/web/live/ActiveSettings.ts	Sun May 29 01:43:11 2022 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,118 +0,0 @@
-import debug from "debug";
-import { NamedNode } from "n3";
-import { SyncedGraph } from "../SyncedGraph";
-const log = debug("active");
-
-interface SettingRow {
-  setting: NamedNode;
-  onChangeFunc: (x: null | undefined | string) => void;
-  jsValue?: string;
-}
-
-export class ActiveSettings {
-  graph: SyncedGraph;
-  settings: Map<string, SettingRow>;
-  keyForSetting: Map<string, string>;
-  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: NamedNode, deviceAttr: NamedNode, setting: NamedNode, 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 k = this.keyForSetting.get(setting.value);
-    if (!k) throw new Error("not found");
-    const row = this.settings.get(k);
-    if (!row) throw new Error(`${setting.value} not found`);
-    row.jsValue = value;
-    if (row.onChangeFunc != null) {
-      return row.onChangeFunc(value);
-    }
-  }
-
-  registerWidget(device: NamedNode, deviceAttr: NamedNode, graphValueChanged: any) {
-    const key = device.value + " " + deviceAttr.value;
-    this.onChanged[key] = graphValueChanged;
-
-    const row = this.settings.get(key);
-    if (!row) throw new Error(`${key} not found`);
-
-    row.onChangeFunc = graphValueChanged;
-    row.onChangeFunc(row.jsValue);
-  }
-
-  effectSettingLookup(device: NamedNode, attr: NamedNode): NamedNode | null {
-    const key = device.value + " " + attr.value;
-    const row = this.settings.get(key);
-    if (row) {
-      return row.setting;
-    }
-
-    return null;
-  }
-
-  deleteSetting(setting: NamedNode) {
-    log("deleteSetting " + setting.value);
-    const key = this.keyForSetting.get(setting.value);
-    if (!key) throw new Error("not found");
-    const row = this.settings.get(key);
-    if (row && !row.setting.equals(setting)) {
-      throw new Error("corrupt row for " + setting.value);
-    }
-    if (row) {
-      row.onChangeFunc(null);
-    }
-    this.settings.delete(key);
-    return this.keyForSetting.delete(setting.value);
-  }
-
-  clear() {
-    this.settings.forEach((row: { onChangeFunc: (arg0: any) => any }, key: any) => {
-      if (row.onChangeFunc != null) {
-        return row.onChangeFunc(null);
-      }
-    });
-    this.settings.clear();
-    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/Effect.ts	Sun May 29 01:49:34 2022 -0700
@@ -0,0 +1,185 @@
+import debug from "debug";
+import { Literal, NamedNode, Quad_Object, Quad_Predicate, Quad_Subject, Term } from "n3";
+import { some } from "underscore";
+import { Patch } from "../patch";
+import { SyncedGraph } from "../SyncedGraph";
+
+type Color = string;
+export type ControlValue = number | Color | NamedNode;
+
+const log = debug("effect");
+
+function isUri(x: Term | number | string): x is NamedNode {
+  return typeof x == "object" && x.termType == "NamedNode";
+}
+
+function valuePred(graph: SyncedGraph, attr: NamedNode): NamedNode {
+  const U = graph.U();
+  const scaledAttributeTypes = [U(":color"), U(":brightness"), U(":uv")];
+  if (some(scaledAttributeTypes, (x: NamedNode) => attr.equals(x))) {
+    return U(":scaledValue");
+  } else {
+    return U(":value");
+  }
+}
+
+// effect settings data; r/w sync with the graph
+export class Effect {
+  private settings: Array<{ device: NamedNode; deviceAttr: NamedNode; setting: NamedNode; value: ControlValue }> = [];
+
+  constructor(
+    public graph: SyncedGraph,
+    public uri: NamedNode,
+    // called if the graph changes our values and not when the caller uses edit()
+    private onValuesChanged: (values: void) => void
+  ) {
+    graph.runHandler(this.rebuildSettingsFromGraph.bind(this), `effect sync ${uri.value}`);
+  }
+
+  private ctxForEffect(): NamedNode {
+    return this.graph.Uri(this.uri.value.replace("light9.bigasterisk.com/effect", "light9.bigasterisk.com/show/dance2019/effect"));
+  }
+
+  addNewEffectToGraph() {
+    const U = this.graph.U();
+    const ctx = this.ctxForEffect();
+    const quad = (s: Quad_Subject, p: Quad_Predicate, o: Quad_Object) => this.graph.Quad(s, p, o, ctx);
+
+    const addQuads = [
+      quad(this.uri, U("rdf:type"), U(":Effect")),
+      quad(this.uri, U("rdfs:label"), this.graph.Literal(this.uri.value.replace(/.*\//, ""))),
+      quad(this.uri, U(":publishAttr"), U(":strength")),
+    ];
+    const patch = { adds: addQuads, dels: [] } as Patch;
+    log("init new effect", patch);
+    this.settings = [];
+    this.graph.applyAndSendPatch(patch);
+  }
+
+  rebuildSettingsFromGraph() {
+    const U = this.graph.U();
+    log("syncFromGraph", this.uri);
+
+    const newSettings = [];
+
+    for (let setting of Array.from(this.graph.objects(this.uri, U(":setting")))) {
+      log(`  setting ${setting.value}`);
+      if (!isUri(setting)) throw new Error();
+      let value: ControlValue;
+      const device = this.graph.uriValue(setting, U(":device"));
+      const deviceAttr = this.graph.uriValue(setting, U(":deviceAttr"));
+
+      const pred = valuePred(this.graph, deviceAttr);
+      try {
+        value = this.graph.uriValue(setting, pred);
+        if (!(value as NamedNode).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); // this may find multi values and throw
+        }
+      }
+      log(`change: graph contains ${deviceAttr.value} ${value}`);
+
+      newSettings.push({ device, deviceAttr, setting, value });
+    }
+    this.settings = newSettings;
+    log(`rebuild to ${this.settings.length}`);
+    this.onValuesChanged();
+  }
+  currentValue(device: NamedNode, deviceAttr: NamedNode): ControlValue | null {
+    for (let s of this.settings) {
+      if (device.equals(s.device) && deviceAttr.equals(s.deviceAttr)) {
+        return s.value;
+      }
+    }
+    return null;
+  }
+  // change this object now, but return the patch to be applied to the graph so it can be coalesced.
+  edit(device: NamedNode, deviceAttr: NamedNode, newValue: ControlValue | null): Patch {
+    log(`edit: value=${newValue}`);
+    let existingSetting: NamedNode | null = null;
+    for (let s of this.settings) {
+      if (device.equals(s.device) && deviceAttr.equals(s.deviceAttr)) {
+        if (existingSetting !== null) {
+          throw new Error(`${this.uri.value} had two settings for ${device.value} - ${deviceAttr.value}`);
+        }
+        existingSetting = s.setting;
+      }
+    }
+
+    if (newValue !== null && this.shouldBeStored(deviceAttr, newValue)) {
+      if (existingSetting === null) {
+        return this._addEffectSetting(device, deviceAttr, newValue);
+      } else {
+        return this._patchExistingEffectSetting(existingSetting, deviceAttr, newValue);
+      }
+    } else {
+      if (existingSetting !== null) {
+        return this._removeEffectSetting(existingSetting);
+      }
+    }
+    return { adds: [], dels: [] };
+  }
+
+  shouldBeStored(deviceAttr: NamedNode, value: ControlValue | null): boolean {
+    // 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";
+  }
+
+  _addEffectSetting(device: NamedNode, deviceAttr: NamedNode, value: ControlValue): Patch {
+    log("  _addEffectSetting", deviceAttr.value, value);
+    const U = (x: string) => this.graph.Uri(x);
+    const ctx = this.ctxForEffect();
+    const quad = (s: Quad_Subject, p: Quad_Predicate, o: Quad_Object) => this.graph.Quad(s, p, o, ctx);
+    if (!this.uri) throw new Error("effect unset");
+    const setting = this.graph.nextNumberedResource(this.uri.value + "_set");
+
+    const addQuads = [
+      quad(this.uri, U(":setting"), setting),
+      quad(setting, U(":device"), device),
+      quad(setting, U(":deviceAttr"), deviceAttr),
+      quad(setting, valuePred(this.graph, deviceAttr), this._nodeForValue(value)),
+    ];
+    const patch = { adds: addQuads, dels: [] } as Patch;
+    log("  save", patch);
+    this.settings.push({ device, deviceAttr, setting, value });
+    return patch;
+  }
+
+  _patchExistingEffectSetting(effectSetting: NamedNode, deviceAttr: NamedNode, value: ControlValue): Patch {
+    log("  patch existing", effectSetting.value);
+    return this.graph.getObjectPatch(
+      effectSetting, //
+      valuePred(this.graph, deviceAttr),
+      this._nodeForValue(value),
+      this.ctxForEffect()
+    );
+  }
+
+  _removeEffectSetting(effectSetting: NamedNode): Patch {
+    const U = (x: string) => this.graph.Uri(x);
+    log("  _removeEffectSetting", effectSetting.value);
+    const toDel = [this.graph.Quad(this.uri, U(":setting"), effectSetting, this.ctxForEffect())];
+    for (let q of this.graph.subjectStatements(effectSetting)) {
+      toDel.push(q);
+    }
+    return { dels: toDel, adds: [] };
+  }
+
+  _nodeForValue(value: ControlValue): NamedNode | Literal {
+    if (value === null) {
+      throw new Error("no value");
+    }
+    if (isUri(value)) {
+      return value;
+    }
+    return this.graph.prettyLiteral(value);
+  }
+}
--- a/light9/web/live/GraphToControls.ts	Sun May 29 01:43:11 2022 -0700
+++ b/light9/web/live/GraphToControls.ts	Sun May 29 01:49:34 2022 -0700
@@ -1,233 +1,69 @@
 import debug from "debug";
-import { BlankNode, Literal, NamedNode, Quad_Object, Quad_Predicate, Quad_Subject, Term } from "n3";
-import { some } from "underscore";
-import { Patch } from "../patch";
+import { NamedNode } from "n3";
 import { SyncedGraph } from "../SyncedGraph";
-import { ActiveSettings } from "./ActiveSettings";
+import { ControlValue, Effect } from "./Effect";
 const log = debug("g2c");
 
-const valuePred = function (graph: SyncedGraph, attr: NamedNode) {
-  const U = graph.U();
-  const scaledAttributeTypes = [U(":color"), U(":brightness"), U(":uv")];
-  if (some(scaledAttributeTypes, (x: NamedNode) => attr.equals(x))) {
-    return U(":scaledValue");
-  } else {
-    return U(":value");
-  }
-};
-
-function isUri(x: Term | number | string): x is NamedNode {
-  return typeof x == "object" && x.termType == "NamedNode";
-}
-
-type ControlValue = number | string | NamedNode | null;
-// Like element.set(path, newArray), but minimizes splices.
-// Dotted paths don't work yet.
-const syncArray = function (
-  element: Element,
-  path: string,
-  newArray: { length?: any },
-  isElementEqual: { (a: any, b: any): boolean; (arg0: any, arg1: any): any }
-) {
-  let pos = 0;
-  let newPos = 0;
+type NewValueCb = (newValue: ControlValue | null) => void;
 
-  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);
-  }
-};
-
+// 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.
 export class GraphToControls {
-  activeSettings: ActiveSettings;
-  effect: NamedNode | null = null;
-  ctx: NamedNode | null = null;
-  // 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(public graph: SyncedGraph) {
-    this.activeSettings = new ActiveSettings(this.graph);
-  }
-
-  ctxForEffect(effect: NamedNode): NamedNode {
-    return this.graph.Uri(effect.value.replace("light9.bigasterisk.com/effect", "light9.bigasterisk.com/show/dance2019/effect"));
-  }
+  // rename to PageControls?
+  effect: Effect | null = null; // this uri should sync to the editchoice
+  registeredWidgets: Map<NamedNode, Map<NamedNode, NewValueCb>> = new Map();
+  constructor(public graph: SyncedGraph) {}
 
   setEffect(effect: NamedNode | null) {
-    this.clearSettings();
-    this.effect = effect;
-    this.ctx = !effect ? null : this.ctxForEffect(effect);
-    // are these going to pile up? consider @graph.triggerHandler('GTC sync')
-    this.graph.runHandler(this.syncFromGraph.bind(this), "GraphToControls sync");
+    log(`setEffect ${effect?.value}`);
+    this.effect = effect ? new Effect(this.graph, effect, this.onValuesChanged.bind(this)) : null;
   }
 
-  newEffect() {
+  newEffect(): NamedNode {
     // wrong- this should be our editor's scratch effect, promoted to a
     // real one when you name it.
-    const U = this.graph.U();
-    const effect = this.graph.nextNumberedResource(U("http://light9.bigasterisk.com/effect/effect"));
-    const ctx = this.ctxForEffect(effect);
-    const quad = (s: Quad_Subject, p: Quad_Predicate, o: Quad_Object) => this.graph.Quad(s, p, o, ctx);
+    const uri = this.graph.nextNumberedResource(this.graph.Uri("http://light9.bigasterisk.com/effect/effect"));
 
-    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 = { adds: addQuads, dels: [] } as Patch;
-    log("init new effect", patch);
-    this.graph.applyAndSendPatch(patch);
-    return effect;
+    this.effect = new Effect(this.graph, uri, this.onValuesChanged.bind(this));
+    log("add new eff");
+    this.effect.addNewEffectToGraph();
+    return this.effect.uri;
   }
 
-  syncFromGraph() {
-    const U = this.graph.U();
-    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")))) {
-      if (!isUri(setting)) throw new Error();
-      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)));
+  onValuesChanged() {
+    log(`i learned values changed for ${this.effect?.uri.value} `);
+    this.registeredWidgets.forEach((d1: Map<NamedNode, NewValueCb>, device: NamedNode) => {
+      d1.forEach((cb: NewValueCb, deviceAttr: NamedNode) => {
+        const v = this.effect ? this.effect.currentValue(device, deviceAttr) : null;
+        cb(v);
+      });
+    });
   }
 
-  clearSettings() {
-    return this.activeSettings.clear();
-  }
-
-  register(device: any, deviceAttr: any, graphValueChanged: any) {
-    return this.activeSettings.registerWidget(device, deviceAttr, graphValueChanged);
-  }
+  register(device: NamedNode, deviceAttr: NamedNode, graphValueChanged: NewValueCb) {
+    // log(`control for ${device.value}-${deviceAttr.value} registring with g2c`);
+    let d1 = this.registeredWidgets.get(device);
+    if (!d1) {
+      d1 = new Map();
+      this.registeredWidgets.set(device, d1);
+    }
+    d1.set(deviceAttr, graphValueChanged);
 
-  shouldBeStored(deviceAttr: any, value: ControlValue) {
-    // 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));
+    if (this.effect) {
+      const nv = this.effect.currentValue(device, deviceAttr);
+      // log(`i have a a cb for ${device.value}-${deviceAttr.value}; start value is ${nv}`);
+      graphValueChanged(nv);
+    }
   }
 
   controlChanged(device: NamedNode, deviceAttr: NamedNode, value: ControlValue) {
     // todo: controls should be disabled if there's no effect and they won't do anything.
     if (!this.effect) {
+      log("controlChanged, no effect");
       return;
     }
-
-    // value is float or #color or (Uri or null)
-
-    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 {
-      if (effectSetting !== null) {
-        return this._removeEffectSetting(effectSetting);
-      }
-    }
-  }
-
-  _nodeForValue(value: ControlValue) {
-    if (value === null) {
-      throw new Error("no value");
-    }
-    if (isUri(value)) {
-      return value;
-    }
-    return this.graph.prettyLiteral(value);
-  }
-
-  _addEffectSetting(device: NamedNode, deviceAttr: NamedNode, value: ControlValue) {
-    log("change: _addEffectSetting", deviceAttr.value, value);
-    const U = (x: string) => this.graph.Uri(x);
-    const quad = (s: Quad_Subject, p: Quad_Predicate, o: Quad_Object) => this.graph.Quad(s, p, o, this.ctx);
-    if (!this.effect) throw new Error("effect unset");
-    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 = { adds: addQuads, dels: [] } as Patch;
-    log("save", patch);
-    return this.graph.applyAndSendPatch(patch);
-  }
-
-  _patchExistingEffectSetting(effectSetting: NamedNode, deviceAttr: NamedNode, value: ControlValue) {
-    if (!this.ctx) throw new Error("no ctx");
-    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: NamedNode) {
-    const U = (x: string) => this.graph.Uri(x);
-    if (effectSetting != null) {
-      log("change: _removeEffectSetting", effectSetting.value);
-      const toDel = [this.graph.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({ dels: toDel, adds: [] } as Patch);
-      return this.activeSettings.deleteSetting(effectSetting);
-    }
+    const p = this.effect.edit(device, deviceAttr, value);
+    this.graph.applyAndSendPatch(p);
   }
 }
--- a/light9/web/live/Light9DeviceControl.ts	Sun May 29 01:43:11 2022 -0700
+++ b/light9/web/live/Light9DeviceControl.ts	Sun May 29 01:49:34 2022 -0700
@@ -13,7 +13,7 @@
 export { Light9LiveControl };
 const log = debug("devcontrol");
 
-interface DeviceAttrRow {
+export interface DeviceAttrRow {
   uri: NamedNode; //devattr
   attrClasses: string; // the css kind
   dataType: NamedNode;
@@ -108,15 +108,15 @@
   @property() devClasses: string = ""; // the css kind
   @property() deviceAttrs: DeviceAttrRow[] = [];
   @property() deviceClass: NamedNode | null = null;
-  @property() selectedAttrs: Set<string> = new Set();
+  @property() selectedAttrs: Set<NamedNode> = new Set();
 
   constructor() {
     super();
     getTopGraph().then((g) => {
       this.graph = g;
-      this.graph.runHandler(this.configureFromGraphz.bind(this), `${this.uri.value} update`);
+      this.graph.runHandler(this.syncDeviceAttrsFromGraph.bind(this), `${this.uri.value} update`);
     });
-    this.selectedAttrs = new Set(); // uri strings
+    this.selectedAttrs = new Set();
   }
 
   _bgStyle(deviceClass: NamedNode | null): string {
@@ -137,19 +137,24 @@
 
   setAttrSelected(devAttr: NamedNode, isSel: boolean) {
     if (isSel) {
-      this.selectedAttrs.add(devAttr.value);
+      this.selectedAttrs.add(devAttr);
     } else {
-      this.selectedAttrs.delete(devAttr.value);
+      this.selectedAttrs.delete(devAttr);
     }
-    return this.configureFromGraphz();
+    // this.syncDeviceAttrsFromGraph();
   }
 
-  configureFromGraphz(patch?: Patch) {
+  syncDeviceAttrsFromGraph(patch?: Patch) {
     const U = this.graph.U();
     if (patch != null && !patchContainsPreds(patch, [U("rdf:type"), U(":deviceAttr"), U(":dataType"), U(":choice")])) {
       return;
     }
+    try {
     this.deviceClass = this.graph.uriValue(this.uri, U("rdf:type"));
+    } catch(e) {
+      // what's likely is we're going through a graph reload and the graph 
+      // is gone but the controls remain
+    }
     this.deviceAttrs = [];
     Array.from(unique(this.graph.sortedUris(this.graph.objects(this.deviceClass, U(":deviceAttr"))))).map((da: NamedNode) =>
       this.deviceAttrs.push(this.attrRow(da))
@@ -165,7 +170,7 @@
       uri: devAttr,
       dataType,
       showColorPicker: dataType.equals(U(":color")),
-      attrClasses: this.selectedAttrs.has(devAttr.value) ? "selected" : "",
+      attrClasses: this.selectedAttrs.has(devAttr) ? "selected" : "",
       useColor: false,
       useChoice: false,
       choices: [] as Choice[],
@@ -198,6 +203,9 @@
   }
 
   clear() {
+    // why can't we just set their values ? what's diff about
+    // the clear state, and should it be represented with `null` value?
+    throw new Error()
     Array.from(this.shadowRoot!.querySelectorAll("light9-live-control")).map((lc: Element) => (lc as Light9LiveControl).clear());
   }
 
--- a/light9/web/live/Light9LiveControl.ts	Sun May 29 01:43:11 2022 -0700
+++ b/light9/web/live/Light9LiveControl.ts	Sun May 29 01:49:34 2022 -0700
@@ -1,12 +1,18 @@
 import debug from "debug";
 const log = debug("control");
-import { css, html, LitElement } from "lit";
+import { css, html, LitElement, PropertyPart, PropertyValues } from "lit";
 import { customElement, property } from "lit/decorators.js";
+import { NamedNode } from "n3";
+import { getTopGraph } from "../RdfdbSyncedGraph";
 import { SyncedGraph } from "../SyncedGraph";
-
+import { ControlValue } from "./Effect";
+import { GraphToControls } from "./GraphToControls";
+import { DeviceAttrRow } from "./Light9DeviceControl";
+import { Choice } from "./Light9Listbox";
+export { Slider } from "@material/mwc-slider";
 @customElement("light9-live-control")
 export class Light9LiveControl extends LitElement {
-  graph!:SyncedGraph
+  graph!: SyncedGraph;
 
   static styles = [
     css`
@@ -40,95 +46,97 @@
   ];
 
   render() {
-    return html`
-      <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}}">
+    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>
-      </template>
-      <template is="dom-if" if="{{deviceAttrRow.useChoice}}">
-        <light9-listbox choices="{{deviceAttrRow.choices}}" value="{{choiceValue}}"> </light9-listbox>
-      </template>
-    `;
+      `;
+    } 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;
+  @property() deviceAttrRow!: DeviceAttrRow;
+  // we'll connect to this and receive graphValueChanged and send uiValueChanged
+  @property() graphToControls!: GraphToControls;
+
+  @property() enableChange: boolean = false;
+  @property() value: ControlValue | null = null;
+
+  // slider mode
+  @property() sliderValue: number = 0;
+
+  // color mode
+
+  // choice mode
+  @property() pickedChoice: Choice | null = null;
+  @property() choiceValue: Choice | null = null;
+
+  constructor() {
+    super();
+    this.dataType = new NamedNode("http://light9.bigasterisk.com/scalar");
+    // getTopGraph().then((g) => {
+    //   this.graph = g;
+    //   // this.graph.runHandler(this.graphReads.bind(this), `${this.device} ${this.deviceAttrRow.uri} reads`);
+    // });
   }
 
-
-
+  // graphReads() {
+  //   const U = this.graph.U();
+  // }
 
-  // "onChange(value)", 
-  // "onChoice(choiceValue)"];
-  // "onGraphToControls(graphToControls)", 
-  // choiceValue: { type: any; };
-  // choiceValue: { type: Object },
-  // choiceValue: any;
-  // device: { type: any; };
-  // device: { type: Object },
-  // deviceAttrRow: { type: any; }; // object returned from attrRow, below
-  // deviceAttrRow: { type: Object }, // object returned from attrRow, below
-  // deviceAttrRow: any;
-  // enableChange: boolean;
-  // graph: { type: Object, notify: true },
-  // graphToControls: { ...; };
-  // graphToControls: { type: Object },
-  // graphToControls: any;
-  // immediateSlider: { notify: boolean; observer: string; };
-  // immediateSlider: { notify: true, observer: "onSlider" },
-  // immediateSlider: any;
-  // pickedChoice: { ...; };
-  // pickedChoice: { observer: "onChange" },
-  // pickedChoice: any;
-  // sliderWriteValue: { ...; };
-  // sliderWriteValue: { type: Number },
-  // sliderWriteValue: { value: any; };
-  // value: { type: any; notify: boolean; }; // null, Uri, float, str
-  // value: { type: Object, notify: true }, // null, Uri, float, str
-  // value: any;
-  
-  constructor() {
-    super();
-    this.enableChange = false; // until 1st graph read
+  updated(changedProperties: PropertyValues) {
+    if (changedProperties.has("graphToControls")) {
+      this.graphToControls.register(this.device, this.deviceAttrRow.uri, this.onGraphValueChanged.bind(this));
+      this.enableChange = true;
+    }
   }
-  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.");
+
+  onSliderInput(ev: CustomEvent) {
+    if (ev.detail === undefined) {
+      // not sure what this is, but it seems to be followed by good events
+      return;
+    }
+    log(ev.type, ev.detail?.value);
+    this.graphToControls.controlChanged(this.device, this.deviceAttrRow.uri, ev.detail.value);
   }
 
-  graphValueChanged(v: { value: any }) {
-    log("change: control gets", v);
-    this.enableChange = false;
-    if (v === null) {
-      this.clear();
-    } else {
-      this.value = v;
+  onGraphValueChanged(v: ControlValue | null) {
+    // log("change: control must display", v);
+    // this.enableChange = false;
+    if (this.dataType.value == "http://light9.bigasterisk.com/scalar") {
+      if (v !== null) {
+        setTimeout(() => {
+          // only needed once per page layout
+          this.shadowRoot?.querySelector("mwc-slider")?.layout(/*skipUpdateUI=*/ false);
+        }, 1);
+        this.sliderValue = v as number;
+      } else {
+        this.sliderValue = 0;
+      }
     }
-    if (this.deviceAttrRow.useSlider) {
-      this.sliderWriteValue = v;
-    }
-    if (this.deviceAttrRow.useChoice) {
-      this.choiceValue = v === null ? v : v.value;
-    }
-    return (this.enableChange = true);
+    // if (v === null) {
+    //   this.clear();
+    // } else {
+    //   this.value = v;
+    // }
+    // if (this.deviceAttrRow.useChoice) {
+    //   this.choiceValue = v === null ? v : v.value;
+    // }
+    // this.enableChange = true;
+  }
+
+  goBlack() {
+    this.value = "#000000";
   }
 
   onChoice(value: any) {
@@ -140,7 +148,7 @@
     } else {
       value = null;
     }
-    return this.graphToControls.controlChanged(this.device, this.deviceAttrRow.uri, value);
+    this.graphToControls.controlChanged(this.device, this.deviceAttrRow.uri, value);
   }
 
   onChange(value: any) {
@@ -154,18 +162,18 @@
     if (value === undefined) {
       value = null;
     }
-    return this.graphToControls.controlChanged(this.device, this.deviceAttrRow.uri, value);
+    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);
-    }
-  }
+  // 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.sliderValue = 0);
+  //   }
+  // }
 }
--- a/light9/web/live/Light9LiveControls.ts	Sun May 29 01:43:11 2022 -0700
+++ b/light9/web/live/Light9LiveControls.ts	Sun May 29 01:49:34 2022 -0700
@@ -1,6 +1,6 @@
 import debug from "debug";
-import { css, html, LitElement } from "lit";
-import { customElement } from "lit/decorators.js";
+import { css, html, LitElement, PropertyValues } from "lit";
+import { customElement, property } from "lit/decorators.js";
 import { NamedNode } from "n3";
 import { sortBy, uniq } from "underscore";
 import { Patch } from "../patch";
@@ -8,7 +8,7 @@
 import { SyncedGraph } from "../SyncedGraph";
 import { GraphToControls } from "./GraphToControls";
 export { Light9DeviceControl as Light9LiveDeviceControl } from "./Light9DeviceControl";
-
+export { EditChoice } from "../EditChoice";
 const log = debug("controls");
 
 @customElement("light9-live-controls")
@@ -47,12 +47,11 @@
 
       <div id="save">
         <div>
-          <button on-click="newEffect">New effect</button>
+          <button @click=${this.newEffect}>New effect</button>
           <edit-choice .uri=${this.effectChoice}></edit-choice>
-          <button on-click="clearAll">clear settings in this effect</button>
+          <button @click=${this.clearAll}>clear settings in this effect</button>
         </div>
       </div>
-
       <div id="deviceControls">
         ${this.devices.map(
           (device: NamedNode) => html`
@@ -66,26 +65,35 @@
   devices: Array<NamedNode> = [];
   // uri of the effect being edited, or null. This is the
   // master value; GraphToControls follows.
-  effectChoice: NamedNode | null = null;
+  @property() effectChoice: NamedNode | null = null;
   graphToControls!: GraphToControls;
   okToWriteUrl: boolean = false;
 
   constructor() {
     super();
+
     getTopGraph().then((g) => {
       this.graph = g;
       this.graph.runHandler(this.findDevices.bind(this), "findDevices");
       this.graphToControls = new GraphToControls(this.graph);
       // this.graph.runHandler(this.xupdate.bind(this), "Light9LiveControls update");
-      this.setEffectFromUrl.bind(this);
+      this.setEffectFromUrl();
     });
   }
 
+  updated(changedProperties: PropertyValues) {
+    if (changedProperties.has("effectChoice")) {
+      log(`effectChoice to ${this.effectChoice?.value}`);
+      this.onEffectChoice();
+    }
+  }
+
   findDevices(patch?: Patch) {
     const U = this.graph.U();
 
     this.devices = [];
     let classes = this.graph.subjects(U("rdf:type"), U(":DeviceClass"));
+    log(`found ${classes.length} device classes`)
     uniq(sortBy(classes, "value"), true).forEach((dc) => {
       sortBy(this.graph.subjects(U("rdf:type"), dc), "value").forEach((dev) => {
         this.devices.push(dev as NamedNode);
@@ -93,48 +101,52 @@
     });
     this.requestUpdate();
   }
+
   setEffectFromUrl() {
     // 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);
+      log(`found effect in url ${effect}`);
       this.effectChoice = this.graph.Uri(effect);
     }
     this.okToWriteUrl = true;
   }
 
-  writeToUrl(effectStr: any) {
+  writeToUrl(effect: NamedNode | null) {
+    const effectStr = effect ? this.graph.shorten(effect) : "";
     if (!this.okToWriteUrl) {
       return;
     }
     const u = new URL(window.location.href);
-    if (u.searchParams.get("effect") === effectStr) {
+    if ((u.searchParams.get("effect") || "") === effectStr) {
       return;
     }
-    u.searchParams.set("effect", effectStr);
+    u.searchParams.set("effect", effectStr); // this escapes : and / and i wish it didn't
     window.history.replaceState({}, "", u.href);
     return log("wrote new url", u.href);
   }
 
   newEffect() {
-    return (this.effectChoice = this.graphToControls.newEffect().value);
+    this.effectChoice = this.graphToControls.newEffect();
   }
 
   onEffectChoice() {
     const U = (x: any) => this.graph.Uri(x);
     if (this.effectChoice == null) {
       // unlink
+      log("onEffectChoice unlink");
       if (this.graphToControls != null) {
         this.graphToControls.setEffect(null);
       }
     } else {
-      log("load", this.effectChoice);
       if (this.graphToControls != null) {
         this.graphToControls.setEffect(this.effectChoice);
+      } else {
+        throw new Error("graphToControls not set");
       }
     }
-    return this.writeToUrl(this.effectChoice);
+    this.writeToUrl(this.effectChoice);
   }
 
   clearAll() {
@@ -145,18 +157,19 @@
   configureFromGraph() {
     const U = (x: string) => this.graph.Uri(x);
 
-    const newDevs = [];
+    const newDevs: NamedNode[] = [];
     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 });
+        if (newDevs.length == 0) newDevs.push(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);
+    log("is this called?");
+    log(`controls update now has ${newDevs.length} devices`);
+    this.devices = newDevs;
+    this.requestUpdate();
 
     return;