Mercurial > code > home > repos > light9
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: {}, - }, -});