Files
portal/web/js/components/portal-controls.js
Jimmy 5094c7bcee Add portal web simulator, SPI bridges, and Pico firmware updates.
Bring the five-panel hex portal online with a browser 3D/schematic preview, Pi SPI backends, and renamed multi-panel Pico UDP firmware.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 14:54:51 +12:00

186 lines
5.6 KiB
JavaScript

const CONTROL_STYLES = `
:host {
display: block;
border-right: 1px solid var(--border, #2a3044);
background: var(--panel, #12151f);
padding: 1rem;
overflow-y: auto;
color: var(--text, #e8ecf7);
font-family: system-ui, sans-serif;
}
h1 {
margin: 0 0 0.25rem;
font-size: 1.15rem;
}
p {
margin: 0 0 1rem;
color: var(--muted, #8b93a8);
font-size: 0.85rem;
line-height: 1.4;
}
a {
color: var(--accent, #5b8cff);
}
label {
display: block;
margin: 0.75rem 0 0.35rem;
font-size: 0.8rem;
color: var(--muted, #8b93a8);
}
select, input[type="range"], button {
width: 100%;
}
select, button {
background: #1a1f2e;
color: inherit;
border: 1px solid var(--border, #2a3044);
border-radius: 6px;
padding: 0.45rem 0.6rem;
font-size: 0.9rem;
}
button {
margin-top: 0.75rem;
cursor: pointer;
background: #243049;
}
button:hover { border-color: var(--accent, #5b8cff); }
button.active {
background: #2a3f6e;
border-color: var(--accent, #5b8cff);
}
.view-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
`;
export class PortalControls extends HTMLElement {
constructor() {
super();
/** @type {import('../api.js').PortalConfig | null} */
this._config = null;
this._animation = "rainbow";
this._brightness = 0.35;
this._fps = 25;
this._paused = false;
this._identify = false;
this._view3d = true;
}
connectedCallback() {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `
<style>${CONTROL_STYLES}</style>
<h1>Portal Simulator</h1>
<p>Hexagonal LED arch (see <a href="https://technical.kiwi/images/portal/IMG_20241029_220222.jpg" target="_blank" rel="noopener">photo</a>). Panel 2 top (Pi); 0, 1, 3, 4 walls; floor bare.</p>
<label for="animation">Animation</label>
<select id="animation"></select>
<label for="brightness">Brightness</label>
<input id="brightness" type="range" min="0.05" max="1" step="0.05" value="0.35" />
<label for="fps">FPS</label>
<input id="fps" type="range" min="5" max="40" step="1" value="25" />
<div class="view-toggle">
<button type="button" data-view="3d" class="active">3D view</button>
<button type="button" data-view="flat">Flat map</button>
</div>
<button type="button" data-action="identify">Identify panels</button>
<button type="button" data-action="pause">Pause</button>
`;
this._animationEl = root.querySelector("#animation");
this._brightnessEl = root.querySelector("#brightness");
this._fpsEl = root.querySelector("#fps");
this._animationEl.addEventListener("change", () => {
this._animation = this._animationEl.value;
if (this._config?.defaultFps[this._animation]) {
this._fps = Math.min(40, Math.round(this._config.defaultFps[this._animation]));
this._fpsEl.value = String(this._fps);
}
this._emitChange();
});
this._brightnessEl.addEventListener("input", () => {
this._brightness = Number(this._brightnessEl.value);
this._emitChange();
});
this._fpsEl.addEventListener("input", () => {
this._fps = Number(this._fpsEl.value);
this._emitChange();
});
root.querySelector('[data-view="3d"]').addEventListener("click", () => {
this._view3d = true;
this._syncViewButtons();
this._emitChange();
});
root.querySelector('[data-view="flat"]').addEventListener("click", () => {
this._view3d = false;
this._syncViewButtons();
this._emitChange();
});
root.querySelector('[data-action="identify"]').addEventListener("click", (e) => {
this._identify = !this._identify;
e.currentTarget.classList.toggle("active", this._identify);
this._emitChange();
});
root.querySelector('[data-action="pause"]').addEventListener("click", (e) => {
this._paused = !this._paused;
e.currentTarget.textContent = this._paused ? "Resume" : "Pause";
this._emitChange();
});
}
/** @param {import('../api.js').PortalConfig} config */
setConfig(config) {
this._config = config;
if (!this.shadowRoot) this.connectedCallback();
this._animationEl.innerHTML = "";
for (const name of config.animations) {
if (name === "solid") continue;
const opt = document.createElement("option");
opt.value = name;
opt.textContent = name;
this._animationEl.appendChild(opt);
}
this._animationEl.value = "rainbow";
this._animation = "rainbow";
this._brightness = config.defaultBrightness ?? 0.35;
this._brightnessEl.value = String(this._brightness);
this._fps = Math.min(40, Math.round(config.defaultFps.rainbow ?? 25));
this._fpsEl.value = String(this._fps);
this._emitChange();
}
_syncViewButtons() {
const root = this.shadowRoot;
root.querySelector('[data-view="3d"]').classList.toggle("active", this._view3d);
root.querySelector('[data-view="flat"]').classList.toggle("active", !this._view3d);
}
_emitChange() {
this.dispatchEvent(
new CustomEvent("portal-settings", {
bubbles: true,
composed: true,
detail: {
animation: this._animation,
brightness: this._brightness,
fps: this._fps,
paused: this._paused,
identify: this._identify,
view3d: this._view3d,
},
}),
);
}
/** @returns {import('../api.js').PortalConfig | null} */
get config() {
return this._config;
}
}
customElements.define("portal-controls", PortalControls);