Mercurial > code > home > repos > light9
changeset 2372:06bf6dae8e64
reorg tools into light9/web/ and a single vite instance
author | drewp@bigasterisk.com |
---|---|
date | Thu, 08 Jun 2023 13:20:23 -0700 |
parents | 5e4321405f54 |
children | 86e569fa59c7 |
files | bin/ascoltami bin/rdfdb light9/ascoltami/Light9AscoltamiUi.ts light9/ascoltami/index.html light9/ascoltami/main.ts light9/ascoltami/vite.config.ts light9/collector/web/Light9CollectorDevice.ts light9/collector/web/Light9CollectorUi.ts light9/collector/web/index.html light9/collector/web/vite.config.ts light9/effect/listing/web/Light9EffectListing.ts light9/effect/listing/web/index.html light9/effect/listing/web/vite.config.ts light9/fade/Light9EffectFader.ts light9/fade/Light9FadeUi.ts light9/fade/Light9Fader.ts light9/fade/index.html light9/fade/vite.config.ts light9/homepage/ServiceButtonRow.ts light9/homepage/StatsLine.ts light9/homepage/StatsProcess.ts light9/homepage/write_config.py light9/live/Effect.ts light9/live/Light9AttrControl.ts light9/live/Light9DeviceControl.ts light9/live/Light9DeviceSettings.ts light9/live/Light9Listbox.ts light9/live/README.md light9/live/index.html light9/live/vite.config.ts light9/web/RdfdbSyncedGraph.ts light9/web/ascoltami/Light9AscoltamiUi.ts light9/web/ascoltami/index.html light9/web/ascoltami/main.ts light9/web/collector/Light9CollectorDevice.ts light9/web/collector/Light9CollectorUi.ts light9/web/collector/index.html light9/web/effects/Light9EffectListing.ts light9/web/effects/index.html light9/web/fade/Light9EffectFader.ts light9/web/fade/Light9FadeUi.ts light9/web/fade/Light9Fader.ts light9/web/fade/index.html light9/web/index.html light9/web/live/Effect.ts light9/web/live/Light9AttrControl.ts light9/web/live/Light9DeviceControl.ts light9/web/live/Light9DeviceSettings.ts light9/web/live/Light9Listbox.ts light9/web/live/README.md light9/web/live/index.html light9/web/metrics/ServiceButtonRow.ts light9/web/metrics/StatsLine.ts light9/web/metrics/StatsProcess.ts light9/web/vite.config.ts |
diffstat | 55 files changed, 2750 insertions(+), 2858 deletions(-) [+] |
line wrap: on
line diff
--- a/bin/ascoltami Thu Jun 08 12:28:27 2023 -0700 +++ b/bin/ascoltami Thu Jun 08 13:20:23 2023 -0700 @@ -1,4 +1,2 @@ #!/bin/zsh -pnpm exec vite -c light9/ascoltami/vite.config.ts & pdm run uvicorn light9.ascoltami.main:app --host 0.0.0.0 --port 8206 --no-access-log -wait
--- a/bin/rdfdb Thu Jun 08 12:28:27 2023 -0700 +++ b/bin/rdfdb Thu Jun 08 13:20:23 2023 -0700 @@ -1,4 +1,2 @@ #!/bin/zsh -#pnpm exec vite -c light9/ascoltami/vite.config.ts & -pdm run uvicorn --port 8209 light9.rdfdb.service:app --no-access-log -wait +exec pdm run uvicorn --port 8209 light9.rdfdb.service:app --no-access-log
--- a/light9/ascoltami/Light9AscoltamiUi.ts Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,310 +0,0 @@ -import debug from "debug"; -import { css, html, LitElement } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import { classMap } from "lit/directives/class-map.js"; -import { NamedNode } from "n3"; -import Sylvester from "sylvester"; -import { Zoom } from "../web/light9-timeline-audio"; -import { PlainViewState } from "../web/Light9CursorCanvas"; -import { getTopGraph } from "../web/RdfdbSyncedGraph"; -import { SyncedGraph } from "../web/SyncedGraph"; -import { TimingUpdate } from "./main"; -import { showRoot } from "../web/show_specific"; -export { Light9TimelineAudio } from "../web/light9-timeline-audio"; -export { Light9CursorCanvas } from "../web/Light9CursorCanvas"; -export { RdfdbSyncedGraph } from "../web/RdfdbSyncedGraph"; -export { ResourceDisplay } from "../web/ResourceDisplay"; -const $V = Sylvester.Vector.create; - -debug.enable("*"); -const log = debug("asco"); - -function byId(id: string): HTMLElement { - return document.getElementById(id)!; -} -async function postJson(url: string, jsBody: Object) { - return fetch(url, { - method: "POST", - headers: { "Content-Type": "applcation/json" }, - body: JSON.stringify(jsBody), - }); -} -@customElement("light9-ascoltami-ui") -export class Light9AscoltamiUi extends LitElement { - graph!: SyncedGraph; - times!: { intro: number; post: number }; - @property() nextText: string = ""; - @property() isPlaying: boolean = false; - @property() show: NamedNode | null = null; - @property() song: NamedNode | null = null; - @property() selectedSong: NamedNode | null = null; - @property() currentDuration: number = 0; - @property() zoom: Zoom; - @property() overviewZoom: Zoom; - @property() viewState: PlainViewState | null = null; - static styles = [ - css` - :host { - display: flex; - flex-direction: column; - } - .timeRow { - margin: 14px; - position: relative; - } - #overview { - height: 60px; - } - #zoomed { - margin-top: 40px; - height: 80px; - } - #cursor { - position: absolute; - left: 0; - top: 0; - width: 100%; - height: 100%; - } - #grow { - flex: 1 1 auto; - display: flex; - } - #grow > span { - display: flex; - position: relative; - width: 50%; - } - #playSelected { - height: 100px; - } - #songList { - overflow-y: scroll; - position: absolute; - left: 0; - top: 0; - right: 0; - bottom: 0; - } - #songList .row { - width: 60%; - min-height: 40px; - text-align: left; - position: relative; - } - #songList .row:nth-child(even) { - background: #333; - } - #songList .row:nth-child(odd) { - background: #444; - } - #songList button { - min-height: 40px; - margin-bottom: 10px; - } - #songList .row.playing { - box-shadow: 0 0 30px red; - background-color: #de5050; - } - `, - ]; - render() { - return html`<rdfdb-synced-graph></rdfdb-synced-graph> - - <link rel="stylesheet" href="./style.css" /> - - <!-- <h1>ascoltami <a href="metrics">[metrics]</a></h1> --> - - <div id="grow"> - <span> - <div id="songList"> - <table> - ${this.songList.map( - (song) => html` - <tr - class="row ${classMap({ - playing: !!(this.song && song.equals(this.song)), - })}" - > - <td><resource-display .uri=${song} noclick></resource-display></td> - <td> - <button @click=${this.onSelectSong.bind(this, song)}> - <span>Select</span> - </button> - </td> - </tr> - ` - )} - </table> - </div> </span - ><span> - <div id="right"> - <div> - Selected: - <resource-display .uri=${this.selectedSong}></resource-display> - </div> - <div> - <button id="playSelected" ?disabled=${this.selectedSong === null} @click=${this.onPlaySelected}>Play selected from start</button> - </div> - </div> - </span> - </div> - - <div class="timeRow"> - <div id="timeSlider"></div> - <light9-timeline-audio id="overview" .show=${this.show} .song=${this.song} .zoom=${this.overviewZoom}> </light9-timeline-audio> - <light9-timeline-audio id="zoomed" .show=${this.show} .song=${this.song} .zoom=${this.zoom}></light9-timeline-audio> - <light9-cursor-canvas id="cursor" .viewState=${this.viewState}></light9-cursor-canvas> - </div> - - <div class="commands"> - <button id="cmd-stop" @click=${this.onCmdStop} class="playMode ${classMap({ active: !this.isPlaying })}"> - <strong>Stop</strong> - <div class="key">s</div> - </button> - <button id="cmd-play" @click=${this.onCmdPlay} class="playMode ${classMap({ active: this.isPlaying })}"> - <strong>Play</strong> - <div class="key">p</div> - </button> - <button id="cmd-intro" @click=${this.onCmdIntro}> - <strong>Skip intro</strong> - <div class="key">i</div> - </button> - <button id="cmd-post" @click=${this.onCmdPost}> - <strong>Skip to Post</strong> - <div class="key">t</div> - </button> - <button id="cmd-go" @click=${this.onCmdGo}> - <strong>Go</strong> - <div class="key">g</div> - <div id="next">${this.nextText}</div> - </button> - </div>`; - } - - onSelectSong(song: NamedNode, ev: MouseEvent) { - if (this.selectedSong && song.equals(this.selectedSong)) { - this.selectedSong = null; - } else { - this.selectedSong = song; - } - } - async onPlaySelected(ev: Event) { - if (!this.selectedSong) { - return; - } - await fetch("api/song", { method: "POST", body: this.selectedSong.value }); - } - - onCmdStop(ev?: MouseEvent): void { - postJson("api/time", { pause: true }); - } - onCmdPlay(ev?: MouseEvent): void { - postJson("api/time", { resume: true }); - } - onCmdIntro(ev?: MouseEvent): void { - postJson("api/time", { t: this.times.intro, resume: true }); - } - onCmdPost(ev?: MouseEvent): void { - postJson("api/time", { - t: this.currentDuration - this.times.post, - resume: true, - }); - } - onCmdGo(ev?: MouseEvent): void { - postJson("api/go", {}); - } - - bindKeys() { - document.addEventListener("keypress", (ev) => { - if (ev.which == 115) { - this.onCmdStop(); - return false; - } - if (ev.which == 112) { - this.onCmdPlay(); - return false; - } - if (ev.which == 105) { - this.onCmdIntro(); - return false; - } - if (ev.which == 116) { - this.onCmdPost(); - return false; - } - - if (ev.key == "g") { - this.onCmdGo(); - return false; - } - return true; - }); - } - - async musicSetup() { - // shoveled over from the vanillajs version - const config = await (await fetch("api/config")).json(); - this.show = new NamedNode(config.show); - this.times = config.times; - document.title = document.title.replace("{{host}}", config.host); - try { - const h1 = document.querySelector("h1")!; - h1.innerText = h1.innerText.replace("{{host}}", config.host); - } catch (e) {} - - (window as any).finishOldStyleSetup(this.times, this.onOldStyleUpdate.bind(this)); - } - - onOldStyleUpdate(data: TimingUpdate) { - this.nextText = data.next; - this.isPlaying = data.playing; - this.currentDuration = data.duration; - this.song = new NamedNode(data.song); - this.overviewZoom = { duration: data.duration, t1: 0, t2: data.duration }; - const t1 = data.t - 2, - t2 = data.t + 20; - this.zoom = { duration: data.duration, t1, t2 }; - const timeRow = this.shadowRoot!.querySelector(".timeRow") as HTMLDivElement; - const w = timeRow.offsetWidth; - this.viewState = { - zoomSpec: { t1: () => t1, t2: () => t2 }, - cursor: { t: () => data.t }, - audioY: () => 0, - audioH: () => 60, - zoomedTimeY: () => 60, - zoomedTimeH: () => 40, - fullZoomX: (sec: number) => (sec / data.duration) * w, - zoomInX: (sec: number) => ((sec - t1) / (t2 - t1)) * w, - mouse: { pos: () => $V([0, 0]) }, - }; - } - - @property() songList: NamedNode[] = []; - constructor() { - super(); - this.bindKeys(); - this.zoom = this.overviewZoom = { duration: null, t1: 0, t2: 1 }; - - getTopGraph().then((g) => { - this.graph = g; - this.musicSetup(); // async - this.graph.runHandler(this.graphChanged.bind(this), "loadsongs"); - }); - } - graphChanged() { - this.songList = []; - try { - const playList = this.graph.uriValue( - // - this.graph.Uri(showRoot), - this.graph.Uri(":playList") - ); - log(playList); - this.songList = this.graph.items(playList) as NamedNode[]; - } catch (e) { - log("no playlist yet"); - } - log(this.songList.length); - } -}
--- a/light9/ascoltami/index.html Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,83 +0,0 @@ -<!DOCTYPE html> -<html> - <head> - <title>ascoltami on {{host}}</title> - <link rel="stylesheet" href="./style.css" /> - <style> - #cmd-go { - min-width: 5em; - } - .song-name { - padding-left: 0.4em; - } - .dimStalled #currentTime { - font-size: 20px; - background: green; - color: black; - padding: 3px; - } - .dimStalled { - font-size: 90%; - } - body { - margin: 0; - padding: 0; - overflow: hidden; - min-height: 100vh; - } - #page { - width: 100%; - height: 100vh; /* my phone was losing the bottom :( */ - display: flex; - flex-direction: column; - } - #page > div, - #page > p { - flex: 0 1 auto; - margin: 0; - } - light9-ascoltami-ui { - flex: 1 1 auto; - } - </style> - <meta - name="viewport" - content="user-scalable=no, width=device-width, initial-scale=.7" - /> - <script type="module" src="../ascoltami/Light9AscoltamiUi"></script> - </head> - <body> - <div id="page"> - <h1>ascoltami on {{host}}</h1> - <div class="songs" style="display: none"></div> - - <div class="dimStalled"> - <table> - <tr> - <td colspan="3"> - <strong>Song:</strong> <span id="currentSong"></span> - </td> - </tr> - <tr> - <td><strong>Time:</strong> <span id="currentTime"></span></td> - <td><strong>Left:</strong> <span id="leftTime"></span></td> - <td> - <strong>Until autostop:</strong> - <span id="leftAutoStopTime"></span> - </td> - </tr> - <tr> - <td colspan="3"> - <span id="states"></span> - </td> - </tr> - </table> - </div> - - <hr /> - <light9-ascoltami-ui></light9-ascoltami-ui> - <p><a href="">reload</a></p> - </div> - <script type="module" src="../ascoltami/main.ts"></script> - </body> -</html>
--- a/light9/ascoltami/main.ts Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,100 +0,0 @@ -function byId(id: string): HTMLElement { - return document.getElementById(id)!; -} - -export interface TimingUpdate { - // GET /ascoltami/time response - duration: number; - next: string; // e.g. 'play' - playing: boolean; - song: string; - started: number; // unix sec - t: number; // seconds into song - state: { current: { name: string }; pending: { name: string } }; -} - -(window as any).finishOldStyleSetup = async (times: { intro: number; post: number }, timingUpdate: (data: TimingUpdate) => void) => { - let currentHighlightedSong = ""; - // let lastPlaying = false; - - - const events = new EventSource("api/time/stream"); - events.addEventListener("message", (m)=>{ - const update = JSON.parse(m.data) as TimingUpdate - updateCurrent(update) - markUpdateTiming(); - }) - - async function updateCurrent(data:TimingUpdate) { - byId("currentSong").innerText = data.song; - if (data.song != currentHighlightedSong) { - showCurrentSong(data.song); - } - byId("currentTime").innerText = data.t.toFixed(1); - byId("leftTime").innerText = (data.duration - data.t).toFixed(1); - byId("leftAutoStopTime").innerText = Math.max(0, data.duration - times.post - data.t).toFixed(1); - byId("states").innerText = JSON.stringify(data.state); - // document.querySelector("#timeSlider").slider({ value: data.t, max: data.duration }); - timingUpdate(data); - } - let recentUpdates: Array<number> = []; - function markUpdateTiming() { - recentUpdates.push(+new Date()); - recentUpdates = recentUpdates.slice(Math.max(recentUpdates.length - 5, 0)); - } - - function refreshUpdateFreqs() { - if (recentUpdates.length > 1) { - if (+new Date() - recentUpdates[recentUpdates.length - 1] > 1000) { - byId("updateActual").innerText = "(stalled)"; - return; - } - - var avgMs = (recentUpdates[recentUpdates.length - 1] - recentUpdates[0]) / (recentUpdates.length - 1); - byId("updateActual").innerText = "" + Math.round(1000 / avgMs); - } - } - setInterval(refreshUpdateFreqs, 2000); - - function showCurrentSong(uri: string) { - document.querySelectorAll(".songs div").forEach((row: Element, i: number) => { - if (row.querySelector("button")!.dataset.uri == uri) { - row.classList.add("currentSong"); - } else { - row.classList.remove("currentSong"); - } - }); - currentHighlightedSong = uri; - } - - const data = await (await fetch("api/songs")).json(); - data.songs.forEach((song: { uri: string; label: string }) => { - const button = document.createElement("button"); - // link is just for dragging, not clicking - const link = document.createElement("a"); - const n = document.createElement("span"); - n.classList.add("num"); - n.innerText = song.label.slice(0, 2); - link.appendChild(n); - - const sn = document.createElement("span"); - sn.classList.add("song-name"); - sn.innerText = song.label.slice(2).trim(); - link.appendChild(sn); - link.setAttribute("href", song.uri); - link.addEventListener("click", (ev) => { - ev.stopPropagation(); - button.click(); - }); - button.appendChild(link); - button.dataset.uri = song.uri; - button.addEventListener("click", async (ev) => { - await fetch("api/song", { method: "POST", body: song.uri }); - showCurrentSong(song.uri); - }); - const dv = document.createElement("div"); - dv.appendChild(button); - document.querySelector(".songs")!.appendChild(dv); - }); - -};
--- a/light9/ascoltami/vite.config.ts Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,20 +0,0 @@ -import { defineConfig } from "vite"; - -const servicePort = 8206; -export default defineConfig({ - base: "/ascoltami/", - root: "./light9/ascoltami", - publicDir: "../web", - server: { - host: "0.0.0.0", - strictPort: true, - port: servicePort + 100, - hmr: { - port: servicePort + 200, - }, - }, - clearScreen: false, - define: { - global: {}, - }, -});
--- a/light9/collector/web/Light9CollectorDevice.ts Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,75 +0,0 @@ -import debug from "debug"; -import { css, html, LitElement } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import { NamedNode } from "n3"; -export { ResourceDisplay } from "../../web/ResourceDisplay"; - -const log = debug("device-el"); - -@customElement("light9-collector-device") -export class Light9CollectorDevice extends LitElement { - static styles = [ - css` - :host { - display: block; - break-inside: avoid-column; - font-size: 80%; - } - h3 { - margin-top: 12px; - margin-bottom: 0; - } - td { - white-space: nowrap; - } - - td.nonzero { - background: #310202; - color: #e25757; - } - td.full { - background: #2b0000; - color: red; - font-weight: bold; - } - `, - ]; - - render() { - return html` - <h3><resource-display .uri=${this.uri}></resource-display></h3> - <table class="borders"> - <tr> - <th>out attr</th> - <th>value</th> - <th>chan</th> - </tr> - ${this.attrs.map( - (item) => html` - <tr> - <td>${item.attr}</td> - <td class=${item.valClass}>${item.val} →</td> - <td>${item.chan}</td> - </tr> - ` - )} - </table> - `; - } - @property({ - converter: acceptStringOrUri(), - }) - uri: NamedNode = new NamedNode(""); - @property() attrs: Array<{ attr: string; valClass: string; val: string; chan: string }> = []; - - setAttrs(attrs: any) { - this.attrs = attrs; - this.attrs.forEach(function (row: any) { - row.valClass = row.val == 255 ? "full" : row.val ? "nonzero" : ""; - }); - } -} - -function acceptStringOrUri() { - return (s: string | null) => new NamedNode(s || ""); -}
--- a/light9/collector/web/Light9CollectorUi.ts Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,91 +0,0 @@ -import debug from "debug"; -import { html, LitElement } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import { NamedNode } from "n3"; -import ReconnectingWebSocket from "reconnectingwebsocket"; -import { sortBy, uniq } from "underscore"; -import { Light9CollectorDevice } from "./Light9CollectorDevice"; -import { Patch } from "../../web/patch"; -import { getTopGraph } from "../../web/RdfdbSyncedGraph"; -import { SyncedGraph } from "../../web/SyncedGraph"; -export { RdfdbSyncedGraph } from "../../web/RdfdbSyncedGraph"; -export { Light9CollectorDevice }; - -debug.enable("*"); -const log = debug("collector"); - -@customElement("light9-collector-ui") -export class Light9CollectorUi extends LitElement { - graph!: SyncedGraph; - render() { - return html`<rdfdb-synced-graph></rdfdb-synced-graph> - <h1>Collector <a href="metrics">[metrics]</a></h1> - - <h2>Devices</h2> - <div style="column-width: 11em">${this.devices.map((d) => html`<light9-collector-device .uri=${d}></light9-collector-device>`)}</div> `; - } - - @property() devices: NamedNode[] = []; - - constructor() { - super(); - getTopGraph().then((g) => { - this.graph = g; - this.graph.runHandler(this.findDevices.bind(this), "findDevices"); - }); - - const ws = new ReconnectingWebSocket(location.href.replace("http", "ws") + "api/updates"); - ws.addEventListener("message", (ev: any) => { - const outputAttrsSet = JSON.parse(ev.data).outputAttrsSet; - if (outputAttrsSet) { - this.updateDev(outputAttrsSet.dev, outputAttrsSet.attrs); - } - }); - } - - findDevices(patch?: Patch) { - const U = this.graph.U(); - - this.devices = []; - this.clearDeviceChildElementCache(); - let classes = this.graph.subjects(U("rdf:type"), U(":DeviceClass")); - uniq(sortBy(classes, "value"), true).forEach((dc) => { - sortBy(this.graph.subjects(U("rdf:type"), dc), "value").forEach((dev) => { - this.devices.push(dev as NamedNode); - }); - }); - } - - deviceElements: Map<string, Light9CollectorDevice> = new Map(); - - clearDeviceChildElementCache() { - this.deviceElements = new Map(); - } - - findDeviceChildElement(uri: string): Light9CollectorDevice | undefined { - const known = this.deviceElements.get(uri); - if (known) { - return known; - } - - for (const el of this.shadowRoot!.querySelectorAll("light9-collector-device")) { - const eld = el as Light9CollectorDevice; - if (eld.uri.value == uri) { - this.deviceElements.set(uri, eld); - return eld; - } - } - - return undefined; - } - - updateDev(uri: string, attrs: { attr: string; chan: string; val: string; valClass: string }[]) { - const el = this.findDeviceChildElement(uri); - if (!el) { - // unresolved race: updates come in before we have device elements to display them - setTimeout(() => this.updateDev(uri, attrs), 300); - return; - } - el.setAttrs(attrs); - } -}
--- a/light9/collector/web/index.html Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,19 +0,0 @@ -<!DOCTYPE html> -<html> - <head> - <title>collector</title> - <meta charset="utf-8" /> - - <link rel="stylesheet" href="./style.css" /> - <script type="module" src="../collector/Light9CollectorUi"></script> - - <style> - td { - white-space: nowrap; - } - </style> - </head> - <body> - <light9-collector-ui></light9-collector-ui> - </body> -</html>
--- a/light9/collector/web/vite.config.ts Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,20 +0,0 @@ -import { defineConfig } from "vite"; - -const servicePort = 8202; -export default defineConfig({ - base: "/collector/", - root: "./light9/collector/web", - publicDir: "../../web", - server: { - host: "0.0.0.0", - strictPort: true, - port: servicePort + 100, - hmr: { - port: servicePort + 200, - }, - }, - clearScreen: false, - define: { - global: {}, - }, -});
--- a/light9/effect/listing/web/Light9EffectListing.ts Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,113 +0,0 @@ -import debug from "debug"; -import { css, html, LitElement } from "lit"; -import { customElement } from "lit/decorators.js"; -import { NamedNode } from "n3"; -import { sortBy } from "underscore"; -import { getTopGraph } from "../../../web/RdfdbSyncedGraph"; -import { SyncedGraph } from "../../../web/SyncedGraph"; -export { ResourceDisplay } from "../../../web/ResourceDisplay"; - -debug.enable("*"); -const log = debug("listing"); - -@customElement("light9-effect-listing") -export class Light9EffectListing extends LitElement { - render() { - return html` - <h1>Effects</h1> - <rdfdb-synced-graph></rdfdb-synced-graph> - - ${this.effects.map((e: NamedNode) => html`<light9-effect-class .uri=${e}></light9-effect-class>`)} - `; - } - graph!: SyncedGraph; - effects: NamedNode[] = []; - - constructor() { - super(); - getTopGraph().then((g) => { - this.graph = g; - this.graph.runHandler(this.getClasses.bind(this), "getClasses"); - }); - } - - getClasses() { - const U = this.graph.U(); - this.effects = this.graph.subjects(U("rdf:type"), U(":Effect")) as NamedNode[]; - this.effects = sortBy(this.effects, (ec: NamedNode) => { - try { - return this.graph.stringValue(ec, U("rdfs:label")); - } catch (e) { - return ec.value; - } - }); - this.requestUpdate(); - } -} - -@customElement("light9-effect-class") -export class Light9EffectClass extends LitElement { - static styles = [ - css` - :host { - display: block; - padding: 5px; - border: 1px solid green; - background: #1e271e; - margin-bottom: 3px; - } - a { - color: #7992d0; - background: #00000859; - min-width: 4em; - min-height: 2em; - display: inline-block; - text-align: center; - vertical-align: middle; - } - resource-display { - min-width: 12em; - font-size: 180%; - } - `, - ]; - render() { - if (!this.uri) { - return html`loading...`; - } - return html` - Effect - <resource-display .uri=${this.uri} rename></resource-display> - <a href="../live?effect=${this.uri.value}">Edit</a> - <iron-ajax id="songEffects" url="/effectEval/songEffects" method="POST" content-type="application/x-www-form-urlencoded"></iron-ajax> - <span style="float:right"> - <button disabled @click=${this.onAdd}>Add to current song</button> - <button disabled @mousedown=${this.onMomentaryPress} @mouseup=${this.onMomentaryRelease}>Add momentary</button> - </span> - `; - } - graph!: SyncedGraph; - uri?: NamedNode; - - onAdd() { - // this.$.songEffects.body = { drop: this.uri.value }; - // this.$.songEffects.generateRequest(); - } - - onMomentaryPress() { - // this.$.songEffects.body = { drop: this.uri.value, event: "start" }; - // this.lastPress = this.$.songEffects.generateRequest(); - // return this.lastPress.completes.then((request: { response: { note: any } }) => { - // return (this.lastMomentaryNote = request.response.note); - // }); - } - - onMomentaryRelease() { - // if (!this.lastMomentaryNote) { - // return; - // } - // this.$.songEffects.body = { drop: this.uri.value, note: this.lastMomentaryNote }; - // this.lastMomentaryNote = null; - // return this.$.songEffects.generateRequest(); - } -}
--- a/light9/effect/listing/web/index.html Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,12 +0,0 @@ -<!doctype html> -<html> - <head> - <title>effect listing</title> - <meta charset="utf-8" /> - <link rel="stylesheet" href="../style.css"> - <script type="module" src="../effectListing/Light9EffectListing"></script> - </head> - <body> - <light9-effect-listing></light9-effect-listing> - </body> -</html>
--- a/light9/effect/listing/web/vite.config.ts Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,21 +0,0 @@ -import { defineConfig } from "vite"; - -const servicePort = 8218; -export default defineConfig({ - base: "/effectListing/", - root: "./light9/effect/listing/web", - publicDir: "../../..", - server: { - host: "0.0.0.0", - base: 'effectListing', - strictPort: true, - port: servicePort + 100, - hmr: { - port: servicePort + 200, - }, - }, - clearScreen: false, - define: { - global: {}, - }, -});
--- a/light9/fade/Light9EffectFader.ts Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,190 +0,0 @@ -import debug from "debug"; -import { css, html, LitElement } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import { NamedNode, Quad } from "n3"; -import { getTopGraph } from "../web/RdfdbSyncedGraph"; -import { showRoot } from "../web/show_specific"; -import { SyncedGraph } from "../web/SyncedGraph"; -import { Patch } from "../web/patch"; -import { Literal } from "n3"; -export { Light9Fader } from "./Light9Fader"; - -const log = debug("efffader") - -////////////////////////////////////// -const RETURN_URI = new NamedNode(""); -const RETURN_FLOAT = 1; -function get2Step<T extends NamedNode | number>(returnWhat: T, graph: SyncedGraph, subj1: NamedNode, pred1: NamedNode, pred2: NamedNode): T | undefined { - // ?subj1 ?pred1 ?x . ?x ?pred2 ?returned . - let x: NamedNode; - try { - x = graph.uriValue(subj1, pred1); - } catch (e) { - return undefined; - } - try { - if (typeof returnWhat === "object" && (returnWhat as NamedNode).termType == "NamedNode") { - return graph.uriValue(x, pred2) as T; - } else if (typeof returnWhat === "number") { - return graph.floatValue(x, pred2) as T; - } - } catch (e) { - return undefined; - } -} -function set2Step( - graph: SyncedGraph, // - subj1: NamedNode, - pred1: NamedNode, - baseName: string, - pred2: NamedNode, - newObjLiteral: Literal -) { } - -function maybeUriValue(graph: SyncedGraph, s: NamedNode, p: NamedNode): NamedNode | undefined { - try { - return graph.uriValue(s, p); - } catch (e) { - return undefined; - } -} -function maybeStringValue(graph: SyncedGraph, s: NamedNode, p: NamedNode): string | undefined { - try { - return graph.stringValue(s, p); - } catch (e) { - return undefined; - } -} -function maybeFloatValue(graph: SyncedGraph, s: NamedNode, p: NamedNode): number | undefined { - try { - return graph.floatValue(s, p); - } catch (e) { - return undefined; - } -} - -////////////////////////////////////// -class EffectFader { - constructor(public uri: NamedNode) { } - column: string = "unset"; - effect?: NamedNode; - effectAttr?: NamedNode; // :strength - setting?: NamedNode; // we assume fader always has exactly one setting - value?: number; -} - -@customElement("light9-effect-fader") -export class Light9EffectFader extends LitElement { - static styles = [ - css` - :host { - display: inline-block; - border: 2px gray outset; - background: #272727; - } - light9-fader { - margin: 0px; - width: 100%; - } - `, - ]; - render() { - if (this.conf === undefined || this.conf.value === undefined) { - return html`...`; - } - return html` - <div><resource-display .uri=${this.uri}></resource-display> - <light9-fader .value=${this.conf.value} @change=${this.onSliderInput}></light9-fader> - <div>${this.conf.value.toPrecision(3)}</div> - <div>effect <edit-choice nounlink .uri=${this.conf.effect} @edited=${this.onEffectChange}></edit-choice></div> - <div>attr <edit-choice nounlink .uri=${this.conf.effectAttr} @edited=${this.onEffectAttrChange}></edit-choice></div> - `; - } - - 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); - } -}
--- a/light9/fade/Light9FadeUi.ts Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,169 +0,0 @@ -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 "../web/patch"; -import { getTopGraph } from "../web/RdfdbSyncedGraph"; -import { showRoot } from "../web/show_specific"; -import { SyncedGraph } from "../web/SyncedGraph"; -export { EditChoice } from "../web/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` - <rdfdb-synced-graph></rdfdb-synced-graph> - - <h1>Fade</h1> -<div id="gm"> - <light9-fader .value=${this.grandMaster} @change=${this.gmChanged}></light9-fader>grand master -</div> - ${(this.fadePages?.pages || []).map(this.renderPage.bind(this))} - - <div><button @click=${this.addPage}>Add new page</button></div> - `; - } - private renderPage(page: FadePage): TemplateResult { - const mappedToHw = this.currentHwPage !== undefined && page.uri.equals(this.currentHwPage); - return html`<div class="${mappedToHw ? "mappedToHw" : ""}"> - <fieldset> - <legend> - Page - <resource-display rename .uri=${page.uri}></resource-display> - ${mappedToHw ? html`mapped to hardware sliders` : html` - <button @click=${(ev: Event) => this.mapThisToHw(page.uri)}>Map this to hw</button> - `} - </legend> - ${page.faderConfigs.map((fd) => html` <light9-effect-fader .uri=${fd.uri}></light9-effect-fader> `)} - </fieldset> - </div>`; - } - - 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)); - } -}
--- a/light9/fade/Light9Fader.ts Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,146 +0,0 @@ -import debug from "debug"; -import { css, html, LitElement, PropertyValueMap } from "lit"; -import { customElement, property, query } from "lit/decorators.js"; - -import { clamp } from "../web/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` <div id="handle"><hr /></div> `; - } - - protected update(changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): 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<any> | Map<PropertyKey, unknown>): void { - super.updated(_changedProperties); - const y = this.sliderTopY(this.value); - this.handleEl.style.top = y + "px"; - } - - protected firstUpdated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): 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); - } -}
--- a/light9/fade/index.html Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,13 +0,0 @@ -<!doctype html> -<html> - <head> - <title>fade</title> - <meta charset="utf-8" /> - <link rel="stylesheet" href="../style.css"> - <script src="node_modules/fpsmeter/dist/fpsmeter.min.js"></script> - <script type="module" src="./Light9FadeUi"></script> - </head> - <body> - <light9-fade-ui></light9-fade-ui> - </body> -</html>
--- a/light9/fade/vite.config.ts Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,22 +0,0 @@ -import { defineConfig } from "vite"; - -const servicePort = 8219; -export default defineConfig({ - base: "/fade/", - root: "./light9/fade/", - publicDir: "../..", - - server: { - host: "0.0.0.0", - base: "/fade/", - strictPort: true, - port: servicePort + 100, - hmr: { - port: servicePort + 200, - }, - }, - clearScreen: false, - define: { - global: {}, - }, -});
--- a/light9/homepage/ServiceButtonRow.ts Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,66 +0,0 @@ -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` - <div> - <div class="left"><a class="big" href="${this.name}/">${this.name}</a></div> - <div class="window"><button @click="${this.click}">window</button></div> - ${this.hasMetrics ? html`<div><a href="${this.name}/metrics">metrics</a></div>` : ""} - </div> - - ${this.hasMetrics ? html`<div id="stats"><stats-line name="${this.name}"></div>` : ""} - `; - } - - click() { - window.open(this.name + "/", "_blank", "scrollbars=1,resizable=1,titlebar=0,location=0"); - } -}
--- a/light9/homepage/StatsLine.ts Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,306 +0,0 @@ -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 "../web/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`<td>${content}</td>`; - } - - 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`<div class="bar" style="height: ${y * scl}px; background: ${color};"></div>`; - }; - return html`<td> - <div class="recents">${d.recents.map(bar)}</div> - <div>avg=${d.average.toPrecision(3)}</div> - </td>`; - } - - table(d: Metrics, path: string[]): TemplateResult { - const byName = new Map<string, Metric>(); - 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`<th>${col}</th>`; - }; - const td = (col: string): TemplateResult => { - const cell = byName.get(col)!; - return html`${this.drawLevel(cell, path.concat(col))}`; - }; - return html` <table> - <tr> - ${cols.map(th)} - </tr> - <tr> - ${cols.map(td)} - </tr> - </table>`; - } - - 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`<div - title="bucket=${level} count=${count}" - style="background: yellow; margin-right: 1px; width: 8px; height: ${h}px; display: inline-block" - ></div>` - ); - } - 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` - <div> - <table> - ${displayedStats.map( - (row, rowNum) => html` - <tr> - <th>${this.tightMetric(row.name)}</th> - <td> - <table> - ${row.metrics.map( - (v) => html` - <tr> - <td>${this.tightLabel(v.labels)}</td> - <td>${this.valueDisplay(row, v)}</td> - </tr> - ` - )} - </table> - </td> - ${rowNum == 0 - ? html` - <td rowspan="${displayedStats.length}"> - <stats-process id="proc" cpu="${this.cpu}" mem="${this.mem}"></stats-process> - </td> - ` - : ""} - </tr> - ` - )} - </table> - </div> - `; - } -}
--- a/light9/homepage/StatsProcess.ts Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,90 +0,0 @@ -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`<canvas></canvas>`; - } -}
--- a/light9/homepage/write_config.py Thu Jun 08 12:28:27 2023 -0700 +++ b/light9/homepage/write_config.py Thu Jun 08 13:20:23 2023 -0700 @@ -74,7 +74,7 @@ showPath = showconfig.showUri().split('/', 3)[-1] root = showconfig.root()[:-len(showPath)].decode('ascii') print(f''' - location /show {{ + location /show/ {{ root {root}; }}
--- a/light9/live/Effect.ts Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,277 +0,0 @@ -import debug from "debug"; -import { Literal, NamedNode, Quad, Quad_Object, Quad_Predicate, Quad_Subject, Term } from "n3"; -import { some } from "underscore"; -import { Patch } from "../web/patch"; -import { SyncedGraph } from "../web/SyncedGraph"; -import { shortShow } from "../web/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<void> = 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); - } -}
--- a/light9/live/Light9AttrControl.ts Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,195 +0,0 @@ -import debug from "debug"; -import { css, html, LitElement, PropertyValues } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; -import { Literal, NamedNode } from "n3"; -import { SubEvent } from "sub-events"; -import { getTopGraph } from "../web/RdfdbSyncedGraph"; -import { SyncedGraph } from "../web/SyncedGraph"; -import { ControlValue, Effect } from "./Effect"; -import { DeviceAttrRow } from "./Light9DeviceControl"; -export { Slider } from "@material/mwc-slider"; -export { Light9ColorPicker } from "../web/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`<mwc-slider .value=${v} step=${1 / 255} min="0" max="1" @input=${this.onValueInput}></mwc-slider> `; - } else if ((this.dataType = "color")) { - const v = this.value || "#000"; - return html` - <div id="colorControls"> - <button @click=${this.goBlack}>0.0</button> - <light9-color-picker .color=${v} @input=${this.onValueInput}></light9-color-picker> - </div> - `; - } else if (this.dataType == "choice") { - return html`<light9-listbox .choices=${this.deviceAttrRow.choices} .value=${this.value}> </light9-listbox> `; - } - } - - updated(changedProperties: PropertyValues<this>) { - 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; - // } - } -}
--- a/light9/live/Light9DeviceControl.ts Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,210 +0,0 @@ -import debug from "debug"; -import { css, html, LitElement } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import { NamedNode } from "n3"; -import { unique } from "underscore"; -import { Patch } from "../web/patch"; -import { getTopGraph } from "../web/RdfdbSyncedGraph"; -import { SyncedGraph } from "../web/SyncedGraph"; -import { Choice } from "./Light9Listbox"; -import { Light9AttrControl } from "./Light9AttrControl"; -import { Effect } from "./Effect"; -export { ResourceDisplay } from "../web/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` - <div class="device ${this.devClasses}"> - <h2 style="${this._bgStyle(this.deviceClass)}" @click=${this.onClick}> - <resource-display id="mainLabel" .uri="${this.uri}"></resource-display> - a <resource-display minor .uri="${this.deviceClass}"></resource-display> - </h2> - - ${this.deviceAttrs.map( - (dattr: DeviceAttrRow) => html` - <div @click="onAttrClick" class="deviceAttr ${dattr.attrClasses}"> - <span> - attr - <resource-display minor .uri=${dattr.uri}></resource-display> - </span> - <light9-attr-control .deviceAttrRow=${dattr} .effect=${this.effect}> - </light9-attr-control> - </div> - ` - )} - </div> - `; - } - - @property() uri!: NamedNode; - @property() effect!: Effect; - - @property() devClasses: string = ""; // the css kind - @property() deviceAttrs: DeviceAttrRow[] = []; - @property() deviceClass: NamedNode | null = null; - @property() selectedAttrs: Set<NamedNode> = new Set(); - - constructor() { - super(); - getTopGraph().then((g) => { - this.graph = g; - this.graph.runHandler(this.syncDeviceAttrsFromGraph.bind(this), `${this.uri.value} update`); - }); - this.selectedAttrs = new Set(); - } - - _bgStyle(deviceClass: NamedNode | null): string { - if (!deviceClass) return ""; - let hash = 0; - const u = deviceClass.value; - for (let i = u.length - 10; i < u.length; i++) { - hash += u.charCodeAt(i); - } - const hue = (hash * 8) % 360; - const accent = `hsl(${hue}, 49%, 22%)`; - return `background: linear-gradient(to right, rgba(31,31,31,0) 50%, ${accent} 100%);`; - } - - setDeviceSelected(isSel: any) { - this.devClasses = isSel ? "selected" : ""; - } - - setAttrSelected(devAttr: NamedNode, isSel: boolean) { - if (isSel) { - this.selectedAttrs.add(devAttr); - } else { - this.selectedAttrs.delete(devAttr); - } - } - - 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 - } -}
--- a/light9/live/Light9DeviceSettings.ts Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,153 +0,0 @@ -import debug from "debug"; -import { css, html, LitElement, PropertyValues } from "lit"; -import { customElement, property } from "lit/decorators.js"; -import { NamedNode } from "n3"; -import { sortBy, uniq } from "underscore"; -import { Patch } from "../web/patch"; -import { getTopGraph } from "../web/RdfdbSyncedGraph"; -import { SyncedGraph } from "../web/SyncedGraph"; -import { Effect, newEffect } from "./Effect"; -export { EditChoice } from "../web/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` - <rdfdb-synced-graph></rdfdb-synced-graph> - - <h1>effect DeviceSettings</h1> - - <div id="save"> - <div> - <button @click=${this.newEffect}>New effect</button> - <edit-choice .uri=${this.currentEffect ? this.currentEffect.uri : null} @edited=${this.onEffectChoice2} rename></edit-choice> - <button @click=${this.clearAll}>clear settings in this effect</button> - </div> - </div> - - <div id="deviceControls"> - ${this.devices.map( - (device: NamedNode) => html` - <light9-device-control .uri=${device} .effect=${this.currentEffect}> .graphToControls={this.graphToControls} </light9-device-control> - ` - )} - </div> - `; - } - - devices: Array<NamedNode> = []; - @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<this>) { - 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() - } -}
--- a/light9/live/Light9Listbox.ts Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,76 +0,0 @@ -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` - <paper-listbox id="list" selected="{{value}}" attr-for-selected="uri" on-focus-changed="selectOnFocus"> - <paper-item on-focus="selectOnFocus">None</paper-item> - <template is="dom-repeat" items="{{choices}}"> - <paper-item on-focus="selectOnFocus" uri="{{item.uri}}">{{item.label}}</paper-item> - </template> - </paper-listbox> - `; - } - @property() choices: Array<Choice> = []; - @property() value: String | null = null; - - constructor() { - super(); - } - selectOnFocus(ev) { - if (ev.target.uri === undefined) { - // *don't* clear for this, or we can't cycle through all choices (including none) with up/down keys - //this.clear(); - //return; - } - this.value = ev.target.uri; - } - updated(changedProperties: PropertyValues) { - if (changedProperties.has("value")) { - if (this.value === null) { - this.clear(); - } - } - } - onValue(value: String | null) { - if (value === null) { - this.clear(); - } - } - clear() { - this.querySelectorAll("paper-item").forEach(function (item) { - item.blur(); - }); - this.value = null; - } -}
--- a/light9/live/README.md Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,24 +0,0 @@ -This is an editor of :Effect resources, which have graphs like this: - - <http://light9.bigasterisk.com/effect/effect43> a :Effect; - rdfs:label "effect43"; - :publishAttr :strength; - :setting <http://light9.bigasterisk.com/effect/effect43_set0> . - - <http://light9.bigasterisk.com/effect/effect43_set0> :device dev:strip1; :deviceAttr :color; :scaledValue 0.337 . - -# Objects - -SyncedGraph has the true data. - -Effect sends/receives data from one :Effect resource in the graph. Only Effect knows that there are :setting edges in the graph. Everything else on the page -sees the effect as a list of (effect, device, deviceAttr, value) tuples. Those values are non-null. Control elements that aren't contributing the effect -(_probably_ at their zero position, but this is not always true) have a null value. - -GraphToControls has a record of all the control widgets on the page, and sends/receives edits with them. - -We deal in ControlValue objects, which are the union of a brightness, color, choice, etc. Some layers deal in ControlValue|null. A null value means there is no -:setting for that device+attribute - -SyncedGraph and GraphToControls live as long as the web page. Effect can come and go (though there is a plan to make a separate web page url per effect, then -the Effect would live as long as the page too)
--- a/light9/live/index.html Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,25 +0,0 @@ -<!DOCTYPE html> -<html> - <head> - <title>device settings</title> - <meta charset="utf-8" /> - <link rel="stylesheet" href="../style.css" /> - <script type="module" src="./Light9DeviceSettings"></script> - </head> - <body> - <style> - body, - html { - margin: 0; - } - light9-device-settings { - position: absolute; - left: 2px; - top: 2px; - right: 8px; - bottom: 0; - } - </style> - <light9-device-settings></light9-device-settings> - </body> -</html>
--- a/light9/live/vite.config.ts Thu Jun 08 12:28:27 2023 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,20 +0,0 @@ -import { defineConfig } from "vite"; - -const servicePort = 8217; -export default defineConfig({ - base: "/live/", - root: "./light9/live", - publicDir: "../..", - server: { - host: "0.0.0.0", - strictPort: true, - port: servicePort + 100, - hmr: { - port: servicePort + 200, - }, - }, - clearScreen: false, - define: { - global: {}, - }, -});
--- a/light9/web/RdfdbSyncedGraph.ts Thu Jun 08 12:28:27 2023 -0700 +++ b/light9/web/RdfdbSyncedGraph.ts Thu Jun 08 13:20:23 2023 -0700 @@ -39,7 +39,7 @@ ["xsd", "http://www.w3.org/2001/XMLSchema#"], ]); this.graph = new SyncedGraph( - this.testGraph ? "unused" : "/rdfdb/api/syncedGraph", + this.testGraph ? "unused" : "/service/rdfdb/syncedGraph", prefixes, (s: string) => { this.status = s;
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/light9/web/ascoltami/Light9AscoltamiUi.ts Thu Jun 08 13:20:23 2023 -0700 @@ -0,0 +1,310 @@ +import debug from "debug"; +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; +import { NamedNode } from "n3"; +import Sylvester from "sylvester"; +import { Zoom } from "../light9-timeline-audio"; +import { PlainViewState } from "../Light9CursorCanvas"; +import { getTopGraph } from "../RdfdbSyncedGraph"; +import { SyncedGraph } from "../SyncedGraph"; +import { TimingUpdate } from "./main"; +import { showRoot } from "../show_specific"; +export { Light9TimelineAudio } from "../light9-timeline-audio"; +export { Light9CursorCanvas } from "../Light9CursorCanvas"; +export { RdfdbSyncedGraph } from "../RdfdbSyncedGraph"; +export { ResourceDisplay } from "../ResourceDisplay"; +const $V = Sylvester.Vector.create; + +debug.enable("*"); +const log = debug("asco"); + +function byId(id: string): HTMLElement { + return document.getElementById(id)!; +} +async function postJson(url: string, jsBody: Object) { + return fetch(url, { + method: "POST", + headers: { "Content-Type": "applcation/json" }, + body: JSON.stringify(jsBody), + }); +} +@customElement("light9-ascoltami-ui") +export class Light9AscoltamiUi extends LitElement { + graph!: SyncedGraph; + times!: { intro: number; post: number }; + @property() nextText: string = ""; + @property() isPlaying: boolean = false; + @property() show: NamedNode | null = null; + @property() song: NamedNode | null = null; + @property() selectedSong: NamedNode | null = null; + @property() currentDuration: number = 0; + @property() zoom: Zoom; + @property() overviewZoom: Zoom; + @property() viewState: PlainViewState | null = null; + static styles = [ + css` + :host { + display: flex; + flex-direction: column; + } + .timeRow { + margin: 14px; + position: relative; + } + #overview { + height: 60px; + } + #zoomed { + margin-top: 40px; + height: 80px; + } + #cursor { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + } + #grow { + flex: 1 1 auto; + display: flex; + } + #grow > span { + display: flex; + position: relative; + width: 50%; + } + #playSelected { + height: 100px; + } + #songList { + overflow-y: scroll; + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + } + #songList .row { + width: 60%; + min-height: 40px; + text-align: left; + position: relative; + } + #songList .row:nth-child(even) { + background: #333; + } + #songList .row:nth-child(odd) { + background: #444; + } + #songList button { + min-height: 40px; + margin-bottom: 10px; + } + #songList .row.playing { + box-shadow: 0 0 30px red; + background-color: #de5050; + } + `, + ]; + render() { + return html`<rdfdb-synced-graph></rdfdb-synced-graph> + + <link rel="stylesheet" href="../style.css" /> + + <!-- <h1>ascoltami <a href="metrics">[metrics]</a></h1> --> + + <div id="grow"> + <span> + <div id="songList"> + <table> + ${this.songList.map( + (song) => html` + <tr + class="row ${classMap({ + playing: !!(this.song && song.equals(this.song)), + })}" + > + <td><resource-display .uri=${song} noclick></resource-display></td> + <td> + <button @click=${this.onSelectSong.bind(this, song)}> + <span>Select</span> + </button> + </td> + </tr> + ` + )} + </table> + </div> </span + ><span> + <div id="right"> + <div> + Selected: + <resource-display .uri=${this.selectedSong}></resource-display> + </div> + <div> + <button id="playSelected" ?disabled=${this.selectedSong === null} @click=${this.onPlaySelected}>Play selected from start</button> + </div> + </div> + </span> + </div> + + <div class="timeRow"> + <div id="timeSlider"></div> + <light9-timeline-audio id="overview" .show=${this.show} .song=${this.song} .zoom=${this.overviewZoom}> </light9-timeline-audio> + <light9-timeline-audio id="zoomed" .show=${this.show} .song=${this.song} .zoom=${this.zoom}></light9-timeline-audio> + <light9-cursor-canvas id="cursor" .viewState=${this.viewState}></light9-cursor-canvas> + </div> + + <div class="commands"> + <button id="cmd-stop" @click=${this.onCmdStop} class="playMode ${classMap({ active: !this.isPlaying })}"> + <strong>Stop</strong> + <div class="key">s</div> + </button> + <button id="cmd-play" @click=${this.onCmdPlay} class="playMode ${classMap({ active: this.isPlaying })}"> + <strong>Play</strong> + <div class="key">p</div> + </button> + <button id="cmd-intro" @click=${this.onCmdIntro}> + <strong>Skip intro</strong> + <div class="key">i</div> + </button> + <button id="cmd-post" @click=${this.onCmdPost}> + <strong>Skip to Post</strong> + <div class="key">t</div> + </button> + <button id="cmd-go" @click=${this.onCmdGo}> + <strong>Go</strong> + <div class="key">g</div> + <div id="next">${this.nextText}</div> + </button> + </div>`; + } + + onSelectSong(song: NamedNode, ev: MouseEvent) { + if (this.selectedSong && song.equals(this.selectedSong)) { + this.selectedSong = null; + } else { + this.selectedSong = song; + } + } + async onPlaySelected(ev: Event) { + if (!this.selectedSong) { + return; + } + await fetch("../service/ascoltami/song", { method: "POST", body: this.selectedSong.value }); + } + + onCmdStop(ev?: MouseEvent): void { + postJson("../service/ascoltami/time", { pause: true }); + } + onCmdPlay(ev?: MouseEvent): void { + postJson("../service/ascoltami/time", { resume: true }); + } + onCmdIntro(ev?: MouseEvent): void { + postJson("../service/ascoltami/time", { t: this.times.intro, resume: true }); + } + onCmdPost(ev?: MouseEvent): void { + postJson("../service/ascoltami/time", { + t: this.currentDuration - this.times.post, + resume: true, + }); + } + onCmdGo(ev?: MouseEvent): void { + postJson("../service/ascoltami/go", {}); + } + + bindKeys() { + document.addEventListener("keypress", (ev) => { + if (ev.which == 115) { + this.onCmdStop(); + return false; + } + if (ev.which == 112) { + this.onCmdPlay(); + return false; + } + if (ev.which == 105) { + this.onCmdIntro(); + return false; + } + if (ev.which == 116) { + this.onCmdPost(); + return false; + } + + if (ev.key == "g") { + this.onCmdGo(); + return false; + } + return true; + }); + } + + async musicSetup() { + // shoveled over from the vanillajs version + const config = await (await fetch("../service/ascoltami/config")).json(); + this.show = new NamedNode(config.show); + this.times = config.times; + document.title = document.title.replace("{{host}}", config.host); + try { + const h1 = document.querySelector("h1")!; + h1.innerText = h1.innerText.replace("{{host}}", config.host); + } catch (e) {} + + (window as any).finishOldStyleSetup(this.times, this.onOldStyleUpdate.bind(this)); + } + + onOldStyleUpdate(data: TimingUpdate) { + this.nextText = data.next; + this.isPlaying = data.playing; + this.currentDuration = data.duration; + this.song = new NamedNode(data.song); + this.overviewZoom = { duration: data.duration, t1: 0, t2: data.duration }; + const t1 = data.t - 2, + t2 = data.t + 20; + this.zoom = { duration: data.duration, t1, t2 }; + const timeRow = this.shadowRoot!.querySelector(".timeRow") as HTMLDivElement; + const w = timeRow.offsetWidth; + this.viewState = { + zoomSpec: { t1: () => t1, t2: () => t2 }, + cursor: { t: () => data.t }, + audioY: () => 0, + audioH: () => 60, + zoomedTimeY: () => 60, + zoomedTimeH: () => 40, + fullZoomX: (sec: number) => (sec / data.duration) * w, + zoomInX: (sec: number) => ((sec - t1) / (t2 - t1)) * w, + mouse: { pos: () => $V([0, 0]) }, + }; + } + + @property() songList: NamedNode[] = []; + constructor() { + super(); + this.bindKeys(); + this.zoom = this.overviewZoom = { duration: null, t1: 0, t2: 1 }; + + getTopGraph().then((g) => { + this.graph = g; + this.musicSetup(); // async + this.graph.runHandler(this.graphChanged.bind(this), "loadsongs"); + }); + } + graphChanged() { + this.songList = []; + try { + const playList = this.graph.uriValue( + // + this.graph.Uri(showRoot), + this.graph.Uri(":playList") + ); + log(playList); + this.songList = this.graph.items(playList) as NamedNode[]; + } catch (e) { + log("no playlist yet"); + } + log(this.songList.length); + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/light9/web/ascoltami/index.html Thu Jun 08 13:20:23 2023 -0700 @@ -0,0 +1,83 @@ +<!DOCTYPE html> +<html> + <head> + <title>ascoltami on {{host}}</title> + <link rel="stylesheet" href="../style.css" /> + <style> + #cmd-go { + min-width: 5em; + } + .song-name { + padding-left: 0.4em; + } + .dimStalled #currentTime { + font-size: 20px; + background: green; + color: black; + padding: 3px; + } + .dimStalled { + font-size: 90%; + } + body { + margin: 0; + padding: 0; + overflow: hidden; + min-height: 100vh; + } + #page { + width: 100%; + height: 100vh; /* my phone was losing the bottom :( */ + display: flex; + flex-direction: column; + } + #page > div, + #page > p { + flex: 0 1 auto; + margin: 0; + } + light9-ascoltami-ui { + flex: 1 1 auto; + } + </style> + <meta + name="viewport" + content="user-scalable=no, width=device-width, initial-scale=.7" + /> + <script type="module" src="./Light9AscoltamiUi"></script> + </head> + <body> + <div id="page"> + <h1>ascoltami on {{host}}</h1> + <div class="songs" style="display: none"></div> + + <div class="dimStalled"> + <table> + <tr> + <td colspan="3"> + <strong>Song:</strong> <span id="currentSong"></span> + </td> + </tr> + <tr> + <td><strong>Time:</strong> <span id="currentTime"></span></td> + <td><strong>Left:</strong> <span id="leftTime"></span></td> + <td> + <strong>Until autostop:</strong> + <span id="leftAutoStopTime"></span> + </td> + </tr> + <tr> + <td colspan="3"> + <span id="states"></span> + </td> + </tr> + </table> + </div> + + <hr /> + <light9-ascoltami-ui></light9-ascoltami-ui> + <p><a href="">reload</a></p> + </div> + <script type="module" src="./main.ts"></script> + </body> +</html>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/light9/web/ascoltami/main.ts Thu Jun 08 13:20:23 2023 -0700 @@ -0,0 +1,100 @@ +function byId(id: string): HTMLElement { + return document.getElementById(id)!; +} + +export interface TimingUpdate { + // GET /ascoltami/time response + duration: number; + next: string; // e.g. 'play' + playing: boolean; + song: string; + started: number; // unix sec + t: number; // seconds into song + state: { current: { name: string }; pending: { name: string } }; +} + +(window as any).finishOldStyleSetup = async (times: { intro: number; post: number }, timingUpdate: (data: TimingUpdate) => void) => { + let currentHighlightedSong = ""; + // let lastPlaying = false; + + + const events = new EventSource("../service/ascoltami/time/stream"); + events.addEventListener("message", (m)=>{ + const update = JSON.parse(m.data) as TimingUpdate + updateCurrent(update) + markUpdateTiming(); + }) + + async function updateCurrent(data:TimingUpdate) { + byId("currentSong").innerText = data.song; + if (data.song != currentHighlightedSong) { + showCurrentSong(data.song); + } + byId("currentTime").innerText = data.t.toFixed(1); + byId("leftTime").innerText = (data.duration - data.t).toFixed(1); + byId("leftAutoStopTime").innerText = Math.max(0, data.duration - times.post - data.t).toFixed(1); + byId("states").innerText = JSON.stringify(data.state); + // document.querySelector("#timeSlider").slider({ value: data.t, max: data.duration }); + timingUpdate(data); + } + let recentUpdates: Array<number> = []; + function markUpdateTiming() { + recentUpdates.push(+new Date()); + recentUpdates = recentUpdates.slice(Math.max(recentUpdates.length - 5, 0)); + } + + function refreshUpdateFreqs() { + if (recentUpdates.length > 1) { + if (+new Date() - recentUpdates[recentUpdates.length - 1] > 1000) { + byId("updateActual").innerText = "(stalled)"; + return; + } + + var avgMs = (recentUpdates[recentUpdates.length - 1] - recentUpdates[0]) / (recentUpdates.length - 1); + byId("updateActual").innerText = "" + Math.round(1000 / avgMs); + } + } + setInterval(refreshUpdateFreqs, 2000); + + function showCurrentSong(uri: string) { + document.querySelectorAll(".songs div").forEach((row: Element, i: number) => { + if (row.querySelector("button")!.dataset.uri == uri) { + row.classList.add("currentSong"); + } else { + row.classList.remove("currentSong"); + } + }); + currentHighlightedSong = uri; + } + + const data = await (await fetch("api/songs")).json(); + data.songs.forEach((song: { uri: string; label: string }) => { + const button = document.createElement("button"); + // link is just for dragging, not clicking + const link = document.createElement("a"); + const n = document.createElement("span"); + n.classList.add("num"); + n.innerText = song.label.slice(0, 2); + link.appendChild(n); + + const sn = document.createElement("span"); + sn.classList.add("song-name"); + sn.innerText = song.label.slice(2).trim(); + link.appendChild(sn); + link.setAttribute("href", song.uri); + link.addEventListener("click", (ev) => { + ev.stopPropagation(); + button.click(); + }); + button.appendChild(link); + button.dataset.uri = song.uri; + button.addEventListener("click", async (ev) => { + await fetch("api/song", { method: "POST", body: song.uri }); + showCurrentSong(song.uri); + }); + const dv = document.createElement("div"); + dv.appendChild(button); + document.querySelector(".songs")!.appendChild(dv); + }); + +};
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/light9/web/collector/Light9CollectorDevice.ts Thu Jun 08 13:20:23 2023 -0700 @@ -0,0 +1,75 @@ +import debug from "debug"; +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { NamedNode } from "n3"; +export { ResourceDisplay } from "../../web/ResourceDisplay"; + +const log = debug("device-el"); + +@customElement("light9-collector-device") +export class Light9CollectorDevice extends LitElement { + static styles = [ + css` + :host { + display: block; + break-inside: avoid-column; + font-size: 80%; + } + h3 { + margin-top: 12px; + margin-bottom: 0; + } + td { + white-space: nowrap; + } + + td.nonzero { + background: #310202; + color: #e25757; + } + td.full { + background: #2b0000; + color: red; + font-weight: bold; + } + `, + ]; + + render() { + return html` + <h3><resource-display .uri=${this.uri}></resource-display></h3> + <table class="borders"> + <tr> + <th>out attr</th> + <th>value</th> + <th>chan</th> + </tr> + ${this.attrs.map( + (item) => html` + <tr> + <td>${item.attr}</td> + <td class=${item.valClass}>${item.val} →</td> + <td>${item.chan}</td> + </tr> + ` + )} + </table> + `; + } + @property({ + converter: acceptStringOrUri(), + }) + uri: NamedNode = new NamedNode(""); + @property() attrs: Array<{ attr: string; valClass: string; val: string; chan: string }> = []; + + setAttrs(attrs: any) { + this.attrs = attrs; + this.attrs.forEach(function (row: any) { + row.valClass = row.val == 255 ? "full" : row.val ? "nonzero" : ""; + }); + } +} + +function acceptStringOrUri() { + return (s: string | null) => new NamedNode(s || ""); +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/light9/web/collector/Light9CollectorUi.ts Thu Jun 08 13:20:23 2023 -0700 @@ -0,0 +1,91 @@ +import debug from "debug"; +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { NamedNode } from "n3"; +import ReconnectingWebSocket from "reconnectingwebsocket"; +import { sortBy, uniq } from "underscore"; +import { Patch } from "../patch"; +import { getTopGraph } from "../RdfdbSyncedGraph"; +import { SyncedGraph } from "../SyncedGraph"; +import { Light9CollectorDevice } from "./Light9CollectorDevice"; +export { RdfdbSyncedGraph } from "../RdfdbSyncedGraph"; +export { Light9CollectorDevice }; + +debug.enable("*"); +const log = debug("collector"); + +@customElement("light9-collector-ui") +export class Light9CollectorUi extends LitElement { + graph!: SyncedGraph; + render() { + return html`<rdfdb-synced-graph></rdfdb-synced-graph> + <h1>Collector <a href="metrics">[metrics]</a></h1> + + <h2>Devices</h2> + <div style="column-width: 11em">${this.devices.map((d) => html`<light9-collector-device .uri=${d}></light9-collector-device>`)}</div> `; + } + + @property() devices: NamedNode[] = []; + + constructor() { + super(); + getTopGraph().then((g) => { + this.graph = g; + this.graph.runHandler(this.findDevices.bind(this), "findDevices"); + }); + + const ws = new ReconnectingWebSocket(location.href.replace("http", "ws") + "../service/collector/updates"); + ws.addEventListener("message", (ev: any) => { + const outputAttrsSet = JSON.parse(ev.data).outputAttrsSet; + if (outputAttrsSet) { + this.updateDev(outputAttrsSet.dev, outputAttrsSet.attrs); + } + }); + } + + findDevices(patch?: Patch) { + const U = this.graph.U(); + + this.devices = []; + this.clearDeviceChildElementCache(); + let classes = this.graph.subjects(U("rdf:type"), U(":DeviceClass")); + uniq(sortBy(classes, "value"), true).forEach((dc) => { + sortBy(this.graph.subjects(U("rdf:type"), dc), "value").forEach((dev) => { + this.devices.push(dev as NamedNode); + }); + }); + } + + deviceElements: Map<string, Light9CollectorDevice> = new Map(); + + clearDeviceChildElementCache() { + this.deviceElements = new Map(); + } + + findDeviceChildElement(uri: string): Light9CollectorDevice | undefined { + const known = this.deviceElements.get(uri); + if (known) { + return known; + } + + for (const el of this.shadowRoot!.querySelectorAll("light9-collector-device")) { + const eld = el as Light9CollectorDevice; + if (eld.uri.value == uri) { + this.deviceElements.set(uri, eld); + return eld; + } + } + + return undefined; + } + + updateDev(uri: string, attrs: { attr: string; chan: string; val: string; valClass: string }[]) { + const el = this.findDeviceChildElement(uri); + if (!el) { + // unresolved race: updates come in before we have device elements to display them + setTimeout(() => this.updateDev(uri, attrs), 300); + return; + } + el.setAttrs(attrs); + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/light9/web/collector/index.html Thu Jun 08 13:20:23 2023 -0700 @@ -0,0 +1,19 @@ +<!DOCTYPE html> +<html> + <head> + <title>collector</title> + <meta charset="utf-8" /> + + <link rel="stylesheet" href="../style.css" /> + <script type="module" src="Light9CollectorUi"></script> + + <style> + td { + white-space: nowrap; + } + </style> + </head> + <body> + <light9-collector-ui></light9-collector-ui> + </body> +</html>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/light9/web/effects/Light9EffectListing.ts Thu Jun 08 13:20:23 2023 -0700 @@ -0,0 +1,113 @@ +import debug from "debug"; +import { css, html, LitElement } from "lit"; +import { customElement } from "lit/decorators.js"; +import { NamedNode } from "n3"; +import { sortBy } from "underscore"; +import { getTopGraph } from "../RdfdbSyncedGraph"; +import { SyncedGraph } from "../SyncedGraph"; +export { ResourceDisplay } from "../ResourceDisplay"; + +debug.enable("*"); +const log = debug("listing"); + +@customElement("light9-effect-listing") +export class Light9EffectListing extends LitElement { + render() { + return html` + <h1>Effects</h1> + <rdfdb-synced-graph></rdfdb-synced-graph> + + ${this.effects.map((e: NamedNode) => html`<light9-effect-class .uri=${e}></light9-effect-class>`)} + `; + } + graph!: SyncedGraph; + effects: NamedNode[] = []; + + constructor() { + super(); + getTopGraph().then((g) => { + this.graph = g; + this.graph.runHandler(this.getClasses.bind(this), "getClasses"); + }); + } + + getClasses() { + const U = this.graph.U(); + this.effects = this.graph.subjects(U("rdf:type"), U(":Effect")) as NamedNode[]; + this.effects = sortBy(this.effects, (ec: NamedNode) => { + try { + return this.graph.stringValue(ec, U("rdfs:label")); + } catch (e) { + return ec.value; + } + }); + this.requestUpdate(); + } +} + +@customElement("light9-effect-class") +export class Light9EffectClass extends LitElement { + static styles = [ + css` + :host { + display: block; + padding: 5px; + border: 1px solid green; + background: #1e271e; + margin-bottom: 3px; + } + a { + color: #7992d0; + background: #00000859; + min-width: 4em; + min-height: 2em; + display: inline-block; + text-align: center; + vertical-align: middle; + } + resource-display { + min-width: 12em; + font-size: 180%; + } + `, + ]; + render() { + if (!this.uri) { + return html`loading...`; + } + return html` + Effect + <resource-display .uri=${this.uri} rename></resource-display> + <a href="../live?effect=${this.uri.value}">Edit</a> + <iron-ajax id="songEffects" url="/effectEval/songEffects" method="POST" content-type="application/x-www-form-urlencoded"></iron-ajax> + <span style="float:right"> + <button disabled @click=${this.onAdd}>Add to current song</button> + <button disabled @mousedown=${this.onMomentaryPress} @mouseup=${this.onMomentaryRelease}>Add momentary</button> + </span> + `; + } + graph!: SyncedGraph; + uri?: NamedNode; + + onAdd() { + // this.$.songEffects.body = { drop: this.uri.value }; + // this.$.songEffects.generateRequest(); + } + + onMomentaryPress() { + // this.$.songEffects.body = { drop: this.uri.value, event: "start" }; + // this.lastPress = this.$.songEffects.generateRequest(); + // return this.lastPress.completes.then((request: { response: { note: any } }) => { + // return (this.lastMomentaryNote = request.response.note); + // }); + } + + onMomentaryRelease() { + // if (!this.lastMomentaryNote) { + // return; + // } + // this.$.songEffects.body = { drop: this.uri.value, note: this.lastMomentaryNote }; + // this.lastMomentaryNote = null; + // return this.$.songEffects.generateRequest(); + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/light9/web/effects/index.html Thu Jun 08 13:20:23 2023 -0700 @@ -0,0 +1,12 @@ +<!doctype html> +<html> + <head> + <title>effect listing</title> + <meta charset="utf-8" /> + <link rel="stylesheet" href="../style.css"> + <script type="module" src="./Light9EffectListing"></script> + </head> + <body> + <light9-effect-listing></light9-effect-listing> + </body> +</html>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/light9/web/fade/Light9EffectFader.ts Thu Jun 08 13:20:23 2023 -0700 @@ -0,0 +1,190 @@ +import debug from "debug"; +import { css, html, LitElement } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { NamedNode, Quad } from "n3"; +import { getTopGraph } from "../RdfdbSyncedGraph"; +import { showRoot } from "../show_specific"; +import { SyncedGraph } from "../SyncedGraph"; +import { Patch } from "../patch"; +import { Literal } from "n3"; +export { Light9Fader } from "./Light9Fader"; + +const log = debug("efffader") + +////////////////////////////////////// +const RETURN_URI = new NamedNode(""); +const RETURN_FLOAT = 1; +function get2Step<T extends NamedNode | number>(returnWhat: T, graph: SyncedGraph, subj1: NamedNode, pred1: NamedNode, pred2: NamedNode): T | undefined { + // ?subj1 ?pred1 ?x . ?x ?pred2 ?returned . + let x: NamedNode; + try { + x = graph.uriValue(subj1, pred1); + } catch (e) { + return undefined; + } + try { + if (typeof returnWhat === "object" && (returnWhat as NamedNode).termType == "NamedNode") { + return graph.uriValue(x, pred2) as T; + } else if (typeof returnWhat === "number") { + return graph.floatValue(x, pred2) as T; + } + } catch (e) { + return undefined; + } +} +function set2Step( + graph: SyncedGraph, // + subj1: NamedNode, + pred1: NamedNode, + baseName: string, + pred2: NamedNode, + newObjLiteral: Literal +) { } + +function maybeUriValue(graph: SyncedGraph, s: NamedNode, p: NamedNode): NamedNode | undefined { + try { + return graph.uriValue(s, p); + } catch (e) { + return undefined; + } +} +function maybeStringValue(graph: SyncedGraph, s: NamedNode, p: NamedNode): string | undefined { + try { + return graph.stringValue(s, p); + } catch (e) { + return undefined; + } +} +function maybeFloatValue(graph: SyncedGraph, s: NamedNode, p: NamedNode): number | undefined { + try { + return graph.floatValue(s, p); + } catch (e) { + return undefined; + } +} + +////////////////////////////////////// +class EffectFader { + constructor(public uri: NamedNode) { } + column: string = "unset"; + effect?: NamedNode; + effectAttr?: NamedNode; // :strength + setting?: NamedNode; // we assume fader always has exactly one setting + value?: number; +} + +@customElement("light9-effect-fader") +export class Light9EffectFader extends LitElement { + static styles = [ + css` + :host { + display: inline-block; + border: 2px gray outset; + background: #272727; + } + light9-fader { + margin: 0px; + width: 100%; + } + `, + ]; + render() { + if (this.conf === undefined || this.conf.value === undefined) { + return html`...`; + } + return html` + <div><resource-display .uri=${this.uri}></resource-display> + <light9-fader .value=${this.conf.value} @change=${this.onSliderInput}></light9-fader> + <div>${this.conf.value.toPrecision(3)}</div> + <div>effect <edit-choice nounlink .uri=${this.conf.effect} @edited=${this.onEffectChange}></edit-choice></div> + <div>attr <edit-choice nounlink .uri=${this.conf.effectAttr} @edited=${this.onEffectAttrChange}></edit-choice></div> + `; + } + + 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); + } +}
--- /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` + <rdfdb-synced-graph></rdfdb-synced-graph> + + <h1>Fade</h1> +<div id="gm"> + <light9-fader .value=${this.grandMaster} @change=${this.gmChanged}></light9-fader>grand master +</div> + ${(this.fadePages?.pages || []).map(this.renderPage.bind(this))} + + <div><button @click=${this.addPage}>Add new page</button></div> + `; + } + private renderPage(page: FadePage): TemplateResult { + const mappedToHw = this.currentHwPage !== undefined && page.uri.equals(this.currentHwPage); + return html`<div class="${mappedToHw ? "mappedToHw" : ""}"> + <fieldset> + <legend> + Page + <resource-display rename .uri=${page.uri}></resource-display> + ${mappedToHw ? html`mapped to hardware sliders` : html` + <button @click=${(ev: Event) => this.mapThisToHw(page.uri)}>Map this to hw</button> + `} + </legend> + ${page.faderConfigs.map((fd) => html` <light9-effect-fader .uri=${fd.uri}></light9-effect-fader> `)} + </fieldset> + </div>`; + } + + 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)); + } +}
--- /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` <div id="handle"><hr /></div> `; + } + + protected update(changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): 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<any> | Map<PropertyKey, unknown>): void { + super.updated(_changedProperties); + const y = this.sliderTopY(this.value); + this.handleEl.style.top = y + "px"; + } + + protected firstUpdated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): 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); + } +}
--- /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 @@ +<!doctype html> +<html> + <head> + <title>fade</title> + <meta charset="utf-8" /> + <link rel="stylesheet" href="../style.css"> + <script src="node_modules/fpsmeter/dist/fpsmeter.min.js"></script> + <script type="module" src="./Light9FadeUi"></script> + </head> + <body> + <light9-fade-ui></light9-fade-ui> + </body> +</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 @@ <title>light9 home</title> <meta charset="utf-8" /> <link rel="stylesheet" href="style.css" /> - <script type="module" src="./ServiceButtonRow.ts"></script> + <script type="module" src="metrics/ServiceButtonRow.ts"></script> </head> <body> <h1>light9 home page</h1>
--- /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<void> = 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); + } +}
--- /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`<mwc-slider .value=${v} step=${1 / 255} min="0" max="1" @input=${this.onValueInput}></mwc-slider> `; + } else if ((this.dataType = "color")) { + const v = this.value || "#000"; + return html` + <div id="colorControls"> + <button @click=${this.goBlack}>0.0</button> + <light9-color-picker .color=${v} @input=${this.onValueInput}></light9-color-picker> + </div> + `; + } else if (this.dataType == "choice") { + return html`<light9-listbox .choices=${this.deviceAttrRow.choices} .value=${this.value}> </light9-listbox> `; + } + } + + updated(changedProperties: PropertyValues<this>) { + 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; + // } + } +}
--- /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` + <div class="device ${this.devClasses}"> + <h2 style="${this._bgStyle(this.deviceClass)}" @click=${this.onClick}> + <resource-display id="mainLabel" .uri="${this.uri}"></resource-display> + a <resource-display minor .uri="${this.deviceClass}"></resource-display> + </h2> + + ${this.deviceAttrs.map( + (dattr: DeviceAttrRow) => html` + <div @click="onAttrClick" class="deviceAttr ${dattr.attrClasses}"> + <span> + attr + <resource-display minor .uri=${dattr.uri}></resource-display> + </span> + <light9-attr-control .deviceAttrRow=${dattr} .effect=${this.effect}> + </light9-attr-control> + </div> + ` + )} + </div> + `; + } + + @property() uri!: NamedNode; + @property() effect!: Effect; + + @property() devClasses: string = ""; // the css kind + @property() deviceAttrs: DeviceAttrRow[] = []; + @property() deviceClass: NamedNode | null = null; + @property() selectedAttrs: Set<NamedNode> = new Set(); + + constructor() { + super(); + getTopGraph().then((g) => { + this.graph = g; + this.graph.runHandler(this.syncDeviceAttrsFromGraph.bind(this), `${this.uri.value} update`); + }); + this.selectedAttrs = new Set(); + } + + _bgStyle(deviceClass: NamedNode | null): string { + if (!deviceClass) return ""; + let hash = 0; + const u = deviceClass.value; + for (let i = u.length - 10; i < u.length; i++) { + hash += u.charCodeAt(i); + } + const hue = (hash * 8) % 360; + const accent = `hsl(${hue}, 49%, 22%)`; + return `background: linear-gradient(to right, rgba(31,31,31,0) 50%, ${accent} 100%);`; + } + + setDeviceSelected(isSel: any) { + this.devClasses = isSel ? "selected" : ""; + } + + setAttrSelected(devAttr: NamedNode, isSel: boolean) { + if (isSel) { + this.selectedAttrs.add(devAttr); + } else { + this.selectedAttrs.delete(devAttr); + } + } + + 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 + } +}
--- /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` + <rdfdb-synced-graph></rdfdb-synced-graph> + + <h1>effect DeviceSettings</h1> + + <div id="save"> + <div> + <button @click=${this.newEffect}>New effect</button> + <edit-choice .uri=${this.currentEffect ? this.currentEffect.uri : null} @edited=${this.onEffectChoice2} rename></edit-choice> + <button @click=${this.clearAll}>clear settings in this effect</button> + </div> + </div> + + <div id="deviceControls"> + ${this.devices.map( + (device: NamedNode) => html` + <light9-device-control .uri=${device} .effect=${this.currentEffect}> .graphToControls={this.graphToControls} </light9-device-control> + ` + )} + </div> + `; + } + + devices: Array<NamedNode> = []; + @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<this>) { + 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() + } +}
--- /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` + <paper-listbox id="list" selected="{{value}}" attr-for-selected="uri" on-focus-changed="selectOnFocus"> + <paper-item on-focus="selectOnFocus">None</paper-item> + <template is="dom-repeat" items="{{choices}}"> + <paper-item on-focus="selectOnFocus" uri="{{item.uri}}">{{item.label}}</paper-item> + </template> + </paper-listbox> + `; + } + @property() choices: Array<Choice> = []; + @property() value: String | null = null; + + constructor() { + super(); + } + selectOnFocus(ev) { + if (ev.target.uri === undefined) { + // *don't* clear for this, or we can't cycle through all choices (including none) with up/down keys + //this.clear(); + //return; + } + this.value = ev.target.uri; + } + updated(changedProperties: PropertyValues) { + if (changedProperties.has("value")) { + if (this.value === null) { + this.clear(); + } + } + } + onValue(value: String | null) { + if (value === null) { + this.clear(); + } + } + clear() { + this.querySelectorAll("paper-item").forEach(function (item) { + item.blur(); + }); + this.value = null; + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/light9/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: + + <http://light9.bigasterisk.com/effect/effect43> a :Effect; + rdfs:label "effect43"; + :publishAttr :strength; + :setting <http://light9.bigasterisk.com/effect/effect43_set0> . + + <http://light9.bigasterisk.com/effect/effect43_set0> :device dev:strip1; :deviceAttr :color; :scaledValue 0.337 . + +# Objects + +SyncedGraph has the true data. + +Effect sends/receives data from one :Effect resource in the graph. Only Effect knows that there are :setting edges in the graph. Everything else on the page +sees the effect as a list of (effect, device, deviceAttr, value) tuples. Those values are non-null. Control elements that aren't contributing the effect +(_probably_ at their zero position, but this is not always true) have a null value. + +GraphToControls has a record of all the control widgets on the page, and sends/receives edits with them. + +We deal in ControlValue objects, which are the union of a brightness, color, choice, etc. Some layers deal in ControlValue|null. A null value means there is no +:setting for that device+attribute + +SyncedGraph and GraphToControls live as long as the web page. Effect can come and go (though there is a plan to make a separate web page url per effect, then +the Effect would live as long as the page too)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/light9/web/live/index.html Thu Jun 08 13:20:23 2023 -0700 @@ -0,0 +1,25 @@ +<!DOCTYPE html> +<html> + <head> + <title>device settings</title> + <meta charset="utf-8" /> + <link rel="stylesheet" href="../style.css" /> + <script type="module" src="./Light9DeviceSettings"></script> + </head> + <body> + <style> + body, + html { + margin: 0; + } + light9-device-settings { + position: absolute; + left: 2px; + top: 2px; + right: 8px; + bottom: 0; + } + </style> + <light9-device-settings></light9-device-settings> + </body> +</html>
--- /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` + <div> + <div class="left"><a class="big" href="${this.name}/">${this.name}</a></div> + <div class="window"><button @click="${this.click}">window</button></div> + ${this.hasMetrics ? html`<div><a href="${this.name}/metrics">metrics</a></div>` : ""} + </div> + + ${this.hasMetrics ? html`<div id="stats"><stats-line name="${this.name}"></div>` : ""} + `; + } + + click() { + window.open(this.name + "/", "_blank", "scrollbars=1,resizable=1,titlebar=0,location=0"); + } +}
--- /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`<td>${content}</td>`; + } + + 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`<div class="bar" style="height: ${y * scl}px; background: ${color};"></div>`; + }; + return html`<td> + <div class="recents">${d.recents.map(bar)}</div> + <div>avg=${d.average.toPrecision(3)}</div> + </td>`; + } + + table(d: Metrics, path: string[]): TemplateResult { + const byName = new Map<string, Metric>(); + 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`<th>${col}</th>`; + }; + const td = (col: string): TemplateResult => { + const cell = byName.get(col)!; + return html`${this.drawLevel(cell, path.concat(col))}`; + }; + return html` <table> + <tr> + ${cols.map(th)} + </tr> + <tr> + ${cols.map(td)} + </tr> + </table>`; + } + + 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`<div + title="bucket=${level} count=${count}" + style="background: yellow; margin-right: 1px; width: 8px; height: ${h}px; display: inline-block" + ></div>` + ); + } + 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` + <div> + <table> + ${displayedStats.map( + (row, rowNum) => html` + <tr> + <th>${this.tightMetric(row.name)}</th> + <td> + <table> + ${row.metrics.map( + (v) => html` + <tr> + <td>${this.tightLabel(v.labels)}</td> + <td>${this.valueDisplay(row, v)}</td> + </tr> + ` + )} + </table> + </td> + ${rowNum == 0 + ? html` + <td rowspan="${displayedStats.length}"> + <stats-process id="proc" cpu="${this.cpu}" mem="${this.mem}"></stats-process> + </td> + ` + : ""} + </tr> + ` + )} + </table> + </div> + `; + } +}
--- /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`<canvas></canvas>`; + } +}
--- 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,