changeset 2235:f9edd9819b7d

move live/ out of web; it's just a normal (web-only) tool now
author drewp@bigasterisk.com
date Wed, 24 May 2023 14:37:11 -0700
parents e8401b82e6bc
children 51e9cb155495
files bin/live light9/live/Effect.ts light9/live/GraphToControls.ts light9/live/Light9DeviceControl.ts light9/live/Light9Listbox.ts light9/live/Light9LiveControl.ts light9/live/Light9LiveControls.ts light9/live/README.md light9/live/index.html light9/live/vite.config.ts light9/web/live/Effect.ts light9/web/live/GraphToControls.ts light9/web/live/Light9DeviceControl.ts light9/web/live/Light9Listbox.ts light9/web/live/Light9LiveControl.ts light9/web/live/Light9LiveControls.ts light9/web/live/README.md light9/web/live/index.html light9/web/live/vite.config.ts
diffstat 19 files changed, 1003 insertions(+), 1004 deletions(-) [+]
line wrap: on
line diff
--- a/bin/live	Wed May 24 14:10:15 2023 -0700
+++ b/bin/live	Wed May 24 14:37:11 2023 -0700
@@ -1,3 +1,2 @@
 #!/bin/zsh
-pnpm exec vite -c light9/web/live/vite.config.ts &
-wait
+exec pnpm exec vite -c light9/live/vite.config.ts
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/live/Effect.ts	Wed May 24 14:37:11 2023 -0700
@@ -0,0 +1,194 @@
+import debug from "debug";
+import { Literal, NamedNode, Quad_Object, Quad_Predicate, Quad_Subject, Term } from "n3";
+import { some } from "underscore";
+import { Patch, patchContainsPreds, patchUpdate } from "../web/patch";
+import { SyncedGraph } from "../web/SyncedGraph";
+import { shortShow } from "../web/show_specific";
+
+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 }> = [];
+  private ctxForEffect: NamedNode
+  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
+  ) {
+    this.ctxForEffect = this.graph.Uri(this.uri.value.replace("light9.bigasterisk.com/effect", `light9.bigasterisk.com/show/${shortShow}/effect`));
+    graph.runHandler(this.rebuildSettingsFromGraph.bind(this), `effect sync ${uri.value}`);
+  }
+
+  addNewEffectToGraph() {
+    const U = this.graph.U();
+    const quad = (s: Quad_Subject, p: Quad_Predicate, o: Quad_Object) => this.graph.Quad(s, p, o, this.ctxForEffect);
+
+    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(patch?: Patch) {
+    const U = this.graph.U();
+    if (patch && !patchContainsPreds(patch, [U(":setting"), U(":device"), U(":deviceAttr")])) {
+      // that's an approx list of preds , but it just means we'll miss some pathological settings edits
+      //   return;
+    }
+
+    // log("syncFromGraph", this.uri);
+
+    // this repeats work- it gathers all settings when really some values changed (and we might even know about them). maybe push the value-fetching into a secnod phase of the run, and have the 1st phase drop out early
+    const newSettings = [];
+
+    const seenDevAttrPairs: Set<string> = new Set();
+
+    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;
+    let result = { adds: [], dels: [] };
+    for (let s of this.settings) {
+      if (device.equals(s.device) && deviceAttr.equals(s.deviceAttr)) {
+        if (existingSetting !== null) {
+          // this is corrupt. There was only supposed to be one setting per (dev,attr) pair. But we can fix it because we're going to update existingSetting to the user's requested value.
+          log(`${this.uri.value} had two settings for ${device.value} - ${deviceAttr.value} - deleting ${s.setting}`);
+          patchUpdate(result,  this._removeEffectSetting(s.setting));
+        }
+        existingSetting = s.setting;
+      }
+    }
+
+    if (newValue !== null && this.shouldBeStored(deviceAttr, newValue)) {
+      if (existingSetting === null) {
+        patchUpdate(result, this._addEffectSetting(device, deviceAttr, newValue));
+      } else {
+        patchUpdate(result, this._patchExistingEffectSetting(existingSetting, deviceAttr, newValue));
+      }
+    } else {
+      if (existingSetting !== null) {
+        patchUpdate(result, this._removeEffectSetting(existingSetting));
+      }
+    }
+    return result;
+  }
+
+  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 quad = (s: Quad_Subject, p: Quad_Predicate, o: Quad_Object) => this.graph.Quad(s, p, o, this.ctxForEffect);
+    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);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/live/GraphToControls.ts	Wed May 24 14:37:11 2023 -0700
@@ -0,0 +1,69 @@
+import debug from "debug";
+import { NamedNode } from "n3";
+import { SyncedGraph } from "../web/SyncedGraph";
+import { ControlValue, Effect } from "./Effect";
+const log = debug("g2c");
+
+type NewValueCb = (newValue: ControlValue | null) => void;
+
+// More efficient bridge between liveControl widgets and graph edits (inside Effect),
+// as opposed to letting each widget scan the graph and push lots of
+// tiny patches to it.
+export class GraphToControls {
+  // 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) {
+    log(`setEffect ${effect?.value}`);
+    this.effect = effect ? new Effect(this.graph, effect, this.onValuesChanged.bind(this)) : null;
+  }
+
+  newEffect(): NamedNode {
+    // wrong- this should be our editor's scratch effect, promoted to a
+    // real one when you name it.
+    const uri = this.graph.nextNumberedResource(this.graph.Uri("http://light9.bigasterisk.com/effect/effect"));
+
+    this.effect = new Effect(this.graph, uri, this.onValuesChanged.bind(this));
+    log("add new eff");
+    this.effect.addNewEffectToGraph();
+    return this.effect.uri;
+  }
+
+  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);
+      });
+    });
+  }
+
+  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);
+
+    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;
+    }
+    const p = this.effect.edit(device, deviceAttr, value);
+    this.graph.applyAndSendPatch(p);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/live/Light9DeviceControl.ts	Wed May 24 14:37:11 2023 -0700
@@ -0,0 +1,221 @@
+import debug from "debug";
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { NamedNode } from "n3";
+import { unique } from "underscore";
+import { Patch, patchContainsPreds } from "../web/patch";
+import { getTopGraph } from "../web/RdfdbSyncedGraph";
+import { SyncedGraph } from "../web/SyncedGraph";
+import { GraphToControls } from "./GraphToControls";
+import { Choice } from "./Light9Listbox";
+import { Light9LiveControl } from "./Light9LiveControl";
+export { ResourceDisplay } from "../web/ResourceDisplay";
+export { Light9LiveControl };
+const log = debug("devcontrol");
+
+export interface DeviceAttrRow {
+  uri: NamedNode; //devattr
+  attrClasses: string; // the css kind
+  dataType: NamedNode;
+  showColorPicker: boolean;
+  useColor: boolean;
+  useChoice: boolean;
+  choices: Choice[];
+  choiceSize: number;
+  useSlider: boolean;
+  max: number;
+}
+
+@customElement("light9-device-control")
+export class Light9DeviceControl extends LitElement {
+  graph!: SyncedGraph;
+  static styles = [
+    css`
+      :host {
+        display: inline-block;
+      }
+      .device {
+        border: 2px solid #151e2d;
+        margin: 4px;
+        padding: 1px;
+        background: #171717; /* deviceClass gradient added later */
+        break-inside: avoid-column;
+        width: 335px;
+      }
+      .deviceAttr {
+        border-top: 1px solid #272727;
+        padding-bottom: 2px;
+        display: flex;
+      }
+      .deviceAttr > span {
+      }
+      .deviceAttr > light9-live-control {
+        flex-grow: 1;
+      }
+      h2 {
+        font-size: 110%;
+        padding: 4px;
+        margin-top: 0;
+        margin-bottom: 0;
+      }
+      .device,
+      h2 {
+        border-top-right-radius: 15px;
+      }
+
+      #mainLabel {
+        font-size: 120%;
+        color: #9ab8fd;
+        text-decoration: initial;
+      }
+      .device.selected h2 {
+        outline: 3px solid #ffff0047;
+      }
+      .deviceAttr.selected {
+        background: #cada1829;
+      }
+    `,
+  ];
+
+  render() {
+    return html`
+      <div class="device ${this.devClasses}">
+        <h2 style="${this._bgStyle(this.deviceClass)}" xon-click="onClick">
+          <resource-display id="mainLabel" .uri="${this.uri}"></resource-display>
+          a <resource-display minor .uri="${this.deviceClass}"></resource-display>
+        </h2>
+        ${this.deviceAttrs.map(
+          (dattr: DeviceAttrRow) => html`
+            <div xon-click="onAttrClick" class="deviceAttr ${dattr.attrClasses}">
+              <span>attr <resource-display minor .uri="${dattr.uri}"></resource-display></span>
+              <light9-live-control
+                .device="${this.uri}"
+                .deviceAttrRow="${dattr}"
+                .effect="${this.effect}"
+                .graphToControls="${this.graphToControls}"
+              ></light9-live-control>
+            </div>
+          `
+        )}
+      </div>
+    `;
+  }
+
+  @property() uri!: NamedNode;
+  @property() effect!: NamedNode;
+  @property() graphToControls!: GraphToControls;
+
+  @property() devClasses: string = ""; // the css kind
+  @property() deviceAttrs: DeviceAttrRow[] = [];
+  @property() deviceClass: NamedNode | null = null;
+  @property() selectedAttrs: Set<NamedNode> = new Set();
+
+  constructor() {
+    super();
+    getTopGraph().then((g) => {
+      this.graph = g;
+      this.graph.runHandler(this.syncDeviceAttrsFromGraph.bind(this), `${this.uri.value} update`);
+    });
+    this.selectedAttrs = new Set();
+  }
+
+  _bgStyle(deviceClass: NamedNode | null): string {
+    if (!deviceClass) return "";
+    let hash = 0;
+    const u = deviceClass.value;
+    for (let i = u.length - 10; i < u.length; i++) {
+      hash += u.charCodeAt(i);
+    }
+    const hue = (hash * 8) % 360;
+    const accent = `hsl(${hue}, 49%, 22%)`;
+    return `background: linear-gradient(to right, rgba(31,31,31,0) 50%, ${accent} 100%);`;
+  }
+
+  setDeviceSelected(isSel: any) {
+    this.devClasses = isSel ? "selected" : "";
+  }
+
+  setAttrSelected(devAttr: NamedNode, isSel: boolean) {
+    if (isSel) {
+      this.selectedAttrs.add(devAttr);
+    } else {
+      this.selectedAttrs.delete(devAttr);
+    }
+    // this.syncDeviceAttrsFromGraph();
+  }
+
+  syncDeviceAttrsFromGraph(patch?: Patch) {
+    const U = this.graph.U();
+    if (patch && !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))
+    );
+    this.requestUpdate();
+  }
+
+  attrRow(devAttr: NamedNode): DeviceAttrRow {
+    let x: NamedNode;
+    const U = (x: string) => this.graph.Uri(x);
+    const dataType = this.graph.uriValue(devAttr, U(":dataType"));
+    const daRow = {
+      uri: devAttr,
+      dataType,
+      showColorPicker: dataType.equals(U(":color")),
+      attrClasses: this.selectedAttrs.has(devAttr) ? "selected" : "",
+      useColor: false,
+      useChoice: false,
+      choices: [] as Choice[],
+      choiceSize: 0,
+      useSlider: false,
+      max: 1,
+    };
+    if (dataType.equals(U(":color"))) {
+      daRow.useColor = true;
+    } else if (dataType.equals(U(":choice"))) {
+      daRow.useChoice = true;
+      const choiceUris = this.graph.sortedUris(this.graph.objects(devAttr, U(":choice")));
+      daRow.choices = (() => {
+        const result = [];
+        for (x of Array.from(choiceUris)) {
+          result.push({ uri: x.value, label: this.graph.labelOrTail(x) });
+        }
+        return result;
+      })();
+      daRow.choiceSize = Math.min(choiceUris.length + 1, 10);
+    } else {
+      daRow.useSlider = true;
+      daRow.max = 1;
+      if (dataType.equals(U(":angle"))) {
+        // varies
+        daRow.max = 1;
+      }
+    }
+    return daRow;
+  }
+
+  clear() {
+    // 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());
+  }
+
+  onClick(ev: any) {
+    log("click", this.uri);
+    // select, etc
+  }
+
+  onAttrClick(ev: { model: { dattr: { uri: any } } }) {
+    log("attr click", this.uri, ev.model.dattr.uri);
+    // select
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/live/Light9Listbox.ts	Wed May 24 14:37:11 2023 -0700
@@ -0,0 +1,75 @@
+import debug from "debug";
+const log = debug("listbox");
+import { css, html, LitElement, PropertyValues } from "lit";
+import { customElement, property } from "lit/decorators.js";
+export type Choice = {uri:string,label:string}
+@customElement("light9-listbox")
+export class Light9Listbox extends LitElement {
+  static styles = [
+    css`
+      paper-listbox {
+        --paper-listbox-background-color: none;
+        --paper-listbox-color: white;
+        --paper-listbox: {
+          /* measure biggest item? use flex for columns? */
+          column-width: 9em;
+        }
+      }
+      paper-item {
+        --paper-item-min-height: 0;
+        --paper-item: {
+          display: block;
+          border: 1px outset #0f440f;
+          margin: 0 1px 5px 0;
+          background: #0b1d0b;
+        }
+      }
+      paper-item.iron-selected {
+        background: #7b7b4a;
+      }
+    `,
+  ];
+
+  render() {
+    return html`
+      <paper-listbox id="list" selected="{{value}}" attr-for-selected="uri" on-focus-changed="selectOnFocus">
+        <paper-item on-focus="selectOnFocus">None</paper-item>
+        <template is="dom-repeat" items="{{choices}}">
+          <paper-item on-focus="selectOnFocus" uri="{{item.uri}}">{{item.label}}</paper-item>
+        </template>
+      </paper-listbox>
+    `;
+  }
+  @property() choices: Array<Choice> = [];
+  @property() value: String | null = null;
+
+  constructor() {
+    super();
+  }
+  selectOnFocus(ev) {
+    if (ev.target.uri === undefined) {
+      // *don't* clear for this, or we can't cycle through all choices (including none) with up/down keys
+      //this.clear();
+      //return;
+    }
+    this.value = ev.target.uri;
+  }
+  updated(changedProperties: PropertyValues) {
+    if (changedProperties.has("value")) {
+      if (this.value === null) {
+        this.clear();
+      }
+    }
+  }
+  onValue(value: String | null) {
+    if (value === null) {
+      this.clear();
+    }
+  }
+  clear() {
+    this.querySelectorAll("paper-item").forEach(function (item) {
+      item.blur();
+    });
+    this.value = null;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/live/Light9LiveControl.ts	Wed May 24 14:37:11 2023 -0700
@@ -0,0 +1,179 @@
+import debug from "debug";
+const log = debug("control");
+import { css, html, LitElement, PropertyPart, PropertyValues } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { NamedNode } from "n3";
+import { getTopGraph } from "../web/RdfdbSyncedGraph";
+import { SyncedGraph } from "../web/SyncedGraph";
+import { ControlValue } from "./Effect";
+import { GraphToControls } from "./GraphToControls";
+import { DeviceAttrRow } from "./Light9DeviceControl";
+import { Choice } from "./Light9Listbox";
+export { Slider } from "@material/mwc-slider";
+@customElement("light9-live-control")
+export class Light9LiveControl extends LitElement {
+  graph!: SyncedGraph;
+
+  static styles = [
+    css`
+      #colorControls {
+        display: flex;
+        align-items: center;
+      }
+      #colorControls > * {
+        margin: 0 3px;
+      }
+      #colorControls paper-slider {
+      }
+      paper-slider {
+        width: 100%;
+        height: 25px;
+      }
+
+      paper-slider {
+        --paper-slider-knob-color: var(--paper-red-500);
+        --paper-slider-active-color: var(--paper-red-500);
+
+        --paper-slider-font-color: white;
+        --paper-slider-input: {
+          width: 75px;
+
+          background: black;
+          display: inline-block;
+        }
+      }
+    `,
+  ];
+
+  render() {
+    if (this.dataType.value === "http://light9.bigasterisk.com/scalar") {
+      return html`<mwc-slider .value=${this.sliderValue} step=${1 / 255} min="0" max="1" @input=${this.onSliderInput}></mwc-slider> `;
+    } else if (this.dataType.value === "http://light9.bigasterisk.com/color") {
+      return html`
+        <div id="colorControls">
+          <button on-click="goBlack">0.0</button>
+          <light9-color-picker color="{{value}}"></light9-color-picker>
+        </div>
+      `;
+    } else if (this.dataType.value === "http://light9.bigasterisk.com/choice") {
+      return html` <light9-listbox choices="{{deviceAttrRow.choices}}" value="{{choiceValue}}"> </light9-listbox> `;
+    } else {
+      throw new Error(`${this.dataType} unknown`);
+    }
+  }
+
+  // passed from parent
+  @property() device!: NamedNode;
+  @property() dataType: NamedNode;
+  @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();
+  // }
+
+  updated(changedProperties: PropertyValues) {
+    if (changedProperties.has("graphToControls")) {
+      this.graphToControls.register(this.device, this.deviceAttrRow.uri, this.onGraphValueChanged.bind(this));
+      this.enableChange = true;
+    }
+  }
+
+  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);
+  }
+
+  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 (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) {
+    if (this.graphToControls == null || !this.enableChange) {
+      return;
+    }
+    if (value != null) {
+      value = this.graph.Uri(value);
+    } else {
+      value = null;
+    }
+    this.graphToControls.controlChanged(this.device, this.deviceAttrRow.uri, value);
+  }
+
+  onChange(value: any) {
+    if (this.graphToControls == null || !this.enableChange) {
+      return;
+    }
+    if (typeof value === "number" && isNaN(value)) {
+      return;
+    } // let onChoice do it
+    //log('change: control tells graph', @deviceAttrRow.uri.value, value)
+    if (value === undefined) {
+      value = null;
+    }
+    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.sliderValue = 0);
+  //   }
+  // }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/live/Light9LiveControls.ts	Wed May 24 14:37:11 2023 -0700
@@ -0,0 +1,195 @@
+import debug from "debug";
+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, patchContainsPreds } from "../web/patch";
+import { getTopGraph } from "../web/RdfdbSyncedGraph";
+import { SyncedGraph } from "../web/SyncedGraph";
+import { GraphToControls } from "./GraphToControls";
+export { EditChoice } from "../web/EditChoice";
+export { Light9DeviceControl as Light9LiveDeviceControl } from "./Light9DeviceControl";
+const log = debug("controls");
+
+@customElement("light9-live-controls")
+export class Light9LiveControls extends LitElement {
+  graph!: SyncedGraph;
+
+  static styles = [
+    css`
+      :host {
+        display: flex;
+        flex-direction: column;
+      }
+      #preview {
+        width: 100%;
+      }
+      #deviceControls {
+        flex-grow: 1;
+        position: relative;
+        width: 100%;
+        overflow-y: auto;
+      }
+
+      light9-live-device-control > div {
+        break-inside: avoid-column;
+      }
+      light9-live-device-control {
+      }
+    `,
+  ];
+
+  render() {
+    return html`
+      <rdfdb-synced-graph></rdfdb-synced-graph>
+
+      <h1>device control</h1>
+
+      <div id="save">
+        <div>
+          <button @click=${this.newEffect}>New effect</button>
+          <edit-choice .uri=${this.effectChoice}></edit-choice>
+          <button @click=${this.clearAll}>clear settings in this effect</button>
+        </div>
+      </div>
+      <div id="deviceControls">
+        ${this.devices.map(
+          (device: NamedNode) => html`
+            <light9-device-control .uri=${device} .effect=${this.effectChoice} .graphToControls=${this.graphToControls}></light9-device-control>
+          `
+        )}
+      </div>
+    `;
+  }
+
+  devices: Array<NamedNode> = [];
+  // uri of the effect being edited, or null. This is the
+  // master value; GraphToControls follows.
+  @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();
+    });
+  }
+
+  updated(changedProperties: PropertyValues) {
+    if (changedProperties.has("effectChoice")) {
+      log(`effectChoice to ${this.effectChoice?.value}`);
+      this.onEffectChoice();
+    }
+  }
+
+  findDevices(patch?: Patch) {
+    const U = this.graph.U();
+    if (patch && !patchContainsPreds(patch, [U("rdf:type")])) {
+      return;
+    }
+
+    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) => {
+        log(`found dev ${dev.value}`)
+        this.devices.push(dev as NamedNode);
+      });
+    });
+    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 effect in url ${effect}`);
+      this.effectChoice = this.graph.Uri(effect);
+    }
+    this.okToWriteUrl = true;
+  }
+
+  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) {
+      return;
+    }
+    u.searchParams.set("effect", effectStr); // this escapes : and / and i wish it didn't
+    window.history.replaceState({}, "", u.href);
+    log("wrote new url", u.href);
+  }
+
+  newEffect() {
+    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 {
+      if (this.graphToControls != null) {
+        this.graphToControls.setEffect(this.effectChoice);
+      } else {
+        throw new Error("graphToControls not set");
+      }
+    }
+    this.writeToUrl(this.effectChoice);
+  }
+
+  clearAll() {
+    // clears the effect!
+    return this.graphToControls.emptyEffect();
+  }
+
+  configureFromGraph() {
+    const U = (x: string) => this.graph.Uri(x);
+
+    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;
+        }
+        if (newDevs.length == 0) newDevs.push(dev);
+      }
+    }
+    log("is this called?");
+    log(`controls update now has ${newDevs.length} devices`);
+    this.devices = newDevs;
+    this.requestUpdate();
+
+    return;
+
+    // Tried css columns- big slowdown from relayout as I'm scrolling.
+    // Tried isotope- seems to only scroll to the right.
+    // Tried columnize- fails in jquery maybe from weird elements.
+
+    // not sure how to get this run after the children are created
+    return setTimeout(
+      () =>
+        $("#deviceControls").isotope({
+          // fitColumns would be nice, but it doesn't scroll vertically
+          layoutMode: "masonry",
+          containerStyle: null,
+        }),
+      2000
+    );
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/live/README.md	Wed May 24 14:37:11 2023 -0700
@@ -0,0 +1,24 @@
+This is an editor of :Effect resources, which have graphs like this:
+
+    <http://light9.bigasterisk.com/effect/effect43> a :Effect;
+    rdfs:label "effect43";
+    :publishAttr :strength;
+    :setting <http://light9.bigasterisk.com/effect/effect43_set0> .
+
+    <http://light9.bigasterisk.com/effect/effect43_set0> :device dev:strip1; :deviceAttr :color; :scaledValue 0.337 .
+
+# Objects
+
+SyncedGraph has the true data.
+
+Effect sends/receives data from one :Effect resource in the graph. Only Effect knows that there are :setting edges in the graph. Everything else on the page
+sees the effect as a list of (effect, device, deviceAttr, value) tuples. Those values are non-null. Control elements that aren't contributing the effect
+(_probably_ at their zero position, but this is not always true) have a null value.
+
+GraphToControls has a record of all the control widgets on the page, and sends/receives edits with them.
+
+We deal in ControlValue objects, which are the union of a brightness, color, choice, etc. Some layers deal in ControlValue|null. A null value means there is no
+:setting for that device+attribute
+
+SyncedGraph and GraphToControls live as long as the web page. Effect can come and go (though there is a plan to make a separate web page url per effect, then
+the Effect would live as long as the page too)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/live/index.html	Wed May 24 14:37:11 2023 -0700
@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>device control</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="../style.css" />
+    <script type="module" src="../live/Light9LiveControls"></script>
+  </head>
+  <body>
+    <style>
+      body,
+      html {
+        margin: 0;
+      }
+      light9-live-controls {
+        position: absolute;
+        left: 2px;
+        top: 2px;
+        right: 8px;
+        bottom: 0;
+      }
+    </style>
+    <light9-live-controls></light9-live-controls>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/live/vite.config.ts	Wed May 24 14:37:11 2023 -0700
@@ -0,0 +1,20 @@
+import { defineConfig } from "vite";
+
+const servicePort = 8217;
+export default defineConfig({
+  base: "/live/",
+  root: "./light9/live",
+  publicDir: "../..",
+  server: {
+    host: "0.0.0.0",
+    strictPort: true,
+    port: servicePort + 100,
+    hmr: {
+      port: servicePort + 200,
+    },
+  },
+  clearScreen: false,
+  define: {
+    global: {},
+  },
+});
--- a/light9/web/live/Effect.ts	Wed May 24 14:10:15 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,194 +0,0 @@
-import debug from "debug";
-import { Literal, NamedNode, Quad_Object, Quad_Predicate, Quad_Subject, Term } from "n3";
-import { some } from "underscore";
-import { Patch, patchContainsPreds, patchUpdate } from "../patch";
-import { SyncedGraph } from "../SyncedGraph";
-import { shortShow } from "../show_specific";
-
-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 }> = [];
-  private ctxForEffect: NamedNode
-  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
-  ) {
-    this.ctxForEffect = this.graph.Uri(this.uri.value.replace("light9.bigasterisk.com/effect", `light9.bigasterisk.com/show/${shortShow}/effect`));
-    graph.runHandler(this.rebuildSettingsFromGraph.bind(this), `effect sync ${uri.value}`);
-  }
-
-  addNewEffectToGraph() {
-    const U = this.graph.U();
-    const quad = (s: Quad_Subject, p: Quad_Predicate, o: Quad_Object) => this.graph.Quad(s, p, o, this.ctxForEffect);
-
-    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(patch?: Patch) {
-    const U = this.graph.U();
-    if (patch && !patchContainsPreds(patch, [U(":setting"), U(":device"), U(":deviceAttr")])) {
-      // that's an approx list of preds , but it just means we'll miss some pathological settings edits
-      //   return;
-    }
-
-    // log("syncFromGraph", this.uri);
-
-    // this repeats work- it gathers all settings when really some values changed (and we might even know about them). maybe push the value-fetching into a secnod phase of the run, and have the 1st phase drop out early
-    const newSettings = [];
-
-    const seenDevAttrPairs: Set<string> = new Set();
-
-    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;
-    let result = { adds: [], dels: [] };
-    for (let s of this.settings) {
-      if (device.equals(s.device) && deviceAttr.equals(s.deviceAttr)) {
-        if (existingSetting !== null) {
-          // this is corrupt. There was only supposed to be one setting per (dev,attr) pair. But we can fix it because we're going to update existingSetting to the user's requested value.
-          log(`${this.uri.value} had two settings for ${device.value} - ${deviceAttr.value} - deleting ${s.setting}`);
-          patchUpdate(result,  this._removeEffectSetting(s.setting));
-        }
-        existingSetting = s.setting;
-      }
-    }
-
-    if (newValue !== null && this.shouldBeStored(deviceAttr, newValue)) {
-      if (existingSetting === null) {
-        patchUpdate(result, this._addEffectSetting(device, deviceAttr, newValue));
-      } else {
-        patchUpdate(result, this._patchExistingEffectSetting(existingSetting, deviceAttr, newValue));
-      }
-    } else {
-      if (existingSetting !== null) {
-        patchUpdate(result, this._removeEffectSetting(existingSetting));
-      }
-    }
-    return result;
-  }
-
-  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 quad = (s: Quad_Subject, p: Quad_Predicate, o: Quad_Object) => this.graph.Quad(s, p, o, this.ctxForEffect);
-    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	Wed May 24 14:10:15 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,69 +0,0 @@
-import debug from "debug";
-import { NamedNode } from "n3";
-import { SyncedGraph } from "../SyncedGraph";
-import { ControlValue, Effect } from "./Effect";
-const log = debug("g2c");
-
-type NewValueCb = (newValue: ControlValue | null) => void;
-
-// More efficient bridge between liveControl widgets and graph edits (inside Effect),
-// as opposed to letting each widget scan the graph and push lots of
-// tiny patches to it.
-export class GraphToControls {
-  // 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) {
-    log(`setEffect ${effect?.value}`);
-    this.effect = effect ? new Effect(this.graph, effect, this.onValuesChanged.bind(this)) : null;
-  }
-
-  newEffect(): NamedNode {
-    // wrong- this should be our editor's scratch effect, promoted to a
-    // real one when you name it.
-    const uri = this.graph.nextNumberedResource(this.graph.Uri("http://light9.bigasterisk.com/effect/effect"));
-
-    this.effect = new Effect(this.graph, uri, this.onValuesChanged.bind(this));
-    log("add new eff");
-    this.effect.addNewEffectToGraph();
-    return this.effect.uri;
-  }
-
-  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);
-      });
-    });
-  }
-
-  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);
-
-    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;
-    }
-    const p = this.effect.edit(device, deviceAttr, value);
-    this.graph.applyAndSendPatch(p);
-  }
-}
--- a/light9/web/live/Light9DeviceControl.ts	Wed May 24 14:10:15 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,221 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import { NamedNode } from "n3";
-import { unique } from "underscore";
-import { Patch, patchContainsPreds } from "../patch";
-import { getTopGraph } from "../RdfdbSyncedGraph";
-import { SyncedGraph } from "../SyncedGraph";
-import { GraphToControls } from "./GraphToControls";
-import { Choice } from "./Light9Listbox";
-import { Light9LiveControl } from "./Light9LiveControl";
-export { ResourceDisplay } from "../ResourceDisplay";
-export { Light9LiveControl };
-const log = debug("devcontrol");
-
-export interface DeviceAttrRow {
-  uri: NamedNode; //devattr
-  attrClasses: string; // the css kind
-  dataType: NamedNode;
-  showColorPicker: boolean;
-  useColor: boolean;
-  useChoice: boolean;
-  choices: Choice[];
-  choiceSize: number;
-  useSlider: boolean;
-  max: number;
-}
-
-@customElement("light9-device-control")
-export class Light9DeviceControl extends LitElement {
-  graph!: SyncedGraph;
-  static styles = [
-    css`
-      :host {
-        display: inline-block;
-      }
-      .device {
-        border: 2px solid #151e2d;
-        margin: 4px;
-        padding: 1px;
-        background: #171717; /* deviceClass gradient added later */
-        break-inside: avoid-column;
-        width: 335px;
-      }
-      .deviceAttr {
-        border-top: 1px solid #272727;
-        padding-bottom: 2px;
-        display: flex;
-      }
-      .deviceAttr > span {
-      }
-      .deviceAttr > light9-live-control {
-        flex-grow: 1;
-      }
-      h2 {
-        font-size: 110%;
-        padding: 4px;
-        margin-top: 0;
-        margin-bottom: 0;
-      }
-      .device,
-      h2 {
-        border-top-right-radius: 15px;
-      }
-
-      #mainLabel {
-        font-size: 120%;
-        color: #9ab8fd;
-        text-decoration: initial;
-      }
-      .device.selected h2 {
-        outline: 3px solid #ffff0047;
-      }
-      .deviceAttr.selected {
-        background: #cada1829;
-      }
-    `,
-  ];
-
-  render() {
-    return html`
-      <div class="device ${this.devClasses}">
-        <h2 style="${this._bgStyle(this.deviceClass)}" xon-click="onClick">
-          <resource-display id="mainLabel" .uri="${this.uri}"></resource-display>
-          a <resource-display minor .uri="${this.deviceClass}"></resource-display>
-        </h2>
-        ${this.deviceAttrs.map(
-          (dattr: DeviceAttrRow) => html`
-            <div xon-click="onAttrClick" class="deviceAttr ${dattr.attrClasses}">
-              <span>attr <resource-display minor .uri="${dattr.uri}"></resource-display></span>
-              <light9-live-control
-                .device="${this.uri}"
-                .deviceAttrRow="${dattr}"
-                .effect="${this.effect}"
-                .graphToControls="${this.graphToControls}"
-              ></light9-live-control>
-            </div>
-          `
-        )}
-      </div>
-    `;
-  }
-
-  @property() uri!: NamedNode;
-  @property() effect!: NamedNode;
-  @property() graphToControls!: GraphToControls;
-
-  @property() devClasses: string = ""; // the css kind
-  @property() deviceAttrs: DeviceAttrRow[] = [];
-  @property() deviceClass: NamedNode | null = null;
-  @property() selectedAttrs: Set<NamedNode> = new Set();
-
-  constructor() {
-    super();
-    getTopGraph().then((g) => {
-      this.graph = g;
-      this.graph.runHandler(this.syncDeviceAttrsFromGraph.bind(this), `${this.uri.value} update`);
-    });
-    this.selectedAttrs = new Set();
-  }
-
-  _bgStyle(deviceClass: NamedNode | null): string {
-    if (!deviceClass) return "";
-    let hash = 0;
-    const u = deviceClass.value;
-    for (let i = u.length - 10; i < u.length; i++) {
-      hash += u.charCodeAt(i);
-    }
-    const hue = (hash * 8) % 360;
-    const accent = `hsl(${hue}, 49%, 22%)`;
-    return `background: linear-gradient(to right, rgba(31,31,31,0) 50%, ${accent} 100%);`;
-  }
-
-  setDeviceSelected(isSel: any) {
-    this.devClasses = isSel ? "selected" : "";
-  }
-
-  setAttrSelected(devAttr: NamedNode, isSel: boolean) {
-    if (isSel) {
-      this.selectedAttrs.add(devAttr);
-    } else {
-      this.selectedAttrs.delete(devAttr);
-    }
-    // this.syncDeviceAttrsFromGraph();
-  }
-
-  syncDeviceAttrsFromGraph(patch?: Patch) {
-    const U = this.graph.U();
-    if (patch && !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))
-    );
-    this.requestUpdate();
-  }
-
-  attrRow(devAttr: NamedNode): DeviceAttrRow {
-    let x: NamedNode;
-    const U = (x: string) => this.graph.Uri(x);
-    const dataType = this.graph.uriValue(devAttr, U(":dataType"));
-    const daRow = {
-      uri: devAttr,
-      dataType,
-      showColorPicker: dataType.equals(U(":color")),
-      attrClasses: this.selectedAttrs.has(devAttr) ? "selected" : "",
-      useColor: false,
-      useChoice: false,
-      choices: [] as Choice[],
-      choiceSize: 0,
-      useSlider: false,
-      max: 1,
-    };
-    if (dataType.equals(U(":color"))) {
-      daRow.useColor = true;
-    } else if (dataType.equals(U(":choice"))) {
-      daRow.useChoice = true;
-      const choiceUris = this.graph.sortedUris(this.graph.objects(devAttr, U(":choice")));
-      daRow.choices = (() => {
-        const result = [];
-        for (x of Array.from(choiceUris)) {
-          result.push({ uri: x.value, label: this.graph.labelOrTail(x) });
-        }
-        return result;
-      })();
-      daRow.choiceSize = Math.min(choiceUris.length + 1, 10);
-    } else {
-      daRow.useSlider = true;
-      daRow.max = 1;
-      if (dataType.equals(U(":angle"))) {
-        // varies
-        daRow.max = 1;
-      }
-    }
-    return daRow;
-  }
-
-  clear() {
-    // 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());
-  }
-
-  onClick(ev: any) {
-    log("click", this.uri);
-    // select, etc
-  }
-
-  onAttrClick(ev: { model: { dattr: { uri: any } } }) {
-    log("attr click", this.uri, ev.model.dattr.uri);
-    // select
-  }
-}
--- a/light9/web/live/Light9Listbox.ts	Wed May 24 14:10:15 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,75 +0,0 @@
-import debug from "debug";
-const log = debug("listbox");
-import { css, html, LitElement, PropertyValues } from "lit";
-import { customElement, property } from "lit/decorators.js";
-export type Choice = {uri:string,label:string}
-@customElement("light9-listbox")
-export class Light9Listbox extends LitElement {
-  static styles = [
-    css`
-      paper-listbox {
-        --paper-listbox-background-color: none;
-        --paper-listbox-color: white;
-        --paper-listbox: {
-          /* measure biggest item? use flex for columns? */
-          column-width: 9em;
-        }
-      }
-      paper-item {
-        --paper-item-min-height: 0;
-        --paper-item: {
-          display: block;
-          border: 1px outset #0f440f;
-          margin: 0 1px 5px 0;
-          background: #0b1d0b;
-        }
-      }
-      paper-item.iron-selected {
-        background: #7b7b4a;
-      }
-    `,
-  ];
-
-  render() {
-    return html`
-      <paper-listbox id="list" selected="{{value}}" attr-for-selected="uri" on-focus-changed="selectOnFocus">
-        <paper-item on-focus="selectOnFocus">None</paper-item>
-        <template is="dom-repeat" items="{{choices}}">
-          <paper-item on-focus="selectOnFocus" uri="{{item.uri}}">{{item.label}}</paper-item>
-        </template>
-      </paper-listbox>
-    `;
-  }
-  @property() choices: Array<Choice> = [];
-  @property() value: String | null = null;
-
-  constructor() {
-    super();
-  }
-  selectOnFocus(ev) {
-    if (ev.target.uri === undefined) {
-      // *don't* clear for this, or we can't cycle through all choices (including none) with up/down keys
-      //this.clear();
-      //return;
-    }
-    this.value = ev.target.uri;
-  }
-  updated(changedProperties: PropertyValues) {
-    if (changedProperties.has("value")) {
-      if (this.value === null) {
-        this.clear();
-      }
-    }
-  }
-  onValue(value: String | null) {
-    if (value === null) {
-      this.clear();
-    }
-  }
-  clear() {
-    this.querySelectorAll("paper-item").forEach(function (item) {
-      item.blur();
-    });
-    this.value = null;
-  }
-}
--- a/light9/web/live/Light9LiveControl.ts	Wed May 24 14:10:15 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,179 +0,0 @@
-import debug from "debug";
-const log = debug("control");
-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;
-
-  static styles = [
-    css`
-      #colorControls {
-        display: flex;
-        align-items: center;
-      }
-      #colorControls > * {
-        margin: 0 3px;
-      }
-      #colorControls paper-slider {
-      }
-      paper-slider {
-        width: 100%;
-        height: 25px;
-      }
-
-      paper-slider {
-        --paper-slider-knob-color: var(--paper-red-500);
-        --paper-slider-active-color: var(--paper-red-500);
-
-        --paper-slider-font-color: white;
-        --paper-slider-input: {
-          width: 75px;
-
-          background: black;
-          display: inline-block;
-        }
-      }
-    `,
-  ];
-
-  render() {
-    if (this.dataType.value === "http://light9.bigasterisk.com/scalar") {
-      return html`<mwc-slider .value=${this.sliderValue} step=${1 / 255} min="0" max="1" @input=${this.onSliderInput}></mwc-slider> `;
-    } else if (this.dataType.value === "http://light9.bigasterisk.com/color") {
-      return html`
-        <div id="colorControls">
-          <button on-click="goBlack">0.0</button>
-          <light9-color-picker color="{{value}}"></light9-color-picker>
-        </div>
-      `;
-    } else if (this.dataType.value === "http://light9.bigasterisk.com/choice") {
-      return html` <light9-listbox choices="{{deviceAttrRow.choices}}" value="{{choiceValue}}"> </light9-listbox> `;
-    } else {
-      throw new Error(`${this.dataType} unknown`);
-    }
-  }
-
-  // passed from parent
-  @property() device!: NamedNode;
-  @property() dataType: NamedNode;
-  @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();
-  // }
-
-  updated(changedProperties: PropertyValues) {
-    if (changedProperties.has("graphToControls")) {
-      this.graphToControls.register(this.device, this.deviceAttrRow.uri, this.onGraphValueChanged.bind(this));
-      this.enableChange = true;
-    }
-  }
-
-  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);
-  }
-
-  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 (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) {
-    if (this.graphToControls == null || !this.enableChange) {
-      return;
-    }
-    if (value != null) {
-      value = this.graph.Uri(value);
-    } else {
-      value = null;
-    }
-    this.graphToControls.controlChanged(this.device, this.deviceAttrRow.uri, value);
-  }
-
-  onChange(value: any) {
-    if (this.graphToControls == null || !this.enableChange) {
-      return;
-    }
-    if (typeof value === "number" && isNaN(value)) {
-      return;
-    } // let onChoice do it
-    //log('change: control tells graph', @deviceAttrRow.uri.value, value)
-    if (value === undefined) {
-      value = null;
-    }
-    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.sliderValue = 0);
-  //   }
-  // }
-}
--- a/light9/web/live/Light9LiveControls.ts	Wed May 24 14:10:15 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,195 +0,0 @@
-import debug from "debug";
-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, patchContainsPreds } from "../patch";
-import { getTopGraph } from "../RdfdbSyncedGraph";
-import { SyncedGraph } from "../SyncedGraph";
-import { GraphToControls } from "./GraphToControls";
-export { EditChoice } from "../EditChoice";
-export { Light9DeviceControl as Light9LiveDeviceControl } from "./Light9DeviceControl";
-const log = debug("controls");
-
-@customElement("light9-live-controls")
-export class Light9LiveControls extends LitElement {
-  graph!: SyncedGraph;
-
-  static styles = [
-    css`
-      :host {
-        display: flex;
-        flex-direction: column;
-      }
-      #preview {
-        width: 100%;
-      }
-      #deviceControls {
-        flex-grow: 1;
-        position: relative;
-        width: 100%;
-        overflow-y: auto;
-      }
-
-      light9-live-device-control > div {
-        break-inside: avoid-column;
-      }
-      light9-live-device-control {
-      }
-    `,
-  ];
-
-  render() {
-    return html`
-      <rdfdb-synced-graph></rdfdb-synced-graph>
-
-      <h1>device control</h1>
-
-      <div id="save">
-        <div>
-          <button @click=${this.newEffect}>New effect</button>
-          <edit-choice .uri=${this.effectChoice}></edit-choice>
-          <button @click=${this.clearAll}>clear settings in this effect</button>
-        </div>
-      </div>
-      <div id="deviceControls">
-        ${this.devices.map(
-          (device: NamedNode) => html`
-            <light9-device-control .uri=${device} .effect=${this.effectChoice} .graphToControls=${this.graphToControls}></light9-device-control>
-          `
-        )}
-      </div>
-    `;
-  }
-
-  devices: Array<NamedNode> = [];
-  // uri of the effect being edited, or null. This is the
-  // master value; GraphToControls follows.
-  @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();
-    });
-  }
-
-  updated(changedProperties: PropertyValues) {
-    if (changedProperties.has("effectChoice")) {
-      log(`effectChoice to ${this.effectChoice?.value}`);
-      this.onEffectChoice();
-    }
-  }
-
-  findDevices(patch?: Patch) {
-    const U = this.graph.U();
-    if (patch && !patchContainsPreds(patch, [U("rdf:type")])) {
-      return;
-    }
-
-    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) => {
-        log(`found dev ${dev.value}`)
-        this.devices.push(dev as NamedNode);
-      });
-    });
-    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 effect in url ${effect}`);
-      this.effectChoice = this.graph.Uri(effect);
-    }
-    this.okToWriteUrl = true;
-  }
-
-  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) {
-      return;
-    }
-    u.searchParams.set("effect", effectStr); // this escapes : and / and i wish it didn't
-    window.history.replaceState({}, "", u.href);
-    log("wrote new url", u.href);
-  }
-
-  newEffect() {
-    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 {
-      if (this.graphToControls != null) {
-        this.graphToControls.setEffect(this.effectChoice);
-      } else {
-        throw new Error("graphToControls not set");
-      }
-    }
-    this.writeToUrl(this.effectChoice);
-  }
-
-  clearAll() {
-    // clears the effect!
-    return this.graphToControls.emptyEffect();
-  }
-
-  configureFromGraph() {
-    const U = (x: string) => this.graph.Uri(x);
-
-    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;
-        }
-        if (newDevs.length == 0) newDevs.push(dev);
-      }
-    }
-    log("is this called?");
-    log(`controls update now has ${newDevs.length} devices`);
-    this.devices = newDevs;
-    this.requestUpdate();
-
-    return;
-
-    // Tried css columns- big slowdown from relayout as I'm scrolling.
-    // Tried isotope- seems to only scroll to the right.
-    // Tried columnize- fails in jquery maybe from weird elements.
-
-    // not sure how to get this run after the children are created
-    return setTimeout(
-      () =>
-        $("#deviceControls").isotope({
-          // fitColumns would be nice, but it doesn't scroll vertically
-          layoutMode: "masonry",
-          containerStyle: null,
-        }),
-      2000
-    );
-  }
-}
--- a/light9/web/live/README.md	Wed May 24 14:10:15 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,24 +0,0 @@
-This is an editor of :Effect resources, which have graphs like this:
-
-    <http://light9.bigasterisk.com/effect/effect43> a :Effect;
-    rdfs:label "effect43";
-    :publishAttr :strength;
-    :setting <http://light9.bigasterisk.com/effect/effect43_set0> .
-
-    <http://light9.bigasterisk.com/effect/effect43_set0> :device dev:strip1; :deviceAttr :color; :scaledValue 0.337 .
-
-# Objects
-
-SyncedGraph has the true data.
-
-Effect sends/receives data from one :Effect resource in the graph. Only Effect knows that there are :setting edges in the graph. Everything else on the page
-sees the effect as a list of (effect, device, deviceAttr, value) tuples. Those values are non-null. Control elements that aren't contributing the effect
-(_probably_ at their zero position, but this is not always true) have a null value.
-
-GraphToControls has a record of all the control widgets on the page, and sends/receives edits with them.
-
-We deal in ControlValue objects, which are the union of a brightness, color, choice, etc. Some layers deal in ControlValue|null. A null value means there is no
-:setting for that device+attribute
-
-SyncedGraph and GraphToControls live as long as the web page. Effect can come and go (though there is a plan to make a separate web page url per effect, then
-the Effect would live as long as the page too)
--- a/light9/web/live/index.html	Wed May 24 14:10:15 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,25 +0,0 @@
-<!DOCTYPE html>
-<html>
-  <head>
-    <title>device control</title>
-    <meta charset="utf-8" />
-    <link rel="stylesheet" href="../style.css" />
-    <script type="module" src="../live/Light9LiveControls"></script>
-  </head>
-  <body>
-    <style>
-      body,
-      html {
-        margin: 0;
-      }
-      light9-live-controls {
-        position: absolute;
-        left: 2px;
-        top: 2px;
-        right: 8px;
-        bottom: 0;
-      }
-    </style>
-    <light9-live-controls></light9-live-controls>
-  </body>
-</html>
--- a/light9/web/live/vite.config.ts	Wed May 24 14:10:15 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,20 +0,0 @@
-import { defineConfig } from "vite";
-
-const servicePort = 8217;
-export default defineConfig({
-  base: "/live/",
-  root: "./light9/web/live",
-  publicDir: "../web",
-  server: {
-    host: "0.0.0.0",
-    strictPort: true,
-    port: servicePort + 100,
-    hmr: {
-      port: servicePort + 200,
-    },
-  },
-  clearScreen: false,
-  define: {
-    global: {},
-  },
-});