# HG changeset patch # User drewp@bigasterisk.com # Date 2022-05-25 07:06:00 # Node ID c57cf4049004feaa36a157798709f9f67f44fda4 # Parent 6eb1fcbad5f66152294949739b4df8091fd1ef35 dice up the live/ elements and code into ts files (no conversion yet except auto coffee->ts) diff --git a/light9/web/live/ActiveSettings.ts b/light9/web/live/ActiveSettings.ts new file mode 100644 --- /dev/null +++ b/light9/web/live/ActiveSettings.ts @@ -0,0 +1,98 @@ +class ActiveSettings { + graph: any; + settings: any; + keyForSetting: any; + 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: { value: string; }, deviceAttr: { value: string; }, setting: { value: string; }, 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 row = this.settings.get(this.keyForSetting.get(setting.value)); + row.jsValue = value; + if (row.onChangeFunc != null) { return row.onChangeFunc(value); } + } + + registerWidget(device: { value: string; }, deviceAttr: { value: string; }, graphValueChanged: any) { + const key = device.value + " " + deviceAttr.value; + this.onChanged[key] = graphValueChanged; + + if (this.settings.has(key)) { + const row = this.settings.get(key); + row.onChangeFunc = graphValueChanged; + return row.onChangeFunc(row.jsValue); + } + } + + effectSettingLookup(device: { value: string; }, attr: { value: string; }) { + const key = device.value + " " + attr.value; + if (this.settings.has(key)) { + return this.settings.get(key).setting; + } + + return null; + } + + deleteSetting(setting: { value: string; }) { + log('deleteSetting ' + setting.value); + const key = this.keyForSetting.get(setting.value); + const row = this.settings.get(key); + if ((row != null) && !row.setting.equals(setting)) { + throw new Error('corrupt row for ' + setting.value); + } + if ((row != null ? row.onChangeFunc : undefined) != null) { row.onChangeFunc(null); } + this.settings.delete(key); + return this.keyForSetting.delete(setting); + } + + clear() { + new Map(this.settings).forEach(function (row: { onChangeFunc: (arg0: any) => any; }, key: any) { + if (row.onChangeFunc != null) { return row.onChangeFunc(null); } + }); + this.settings.clear(); + return 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/GraphToControls.ts b/light9/web/live/GraphToControls.ts new file mode 100644 --- /dev/null +++ b/light9/web/live/GraphToControls.ts @@ -0,0 +1,207 @@ + +const valuePred = function(graph: { Uri: (arg0: any) => any; }, attr: { equals: (arg0: any) => any; }) { + const U = (x: string) => graph.Uri(x); + const scaledAttributeTypes = [U(':color'), U(':brightness'), U(':uv')]; + if (_.some(scaledAttributeTypes, + ( x: any) => attr.equals(x))) { return U(':scaledValue'); } else { return U(':value'); } +}; + +const log = debug('live'); + +// Like element.set(path, newArray), but minimizes splices. +// Dotted paths don't work yet. +const syncArray = function(element: this, path: string, newArray: { length?: any; }, isElementEqual: { (a: any, b: any): boolean; (arg0: any, arg1: any): any; }) { + let pos = 0; + let newPos = 0; + + 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); + } +}; + + +class GraphToControls { + graph: any; + activeSettings: ActiveSettings; + effect: any; + ctx: any; + // 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(graph: any) { + this.graph = graph; + this.activeSettings = new ActiveSettings(this.graph); + this.effect = null; + } + + ctxForEffect(effect: { value: { replace: (arg0: string, arg1: string) => any; }; }) { + return this.graph.Uri(effect.value.replace( + "light9.bigasterisk.com/effect", + "light9.bigasterisk.com/show/dance2019/effect")); + } + + setEffect(effect: any) { + this.clearSettings(); + this.effect = effect; + this.ctx = this.ctxForEffect(this.effect); + // are these going to pile up? consider @graph.triggerHandler('GTC sync') + return this.graph.runHandler(this.syncFromGraph.bind(this), 'GraphToControls sync'); + } + + newEffect() { + // wrong- this should be our editor's scratch effect, promoted to a + // real one when you name it. + const U = (x: string) => this.graph.Uri(x); + const effect = this.graph.nextNumberedResource(U('http://light9.bigasterisk.com/effect/effect')); + const ctx = this.ctxForEffect(effect); + const quad = (s: any, p: any, o: any) => this.graph.Quad(s, p, o, ctx); + + 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 = { addQuads, delQuads: [] }; + log('init new effect', patch); + this.graph.applyAndSendPatch(patch); + return effect; + } + + syncFromGraph() { + const U = (x: string) => this.graph.Uri(x); + 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')))) { + 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))); + } + + clearSettings() { + return this.activeSettings.clear(); + } + + register(device: any, deviceAttr: any, graphValueChanged: any) { + return this.activeSettings.registerWidget(device, deviceAttr, graphValueChanged); + } + + shouldBeStored(deviceAttr: any, value: string | number) { + // 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)); + } + + controlChanged(device: any, deviceAttr: any, value: string) { + // todo: controls should be disabled if there's no effect and they won't do anything. + if (!this.effect) { return; } + + // value is float or #color or (Uri or null) + if ((value === undefined) || ((typeof value === "number") && isNaN(value)) || ((typeof value === "object") && (value !== null) && !value.id)) { + throw new Error("controlChanged sent bad value " + value); + } + 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 { + return this._removeEffectSetting(effectSetting); + } + } + + _nodeForValue(value: { id: any; }) { + if (value.id != null) { + return value; + } + return this.graph.prettyLiteral(value); + } + + _addEffectSetting(device: any, deviceAttr: { value: any; }, value: any) { + log('change: _addEffectSetting', deviceAttr.value, value); + const U = (x: string) => this.graph.Uri(x); + const quad = (s: any, p: any, o: any) => this.graph.Quad(s, p, o, this.ctx); + 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 = { addQuads, delQuads: [] }; + log('save', patch); + return this.graph.applyAndSendPatch(patch); + } + + _patchExistingEffectSetting(effectSetting: { value: any; }, deviceAttr: any, value: any) { + 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: { value: any; }) { + const U = (x: string) => this.graph.Uri(x); + const quad = (s: any, p: any, o: any) => this.graph.Quad(s, p, o, this.ctx); + if (effectSetting != null) { + log('change: _removeEffectSetting', effectSetting.value); + const toDel = [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({ delQuads: toDel, addQuads: [] }); + return this.activeSettings.deleteSetting(effectSetting); + } + } +} diff --git a/light9/web/live/Light9Listbox.ts b/light9/web/live/Light9Listbox.ts new file mode 100644 --- /dev/null +++ b/light9/web/live/Light9Listbox.ts @@ -0,0 +1,72 @@ + + + + \ No newline at end of file diff --git a/light9/web/live/Light9LiveControl.ts b/light9/web/live/Light9LiveControl.ts new file mode 100644 --- /dev/null +++ b/light9/web/live/Light9LiveControl.ts @@ -0,0 +1,161 @@ + + + + + + +const coffeeElementSetupLight9LiveControl = (function() { + class Light9LiveControl extends Polymer.Element { + static is: string; + static getter_properties: { + graph: { type: any; notify: boolean; }; device: { type: any; }; deviceAttrRow: { type: any; }; // object returned from attrRow, below + value: { type: any; notify: boolean; }; // null, Uri, float, str + choiceValue: { type: any; }; immediateSlider: { notify: boolean; observer: string; }; sliderWriteValue: { ...; }; pickedChoice: { ...; }; graphToControls: { ...; }; + }; + static getter_observers: {}; + enableChange: boolean; + value: any; + immediateSlider: any; + deviceAttrRow: any; + sliderWriteValue: { value: any; }; + choiceValue: any; + graphToControls: any; + graph: any; + pickedChoice: any; + static initClass() { + this.is = 'light9-live-control'; + this.getter_properties = { + graph: { type: Object, notify: true }, + device: { type: Object }, + deviceAttrRow: { type: Object }, // object returned from attrRow, below + value: { type: Object, notify: true }, // null, Uri, float, str + choiceValue: { type: Object }, + + immediateSlider: { notify: true, observer: 'onSlider' }, + sliderWriteValue: { type: Number }, + + pickedChoice: { observer: 'onChange' }, + graphToControls: { type: Object } + }; + this.getter_observers = [ + 'onChange(value)', + 'onGraphToControls(graphToControls)', + 'onChoice(choiceValue)' + ]; + } + constructor() { + super(); + this.enableChange = false; // until 1st graph read + } + 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."); + } + + graphValueChanged(v: { value: any, }) { + log('change: control gets', v); + this.enableChange = false; + if (v === null) { + this.clear(); + } else { + this.value = v; + } + if (this.deviceAttrRow.useSlider) { this.sliderWriteValue = v; } + if (this.deviceAttrRow.useChoice) { this.choiceValue = (v === null ? v : v.value); } + return this.enableChange = true; + } + + onChoice(value: any) { + if ((this.graphToControls == null) || !this.enableChange) { return; } + if (value != null) { + value = this.graph.Uri(value); + } else { + value = null; + } + return 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; + } + return 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); + } + } + } + + Light9LiveControl.initClass(); + return Light9LiveControl; + })(); + \ No newline at end of file diff --git a/light9/web/live/Light9LiveControls.ts b/light9/web/live/Light9LiveControls.ts new file mode 100644 --- /dev/null +++ b/light9/web/live/Light9LiveControls.ts @@ -0,0 +1,178 @@ + + + + + +const coffeeElementSetupLight9LiveControls = (function() { + class Light9LiveControls extends Polymer.Element { + static is: string; + static getter_properties: { + graph: { type: any; notify: boolean; }; devices: { type: any; notify: boolean; value: {}; }; + // string uri of the effect being edited, or null. This is the + // master value; GraphToControls follows. + effectChoice: { type: any; notify: boolean; value: any; }; graphToControls: { type: any; }; + }; + static getter_observers: {}; + graphToControls: any; + okToWriteUrl: boolean; + currentSettings: {}; + graph: any; + effectChoice: any; + static initClass() { + this.is = "light9-live-controls"; + this.getter_properties = { + graph: { type: Object, notify: true }, + devices: { type: Array, notify: true, value: [] }, + // string uri of the effect being edited, or null. This is the + // master value; GraphToControls follows. + effectChoice: { type: String, notify: true, value: null }, + graphToControls: { type: Object } + }; + this.getter_observers = [ + 'onGraph(graph)', + 'onEffectChoice(effectChoice)' + ]; + } + + constructor() { + super(); + this.graphToControls = null; + this.okToWriteUrl = false; + } + + ready() { + super.ready(...arguments).ready(); + return this.currentSettings = {}; + } + + onGraph() { + this.graphToControls = new GraphToControls(this.graph); + this.graph.runHandler(this.update.bind(this), 'Light9LiveControls update'); + + // need graph to be loaded, so we don't make double settings? not sure. + return setTimeout(this.setFromUrl.bind(this), 1); + } + + setFromUrl() { + // 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); + this.effectChoice = effect; + } + return this.okToWriteUrl = true; + } + + writeToUrl(effectStr: any) { + if (!this.okToWriteUrl) { return; } + const u = new URL(window.location.href); + if (u.searchParams.get('effect') === effectStr) { + return; + } + u.searchParams.set('effect', effectStr); + window.history.replaceState({}, "", u.href); + return log('wrote new url', u.href); + } + + newEffect() { + return this.effectChoice = this.graphToControls.newEffect().value; + } + + onEffectChoice() { + const U = (x: any) => this.graph.Uri(x); + if ((this.effectChoice == null)) { + // unlink + if (this.graphToControls != null) { this.graphToControls.setEffect(null); } + } else { + log('load', this.effectChoice); + if (this.graphToControls != null) { this.graphToControls.setEffect(this.graph.Uri(this.effectChoice)); } + } + return this.writeToUrl(this.effectChoice); + } + + clearAll() { + // clears the effect! + return this.graphToControls.emptyEffect(); + } + + update() { + const U = (x: string) => this.graph.Uri(x); + + const newDevs = []; + 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}); + } + } + + //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); + + 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); + } + } + + Light9LiveControls.initClass(); + return Light9LiveControls; + })(); \ No newline at end of file diff --git a/light9/web/live/Light9LiveDeviceControl.ts b/light9/web/live/Light9LiveDeviceControl.ts new file mode 100644 --- /dev/null +++ b/light9/web/live/Light9LiveDeviceControl.ts @@ -0,0 +1,200 @@ + + + + + +const coffeeElementSetupLight9LiveDeviceControl = (function() { + class Light9LiveDeviceControl extends Polymer.Element { + static is: string; + static getter_properties: { + graph: { type: any; notify: boolean; }; uri: { type: any; notify: boolean; }; effect: { type: any; }; deviceClass: { type: any; notify: boolean; }; // the uri str + deviceAttrs: { type: any; notify: boolean; }; graphToControls: { ...; }; bgStyle: { ...; }; devClasses: { ...; }; // the css kind + }; + static getter_observers: {}; + selectedAttrs: any; + graph: any; + uri: any; + devClasses: string; + deviceClass: any; + deviceAttrs: {}; + shadowRoot: any; + static initClass() { + this.is = "light9-live-device-control"; + this.getter_properties = { + graph: { type: Object, notify: true }, + uri: { type: String, notify: true }, + effect: { type: String }, + deviceClass: { type: String, notify: true }, // the uri str + deviceAttrs: { type: Array, notify: true }, + graphToControls: { type: Object }, + bgStyle: { type: String, computed: '_bgStyle(deviceClass)' }, + devClasses: { type: String, value: '' } // the css kind + }; + this.getter_observers = [ + 'onGraph(graph)' + ]; + } + constructor() { + super(); + this.selectedAttrs = new Set(); // uri strings + } + _bgStyle(deviceClass: { value: any, length: number, charCodeAt: (arg0: number) => number, }) { + let hash = 0; + deviceClass = deviceClass.value; + for (let start = deviceClass.length-10, i = start, end = deviceClass.length, asc = start <= end; asc ? i < end : i > end; asc ? i++ : i--) { + hash += deviceClass.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%);`; + } + + onGraph() { + return this.graph.runHandler(this.update.bind(this), `${this.uri.value} update`); + } + + setDeviceSelected(isSel: any) { + return this.devClasses = isSel ? 'selected' : ''; + } + + setAttrSelected(devAttr: { value: any, }, isSel: any) { + if (isSel) { + this.selectedAttrs.add(devAttr.value); + } else { + this.selectedAttrs.delete(devAttr.value); + } + return this.update(); + } + + update(patch: null) { + const U = (x: string) => this.graph.Uri(x); + if ((patch != null) && !SyncedGraph.patchContainsPreds( + patch, [U('rdf:type'), U(':deviceAttr'), U(':dataType'), U(':choice')])) { return; } + this.deviceClass = this.graph.uriValue(this.uri, U('rdf:type')); + this.deviceAttrs = []; + return Array.from(_.unique(this.graph.sortedUris(this.graph.objects(this.deviceClass, U(':deviceAttr'))))).map((da: any) => + this.push('deviceAttrs', this.attrRow(da))); + } + push(arg0: string, arg1: { uri: { value: any, }, dataType: any, showColorPicker: any, attrClasses: string, }) { + throw new Error("Method not implemented."); + } + + attrRow(devAttr: { value: any, }) { + let x: { value: any; }; + 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.value) ? 'selected' : '' + }; + 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() { + return Array.from(this.shadowRoot.querySelectorAll("light9-live-control")).map((lc: { clear: () => any; }) => + lc.clear()); + } + + onClick(ev: any) { + return log('click', this.uri); + } + // select, etc + + onAttrClick(ev: { model: { dattr: { uri: any, }, }, }) { + return log('attr click', this.uri, ev.model.dattr.uri); + } + } + // select + + Light9LiveDeviceControl.initClass(); + return Light9LiveDeviceControl; + })(); + + \ No newline at end of file diff --git a/light9/web/live/elements.html b/light9/web/live/elements.html deleted file mode 100644 --- a/light9/web/live/elements.html +++ /dev/null @@ -1,279 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/light9/web/live/index.html b/light9/web/live/index.html --- a/light9/web/live/index.html +++ b/light9/web/live/index.html @@ -4,8 +4,6 @@ device control - -