+
+
${this.conf.value.toPrecision(3)}
+
effect
+
attr
+ `;
+ }
+
+ graph?: SyncedGraph;
+ ctx: NamedNode = new NamedNode(showRoot + "/fade");
+ @property() uri!: NamedNode;
+ @state() conf?: EffectFader; // compiled from graph
+
+ constructor() {
+ super();
+ getTopGraph().then((g) => {
+ this.graph = g;
+ this.graph.runHandler(this.compile.bind(this, this.graph), `fader config ${this.uri.value}`);
+ });
+ }
+
+ private compile(graph: SyncedGraph) {
+ const U = graph.U();
+ this.conf = undefined;
+
+ const conf = new EffectFader(this.uri);
+
+ if (!graph.contains(this.uri, U("rdf:type"), U(":Fader"))) {
+ // not loaded yet, perhaps
+ return;
+ }
+
+ conf.column = maybeStringValue(graph, this.uri, U(":column")) || "unset";
+ conf.effect = maybeUriValue(graph, this.uri, U(":effect"));
+ conf.effectAttr = get2Step(RETURN_URI, graph, this.uri, U(":setting"), U(":effectAttr"));
+
+ this.conf = conf;
+ graph.runHandler(this.compileValue.bind(this, graph, this.conf), `fader config.value ${this.uri.value}`);
+ }
+
+ private compileValue(graph: SyncedGraph, conf: EffectFader) {
+ // external graph change -> conf.value
+ const U = graph.U();
+ conf.value = get2Step(RETURN_FLOAT, graph, this.uri, U(":setting"), U(":value"));
+ // since conf attrs aren't watched as property:
+ this.requestUpdate()
+ }
+
+ onSliderInput(ev: CustomEvent) {
+ // slider user input -> graph
+ if (this.conf === undefined) return;
+ this.conf.value = ev.detail.value
+ this.writeValueToGraph()
+ }
+
+ writeValueToGraph() {
+ // this.value -> graph
+ if (this.graph === undefined) {
+ return;
+ }
+ const U = this.graph.U();
+ if (this.conf === undefined) {
+ return;
+ }
+ if (this.conf.value === undefined) {
+ log(`value of ${this.uri} is undefined`)
+ return;
+ }
+ log('writeValueToGraph', this.conf.value)
+ const valueTerm = this.graph.LiteralRoundedFloat(this.conf.value);
+ const settingNode = this.graph.uriValue(this.uri, U(":setting"));
+ this.graph.patchObject(settingNode, this.graph.Uri(":value"), valueTerm, this.ctx);
+
+ }
+
+ onEffectChange(ev: CustomEvent) {
+ if (this.graph === undefined) {
+ return;
+ }
+ const { newValue } = ev.detail;
+ this.graph.patchObject(this.uri, this.graph.Uri(":effect"), newValue, this.ctx);
+ }
+
+ onEffectAttrChange(ev: CustomEvent) {
+ if (this.graph === undefined) {
+ return;
+ }
+ // const { newValue } = ev.detail;
+ // if (this.setting === undefined) {
+ // this.setting = this.graph.nextNumberedResource(this.graph.Uri(":fade_set"));
+ // this.graph.patchObject(this.uri, this.graph.Uri(":setting"), this.setting, this.ctx);
+ // }
+ // this.graph.patchObject(this.setting, this.graph.Uri(":effectAttr"), newValue, this.ctx);
+ }
+}
diff -r 5e4321405f54 -r 06bf6dae8e64 light9/web/fade/Light9FadeUi.ts
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/fade/Light9FadeUi.ts Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,169 @@
+import debug from "debug";
+import { css, html, LitElement, TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import * as N3 from "n3";
+import { NamedNode, Quad } from "n3";
+import { Patch } from "../patch";
+import { getTopGraph } from "../RdfdbSyncedGraph";
+import { showRoot } from "../show_specific";
+import { SyncedGraph } from "../SyncedGraph";
+export { EditChoice } from "../EditChoice";
+export { Light9EffectFader } from "./Light9EffectFader";
+export { Light9Fader } from "./Light9Fader";
+
+debug.enable("*,autodep");
+const log = debug("fade");
+
+class FaderConfig {
+ constructor(public uri: NamedNode, public column: number) { }
+}
+
+class FadePage {
+ constructor(public uri: NamedNode) { }
+ faderConfigs: FaderConfig[] = [];
+}
+class FadePages {
+ pages: FadePage[] = [];
+}
+
+@customElement("light9-fade-ui")
+export class Light9FadeUi extends LitElement {
+ static styles = [
+ css`
+ :host {
+ display: block;
+ user-select: none; /* really this is only desirable during slider drag events */
+ }
+ .mappedToHw {
+ background: #393945;
+ }
+ #gm light9-fader {
+ width: 300px;
+ }
+ `,
+ ];
+ render() {
+ return html`
+
+
+
Fade
+
+ grand master
+
+ ${(this.fadePages?.pages || []).map(this.renderPage.bind(this))}
+
+
+ `;
+ }
+ private renderPage(page: FadePage): TemplateResult {
+ const mappedToHw = this.currentHwPage !== undefined && page.uri.equals(this.currentHwPage);
+ return html`
+
+ `;
+ }
+
+ graph!: SyncedGraph;
+ ctx: NamedNode = new NamedNode(showRoot + "/fade");
+
+ @property() fadePages?: FadePages;
+ @property() currentHwPage?: NamedNode;
+ @property() grandMaster?: number;
+
+ constructor() {
+ super();
+ getTopGraph().then((g) => {
+ this.graph = g;
+ this.graph.runHandler(this.compile.bind(this), `faders layout`);
+ this.graph.runHandler(this.compileGm.bind(this), `faders gm`);
+ });
+ }
+ connectedCallback(): void {
+ super.connectedCallback();
+ }
+
+ compile() {
+ const U = this.graph.U();
+ this.fadePages = undefined;
+ const fadePages = new FadePages();
+ for (let page of this.graph.subjects(U("rdf:type"), U(":FadePage"))) {
+ const fp = new FadePage(page as NamedNode);
+ try {
+ for (let fader of this.graph.objects(page, U(":fader"))) {
+ const colLit = this.graph.stringValue(fader, U(':column'))
+ fp.faderConfigs.push(new FaderConfig(fader as NamedNode, parseFloat(colLit)));
+ }
+ fp.faderConfigs.sort((a, b) => {
+ return a.column - (b.column);
+ });
+ fadePages.pages.push(fp);
+ } catch (e) { }
+ }
+ fadePages.pages.sort((a, b) => {
+ return a.uri.value.localeCompare(b.uri.value);
+ });
+ this.fadePages = fadePages;
+ this.currentHwPage = undefined;
+ try {
+ const mc = this.graph.uriValue(U(":midiControl"), U(":map"));
+ this.currentHwPage = this.graph.uriValue(mc, U(":outputs"));
+ } catch (e) { }
+ }
+ compileGm() {
+ const U = this.graph.U();
+ this.grandMaster = undefined
+ let newVal
+ try {
+
+ newVal = this.graph.floatValue(U(':grandMaster'), U(':value'))
+ } catch (e) {
+ return
+ }
+ this.grandMaster = newVal;
+
+ }
+ gmChanged(ev: CustomEvent) {
+ const U = this.graph.U();
+ const newVal = ev.detail.value
+ // this.grandMaster = newVal;
+ this.graph.patchObject(U(':grandMaster'), U(':value'), this.graph.LiteralRoundedFloat(newVal), this.ctx)
+
+ }
+
+
+ mapThisToHw(page: NamedNode) {
+ const U = this.graph.U();
+ log("map to hw", page);
+ const mc = this.graph.uriValue(U(":midiControl"), U(":map"));
+ this.graph.patchObject(mc, U(":outputs"), page, this.ctx);
+ }
+
+ addPage() {
+ const U = this.graph.U();
+ const uri = this.graph.nextNumberedResource(showRoot + "/fadePage");
+ const adds = [
+ //
+ new Quad(uri, U("rdf:type"), U(":FadePage"), this.ctx),
+ new Quad(uri, U("rdfs:label"), N3.DataFactory.literal("unnamed"), this.ctx),
+ ];
+ for (let n = 1; n <= 8; n++) {
+ const f = this.graph.nextNumberedResource(showRoot + "/fader");
+ const s = this.graph.nextNumberedResource(showRoot + "/faderset");
+ adds.push(new Quad(uri, U(":fader"), f, this.ctx));
+ adds.push(new Quad(f, U("rdf:type"), U(":Fader"), this.ctx));
+ adds.push(new Quad(f, U(":column"), N3.DataFactory.literal("" + n), this.ctx));
+ adds.push(new Quad(f, U(":setting"), s, this.ctx));
+ adds.push(new Quad(s, U(":effectAttr"), U(":strength"), this.ctx));
+ adds.push(new Quad(s, U(":value"), this.graph.LiteralRoundedFloat(0), this.ctx));
+ }
+ this.graph.applyAndSendPatch(new Patch([], adds));
+ }
+}
diff -r 5e4321405f54 -r 06bf6dae8e64 light9/web/fade/Light9Fader.ts
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/fade/Light9Fader.ts Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,146 @@
+import debug from "debug";
+import { css, html, LitElement, PropertyValueMap } from "lit";
+import { customElement, property, query } from "lit/decorators.js";
+
+import { clamp } from "../floating_color_picker";
+const log = debug("fade");
+
+class Drag {
+ constructor(public startDragPxY: number, public startDragValue: number) {}
+}
+
+@customElement("light9-fader")
+export class Light9Fader extends LitElement {
+ static styles = [
+ css`
+ :host {
+ display: inline-block;
+ border: 2px gray inset;
+ background: #000;
+ height: 80px;
+ }
+ #handle {
+ background: gray;
+ border: 5px gray outset;
+ position: relative;
+ left: 0;
+ right: -25px;
+ }
+ `,
+ ];
+
+ @property() value: number = 0;
+
+ @query("#handle") handleEl!: HTMLElement;
+
+ troughHeight = 80 - 2 - 2 - 5 - 5;
+ handleHeight = 10;
+
+ drag?: Drag;
+ unmutedValue: number = 1;
+
+ render() {
+ return html`
`;
+ }
+
+ protected update(changedProperties: PropertyValueMap
| Map): void {
+ super.update(changedProperties);
+ if (changedProperties.has("value")) {
+
+ }
+ }
+ valueChangedFromUi() {
+ this.value= clamp(this.value, 0, 1)
+ this.dispatchEvent(new CustomEvent("change", { detail: { value: this.value } }));
+ }
+
+ protected updated(_changedProperties: PropertyValueMap | Map): void {
+ super.updated(_changedProperties);
+ const y = this.sliderTopY(this.value);
+ this.handleEl.style.top = y + "px";
+ }
+
+ protected firstUpdated(_changedProperties: PropertyValueMap | Map): void {
+ super.firstUpdated(_changedProperties);
+ this.handleEl.style.height = this.handleHeight + "px";
+ this.events();
+ }
+
+ events() {
+ const hand = this.handleEl;
+ hand.addEventListener("mousedown", (ev: MouseEvent) => {
+ ev.stopPropagation();
+ if (ev.buttons == 1) {
+ this.drag = new Drag(ev.clientY, this.value);
+ } else if (ev.buttons == 2) {
+ this.onRmb();
+ }
+ });
+ this.addEventListener("mousedown", (ev: MouseEvent) => {
+ ev.stopPropagation();
+ if (ev.buttons == 1) {
+ this.value = this.sliderValue(ev.offsetY);
+ this.valueChangedFromUi()
+ this.drag = new Drag(ev.clientY, this.value);
+ } else if (ev.buttons == 2) {
+ // RMB in trough
+ this.onRmb();
+ }
+ });
+
+ this.addEventListener("contextmenu", (event) => {
+ event.preventDefault();
+ });
+
+ this.addEventListener("wheel", (ev: WheelEvent) => {
+ ev.preventDefault();
+ this.value += ev.deltaY / this.troughHeight * -.05;
+ this.valueChangedFromUi()
+ });
+
+ const maybeDrag = (ev: MouseEvent) => {
+ if (ev.buttons != 1) return;
+ if (this.drag === undefined) return;
+ ev.stopPropagation();
+ this.onMouseDrag(ev.clientY - this.drag.startDragPxY!);
+ };
+ hand.addEventListener("mousemove", maybeDrag);
+ this.addEventListener("mousemove", maybeDrag);
+ window.addEventListener("mousemove", maybeDrag);
+
+ hand.addEventListener("mouseup", this.onMouseUpAnywhere.bind(this));
+ this.addEventListener("mouseup", this.onMouseUpAnywhere.bind(this));
+ window.addEventListener("mouseup", this.onMouseUpAnywhere.bind(this));
+ }
+ onRmb() {
+ if (this.value > 0.1) {
+ // mute
+ this.unmutedValue = this.value;
+ this.value = 0;
+ } else {
+ // unmute
+ this.value = this.unmutedValue;
+ }
+ this.valueChangedFromUi()
+ }
+ onMouseDrag(dy: number) {
+ if (this.drag === undefined) throw "unexpected";
+ this.value = this.drag.startDragValue - dy / this.troughHeight;
+ this.valueChangedFromUi()
+ }
+
+ onMouseUpAnywhere() {
+ this.drag = undefined;
+ }
+
+ sliderTopY(value: number): number {
+ const usableY = this.troughHeight - this.handleHeight;
+ const yAdj = this.handleHeight / 2 - 5 - 2;
+ return (1 - value) * usableY + yAdj;
+ }
+ sliderValue(offsetY: number): number {
+ const usableY = this.troughHeight - this.handleHeight;
+ const yAdj = this.handleHeight / 2 - 5 - 2;
+ return clamp(1 - (offsetY - yAdj) / usableY, 0, 1);
+ }
+}
diff -r 5e4321405f54 -r 06bf6dae8e64 light9/web/fade/index.html
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/fade/index.html Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,13 @@
+
+
+
+ fade
+
+
+
+
+
+
+
+
+
diff -r 5e4321405f54 -r 06bf6dae8e64 light9/web/index.html
--- a/light9/web/index.html Thu Jun 08 12:28:27 2023 -0700
+++ b/light9/web/index.html Thu Jun 08 13:20:23 2023 -0700
@@ -4,7 +4,7 @@
light9 home
-
+
light9 home page
diff -r 5e4321405f54 -r 06bf6dae8e64 light9/web/live/Effect.ts
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/live/Effect.ts Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,277 @@
+import debug from "debug";
+import { Literal, NamedNode, Quad, Quad_Object, Quad_Predicate, Quad_Subject, Term } from "n3";
+import { some } from "underscore";
+import { Patch } from "../patch";
+import { SyncedGraph } from "../SyncedGraph";
+import { shortShow } from "../show_specific";
+import { SubEvent } from "sub-events";
+
+// todo: Align these names with newtypes.py, which uses HexColor and VTUnion.
+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";
+}
+
+// todo: eliminate this. address the scaling when we actually scale
+// stuff, instead of making a mess of every setting
+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(":value");
+ } else {
+ return U(":value");
+ }
+}
+
+// also see resourcedisplay's version of this
+function effContext(graph: SyncedGraph, uri: NamedNode): NamedNode {
+ return graph.Uri(uri.value.replace("light9.bigasterisk.com/effect", `light9.bigasterisk.com/show/${shortShow}/effect`));
+}
+
+export function newEffect(graph: SyncedGraph): NamedNode {
+ // wrong- this should be our editor's scratch effect, promoted to a
+ // real one when you name it.
+ const uri = graph.nextNumberedResource(graph.Uri("http://light9.bigasterisk.com/effect/effect"));
+
+ const effect = new Effect(graph, uri);
+ const U = graph.U();
+ const ctx = effContext(graph, uri);
+ const quad = (s: Quad_Subject, p: Quad_Predicate, o: Quad_Object) => graph.Quad(s, p, o, ctx);
+
+ const addQuads = [
+ quad(uri, U("rdf:type"), U(":Effect")),
+ quad(uri, U("rdfs:label"), graph.Literal(uri.value.replace(/.*\//, ""))),
+ quad(uri, U(":publishAttr"), U(":strength")),
+ quad(uri, U(":effectFunction"), U(":effectFunction/scale")),
+ ];
+ const patch = new Patch([], addQuads);
+ log("init new effect", patch);
+ graph.applyAndSendPatch(patch);
+
+ return effect.uri;
+}
+
+// effect settings data; r/w sync with the graph
+export class Effect {
+ // :effect1 a Effect; :setting ?eset . ?eset :effectAttr :deviceSettings; :value ?dset . ?dset :device ..
+ private eset?: NamedNode;
+ private dsettings: Array<{ dset: NamedNode; device: NamedNode; deviceAttr: NamedNode; value: ControlValue }> = [];
+
+ private ctxForEffect: NamedNode;
+ settingsChanged: SubEvent = new SubEvent();
+
+ constructor(public graph: SyncedGraph, public uri: NamedNode) {
+ this.ctxForEffect = effContext(this.graph, this.uri);
+ graph.runHandler(this.rebuildSettingsFromGraph.bind(this), `effect sync ${uri.value}`);
+ }
+
+ private getExistingEset(): NamedNode | null {
+ const U = this.graph.U();
+ for (let eset of this.graph.objects(this.uri, U(":setting"))) {
+ if (this.graph.uriValue(eset as Quad_Subject, U(":effectAttr")).equals(U(":deviceSettings"))) {
+ return eset as NamedNode;
+ }
+ }
+ return null;
+ }
+ private getExistingEsetValueNode(): NamedNode | null {
+ const U = this.graph.U();
+ const eset = this.getExistingEset();
+ if (eset === null) return null;
+ try {
+ return this.graph.uriValue(eset, U(":value"));
+ } catch (e) {
+ return null;
+ }
+ }
+ private patchForANewEset(): { p: Patch; eset: NamedNode } {
+ const U = this.graph.U();
+ const eset = this.graph.nextNumberedResource(U(":e_set"));
+ return {
+ eset: eset,
+ p: new Patch(
+ [],
+ [
+ //
+ new Quad(this.uri, U(":setting"), eset, this.ctxForEffect),
+ new Quad(eset, U(":effectAttr"), U(":deviceSettings"), this.ctxForEffect),
+ ]
+ ),
+ };
+ }
+
+ private rebuildSettingsFromGraph(patch?: Patch) {
+ const U = this.graph.U();
+
+ 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 deviceSettingsNode = this.getExistingEsetValueNode();
+ if (deviceSettingsNode !== null) {
+ for (let dset of Array.from(this.graph.objects(deviceSettingsNode, U(":setting"))) as NamedNode[]) {
+ // // log(` setting ${setting.value}`);
+ // if (!isUri(dset)) throw new Error();
+ let value: ControlValue;
+ const device = this.graph.uriValue(dset, U(":device"));
+ const deviceAttr = this.graph.uriValue(dset, U(":deviceAttr"));
+
+ const pred = valuePred(this.graph, deviceAttr);
+ try {
+ value = this.graph.uriValue(dset, pred);
+ if (!(value as NamedNode).id.match(/^http/)) {
+ throw new Error("not uri");
+ }
+ } catch (error) {
+ try {
+ value = this.graph.floatValue(dset, pred);
+ } catch (error1) {
+ value = this.graph.stringValue(dset, pred); // this may find multi values and throw
+ }
+ }
+ // log(`change: graph contains ${deviceAttr.value} ${value}`);
+
+ newSettings.push({ dset, device, deviceAttr, value });
+ }
+ }
+ this.dsettings = newSettings;
+ log(`settings is rebuilt to length ${this.dsettings.length}`);
+ this.settingsChanged.emit(); // maybe one emitter per dev+attr?
+ // this.onValuesChanged();
+ }
+
+ currentValue(device: NamedNode, deviceAttr: NamedNode): ControlValue | null {
+ for (let s of this.dsettings) {
+ 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 = new Patch([], []);
+
+ for (let s of this.dsettings) {
+ 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.dset}`);
+ result = result.update(this.removeEffectSetting(s.dset));
+ }
+ existingSetting = s.dset;
+ }
+ }
+
+ if (newValue !== null && this.shouldBeStored(deviceAttr, newValue)) {
+ if (existingSetting === null) {
+ result = result.update(this.addEffectSetting(device, deviceAttr, newValue));
+ } else {
+ result = result.update(this.patchExistingDevSetting(existingSetting, deviceAttr, newValue));
+ }
+ } else {
+ if (existingSetting !== null) {
+ result = result.update(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";
+ }
+
+ private 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);
+
+ let patch = new Patch([], []);
+
+ let eset = this.getExistingEset();
+ if (eset === null) {
+ const ret = this.patchForANewEset();
+ patch = patch.update(ret.p);
+ eset = ret.eset;
+ }
+
+ let dsValue;
+ try {
+ dsValue = this.graph.uriValue(eset, U(":value"));
+ } catch (e) {
+ dsValue = this.graph.nextNumberedResource(U(":ds_val"));
+ patch = patch.update(new Patch([], [quad(eset, U(":value"), dsValue)]));
+ }
+
+ const dset = this.graph.nextNumberedResource(this.uri.value + "_set");
+
+ patch = patch.update(
+ new Patch(
+ [],
+ [
+ quad(dsValue, U(":setting"), dset),
+ quad(dset, U(":device"), device),
+ quad(dset, U(":deviceAttr"), deviceAttr),
+ quad(dset, valuePred(this.graph, deviceAttr), this.nodeForValue(value)),
+ ]
+ )
+ );
+ log(" save", patch);
+ this.dsettings.push({ dset, device, deviceAttr, value });
+ return patch;
+ }
+
+ private patchExistingDevSetting(devSetting: NamedNode, deviceAttr: NamedNode, value: ControlValue): Patch {
+ log(" patch existing", devSetting.value);
+ return this.graph.getObjectPatch(
+ devSetting, //
+ valuePred(this.graph, deviceAttr),
+ this.nodeForValue(value),
+ this.ctxForEffect
+ );
+ }
+
+ private removeEffectSetting(effectSetting: NamedNode): Patch {
+ const U = (x: string) => this.graph.Uri(x);
+ log(" _removeEffectSetting", effectSetting.value);
+
+ const eset = this.getExistingEset();
+ if (eset === null) throw "unexpected";
+ const dsValue = this.graph.uriValue(eset, U(":value"));
+ if (dsValue === null) throw "unexpected";
+ const toDel = [this.graph.Quad(dsValue, U(":setting"), effectSetting, this.ctxForEffect)];
+ for (let q of this.graph.subjectStatements(effectSetting)) {
+ toDel.push(q);
+ }
+ return new Patch(toDel, []);
+ }
+
+ clearAllSettings() {
+ for (let s of this.dsettings) {
+ this.graph.applyAndSendPatch(this.removeEffectSetting(s.dset));
+ }
+ }
+
+ private nodeForValue(value: ControlValue): NamedNode | Literal {
+ if (value === null) {
+ throw new Error("no value");
+ }
+ if (isUri(value)) {
+ return value;
+ }
+ return this.graph.prettyLiteral(value);
+ }
+}
diff -r 5e4321405f54 -r 06bf6dae8e64 light9/web/live/Light9AttrControl.ts
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/live/Light9AttrControl.ts Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,195 @@
+import debug from "debug";
+import { css, html, LitElement, PropertyValues } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { Literal, NamedNode } from "n3";
+import { SubEvent } from "sub-events";
+import { getTopGraph } from "../RdfdbSyncedGraph";
+import { SyncedGraph } from "../SyncedGraph";
+import { ControlValue, Effect } from "./Effect";
+import { DeviceAttrRow } from "./Light9DeviceControl";
+export { Slider } from "@material/mwc-slider";
+export { Light9ColorPicker } from "../light9-color-picker";
+export { Light9Listbox } from "./Light9Listbox";
+const log = debug("settings.dev.attr");
+
+type DataTypeNames = "scalar" | "color" | "choice";
+const makeType = (d: DataTypeNames) => new NamedNode(`http://light9.bigasterisk.com/${d}`);
+
+// UI for one device attr (of any type).
+@customElement("light9-attr-control")
+export class Light9AttrControl extends LitElement {
+ graph!: SyncedGraph;
+
+ static styles = [
+ css`
+ #colorControls {
+ display: flex;
+ align-items: center;
+ }
+ #colorControls > * {
+ margin: 0 3px;
+ }
+ :host {
+ }
+ mwc-slider {
+ width: 250px;
+ }
+ `,
+ ];
+
+ @property() deviceAttrRow: DeviceAttrRow | null = null;
+ @state() dataType: DataTypeNames = "scalar";
+ @property() effect: Effect | null = null;
+ @property() enableChange: boolean = false;
+ @property() value: ControlValue | null = null; // e.g. color string
+
+ constructor() {
+ super();
+ getTopGraph().then((g) => {
+ this.graph = g;
+ if (this.deviceAttrRow === null) throw new Error();
+ });
+ }
+
+ connectedCallback(): void {
+ super.connectedCallback();
+ setTimeout(() => {
+ // only needed once per page layout
+ this.shadowRoot?.querySelector("mwc-slider")?.layout(/*skipUpdateUI=*/ false);
+ }, 1);
+ }
+
+ render() {
+ if (this.deviceAttrRow === null) throw new Error();
+ if (this.dataType == "scalar") {
+ const v = this.value || 0;
+ return html` `;
+ } else if ((this.dataType = "color")) {
+ const v = this.value || "#000";
+ return html`
+
+
+
+
+ `;
+ } else if (this.dataType == "choice") {
+ return html` `;
+ }
+ }
+
+ updated(changedProperties: PropertyValues) {
+ super.updated(changedProperties);
+
+ if (changedProperties.has("deviceAttrRow")) {
+ this.onDeviceAttrRowProperty();
+ }
+ if (changedProperties.has("effect")) {
+ this.onEffectProperty();
+ }
+ if (changedProperties.has("value")) {
+ this.onValueProperty();
+ }
+ }
+
+ private onValueProperty() {
+ if (this.deviceAttrRow === null) throw new Error();
+ if (!this.graph) {
+ log('ignoring value change- no graph yet')
+ return;
+ }
+ if (this.effect === null) {
+ this.value = null;
+ } else {
+ const p = this.effect.edit(
+ //
+ this.deviceAttrRow.device,
+ this.deviceAttrRow.uri,
+ this.value
+ );
+ if (!p.isEmpty()) {
+ log("Effect told us to graph.patch this:\n", p.dump());
+ this.graph.applyAndSendPatch(p);
+ }
+ }
+ }
+
+ private onEffectProperty() {
+ if (this.effect === null) {
+ log('no effect obj yet')
+ return;
+ }
+ // effect will read graph changes on its own, but emit an event when it does
+ this.effect.settingsChanged.subscribe(() => {
+ this.effectSettingsChanged();
+ });
+ this.effectSettingsChanged();
+ }
+
+ private effectSettingsChanged() {
+ // something in the settings graph is new
+ if (this.deviceAttrRow === null) throw new Error();
+ if (this.effect === null) throw new Error();
+ // log("graph->ui on ", this.deviceAttrRow.device, this.deviceAttrRow.uri);
+ const v = this.effect.currentValue(this.deviceAttrRow.device, this.deviceAttrRow.uri);
+ this.onGraphValueChanged(v);
+ }
+
+ private onDeviceAttrRowProperty() {
+ if (this.deviceAttrRow === null) throw new Error();
+ const d = this.deviceAttrRow.dataType;
+ if (d.equals(makeType("scalar"))) {
+ this.dataType = "scalar";
+ } else if (d.equals(makeType("color"))) {
+ this.dataType = "color";
+ } else if (d.equals(makeType("choice"))) {
+ this.dataType = "choice";
+ }
+ }
+
+ onValueInput(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.value = ev.detail.value;
+ // this.graphToControls.controlChanged(this.device, this.deviceAttrRow.uri, ev.detail.value);
+ }
+
+ onGraphValueChanged(v: ControlValue | null) {
+ if (this.deviceAttrRow === null) throw new Error();
+ // log("change: control must display", v, "for", this.deviceAttrRow.device.value, this.deviceAttrRow.uri.value);
+ // this.enableChange = false;
+ if (this.dataType == "scalar") {
+ if (v !== null) {
+ this.value = v;
+ } else {
+ this.value = 0;
+ }
+ } else if (this.dataType == "color") {
+ this.value = v;
+ }
+ }
+
+ goBlack() {
+ this.value = "#000000";
+ }
+
+ onChoice(value: any) {
+ // if (value != null) {
+ // value = this.graph.Uri(value);
+ // } else {
+ // value = null;
+ // }
+ }
+
+ onChange(value: any) {
+ // 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;
+ // }
+ }
+}
diff -r 5e4321405f54 -r 06bf6dae8e64 light9/web/live/Light9DeviceControl.ts
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/live/Light9DeviceControl.ts Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,210 @@
+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 } from "../patch";
+import { getTopGraph } from "../RdfdbSyncedGraph";
+import { SyncedGraph } from "../SyncedGraph";
+import { Choice } from "./Light9Listbox";
+import { Light9AttrControl } from "./Light9AttrControl";
+import { Effect } from "./Effect";
+export { ResourceDisplay } from "../ResourceDisplay";
+export { Light9AttrControl };
+const log = debug("settings.dev");
+
+export interface DeviceAttrRow {
+ uri: NamedNode; //devattr
+ device: NamedNode;
+ attrClasses: string; // the css kind
+ dataType: NamedNode;
+ choices: Choice[];
+ // choiceSize: number;
+ // max: number;
+}
+
+// Widgets for one device with multiple Light9LiveControl rows for the attr(s).
+@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`
+
+
+
+ a
+
+
+ ${this.deviceAttrs.map(
+ (dattr: DeviceAttrRow) => html`
+
+
+ attr
+
+
+
+
+
+ `
+ )}
+
+ `;
+ }
+
+ @property() uri!: NamedNode;
+ @property() effect!: Effect;
+
+ @property() devClasses: string = ""; // the css kind
+ @property() deviceAttrs: DeviceAttrRow[] = [];
+ @property() deviceClass: NamedNode | null = null;
+ @property() selectedAttrs: Set = 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);
+ }
+ }
+
+ syncDeviceAttrsFromGraph(patch?: Patch) {
+ const U = this.graph.U();
+ if (patch && !patch.containsAnyPreds([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,
+ device: this.uri,
+ dataType,
+ attrClasses: this.selectedAttrs.has(devAttr) ? "selected" : "",
+ choices: [] as Choice[],
+ choiceSize: 0,
+ max: 1,
+ };
+ if (dataType.equals(U(":choice"))) {
+ 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.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
+ }
+}
diff -r 5e4321405f54 -r 06bf6dae8e64 light9/web/live/Light9DeviceSettings.ts
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/live/Light9DeviceSettings.ts Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,153 @@
+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 } from "../patch";
+import { getTopGraph } from "../RdfdbSyncedGraph";
+import { SyncedGraph } from "../SyncedGraph";
+import { Effect, newEffect } from "./Effect";
+export { EditChoice } from "../EditChoice";
+export { Light9DeviceControl as Light9LiveDeviceControl } from "./Light9DeviceControl";
+const log = debug("settings");
+
+@customElement("light9-device-settings")
+export class Light9DeviceSettings 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-device-control > div {
+ break-inside: avoid-column;
+ }
+ light9-device-control {
+ vertical-align: top;
+ }
+ `,
+ ];
+
+ render() {
+ return html`
+
+
+ effect DeviceSettings
+
+
+
+
+
+
+
+
+
+
+ ${this.devices.map(
+ (device: NamedNode) => html`
+ .graphToControls={this.graphToControls}
+ `
+ )}
+
+ `;
+ }
+
+ devices: Array = [];
+ @property() currentEffect: Effect | null = null;
+ okToWriteUrl: boolean = false;
+
+ constructor() {
+ super();
+
+ getTopGraph().then((g) => {
+ this.graph = g;
+ this.graph.runHandler(this.compile.bind(this), "findDevices");
+ this.setEffectFromUrl();
+ });
+ }
+
+ onEffectChoice2(ev: CustomEvent) {
+ const uri = ev.detail.newValue as NamedNode;
+ this.setCurrentEffect(uri);
+ }
+ setCurrentEffect(uri: NamedNode) {
+ if (uri === null) {
+ this.currentEffect = null;
+ // todo: wipe the UI settings
+ } else {
+ this.currentEffect = new Effect(this.graph, uri);
+ }
+ }
+
+ updated(changedProperties: PropertyValues) {
+ log("ctls udpated", changedProperties);
+ if (changedProperties.has("currentEffect")) {
+ log(`effectChoice to ${this.currentEffect?.uri?.value}`);
+ this.writeToUrl(this.currentEffect?.uri);
+ }
+ // this.graphToControls?.debugDump();
+ }
+
+ // Note that this doesn't fetch setting values, so it only should get rerun
+ // upon (rarer) changes to the devices etc. todo: make that be true
+ private compile(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) => {
+ 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) {
+ this.currentEffect = new Effect(this.graph, this.graph.Uri(effect));
+ }
+ this.okToWriteUrl = true;
+ }
+
+ writeToUrl(effect: NamedNode | undefined) {
+ 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.setCurrentEffect(newEffect(this.graph));
+ }
+
+ clearAll() {
+ this.currentEffect?.clearAllSettings()
+ }
+}
diff -r 5e4321405f54 -r 06bf6dae8e64 light9/web/live/Light9Listbox.ts
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/live/Light9Listbox.ts Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,76 @@
+import debug from "debug";
+import { css, html, LitElement, PropertyValues } from "lit";
+import { customElement, property } from "lit/decorators.js";
+const log = debug("listbox");
+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`
+
+ None
+
+ {{item.label}}
+
+
+ `;
+ }
+ @property() choices: Array = [];
+ @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;
+ }
+}
diff -r 5e4321405f54 -r 06bf6dae8e64 light9/web/live/README.md
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/live/README.md Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,24 @@
+This is an editor of :Effect resources, which have graphs like this:
+
+ a :Effect;
+ rdfs:label "effect43";
+ :publishAttr :strength;
+ :setting .
+
+ :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)
diff -r 5e4321405f54 -r 06bf6dae8e64 light9/web/live/index.html
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/live/index.html Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,25 @@
+
+
+
+ device settings
+
+
+
+
+
+
+
+
+
diff -r 5e4321405f54 -r 06bf6dae8e64 light9/web/metrics/ServiceButtonRow.ts
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/metrics/ServiceButtonRow.ts Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,66 @@
+import { LitElement, html, css } from "lit";
+import { customElement, property } from "lit/decorators.js";
+export { StatsLine } from "./StatsLine";
+
+@customElement("service-button-row")
+export class ServiceButtonRow extends LitElement {
+ @property() name: string = "?";
+ @property({ type:Boolean, attribute: "metrics" }) hasMetrics: boolean = false;
+ static styles = [
+ css`
+ :host {
+ padding-bottom: 10px;
+ border-bottom: 1px solid #333;
+ }
+ a {
+ color: #7d7dec;
+ }
+ div {
+ display: flex;
+ justify-content: space-between;
+ padding: 2px 3px;
+ }
+ .left {
+ display: inline-block;
+ margin-right: 3px;
+ flex-grow: 1;
+ min-width: 9em;
+ }
+ .window {
+ }
+ .serviceGrid > td {
+ border: 5px solid red;
+ display: inline-block;
+ }
+ .big {
+ font-size: 120%;
+ display: inline-block;
+ padding: 10px 0;
+ }
+
+ :host > div {
+ display: inline-block;
+ vertical-align: top;
+ }
+ :host > div:nth-child(2) {
+ width: 9em;
+ }
+ `,
+ ];
+
+ render() {
+ return html`
+
+
+
+ ${this.hasMetrics ? html`
` : ""}
+
+
+ ${this.hasMetrics ? html`
` : ""}
+ `;
+ }
+
+ click() {
+ window.open(this.name + "/", "_blank", "scrollbars=1,resizable=1,titlebar=0,location=0");
+ }
+}
diff -r 5e4321405f54 -r 06bf6dae8e64 light9/web/metrics/StatsLine.ts
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/metrics/StatsLine.ts Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,306 @@
+import { css, html, LitElement, TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators.js";
+export { StatsProcess } from "./StatsProcess";
+import parsePrometheusTextFormat from "parse-prometheus-text-format";
+import debug from "debug";
+import { clamp } from "../floating_color_picker";
+const log = debug("home");
+
+interface Value {
+ labels: { string: string };
+ value?: string;
+ count?: number;
+ sum?: number;
+ buckets?: { [value: string]: string };
+}
+interface Metric {
+ name: string;
+ help: string;
+ type: "GAUGE" | "SUMMARY" | "COUNTER" | "HISTOGRAM" | "UNTYPED";
+ metrics: Value[];
+}
+type Metrics = Metric[];
+
+function nonBoring(m: Metric) {
+ return (
+ !m.name.endsWith("_created") && //
+ !m.name.startsWith("python_gc_") &&
+ m.name != "python_info" &&
+ m.name != "process_max_fds" &&
+ m.name != "process_virtual_memory_bytes" &&
+ m.name != "process_resident_memory_bytes" &&
+ m.name != "process_start_time_seconds" &&
+ m.name != "process_cpu_seconds_total"
+ );
+}
+
+@customElement("stats-line")
+export class StatsLine extends LitElement {
+ @property() name = "?";
+ @property() stats: Metrics = [];
+
+ prevCpuNow = 0;
+ prevCpuTotal = 0;
+ @property() cpu = 0;
+ @property() mem = 0;
+
+ updated(changedProperties: any) {
+ changedProperties.forEach((oldValue: any, propName: string) => {
+ if (propName == "name") {
+ const reload = () => {
+ fetch(this.name + "/metrics").then((resp) => {
+ if (resp.ok) {
+ resp
+ .text()
+ .then((msg) => {
+ this.stats = parsePrometheusTextFormat(msg) as Metrics;
+ this.extractProcessStats(this.stats);
+ setTimeout(reload, 1000);
+ })
+ .catch((err) => {
+ log(`${this.name} failing`, err)
+ setTimeout(reload, 1000);
+ });
+ } else {
+ if (resp.status == 502) {
+ setTimeout(reload, 5000);
+ }
+ // 404: likely not mapped to a responding server
+ }
+ });
+ };
+ reload();
+ }
+ });
+ }
+ extractProcessStats(stats: Metrics) {
+ stats.forEach((row: Metric) => {
+ if (row.name == "process_resident_memory_bytes") {
+ this.mem = parseFloat(row.metrics[0].value!) / 1024 / 1024;
+ }
+ if (row.name == "process_cpu_seconds_total") {
+ const now = Date.now() / 1000;
+ const cpuSecondsTotal = parseFloat(row.metrics[0].value!);
+ this.cpu = (cpuSecondsTotal - this.prevCpuTotal) / (now - this.prevCpuNow);
+ this.prevCpuTotal = cpuSecondsTotal;
+ this.prevCpuNow = now;
+ }
+ });
+ }
+
+ static styles = [
+ css`
+ :host {
+ border: 2px solid #46a79f;
+ display: inline-block;
+ }
+ table {
+ border-collapse: collapse;
+ background: #000;
+ color: #ccc;
+ font-family: sans-serif;
+ }
+ th,
+ td {
+ outline: 1px solid #000;
+ }
+ th {
+ padding: 2px 4px;
+ background: #2f2f2f;
+ text-align: left;
+ }
+ td {
+ padding: 0;
+ vertical-align: top;
+ text-align: center;
+ }
+ td.val {
+ padding: 2px 4px;
+ background: #3b5651;
+ }
+ .recents {
+ display: flex;
+ align-items: flex-end;
+ height: 30px;
+ }
+ .recents > div {
+ width: 3px;
+ background: red;
+ border-right: 1px solid black;
+ }
+ .bigInt {
+ min-width: 6em;
+ }
+ `,
+ ];
+
+ tdWrap(content: TemplateResult): TemplateResult {
+ return html`${content} | `;
+ }
+
+ recents(d: any, path: string[]): TemplateResult {
+ const hi = Math.max.apply(null, d.recents);
+ const scl = 30 / hi;
+
+ const bar = (y: number) => {
+ let color;
+ if (y < d.average) {
+ color = "#6a6aff";
+ } else {
+ color = "#d09e4c";
+ }
+ return html``;
+ };
+ return html`
+ ${d.recents.map(bar)}
+ avg=${d.average.toPrecision(3)}
+ | `;
+ }
+
+ table(d: Metrics, path: string[]): TemplateResult {
+ const byName = new Map();
+ d.forEach((row) => {
+ byName.set(row.name, row);
+ });
+ let cols = d.map((row) => row.name);
+ cols.sort();
+
+ if (path.length == 0) {
+ ["webServer", "process"].forEach((earlyKey) => {
+ let i = cols.indexOf(earlyKey);
+ if (i != -1) {
+ cols = [earlyKey].concat(cols.slice(0, i), cols.slice(i + 1));
+ }
+ });
+ }
+
+ const th = (col: string): TemplateResult => {
+ return html`${col} | `;
+ };
+ const td = (col: string): TemplateResult => {
+ const cell = byName.get(col)!;
+ return html`${this.drawLevel(cell, path.concat(col))}`;
+ };
+ return html`
+
+ ${cols.map(th)}
+
+
+ ${cols.map(td)}
+
+
`;
+ }
+
+ drawLevel(d: Metric, path: string[]) {
+ return html`[NEW ${JSON.stringify(d)} ${path}]`;
+ }
+
+
+ valueDisplay(m: Metric, v: Value): TemplateResult {
+ if (m.type == "GAUGE") {
+ return html`${v.value}`;
+ } else if (m.type == "COUNTER") {
+ return html`${v.value}`;
+ } else if (m.type == "HISTOGRAM") {
+ return this.histoDisplay(v.buckets!);
+ } else if (m.type == "UNTYPED") {
+ return html`${v.value}`;
+ } else if (m.type == "SUMMARY") {
+ if (!v.count) {
+ return html`err: summary without count`;
+ }
+ return html`n=${v.count} percall=${((v.count && v.sum ? v.sum / v.count : 0) * 1000).toPrecision(3)}ms`;
+ } else {
+ throw m.type;
+ }
+ }
+
+ private histoDisplay(b: { [value: string]: string }) {
+ const lines: TemplateResult[] = [];
+ let firstLevel;
+ let lastLevel;
+ let prev = 0;
+
+ let maxDelta = 0;
+ for (let level in b) {
+ if (firstLevel === undefined) firstLevel = level;
+ lastLevel = level;
+ let count = parseFloat(b[level]);
+ let delta = count - prev;
+ prev = count;
+ if (delta > maxDelta) maxDelta = delta;
+ }
+ prev = 0;
+ const maxBarH = 30;
+ for (let level in b) {
+ let count = parseFloat(b[level]);
+ let delta = count - prev;
+ prev = count;
+ let levelf = parseFloat(level);
+ const h = clamp((delta / maxDelta) * maxBarH, 1, maxBarH);
+ lines.push(
+ html``
+ );
+ }
+ return html`${firstLevel} ${lines} ${lastLevel}`;
+ }
+
+ tightLabel(labs: { [key: string]: string }): string {
+ const d: { [key: string]: string } = {}
+ for (let k in labs) {
+ if (k == 'app_name') continue;
+ if (k == 'output') continue;
+ if (k=='status_code'&&labs[k]=="200") continue;
+ d[k] = labs[k]
+ }
+ const ret = JSON.stringify(d)
+ return ret == "{}" ? "" : ret
+ }
+ tightMetric(name: string): string {
+ return name
+ .replace('starlette', '⭐')
+ .replace("_request" ,"_req")
+ .replace("_duration" ,"_dur")
+ .replace('_seconds', '_s')
+ }
+ render() {
+ const now = Date.now() / 1000;
+
+ const displayedStats = this.stats.filter(nonBoring);
+ return html`
+
+
+ ${displayedStats.map(
+ (row, rowNum) => html`
+
+ ${this.tightMetric(row.name)} |
+
+
+ ${row.metrics.map(
+ (v) => html`
+
+ ${this.tightLabel(v.labels)} |
+ ${this.valueDisplay(row, v)} |
+
+ `
+ )}
+
+ |
+ ${rowNum == 0
+ ? html`
+
+
+ |
+ `
+ : ""}
+
+ `
+ )}
+
+
+ `;
+ }
+}
diff -r 5e4321405f54 -r 06bf6dae8e64 light9/web/metrics/StatsProcess.ts
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/metrics/StatsProcess.ts Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,90 @@
+import { LitElement, html, css } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import debug from "debug";
+
+const log = debug("process");
+
+const remap = (x: number, lo: number, hi: number, outLo: number, outHi: number) => {
+ return outLo + (outHi - outLo) * Math.max(0, Math.min(1, (x - lo) / (hi - lo)));
+};
+
+@customElement("stats-process")
+export class StatsProcess extends LitElement {
+ // inspired by https://codepen.io/qiruiyin/pen/qOopQx
+ @property() cpu = 0; // process_cpu_seconds_total
+ @property() mem = 0; // process_resident_memory_bytes
+
+ w = 64;
+ h = 64;
+ revs = 0;
+ prev = 0;
+ canvas?: HTMLCanvasElement;
+ ctx?: CanvasRenderingContext2D;
+ connectedCallback() {
+ super.connectedCallback();
+ this.initCanvas(this.shadowRoot!.firstElementChild as HTMLCanvasElement);
+ this.prev = Date.now() / 1000;
+
+ var animate = () => {
+ requestAnimationFrame(animate);
+ this.redraw();
+ };
+ animate();
+ }
+ initCanvas(canvas: HTMLCanvasElement) {
+ if (!canvas) {
+ return;
+ }
+ this.canvas = canvas;
+ this.ctx = this.canvas.getContext("2d")!;
+
+ this.canvas.width = this.w;
+ this.canvas.height = this.h;
+ }
+ redraw() {
+ if (!this.ctx) {
+ this.initCanvas(this.shadowRoot!.firstElementChild as HTMLCanvasElement);
+ }
+ if (!this.ctx) return;
+
+ this.canvas!.setAttribute("title",
+ `cpu ${new Number(this.cpu).toPrecision(3)}% mem ${new Number(this.mem).toPrecision(3)}MB`);
+
+ const now = Date.now() / 1000;
+ const ctx = this.ctx;
+ ctx.beginPath();
+ // wrong type of fade- never goes to 0
+ ctx.fillStyle = "#00000003";
+ ctx.fillRect(0, 0, this.w, this.h);
+ const dt = now - this.prev;
+ this.prev = now;
+
+ const size = remap(this.mem.valueOf() / 1024 / 1024, /*in*/ 20, 80, /*out*/ 3, 30);
+ this.revs += dt * remap(this.cpu.valueOf(), /*in*/ 0, 100, /*out*/ 4, 120);
+ const rad = remap(size, /*in*/ 3, 30, /*out*/ 14, 5);
+
+ var x = this.w / 2 + rad * Math.cos(this.revs / 6.28),
+ y = this.h / 2 + rad * Math.sin(this.revs / 6.28);
+
+ ctx.save();
+ ctx.beginPath();
+ ctx.fillStyle = "hsl(194, 100%, 42%)";
+ ctx.arc(x, y, size, 0, 2 * Math.PI);
+ ctx.fill();
+ ctx.restore();
+ }
+
+ static styles = [
+ css`
+ :host {
+ display: inline-block;
+ width: 64px;
+ height: 64px;
+ }
+ `,
+ ];
+
+ render() {
+ return html``;
+ }
+}
diff -r 5e4321405f54 -r 06bf6dae8e64 light9/web/vite.config.ts
--- a/light9/web/vite.config.ts Thu Jun 08 12:28:27 2023 -0700
+++ b/light9/web/vite.config.ts Thu Jun 08 13:20:23 2023 -0700
@@ -1,16 +1,15 @@
import { defineConfig } from "vite";
-const servicePort = 8200; // (not really, for homepage)
export default defineConfig({
base: "/",
root: "./light9/web",
- publicDir: ".",
+ // publicDir: ".",
server: {
host: "0.0.0.0",
strictPort: true,
- port: servicePort + 100,
+ port: 8300,
hmr: {
- port: servicePort + 200,
+ port: 8301,
},
},
clearScreen: false,