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