changeset 2074:1a96f8647126

big graph & autodep porting to make collector display labels from a syncedgraph
author drewp@bigasterisk.com
date Mon, 23 May 2022 23:32:37 -0700
parents 17b268d2b7f3
children fddd07e694ab
files light9/collector/web/Light9CollectorDevice.ts light9/collector/web/Light9CollectorUi.ts light9/collector/web/index.html light9/web/AutoDependencies.ts light9/web/GraphAwarePage.ts light9/web/RdfdbSyncedGraph.ts light9/web/ResourceDisplay.ts light9/web/SyncedGraph.ts light9/web/collector/Light9CollectorDevice.ts light9/web/collector/README.md light9/web/graph.ts light9/web/rdfdb-synced-graph.html light9/web/rdfdbclient.ts light9/web/resource-display.html
diffstat 14 files changed, 984 insertions(+), 899 deletions(-) [+]
line wrap: on
line diff
--- a/light9/collector/web/Light9CollectorDevice.ts	Sun May 22 03:04:18 2022 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,71 +0,0 @@
-import * as debug from "debug";
-import { css, html, LitElement } from "lit";
-import { customElement, property } from "lit/decorators.js";
-debug.enable("*");
-
-@customElement("light9-collector-device")
-export class Light9CollectorDevice extends LitElement {
-  static styles = [
-    css`
-      :host {
-        display: block;
-        break-inside: avoid-column;
-        font-size: 80%;
-      }
-      h3 {
-        margin-top: 12px;
-        margin-bottom: 0;
-      }
-      td {
-        white-space: nowrap;
-      }
-
-      td.nonzero {
-        background: #310202;
-        color: #e25757;
-      }
-      td.full {
-        background: #2b0000;
-        color: red;
-        font-weight: bold;
-      }
-    `,
-  ];
-
-  render() {
-    return html`
-      <h3><resource-display graph="{{graph}}" uri="{{uri}}"></resource-display></h3>
-      <table class="borders">
-        <tr>
-          <th>out attr</th>
-          <th>value</th>
-          <th>chan</th>
-        </tr>
-        <template is="dom-repeat" items="{{attrs}}">
-          <tr>
-            <td>{{item.attr}}</td>
-            <td class$="{{item.valClass}}">{{item.val}} →</td>
-            <td>{{item.chan}}</td>
-          </tr>
-        </template>
-      </table>
-    `;
-  }
-  @property() graph: Object = {};
-  @property() uri: Object = {};
-  @property() attrs: Array = [];
-
-  //  observers: [
-  //    "initUpdates(updates)",
-  //  ],
-  initUpdates(updates) {
-    updates.addListener(function (msg) {
-      if (msg.outputAttrsSet && msg.outputAttrsSet.dev == this.uri.value) {
-        this.set("attrs", msg.outputAttrsSet.attrs);
-        this.attrs.forEach(function (row) {
-          row.valClass = row.val == 255 ? "full" : row.val ? "nonzero" : "";
-        });
-      }
-    });
-  }
-}
--- a/light9/collector/web/Light9CollectorUi.ts	Sun May 22 03:04:18 2022 -0700
+++ b/light9/collector/web/Light9CollectorUi.ts	Mon May 23 23:32:37 2022 -0700
@@ -1,75 +1,63 @@
 import debug from "debug";
 import { html, LitElement } from "lit";
-import { customElement, property } from "lit/decorators.js";
+import { customElement, property, state } from "lit/decorators.js";
 import ReconnectingWebSocket from "reconnectingwebsocket";
 import { sortBy, uniq } from "underscore";
+import { SyncedGraph } from "../../web/SyncedGraph";
+import { GraphAwarePage } from "../../web/GraphAwarePage";
+import { getTopGraph, GraphChangedEvent } from "../../web/RdfdbSyncedGraph";
+import { NamedNode } from "n3";
+import { Patch } from "../../web/patch";
+import { linkHorizontal } from "d3";
 
-debug.enable('*');
+export { RdfdbSyncedGraph } from "../../web/RdfdbSyncedGraph";
+export { Light9CollectorDevice } from "../../web/collector/Light9CollectorDevice";
+
+debug.enable("*");
 const log = debug("collector");
 
-class Updates {
-  constructor() {
-    this.listeners = [];
-  }
-  addListener(cb) {
-    this.listeners.push(cb);
-  }
-  onMessage(msg) {
-    this.listeners.forEach(function (lis) {
-      lis(msg);
-    });
-  }
-}
-
 @customElement("light9-collector-ui")
-export class Light9CollectorUi extends LitElement {
+export class Light9CollectorUi extends GraphAwarePage {
+  graph?: SyncedGraph;
   static styles = [];
   render() {
-    return html`
-      <rdfdb-synced-graph graph="{{graph}}"></rdfdb-synced-graph>
-
+    return html`${super.render()}
       <h1>Collector <a href="metrics">[metrics]</a></h1>
 
       <h2>Devices</h2>
-      <div style="column-width: 11em">
-        <template is="dom-repeat" items="{{devices}}">
-          ${this.devices.map((d)=>html`<light9-collector-device graph="${this.graph}" updates="${this.updates}" uri="${d}"></light9-collector-device>`)}
-        </template>
-      </div>
+      <light9-collector-device-list></light9-collector-device-list> `;
+  }
+}
+
+@customElement("light9-collector-device-list")
+export class Light9CollectorDeviceList extends LitElement {
+  graph!: SyncedGraph;
+  @property() devices: NamedNode[] = [];
+  
+  render() {
+    return html`
+      <h2>Devices</h2>
+      <light9-collector-device uri="http://light9.bigasterisk.com/theater/skyline/device/strip1"></light9-collector-device>
+      <div style="column-width: 11em">${this.devices.map((d) => html`<light9-collector-device uri="${d.value}"></light9-collector-device>`)}</div>
     `;
   }
-
-  @property() graph: Object = {};
-  @property() updates: Updates;
-  @property() devices: Array<string> = [];
-  //  observers: [
-  //    'onGraph(graph)',
-  //  ],
-
+  
   constructor() {
     super();
-    this.updates = new Updates();
-    const ws = new ReconnectingWebSocket(location.href.replace("http", "ws") + "api/updates");
-    ws.addEventListener("message", (ev: any) => {
-      log("ws msg", ev);
-      this.updates.onMessage(ev.data);
+    getTopGraph().then((g) => {
+      this.graph = g;
+      this.graph.runHandler(this.findDevices.bind(this), "findDevices");
     });
   }
-
-  onGraph(graph) {
-    this.graph.runHandler(this.findDevices.bind(this), "findDevices");
-  }
-
-  findDevices() {
-    var U = function (x) {
-      return this.graph.Uri(x);
-    };
-    this.set("devices", []);
-
+  
+  findDevices(patch?: Patch) {
+    const U = this.graph.U();
+    
+    this.devices = [];
     let classes = this.graph.subjects(U("rdf:type"), U(":DeviceClass"));
     uniq(sortBy(classes, "value"), true).forEach((dc) => {
       sortBy(this.graph.subjects(U("rdf:type"), dc), "value").forEach((dev) => {
-        this.push("devices", dev);
+        this.devices.push(dev as NamedNode);
       });
     });
   }
--- a/light9/collector/web/index.html	Sun May 22 03:04:18 2022 -0700
+++ b/light9/collector/web/index.html	Mon May 23 23:32:37 2022 -0700
@@ -5,7 +5,7 @@
     <meta charset="utf-8" />
 
     <link rel="stylesheet" href="./style.css" />
-    <script type="module" src="../../collector/Light9CollectorUi"></script>
+    <script type="module" src="../collector/Light9CollectorUi"></script>
 
     <style>
       td {
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/AutoDependencies.ts	Mon May 23 23:32:37 2022 -0700
@@ -0,0 +1,137 @@
+import debug from "debug";
+import { Quad_Graph, Quad_Object, Quad_Predicate, Quad_Subject } from "n3";
+import { filter } from "underscore";
+import { allPatchSubjs, Patch } from "./patch";
+
+const log = debug("autodep");
+
+interface QuadPattern {
+  subject: Quad_Subject | null;
+  predicate: Quad_Predicate | null;
+  object: Quad_Object | null;
+  graph: Quad_Graph | null;
+}
+
+// use patch as an optional optimization, but you can't count on it
+export type HandlerFunc = (p?: Patch) => void;
+
+class Handler {
+  patterns: QuadPattern[];
+  innerHandlers: Handler[];
+  // a function and the quad patterns it cared about
+  constructor(public func: HandlerFunc | null, public label: string) {
+    this.patterns = []; // s,p,o,g quads that should trigger the next run
+    this.innerHandlers = []; // Handlers requested while this one was running
+  }
+}
+
+export class AutoDependencies {
+  handlers: Handler;
+  handlerStack: Handler[];
+  constructor() {
+    // tree of all known Handlers (at least those with non-empty
+    // patterns). Top node is not a handler.
+    this.handlers = new Handler(null, "root");
+    this.handlerStack = [this.handlers]; // currently running
+  }
+
+  runHandler(func: HandlerFunc, label: string) {
+    // what if we have this func already? duplicate is safe?
+    if (label == null) {
+      throw new Error("missing label");
+    }
+
+    const h = new Handler(func, label);
+    const tailChildren = this.handlerStack[this.handlerStack.length - 1].innerHandlers;
+    const matchingLabel = filter(tailChildren, (c: { label: any }) => c.label === label).length;
+    // ohno, something depends on some handlers getting run twice :(
+    if (matchingLabel < 2) {
+      tailChildren.push(h);
+    }
+    //console.time("handler #{label}") 
+    // todo: this may fire 1-2 times before the
+    // graph is initially loaded, which is a waste. Try deferring it if we
+    // haven't gotten the graph yet.
+    return this._rerunHandler(h, undefined);
+  }
+  //console.timeEnd("handler #{label}")
+  //@_logHandlerTree()
+  _rerunHandler(handler: Handler, patch?: Patch) {
+    handler.patterns = [];
+    this.handlerStack.push(handler);
+    try {
+      if (handler.func === null) {
+        throw new Error("tried to rerun root");
+      }
+      handler.func(patch);
+    } catch (e) {
+      log("error running handler: ", e);
+    } finally {
+      // assuming here it didn't get to do all its queries, we could
+      // add a *,*,*,* handler to call for sure the next time?
+      // log('done. got: ', handler.patterns)
+      this.handlerStack.pop();
+    }
+  }
+  // handler might have no watches, in which case we could forget about it
+  _logHandlerTree() {
+    log("handler tree:");
+    var prn = function (h: Handler, depth: number) {
+      let indent = "";
+      for (let i = 0; i < depth; i++) {
+        indent += "  ";
+      }
+      log(`${indent} \"${h.label}\" ${h.patterns.length} pats`);
+      return Array.from(h.innerHandlers).map((c: any) => prn(c, depth + 1));
+    };
+    return prn(this.handlers, 0);
+  }
+
+  _handlerIsAffected(child: Handler, patchSubjs: Set<string>) {
+    if (patchSubjs === null) {
+      return true;
+    }
+    if (!child.patterns.length) {
+      return false;
+    }
+
+    for (let stmt of Array.from(child.patterns)) {
+      if (stmt.subject === null) {
+        // wildcard on subject
+        return true;
+      }
+      if (patchSubjs.has(stmt.subject.value)) {
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  graphChanged(patch: Patch) {
+    // SyncedGraph is telling us this patch just got applied to the graph.
+    const subjs = allPatchSubjs(patch);
+
+    var rerunInners = (cur: Handler) => {
+      const toRun = cur.innerHandlers.slice();
+      for (let child of Array.from(toRun)) {
+        //match = @_handlerIsAffected(child, subjs)
+        //continue if not match
+        //log('match', child.label, match)
+        //child.innerHandlers = [] # let all children get called again
+        this._rerunHandler(child, patch);
+        rerunInners(child);
+      }
+    };
+    return rerunInners(this.handlers);
+  }
+
+  askedFor(s: Quad_Subject | null, p: Quad_Predicate | null, o: Quad_Object | null, g: Quad_Graph | null) {
+    // SyncedGraph is telling us someone did a query that depended on
+    // quads in the given pattern.
+    const current = this.handlerStack[this.handlerStack.length - 1];
+    if (current != null && current !== this.handlers) {
+      return current.patterns.push({ subject: s, predicate: p, object: o, graph: g } as QuadPattern);
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/GraphAwarePage.ts	Mon May 23 23:32:37 2022 -0700
@@ -0,0 +1,25 @@
+import debug from "debug";
+import { html, LitElement } from "lit";
+import { patchSizeSummary } from "../web/patch";
+import { GraphChangedEvent } from "../web/RdfdbSyncedGraph";
+import { SyncedGraph } from "./SyncedGraph";
+
+const log = debug("graphaware");
+
+export class GraphAwarePage extends LitElement {
+  constructor() {
+    super();
+    this.classList.add("graph-events");
+  }
+  // prepend this to your subclass's render output, like
+  // render() { return html`${super.render()} ....your page`; }
+  render() {
+    return html`<rdfdb-synced-graph @changed="${this.onGraphChanged}"></rdfdb-synced-graph>`;
+  }
+  onGraphChanged(ev: GraphChangedEvent) {
+    log("patch from server [3]", patchSizeSummary(ev.detail.patch));
+    // this.dispatchEvent(new CustomEvent("changed", { detail: ev.detail }));
+    log("patch from server [4]");
+  }
+}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/RdfdbSyncedGraph.ts	Mon May 23 23:32:37 2022 -0700
@@ -0,0 +1,91 @@
+import debug from "debug";
+import { html, LitElement, css } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { Patch } from "./patch";
+import { SyncedGraph } from "./SyncedGraph";
+
+const log = debug("syncedgraph-el");
+
+// consider https://vitaly-t.github.io/sub-events/ for this stuff
+export interface GraphChangedDetail {
+  graph: SyncedGraph;
+  patch: Patch;
+}
+
+export class GraphChangedEvent extends CustomEvent<GraphChangedDetail> {
+  constructor(type: string, opts: { detail: GraphChangedDetail; bubbles: boolean; composed: boolean }) {
+    super(type, opts);
+  }
+}
+
+let RdfdbSyncedGraph
+
+(window as any).topSyncedGraph = new Promise((res, rej) => {
+  // Contains a SyncedGraph,
+  // displays a little status box,
+  // and emits 'changed' events with the graph and latest patch when it changes
+  
+  RdfdbSyncedGraph=customElement("rdfdb-synced-graph")(class RdfdbSyncedGraph extends LitElement {
+    /*@property()*/ graph: SyncedGraph;
+    /*@property()*/ status: string;
+    /*@property()*/ testGraph = false;
+    static styles = [
+      css`
+        :host {
+          display: inline-block;
+          border: 1px solid gray;
+          min-width: 22em;
+          background: #05335a;
+          color: #4fc1d4;
+        }
+      `,
+    ];
+    render() {
+      return html`graph: ${this.status}`;
+    }
+
+    onClear() {
+      console.log("reset");
+    }
+
+    constructor() {
+      super();
+      this.status = "startup";
+      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#",
+        },
+        (s: string) => {
+          this.status = s;
+        },
+        this.onClear.bind(this),
+        this.onGraphChanged.bind(this)
+      );
+      // (window as any).topSyncedGraph = this.graph;
+      res(this.graph);
+    }
+
+    private onGraphChanged(graph: SyncedGraph, patch: Patch) {
+      this.dispatchEvent(
+        new GraphChangedEvent("changed", {
+          detail: { graph, patch },
+          bubbles: true,
+          composed: true,
+        })
+      );
+    }
+  });
+});
+
+
+async function getTopGraph(): Promise<SyncedGraph> {
+  const s = (window as any).topSyncedGraph;
+  return await s;
+}
+
+export { RdfdbSyncedGraph, getTopGraph };
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/ResourceDisplay.ts	Mon May 23 23:32:37 2022 -0700
@@ -0,0 +1,165 @@
+import debug from "debug";
+import { css, html, LitElement, PropertyValues } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { NamedNode } from "n3";
+// import { GraphChangedEvent } from "../../web/RdfdbSyncedGraph";
+// import { runHandler } from "./GraphAwarePage";
+import { Patch, patchContainsPreds, patchSizeSummary } from "./patch";
+import { getTopGraph } from "./RdfdbSyncedGraph";
+import { SyncedGraph } from "./SyncedGraph";
+debug.enable("*");
+const log = debug("device-el");
+const RDFS_LABEL = new NamedNode("http://www.w3.org/2000/01/rdf-schema#label");
+
+@customElement("resource-display")
+export class ResourceDisplay extends LitElement {
+  graph!: SyncedGraph;
+  static styles = [
+    css`
+      :host {
+        display: inline-block;
+      }
+
+      a.resource {
+        color: inherit;
+        text-decoration: none;
+      }
+
+      .resource {
+        border: 1px solid #545454;
+        border-radius: 5px;
+        padding: 1px;
+        margin: 2px;
+        background: rgb(49, 49, 49);
+        display: inline-block;
+        text-shadow: 1px 1px 2px black;
+      }
+      .resource.minor {
+        background: none;
+        border: none;
+      }
+      .resource a {
+        color: rgb(150, 150, 255);
+        padding: 1px;
+        display: inline-block;
+      }
+      .resource.minor a {
+        text-decoration: none;
+        color: rgb(155, 155, 193);
+        padding: 0;
+      }
+    `,
+  ];
+
+  render() {
+    return html` <span class="${this.resClasses()}">
+      <a href="${this.href()}" id="uri"> <!-- type icon goes here -->${this.label}</a>
+    </span>`;
+    // <template is="dom-if" if="{{rename}}">
+    //   <button on-click="onRename">Rename</button>
+
+    //   <paper-dialog id="renameDialog" modal on-iron-overlay-closed="onRenameClosed">
+    //     <p>
+    //       New label:
+    //       <input id="renameTo" autofocus type="text" value="{{renameTo::input}}" on-keydown="onRenameKey" />
+    //     </p>
+    //     <div class="buttons">
+    //       <paper-button dialog-dismiss>Cancel</paper-button>
+    //       <paper-button dialog-confirm>OK</paper-button>
+    //     </div>
+    //   </paper-dialog>
+    // </template>
+    //    `;
+  }
+  // callers might set this as string or pass a NamedNode.
+  @property() uri?: NamedNode | string;
+
+  @state() label: string = "";
+  @property() rename: boolean = false;
+  @property() minor: boolean = false;
+  // @state() renameTo: String; notify: true };
+
+  constructor() {
+    super();
+    getTopGraph().then((g) => {
+      this.graph = g;
+      this.runUriHandler();
+    });
+  }
+
+  realUri(): NamedNode {
+    if (!this.uri) {
+      return new NamedNode("");
+    }
+    return typeof this.uri === "string" ? new NamedNode(this.uri) : this.uri;
+  }
+
+  href() {
+    if (!this.uri) {
+      return "javascript:;";
+    }
+    return typeof this.uri === "string" ? this.uri : this.uri.value;
+  }
+
+  updated(changedProperties: PropertyValues) {
+    if (changedProperties.has("uri")) {
+      if (!this.graph) {
+        return; /*too soon*/
+      }
+      this.runUriHandler();
+    }
+  }
+
+  resClasses() {
+    return this.minor ? "resource minor" : "resource";
+  }
+
+  runUriHandler() {
+    this.graph.runHandler(this.onUri.bind(this), `rdisplay ${this.href()}` /*needs uniqueness?*/);
+  }
+
+  onUri(patch?: Patch) {
+    if (!this.uri) {
+      this.label = "<no uri>";
+      return;
+    }
+
+    const uri = this.realUri();
+    this.graph.runHandler(this.setLabel.bind(this), `label ${uri.value}`);
+  }
+
+  setLabel(patch?: Patch) {
+    if (patch && !patchContainsPreds(patch, [RDFS_LABEL])) {
+      return;
+    }
+    const uri = this.realUri();
+    this.label = this.graph.labelOrTail(uri);
+  }
+
+  onRename() {
+    this.renameTo = this.label;
+    this.shadowRoot.querySelector("#renameDialog").open();
+    this.shadowRoot.querySelector("#renameTo").setSelectionRange(0, -1);
+  }
+
+  onRenameKey(ev) {
+    if (ev.key == "Enter") {
+      this.shadowRoot.querySelector("[dialog-confirm]").click();
+    }
+    if (ev.key == "Escape") {
+      this.shadowRoot.querySelector("[dialog-dismiss]").click();
+    }
+  }
+
+  onRenameClosed() {
+    var dialog = this.shadowRoot.querySelector("#renameDialog");
+    if (dialog.closingReason.confirmed) {
+      var label = this.graph.Uri("rdfs:label");
+      var ctxs = this.graph.contextsWithPattern(this.uri, label, null);
+      if (ctxs.length != 1) {
+        throw new Error(`${ctxs.length} label stmts for ${this.uri.label}`);
+      }
+      this.graph.patchObject(typeof this.uri === "string" ? this.graph.Uri(this.uri) : this.uri, label, this.graph.Literal(this.renameTo), ctxs[0]);
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/SyncedGraph.ts	Mon May 23 23:32:37 2022 -0700
@@ -0,0 +1,404 @@
+import * as d3 from "d3";
+import debug from "debug";
+import * as N3 from "n3";
+import { Quad, Quad_Object, Quad_Predicate, Quad_Subject } from "n3";
+import { sortBy, unique } from "underscore";
+import { AutoDependencies, HandlerFunc } from "./AutoDependencies";
+import { Patch, patchSizeSummary } from "./patch";
+import { RdfDbClient } from "./rdfdbclient";
+const log = debug("graph");
+
+const RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
+
+export class SyncedGraph {
+  private _autoDeps: AutoDependencies;
+  private _client: RdfDbClient;
+  private graph: N3.Store;
+  cachedFloatValues: any;
+  cachedUriValues: any;
+  prefixFuncs: (x: string) => string = (x) => x;
+  serial: any;
+  _nextNumber: any;
+  // Main graph object for a browser to use. Consider using RdfdbSyncedGraph element to create & own
+  // one of these. Syncs both ways with rdfdb. Meant to hide the choice of RDF lib, so we can change it
+  // later.
+  //
+  // Note that _applyPatch is the only method to write to the graph, so
+  // it can fire subscriptions.
+
+  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 },
+    private setStatus: any,
+    // called if we clear the graph
+    private clearCb: any,
+    private onGraphChanged: (graph: SyncedGraph, newPatch: Patch)=>void
+  ) {
+    this.graph = new N3.Store();
+    this._autoDeps = new AutoDependencies();
+    this.clearGraph();
+
+    this._client = new RdfDbClient(this.url, this._clearGraphOnNewConnection.bind(this), this._applyPatch.bind(this), this.setStatus);
+  }
+
+  clearGraph() {
+    // just deletes the statements; watchers are unaffected.
+    this.cachedFloatValues = new Map(); // s + '|' + p -> number
+    this.cachedUriValues = new Map(); // s + '|' + p -> Uri
+
+    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);
+  }
+
+  _clearGraphOnNewConnection() {
+    // must not send a patch to the server!
+    log("clearGraphOnNewConnection");
+    this.clearGraph();
+    log("clearGraphOnNewConnection done");
+    if (this.clearCb != null) {
+      return this.clearCb();
+    }
+  }
+
+  _addPrefixes(prefixes: { [x: string]: string }) {
+    for (let k of Array.from(prefixes || {})) {
+      this.prefixes[k] = prefixes[k];
+    }
+    this.prefixFuncs = N3.Util.prefixes(this.prefixes);
+  }
+
+  U() { // just a shorthand
+    return this.Uri.bind(this);
+  }
+
+  Uri(curie: string) {
+    if (curie == null) {
+      throw new Error("no uri");
+    }
+    if (curie.match(/^http/)) {
+      return N3.DataFactory.namedNode(curie);
+    }
+    const part = curie.split(":");
+    return this.prefixFuncs(part[0])(part[1]);
+  }
+
+  Literal(jsValue: any) {
+    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"));
+  }
+
+  Quad(s: any, p: any, o: any, g: any) {
+    return N3.DataFactory.quad(s, p, o, g);
+  }
+
+  toJs(literal: { value: any }) {
+    // incomplete
+    return parseFloat(literal.value);
+  }
+
+  loadTrig(trig: any, cb: () => any) {
+    // for debugging
+    const patch: Patch = { dels: [], adds: [] };
+    const parser = new N3.Parser();
+    return parser.parse(trig, (error: any, quad: any, prefixes: any) => {
+      if (error) {
+        throw new Error(error);
+      }
+      if (quad) {
+        return patch.adds.push(quad);
+      } else {
+        this._applyPatch(patch);
+        this._addPrefixes(prefixes);
+        if (cb) {
+          return cb();
+        }
+      }
+    });
+  }
+
+  quads(): any {
+    // for debugging
+    return Array.from(this.graph.getQuads(null, null, null, null)).map((q: Quad) => [q.subject, q.predicate, q.object, q.graph]);
+  }
+
+  applyAndSendPatch(patch: Patch) {
+    console.time("applyAndSendPatch");
+    if (!this._client) {
+      log("not connected-- dropping patch");
+      return;
+    }
+    if (!Array.isArray(patch.adds) || !Array.isArray(patch.dels)) {
+      console.timeEnd("applyAndSendPatch");
+      log("corrupt patch");
+      throw new Error(`corrupt patch: ${JSON.stringify(patch)}`);
+    }
+
+    this._validatePatch(patch);
+
+    this._applyPatch(patch);
+    if (this._client) {
+      this._client.sendPatch(patch);
+    }
+    return console.timeEnd("applyAndSendPatch");
+  }
+
+  _validatePatch(patch: Patch) {
+    return [patch.adds, patch.dels].map((qs: Quad[]) =>
+      (() => {
+        const result = [];
+        for (let q of Array.from(qs)) {
+          if (!q.equals) {
+            throw new Error("doesn't look like a proper Quad");
+          }
+          if (!q.subject.id || q.graph.id == null || q.predicate.id == null) {
+            throw new Error(`corrupt patch: ${JSON.stringify(q)}`);
+          } else {
+            result.push(undefined);
+          }
+        }
+        return result;
+      })()
+    );
+  }
+
+  _applyPatch(patch: Patch) {
+    // In most cases you want applyAndSendPatch.
+    //
+    // This is the only method that writes to this.graph!
+    log("patch from server [1]")
+    this.cachedFloatValues.clear();
+    this.cachedUriValues.clear();
+    for (let quad of Array.from(patch.dels)) {
+      //log("remove #{JSON.stringify(quad)}")
+      const did = this.graph.removeQuad(quad);
+    }
+    //log("removed: #{did}")
+    for (let quad of Array.from(patch.adds)) {
+      this.graph.addQuad(quad);
+    }
+    log("applied patch locally", patchSizeSummary(patch));
+    this._autoDeps.graphChanged(patch);
+    this.onGraphChanged(this, patch);
+  }
+
+  getObjectPatch(s: N3.NamedNode, p: N3.NamedNode, newObject: N3.Quad_Object, g: N3.NamedNode): Patch {
+    // make a patch which removes existing values for (s,p,*,c) and
+    // adds (s,p,newObject,c). Values in other graphs are not affected.
+    const existing = this.graph.getQuads(s, p, null, g);
+    return {
+      dels: existing,
+      adds: [this.Quad(s, p, newObject, g)],
+    };
+  }
+
+  patchObject(s: N3.NamedNode, p: N3.NamedNode, newObject: N3.Quad_Object, g: N3.NamedNode) {
+    this.applyAndSendPatch(this.getObjectPatch(s, p, newObject, g));
+  }
+
+  clearObjects(s: N3.NamedNode, p: N3.NamedNode, g: N3.NamedNode) {
+    return this.applyAndSendPatch({
+      dels: this.graph.getQuads(s, p, null, g),
+      adds: [],
+    });
+  }
+
+  runHandler(func: HandlerFunc, label: string) {
+    // runs your func once, tracking graph calls. if a future patch
+    // matches what you queried, we runHandler your func again (and
+    // forget your queries from the first time).
+
+    // helps with memleak? not sure yet. The point was if two matching
+    // labels get puushed on, we should run only one. So maybe
+    // appending a serial number is backwards.
+    if (!this.serial) {
+      this.serial = 1;
+    }
+    this.serial += 1;
+    //label = label + @serial
+
+    this._autoDeps.runHandler(func, label);
+  }
+
+  _singleValue(s: Quad_Subject, p: Quad_Predicate) {
+    this._autoDeps.askedFor(s, p, null, null);
+    const quads = this.graph.getQuads(s, p, null, null);
+    const objs = new Set(Array.from(quads).map((q: Quad) => q.object));
+
+    switch (objs.size) {
+      case 0:
+        throw new Error("no value for " + s.value + " " + p.value);
+      case 1:
+        var obj = objs.values().next().value;
+        return obj;
+      default:
+        throw new Error("too many different values: " + JSON.stringify(quads));
+    }
+  }
+
+  floatValue(s: Quad_Subject, p: Quad_Predicate) {
+    const key = s.value + "|" + p.value;
+    const hit = this.cachedFloatValues.get(key);
+    if (hit !== undefined) {
+      return hit;
+    }
+    //log('float miss', s, p)
+
+    const v = this._singleValue(s, p).value;
+    const ret = parseFloat(v);
+    if (isNaN(ret)) {
+      throw new Error(`${s.value} ${p.value} -> ${v} not a float`);
+    }
+    this.cachedFloatValues.set(key, ret);
+    return ret;
+  }
+
+  stringValue(s: any, p: any) {
+    return this._singleValue(s, p).value;
+  }
+
+  uriValue(s: Quad_Subject, p: Quad_Predicate) {
+    const key = s.value + "|" + p.value;
+    const hit = this.cachedUriValues.get(key);
+    if (hit !== undefined) {
+      return hit;
+    }
+
+    const ret = this._singleValue(s, p);
+    this.cachedUriValues.set(key, ret);
+    return ret;
+  }
+
+  labelOrTail(uri: { value: { split: (arg0: string) => any } }) {
+    let ret: any;
+    try {
+      ret = this.stringValue(uri, this.Uri("rdfs:label"));
+    } catch (error) {
+      const words = uri.value.split("/");
+      ret = words[words.length - 1];
+    }
+    if (!ret) {
+      ret = uri.value;
+    }
+    return ret;
+  }
+
+  objects(s: any, p: any): Quad_Object[] {
+    this._autoDeps.askedFor(s, p, null, null);
+    const quads = this.graph.getQuads(s, p, null, null);
+    return Array.from(quads).map((q: { object: any }) => q.object);
+  }
+
+  subjects(p: any, o: any): Quad_Subject[] {
+    this._autoDeps.askedFor(null, p, o, null);
+    const quads = this.graph.getQuads(null, p, o, null);
+    return Array.from(quads).map((q: { subject: any }) => q.subject);
+  }
+
+  items(list: any) {
+    const out = [];
+    let current = list;
+    while (true) {
+      if (current === RDF + "nil") {
+        break;
+      }
+
+      this._autoDeps.askedFor(current, null, null, null); // a little loose
+
+      const firsts = this.graph.getQuads(current, RDF + "first", null, null);
+      const rests = this.graph.getQuads(current, RDF + "rest", null, null);
+      if (firsts.length !== 1) {
+        throw new Error(`list node ${current} has ${firsts.length} rdf:first edges`);
+      }
+      out.push(firsts[0].object);
+
+      if (rests.length !== 1) {
+        throw new Error(`list node ${current} has ${rests.length} rdf:rest edges`);
+      }
+      current = rests[0].object;
+    }
+
+    return out;
+  }
+
+  contains(s: any, p: any, o: any): boolean {
+    this._autoDeps.askedFor(s, p, o, null);
+    log("contains calling getQuads when graph has ", this.graph.size);
+    return this.graph.getQuads(s, p, o, null).length > 0;
+  }
+
+  nextNumberedResources(base: { id: any }, howMany: number) {
+    // base is NamedNode or string
+    // Note this is unsafe before we're synced with the graph. It'll
+    // always return 'name0'.
+    if (base.id) {
+      base = base.id;
+    }
+    const results = [];
+
+    // @contains is really slow.
+    if (this._nextNumber == null) {
+      this._nextNumber = new Map();
+    }
+    let start = this._nextNumber.get(base);
+    if (start === undefined) {
+      start = 0;
+    }
+
+    for (let serial = start, asc = start <= 1000; asc ? serial <= 1000 : serial >= 1000; asc ? serial++ : serial--) {
+      const uri = this.Uri(`${base}${serial}`);
+      if (!this.contains(uri, null, null)) {
+        results.push(uri);
+        log("nextNumberedResources", `picked ${uri}`);
+        this._nextNumber.set(base, serial + 1);
+        if (results.length >= howMany) {
+          return results;
+        }
+      }
+    }
+    throw new Error(`can't make sequential uri with base ${base}`);
+  }
+
+  nextNumberedResource(base: any) {
+    return this.nextNumberedResources(base, 1)[0];
+  }
+
+  contextsWithPattern(s: any, p: any, o: any) {
+    this._autoDeps.askedFor(s, p, o, null);
+    const ctxs = [];
+    for (let q of Array.from(this.graph.getQuads(s, p, o, null))) {
+      ctxs.push(q.graph);
+    }
+    return unique(ctxs);
+  }
+
+  sortKey(uri: N3.NamedNode) {
+    const parts = uri.value.split(/([0-9]+)/);
+    const expanded = parts.map(function (p: string) {
+      const f = parseInt(p);
+      if (isNaN(f)) {
+        return p;
+      }
+      return p.padStart(8, "0");
+    });
+    return expanded.join("");
+  }
+
+  sortedUris(uris: any) {
+    return sortBy(uris, this.sortKey);
+  }
+
+  prettyLiteral(x: any) {
+    if (typeof x === "number") {
+      return this.LiteralRoundedFloat(x);
+    } else {
+      return this.Literal(x);
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/collector/Light9CollectorDevice.ts	Mon May 23 23:32:37 2022 -0700
@@ -0,0 +1,87 @@
+import debug from "debug";
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { NamedNode } from "n3";
+import { GraphChangedEvent } from "../RdfdbSyncedGraph";
+export {ResourceDisplay} from "../ResourceDisplay"
+debug.enable("*");
+const log = debug("device-el");
+
+@customElement("light9-collector-device")
+export class Light9CollectorDevice extends LitElement {
+  static styles = [
+    css`
+      :host {
+        display: block;
+        break-inside: avoid-column;
+        font-size: 80%;
+      }
+      h3 {
+        margin-top: 12px;
+        margin-bottom: 0;
+      }
+      td {
+        white-space: nowrap;
+      }
+
+      td.nonzero {
+        background: #310202;
+        color: #e25757;
+      }
+      td.full {
+        background: #2b0000;
+        color: red;
+        font-weight: bold;
+      }
+    `,
+  ];
+
+  render() {
+    return html`
+      <h3><resource-display .uri=${this.uri}></resource-display></h3>
+      <table class="borders">
+        <tr>
+          <th>out attr</th>
+          <th>value</th>
+          <th>chan</th>
+        </tr>
+        ${this.attrs.map(
+          (item) => html`
+            <tr>
+              <td>${item.attr}</td>
+              <td class=${item.valClass}>${item.val} →</td>
+              <td>${item.chan}</td>
+            </tr>
+          `
+        )}
+      </table>
+    `;
+  }
+  @property({
+    // todo don't rebuild uri; pass it right
+    converter: (s: string | null) => new NamedNode(s || ""),
+  })
+  uri: NamedNode = new NamedNode("");
+  @property() attrs: Array<{ attr: string; valClass: string; val: string; chan: string }> = [];
+
+  constructor() {
+    super();
+    // addGraphChangeListener(this.onGraphChanged.bind(this));
+  }
+  onChanged(ev: GraphChangedEvent) {
+    log("patch from server [5]");
+  }
+  //  observers: [
+  //    "initUpdates(updates)",
+  //  ],
+  // initUpdates(updates) {
+  //   updates.addListener(function (msg) {
+  //     if (msg.outputAttrsSet && msg.outputAttrsSet.dev == this.uri.value) {
+  //       this.set("attrs", msg.outputAttrsSet.attrs);
+  //       this.attrs.forEach(function (row) {
+  //         row.valClass = row.val == 255 ? "full" : row.val ? "nonzero" : "";
+  //       });
+  //     }
+  //   });
+  // }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/collector/README.md	Mon May 23 23:32:37 2022 -0700
@@ -0,0 +1,1 @@
+this is meant to be at light9/collector/web but I couldn't figure out the vite paths
\ No newline at end of file
--- a/light9/web/graph.ts	Sun May 22 03:04:18 2022 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,531 +0,0 @@
-import * as d3 from "d3";
-import debug from "debug";
-import * as N3 from "n3";
-import { Quad, Quad_Subject, Quad_Predicate, Quad_Object, Quad_Graph } from "n3";
-import { filter, sortBy, unique } from "underscore";
-import { allPatchSubjs, Patch } from "./patch";
-import { RdfDbClient } from "./rdfdbclient";
-const log = debug("graph");
-
-const RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
-
-interface QuadPattern {
-  subject: Quad_Subject | null;
-  predicate: Quad_Predicate | null;
-  object: Quad_Object | null;
-  graph: Quad_Graph | null;
-}
-
-class Handler {
-  patterns: QuadPattern[];
-  innerHandlers: Handler[];
-  // a function and the quad patterns it cared about
-  constructor(public func: ((p: Patch) => void) | null, public label: string) {
-    this.patterns = []; // s,p,o,g quads that should trigger the next run
-    this.innerHandlers = []; // Handlers requested while this one was running
-  }
-}
-
-class AutoDependencies {
-  handlers: Handler;
-  handlerStack: Handler[];
-  constructor() {
-    // tree of all known Handlers (at least those with non-empty
-    // patterns). Top node is not a handler.
-    this.handlers = new Handler(null, "root");
-    this.handlerStack = [this.handlers]; // currently running
-  }
-
-  runHandler(func: any, label: any) {
-    // what if we have this func already? duplicate is safe?
-
-    if (label == null) {
-      throw new Error("missing label");
-    }
-
-    const h = new Handler(func, label);
-    const tailChildren = this.handlerStack[this.handlerStack.length - 1].innerHandlers;
-    const matchingLabel = filter(tailChildren, (c: { label: any }) => c.label === label).length;
-    // ohno, something depends on some handlers getting run twice :(
-    if (matchingLabel < 2) {
-      tailChildren.push(h);
-    }
-    //console.time("handler #{label}")
-    return this._rerunHandler(h, null);
-  }
-  //console.timeEnd("handler #{label}")
-  //@_logHandlerTree()
-
-  _rerunHandler(handler: Handler, patch: any) {
-    handler.patterns = [];
-    this.handlerStack.push(handler);
-    try {
-      if (handler.func === null) {
-        throw new Error("tried to rerun root");
-      }
-      return handler.func(patch);
-    } catch (e) {
-      return log("error running handler: ", e);
-    } finally {
-      // assuming here it didn't get to do all its queries, we could
-      // add a *,*,*,* handler to call for sure the next time?
-      //log('done. got: ', handler.patterns)
-      this.handlerStack.pop();
-    }
-  }
-  // handler might have no watches, in which case we could forget about it
-
-  _logHandlerTree() {
-    log("handler tree:");
-    var prn = function (h: Handler, depth: number) {
-      let indent = "";
-      for (let i = 0, end = depth, asc = 0 <= end; asc ? i < end : i > end; asc ? i++ : i--) {
-        indent += "  ";
-      }
-      log(`${indent} \"${h.label}\" ${h.patterns.length} pats`);
-      return Array.from(h.innerHandlers).map((c: any) => prn(c, depth + 1));
-    };
-    return prn(this.handlers, 0);
-  }
-
-  _handlerIsAffected(child: Handler, patchSubjs: Set<string>) {
-    if (patchSubjs === null) {
-      return true;
-    }
-    if (!child.patterns.length) {
-      return false;
-    }
-
-    for (let stmt of Array.from(child.patterns)) {
-      if (stmt.subject === null) {
-        // wildcard on subject
-        return true;
-      }
-      if (patchSubjs.has(stmt.subject.value)) {
-        return true;
-      }
-    }
-
-    return false;
-  }
-
-  graphChanged(patch: Patch) {
-    // SyncedGraph is telling us this patch just got applied to the graph.
-
-    const subjs = allPatchSubjs(patch);
-
-    var rerunInners = (cur: Handler) => {
-      const toRun = cur.innerHandlers.slice();
-      for (let child of Array.from(toRun)) {
-        //match = @_handlerIsAffected(child, subjs)
-        //continue if not match
-        //log('match', child.label, match)
-        //child.innerHandlers = [] # let all children get called again
-
-        this._rerunHandler(child, patch);
-        rerunInners(child);
-      }
-    };
-    return rerunInners(this.handlers);
-  }
-
-  askedFor(s: Quad_Subject | null, p: Quad_Predicate | null, o: Quad_Object | null, g: Quad_Graph | null) {
-    // SyncedGraph is telling us someone did a query that depended on
-    // quads in the given pattern.
-    const current = this.handlerStack[this.handlerStack.length - 1];
-    if (current != null && current !== this.handlers) {
-      return current.patterns.push({ subject: s, predicate: p, object: o, graph: g } as QuadPattern);
-    }
-  }
-}
-
-export class SyncedGraph {
-  _autoDeps: AutoDependencies;
-  _client: any;
-  graph: N3.Store;
-  cachedFloatValues: any;
-  cachedUriValues: any;
-  prefixFuncs: (x: string) => string = (x) => x;
-  serial: any;
-  _nextNumber: any;
-  // Main graph object for a browser to use. Syncs both ways with
-  // rdfdb. Meant to hide the choice of RDF lib, so we can change it
-  // later.
-  //
-  // Note that _applyPatch is the only method to write to the graph, so
-  // it can fire subscriptions.
-
-  constructor(
-    // patchSenderUrl is the /syncedGraph path of an rdfdb server.
-    public patchSenderUrl: any,
-    // prefixes can be used in Uri(curie) calls.
-    public prefixes: { [short: string]: string },
-    private setStatus: any,
-    // called if we clear the graph
-    private clearCb: any
-  ) {
-    this.graph = new N3.Store();
-    this._autoDeps = new AutoDependencies(); // replaces GraphWatchers
-    this.clearGraph();
-
-    if (this.patchSenderUrl) {
-      this._client = new RdfDbClient(this.patchSenderUrl, this._clearGraphOnNewConnection.bind(this), this._applyPatch.bind(this), this.setStatus);
-    }
-  }
-
-  clearGraph() {
-    // just deletes the statements; watchers are unaffected.
-    if (this.graph != null) {
-      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.cachedFloatValues = new Map(); // s + '|' + p -> number
-    return (this.cachedUriValues = new Map()); // s + '|' + p -> Uri
-  }
-
-  _clearGraphOnNewConnection() {
-    // must not send a patch to the server!
-    log("graph: clearGraphOnNewConnection");
-    this.clearGraph();
-    log("graph: clearGraphOnNewConnection done");
-    if (this.clearCb != null) {
-      return this.clearCb();
-    }
-  }
-
-  _addPrefixes(prefixes: { [x: string]: string }) {
-    for (let k of Array.from(prefixes || {})) {
-      this.prefixes[k] = prefixes[k];
-    }
-    this.prefixFuncs = N3.Util.prefixes(this.prefixes);
-  }
-
-  Uri(curie: string) {
-    if (curie == null) {
-      throw new Error("no uri");
-    }
-    if (curie.match(/^http/)) {
-      return N3.DataFactory.namedNode(curie);
-    }
-    const part = curie.split(":");
-    return this.prefixFuncs(part[0])(part[1]);
-  }
-
-  Literal(jsValue: any) {
-    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"));
-  }
-
-  Quad(s: any, p: any, o: any, g: any) {
-    return N3.DataFactory.quad(s, p, o, g);
-  }
-
-  toJs(literal: { value: any }) {
-    // incomplete
-    return parseFloat(literal.value);
-  }
-
-  loadTrig(trig: any, cb: () => any) {
-    // for debugging
-    const patch: Patch = { dels: [], adds: [] };
-    const parser = new N3.Parser();
-    return parser.parse(trig, (error: any, quad: any, prefixes: any) => {
-      if (error) {
-        throw new Error(error);
-      }
-      if (quad) {
-        return patch.adds.push(quad);
-      } else {
-        this._applyPatch(patch);
-        this._addPrefixes(prefixes);
-        if (cb) {
-          return cb();
-        }
-      }
-    });
-  }
-
-  quads(): any {
-    // for debugging
-    return Array.from(this.graph.getQuads(null, null, null, null)).map((q: Quad) => [q.subject, q.predicate, q.object, q.graph]);
-  }
-
-  applyAndSendPatch(patch: Patch) {
-    console.time("applyAndSendPatch");
-    if (!this._client) {
-      log("not connected-- dropping patch");
-      return;
-    }
-    if (!Array.isArray(patch.adds) || !Array.isArray(patch.dels)) {
-      console.timeEnd("applyAndSendPatch");
-      log("corrupt patch");
-      throw new Error(`corrupt patch: ${JSON.stringify(patch)}`);
-    }
-
-    this._validatePatch(patch);
-
-    this._applyPatch(patch);
-    if (this._client) {
-      this._client.sendPatch(patch);
-    }
-    return console.timeEnd("applyAndSendPatch");
-  }
-
-  _validatePatch(patch: Patch) {
-    return [patch.adds, patch.dels].map((qs: Quad[]) =>
-      (() => {
-        const result = [];
-        for (let q of Array.from(qs)) {
-          if (!q.equals) {
-            throw new Error("doesn't look like a proper Quad");
-          }
-          if (!q.subject.id || q.graph.id == null || q.predicate.id == null) {
-            throw new Error(`corrupt patch: ${JSON.stringify(q)}`);
-          } else {
-            result.push(undefined);
-          }
-        }
-        return result;
-      })()
-    );
-  }
-
-  _applyPatch(patch: Patch) {
-    // In most cases you want applyAndSendPatch.
-    //
-    // This is the only method that writes to @graph!
-    let quad: any;
-    this.cachedFloatValues.clear();
-    this.cachedUriValues.clear();
-    for (quad of Array.from(patch.dels)) {
-      //log("remove #{JSON.stringify(quad)}")
-      const did = this.graph.removeQuad(quad);
-    }
-    //log("removed: #{did}")
-    for (quad of Array.from(patch.adds)) {
-      this.graph.addQuad(quad);
-    }
-    //log('applied patch locally', patchSizeSummary(patch))
-    return this._autoDeps.graphChanged(patch);
-  }
-
-  getObjectPatch(s: N3.NamedNode, p: N3.NamedNode, newObject: N3.Quad_Object, g: N3.NamedNode): Patch {
-    // make a patch which removes existing values for (s,p,*,c) and
-    // adds (s,p,newObject,c). Values in other graphs are not affected.
-    const existing = this.graph.getQuads(s, p, null, g);
-    return {
-      dels: existing,
-      adds: [this.Quad(s, p, newObject, g)],
-    };
-  }
-
-  patchObject(s: N3.NamedNode, p: N3.NamedNode, newObject: N3.Quad_Object, g: N3.NamedNode) {
-    return this.applyAndSendPatch(this.getObjectPatch(s, p, newObject, g));
-  }
-
-  clearObjects(s: N3.NamedNode, p: N3.NamedNode, g: N3.NamedNode) {
-    return this.applyAndSendPatch({
-      dels: this.graph.getQuads(s, p, null, g),
-      adds: [],
-    });
-  }
-
-  runHandler(func: any, label: any) {
-    // runs your func once, tracking graph calls. if a future patch
-    // matches what you queried, we runHandler your func again (and
-    // forget your queries from the first time).
-
-    // helps with memleak? not sure yet. The point was if two matching
-    // labels get puushed on, we should run only one. So maybe
-    // appending a serial number is backwards.
-    if (!this.serial) {
-      this.serial = 1;
-    }
-    this.serial += 1;
-    //label = label + @serial
-
-    return this._autoDeps.runHandler(func, label);
-  }
-
-  _singleValue(s: Quad_Subject, p: Quad_Predicate) {
-    this._autoDeps.askedFor(s, p, null, null);
-    const quads = this.graph.getQuads(s, p, null, null);
-    const objs = new Set(Array.from(quads).map((q: Quad) => q.object));
-
-    switch (objs.size) {
-      case 0:
-        throw new Error("no value for " + s.value + " " + p.value);
-      case 1:
-        var obj = objs.values().next().value;
-        return obj;
-      default:
-        throw new Error("too many different values: " + JSON.stringify(quads));
-    }
-  }
-
-  floatValue(s: Quad_Subject, p: Quad_Predicate) {
-    const key = s.value + "|" + p.value;
-    const hit = this.cachedFloatValues.get(key);
-    if (hit !== undefined) {
-      return hit;
-    }
-    //log('float miss', s, p)
-
-    const v = this._singleValue(s, p).value;
-    const ret = parseFloat(v);
-    if (isNaN(ret)) {
-      throw new Error(`${s.value} ${p.value} -> ${v} not a float`);
-    }
-    this.cachedFloatValues.set(key, ret);
-    return ret;
-  }
-
-  stringValue(s: any, p: any) {
-    return this._singleValue(s, p).value;
-  }
-
-  uriValue(s: Quad_Subject, p: Quad_Predicate) {
-    const key = s.value + "|" + p.value;
-    const hit = this.cachedUriValues.get(key);
-    if (hit !== undefined) {
-      return hit;
-    }
-
-    const ret = this._singleValue(s, p);
-    this.cachedUriValues.set(key, ret);
-    return ret;
-  }
-
-  labelOrTail(uri: { value: { split: (arg0: string) => any } }) {
-    let ret: any;
-    try {
-      ret = this.stringValue(uri, this.Uri("rdfs:label"));
-    } catch (error) {
-      const words = uri.value.split("/");
-      ret = words[words.length - 1];
-    }
-    if (!ret) {
-      ret = uri.value;
-    }
-    return ret;
-  }
-
-  objects(s: any, p: any) {
-    this._autoDeps.askedFor(s, p, null, null);
-    const quads = this.graph.getQuads(s, p, null, null);
-    return Array.from(quads).map((q: { object: any }) => q.object);
-  }
-
-  subjects(p: any, o: any) {
-    this._autoDeps.askedFor(null, p, o, null);
-    const quads = this.graph.getQuads(null, p, o, null);
-    return Array.from(quads).map((q: { subject: any }) => q.subject);
-  }
-
-  items(list: any) {
-    const out = [];
-    let current = list;
-    while (true) {
-      if (current === RDF + "nil") {
-        break;
-      }
-
-      this._autoDeps.askedFor(current, null, null, null); // a little loose
-
-      const firsts = this.graph.getQuads(current, RDF + "first", null, null);
-      const rests = this.graph.getQuads(current, RDF + "rest", null, null);
-      if (firsts.length !== 1) {
-        throw new Error(`list node ${current} has ${firsts.length} rdf:first edges`);
-      }
-      out.push(firsts[0].object);
-
-      if (rests.length !== 1) {
-        throw new Error(`list node ${current} has ${rests.length} rdf:rest edges`);
-      }
-      current = rests[0].object;
-    }
-
-    return out;
-  }
-
-  contains(s: any, p: any, o: any) {
-    this._autoDeps.askedFor(s, p, o, null);
-    log("contains calling getQuads when graph has ", this.graph.size);
-    return this.graph.getQuads(s, p, o, null).length > 0;
-  }
-
-  nextNumberedResources(base: { id: any }, howMany: number) {
-    // base is NamedNode or string
-    // Note this is unsafe before we're synced with the graph. It'll
-    // always return 'name0'.
-    if (base.id) {
-      base = base.id;
-    }
-    const results = [];
-
-    // @contains is really slow.
-    if (this._nextNumber == null) {
-      this._nextNumber = new Map();
-    }
-    let start = this._nextNumber.get(base);
-    if (start === undefined) {
-      start = 0;
-    }
-
-    for (let serial = start, asc = start <= 1000; asc ? serial <= 1000 : serial >= 1000; asc ? serial++ : serial--) {
-      const uri = this.Uri(`${base}${serial}`);
-      if (!this.contains(uri, null, null)) {
-        results.push(uri);
-        log("nextNumberedResources", `picked ${uri}`);
-        this._nextNumber.set(base, serial + 1);
-        if (results.length >= howMany) {
-          return results;
-        }
-      }
-    }
-    throw new Error(`can't make sequential uri with base ${base}`);
-  }
-
-  nextNumberedResource(base: any) {
-    return this.nextNumberedResources(base, 1)[0];
-  }
-
-  contextsWithPattern(s: any, p: any, o: any) {
-    this._autoDeps.askedFor(s, p, o, null);
-    const ctxs = [];
-    for (let q of Array.from(this.graph.getQuads(s, p, o, null))) {
-      ctxs.push(q.graph);
-    }
-    return unique(ctxs);
-  }
-
-  sortKey(uri: N3.NamedNode) {
-    const parts = uri.value.split(/([0-9]+)/);
-    const expanded = parts.map(function (p: string) {
-      const f = parseInt(p);
-      if (isNaN(f)) {
-        return p;
-      }
-      return p.padStart(8, "0");
-    });
-    return expanded.join("");
-  }
-
-  sortedUris(uris: any) {
-    return sortBy(uris, this.sortKey);
-  }
-
-  prettyLiteral(x: any) {
-    if (typeof x === "number") {
-      return this.LiteralRoundedFloat(x);
-    } else {
-      return this.Literal(x);
-    }
-  }
-}
--- a/light9/web/rdfdb-synced-graph.html	Sun May 22 03:04:18 2022 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,55 +0,0 @@
-<link rel="import" href="/lib/polymer/polymer-element.html">
-<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>
-
-<dom-module id="rdfdb-synced-graph">
-  <template>
-    <style>
-     :host {
-         display: inline-block;
-         border: 1px solid gray;
-         min-width: 22em;
-         background: #05335a;
-         color: #4fc1d4;
-     }
-    </style>
-    graph: [[status]]
-  </template>
-  <script src="rdfdbclient.js"></script>
-  <script src="graph.js"></script>
-  <script>
-   class RdfdbSyncedGraph extends Polymer.Element {
-       static get is() { return "rdfdb-synced-graph"; }
-       
-       static get properties() {
-           return {
-               graph: {type: Object, notify: true},
-               status: {type: String, notify: true},
-               testGraph: {type: Boolean},
-           }
-       }
-
-       onClear() {
-           console.log('reset')
-       }
-     
-       connectedCallback() {
-           super.connectedCallback();
-           this.graph = new SyncedGraph(
-               this.testGraph ? null : '/rdfdb/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#',
-               },
-             function(s) { this.status = s; }.bind(this),
-             this.onClear.bind(this));
-           window.graph = this.graph;
-       }
-   }
-   customElements.define(RdfdbSyncedGraph.is, RdfdbSyncedGraph);
-  </script>
-</dom-module>
--- a/light9/web/rdfdbclient.ts	Sun May 22 03:04:18 2022 -0700
+++ b/light9/web/rdfdbclient.ts	Mon May 23 23:32:37 2022 -0700
@@ -9,10 +9,10 @@
   _patchesReceived: number;
   _patchesSent: number;
   _connectionId: string;
-  _reconnectionTimeout: number | null;
-  ws: WebSocket | undefined;
-  _pingLoopTimeout: any;
-  // Send and receive patches from rdfdb
+  _reconnectionTimeout?: number;
+  ws?: WebSocket;
+  _pingLoopTimeout?: number;
+  // Send and receive patches from rdfdb. Primarily used in SyncedGraph.
   //
   // What this should do, and does not yet, is keep the graph
   // 'coasting' over a reconnect, applying only the diffs from the old
@@ -30,7 +30,6 @@
     this._patchesReceived = 0;
     this._patchesSent = 0;
     this._connectionId = "??";
-    this._reconnectionTimeout = null;
     this.ws = undefined;
 
     this._newConnection();
@@ -63,7 +62,7 @@
   }
 
   sendPatch(patch: Patch) {
-    log("rdfdbclient: queue patch to server ", patchSizeSummary(patch));
+    log("queue patch to server ", patchSizeSummary(patch));
     this._patchesToSend.push(patch);
     this._updateStatus();
     this._continueSending();
@@ -76,35 +75,37 @@
       this.ws.close();
     }
     this.ws = new WebSocket(fullUrl);
+    this.ws.onopen = this.onWsOpen.bind(this);
+    this.ws.onerror = this.onWsError.bind(this);
+    this.ws.onclose = this.onWsClose.bind(this);
+    this.ws.onmessage = this._onMessage.bind(this);
+  }
 
-    this.ws.onopen = () => {
-      log("rdfdbclient: new connection to", fullUrl);
-      this._updateStatus();
-      this.clearGraphOnNewConnection();
-      return this._pingLoop();
-    };
+  private onWsOpen() {
+    log("new connection to", this.patchSenderUrl);
+    this._updateStatus();
+    this.clearGraphOnNewConnection();
+    return this._pingLoop();
+  }
 
-    this.ws.onerror = (e: Event) => {
-      log("rdfdbclient: ws error " + e);
-      if (this.ws !== undefined) {
-        const closeHandler = this.ws.onclose?.bind(this.ws);
-        if (!closeHandler) {
-          throw new Error();
-        }
-        closeHandler(new CloseEvent("forced"));
+  private onWsError(e: Event) {
+    log("ws error", e);
+    if (this.ws !== undefined) {
+      const closeHandler = this.ws.onclose?.bind(this.ws);
+      if (!closeHandler) {
+        throw new Error();
       }
-    };
+      closeHandler(new CloseEvent("forced"));
+    }
+  }
 
-    this.ws.onclose = (ev: CloseEvent) => {
-      log("rdfdbclient: ws close");
-      this._updateStatus();
-      if (this._reconnectionTimeout != null) {
-        clearTimeout(this._reconnectionTimeout);
-      }
-      this._reconnectionTimeout = (setTimeout(this._newConnection.bind(this), 1000) as unknown) as number;
-    };
-
-    this.ws.onmessage = this._onMessage.bind(this);
+  private onWsClose(ev: CloseEvent) {
+    log("ws close");
+    this._updateStatus();
+    if (this._reconnectionTimeout !== undefined) {
+      clearTimeout(this._reconnectionTimeout);
+    }
+    this._reconnectionTimeout = (setTimeout(this._newConnection.bind(this), 1000) as unknown) as number;
   }
 
   _pingLoop() {
@@ -115,7 +116,7 @@
       if (this._pingLoopTimeout != null) {
         clearTimeout(this._pingLoopTimeout);
       }
-      this._pingLoopTimeout = setTimeout(this._pingLoop.bind(this), 10000);
+      this._pingLoopTimeout = (setTimeout(this._pingLoop.bind(this), 10000) as unknown) as number;
     }
   }
 
@@ -131,6 +132,7 @@
     if (input.connectedAs) {
       this._connectionId = input.connectedAs;
     } else {
+      log("patch from server [0]")
       parseJsonPatch(input, this.applyPatch.bind(this));
       this._patchesReceived++;
     }
@@ -148,7 +150,7 @@
 
     const sendOne = (patch: any, cb: (arg0: any) => any) => {
       return toJsonPatch(patch, (json: string) => {
-        log("rdfdbclient: send patch to server, " + json.length + " bytes");
+        log("send patch to server, " + json.length + " bytes");
         if (!this.ws) {
           throw new Error("can't send");
         }
--- a/light9/web/resource-display.html	Sun May 22 03:04:18 2022 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,158 +0,0 @@
-<link rel="import" href="/lib/polymer/polymer-element.html">
-<link rel="import" href="/lib/paper-dialog/paper-dialog.html">
-<link rel="import" href="/lib/paper-button/paper-button.html">
-
-<dom-module id="resource-display">
-  <template>
-    <style>
-     :host {
-         display: inline-block;
-     }
-     
-     a.resource {
-         color: inherit;
-         text-decoration: none;
-     }
-
-     .resource {
-         border: 1px solid #545454;
-         border-radius: 5px;
-         padding: 1px;
-         margin: 2px;
-         background: rgb(49, 49, 49);
-         display: inline-block;
-         text-shadow: 1px 1px 2px black;
-     }
-     .resource.minor {
-         background: none;
-         border: none;
-     }
-     .resource a {
-         color: rgb(150, 150, 255);
-         padding: 1px;
-         display: inline-block;
-     }
-     .resource.minor a {
-         text-decoration: none;
-         color: rgb(155, 155, 193);
-         padding: 0;
-     }
-    </style>
-
-    <span class$="[[resClasses]]">
-      <a href="{{href}}" id="uri">
-        <!-- type icon goes here -->{{label}}</a>
-    </span>
-    <template is="dom-if" if="{{rename}}">
-      <button on-click="onRename">Rename</button>
-
-      <paper-dialog id="renameDialog" modal
-                    on-iron-overlay-closed="onRenameClosed">
-        <p>
-          New label:
-          <input id="renameTo" autofocus type="text"
-                 value="{{renameTo::input}}"
-                 on-keydown="onRenameKey">
-        </p>
-        <div class="buttons">
-          <paper-button dialog-dismiss>Cancel</paper-button>
-          <paper-button dialog-confirm>OK</paper-button>
-        </div>
-      </paper-dialog>     
-    </template>
-    
-  </template>
-  <script>
-   class ResourceDisplay extends Polymer.Element {
-     static get is() { return "resource-display"; }
-     static get properties() {
-       return {
-         graph: { type: Object },
-         // callers might set this as string or NamedNode.
-         uri: { type: Object }, // Use .value for the string
-         href: { type: String },
-         label: { type: String },
-         rename: { type: Boolean },
-         minor: { type: Boolean },
-         resClasses: { type: String, computed: '_resClasses(minor)', value: 'resource' },
-         renameTo: { type: String, notify: true },
-       };
-     }
-     static get observers() { return ['onUri(graph, uri)']; }
-     
-     _resClasses(minor) {
-       return minor ? 'resource minor' : 'resource';
-     }
-     
-     onUri(graph, uri) {
-       if (!this.graph) {
-         this.label = "...";
-         this.href = "javascript:;'";
-         return;
-       }
-       if (!this.uri) {
-         this.setLabel();
-         return;
-       }
-       if (typeof uri === 'string') {
-         uri = this.graph.Uri(uri);
-       }
-       this.graph.runHandler(this.setLabel.bind(this),
-                             `label ${uri.value}`);
-     }
-     
-     setLabel(patch) {
-       if (!this.uri) {
-         this.label = "<no uri>";
-         this.href = "javascript:;";
-         return;
-       }
-       if (patch !== null &&
-           !SyncedGraph.patchContainsPreds(patch,
-                                           [this.graph.Uri('rdfs:label')])) {
-         return;
-       }
-       let uri = this.uri;
-       if (typeof uri === 'string') {
-         uri = this.graph.Uri(uri);
-       }
-       this.label = this.graph.labelOrTail(uri);
-       this.href = uri.value;
-     }
-     
-     onRename() {
-       this.renameTo = this.label;
-       this.shadowRoot.querySelector("#renameDialog").open();
-       this.shadowRoot.querySelector("#renameTo").setSelectionRange(0, -1);
-     }
-     
-     onRenameKey(ev) {
-       if (ev.key == 'Enter') {
-         this.shadowRoot.querySelector("[dialog-confirm]").click();
-       }
-       if (ev.key == 'Escape') {
-         this.shadowRoot.querySelector("[dialog-dismiss]").click();
-       }
-     }
-     
-     onRenameClosed() {
-       var dialog = this.shadowRoot.querySelector("#renameDialog");
-       if (dialog.closingReason.confirmed) {
-         var label = this.graph.Uri('rdfs:label');
-         var ctxs = this.graph.contextsWithPattern(this.uri, label, null);
-         if (ctxs.length != 1) {
-           throw new Error(
-             `${ctxs.length} label stmts for ${this.uri.label}`);
-         }
-         this.graph.patchObject(
-           ((typeof this.uri) === 'string' ?
-            this.graph.Uri(this.uri) : this.uri),
-           label,
-           this.graph.Literal(this.renameTo),
-           ctxs[0]);
-       }
-     }
-   }
-   customElements.define(ResourceDisplay.is, ResourceDisplay);
-  </script>
-</dom-module>