/** * folk-gov-knob — Adjustable Parameter * * An SVG rotary knob (225° sweep) with numeric input fallback. * Optional "temporal viscosity" cooldown delays value propagation. */ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; import type { PortDescriptor } from "./data-types"; const HEADER_COLOR = "#b45309"; const SWEEP = 225; // degrees const START_ANGLE = (360 - SWEEP) / 2 + 90; // start from bottom-left const styles = css` :host { background: var(--rs-bg-surface, #1e293b); border-radius: 10px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); min-width: 160px; min-height: 120px; overflow: hidden; } .header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: ${HEADER_COLOR}; color: white; font-size: 12px; font-weight: 600; cursor: move; border-radius: 10px 10px 0 0; } .header-title { display: flex; align-items: center; gap: 6px; } .header-actions button { background: transparent; border: none; color: white; cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 14px; } .header-actions button:hover { background: rgba(255, 255, 255, 0.2); } .body { display: flex; flex-direction: column; align-items: center; padding: 12px; gap: 8px; } .title-input { background: transparent; border: none; color: var(--rs-text-primary, #e2e8f0); font-size: 12px; font-weight: 600; text-align: center; width: 100%; outline: none; } .title-input::placeholder { color: var(--rs-text-muted, #64748b); } .knob-svg { cursor: grab; user-select: none; } .knob-svg:active { cursor: grabbing; } .value-row { display: flex; align-items: center; gap: 4px; } .value-input { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 4px; color: var(--rs-text-primary, #e2e8f0); font-size: 12px; font-weight: 600; padding: 2px 6px; width: 60px; text-align: center; outline: none; } .unit-label { font-size: 11px; color: var(--rs-text-muted, #94a3b8); } .range-row { display: flex; gap: 4px; font-size: 10px; color: var(--rs-text-muted, #64748b); } .range-input { background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 3px; color: var(--rs-text-muted, #94a3b8); font-size: 10px; padding: 1px 4px; width: 40px; text-align: center; outline: none; } .cooldown-row { display: flex; align-items: center; gap: 4px; font-size: 10px; color: var(--rs-text-muted, #64748b); } .cooldown-input { background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 3px; color: var(--rs-text-muted, #94a3b8); font-size: 10px; padding: 1px 4px; width: 36px; text-align: center; outline: none; } .cooldown-active { color: #f59e0b; font-weight: 600; } `; declare global { interface HTMLElementTagNameMap { "folk-gov-knob": FolkGovKnob; } } export class FolkGovKnob extends FolkShape { static override tagName = "folk-gov-knob"; static override portDescriptors: PortDescriptor[] = [ { name: "value-out", type: "number", direction: "output" }, ]; static { const sheet = new CSSStyleSheet(); const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n"); const childRules = Array.from(styles.cssRules).map(r => r.cssText).join("\n"); sheet.replaceSync(`${parentRules}\n${childRules}`); this.styles = sheet; } #title = "Parameter"; #min = 0; #max = 100; #step = 1; #value = 50; #unit = ""; #cooldown = 0; // seconds (0 = disabled) #cooldownRemaining = 0; #cooldownTimer: ReturnType | null = null; #pendingValue: number | null = null; // DOM refs #titleEl!: HTMLInputElement; #valueEl!: HTMLInputElement; #knobSvg!: SVGSVGElement; #knobArc!: SVGPathElement; #knobDot!: SVGCircleElement; #cooldownRing!: SVGCircleElement; #cooldownLabel!: HTMLElement; get title() { return this.#title; } set title(v: string) { this.#title = v; if (this.#titleEl) this.#titleEl.value = v; } get min() { return this.#min; } set min(v: number) { this.#min = v; this.#updateKnob(); } get max() { return this.#max; } set max(v: number) { this.#max = v; this.#updateKnob(); } get step() { return this.#step; } set step(v: number) { this.#step = v; } get value() { return this.#value; } set value(v: number) { this.#value = Math.max(this.#min, Math.min(this.#max, v)); if (this.#valueEl) this.#valueEl.value = String(this.#value); this.#updateKnob(); } get unit() { return this.#unit; } set unit(v: string) { this.#unit = v; } get cooldown() { return this.#cooldown; } set cooldown(v: number) { this.#cooldown = Math.max(0, v); } override createRenderRoot() { const root = super.createRenderRoot(); this.initPorts(); const wrapper = document.createElement("div"); wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;"; wrapper.innerHTML = html`
🎛️ Knob
min max step
cooldown s
`; const slot = root.querySelector("slot"); const container = slot?.parentElement as HTMLElement; if (container) container.replaceWith(wrapper); // Cache refs this.#titleEl = wrapper.querySelector(".title-input") as HTMLInputElement; this.#valueEl = wrapper.querySelector(".value-input") as HTMLInputElement; this.#knobSvg = wrapper.querySelector(".knob-svg") as unknown as SVGSVGElement; this.#knobArc = wrapper.querySelector(".knob-arc") as unknown as SVGPathElement; this.#knobDot = wrapper.querySelector(".knob-dot") as unknown as SVGCircleElement; this.#cooldownRing = wrapper.querySelector(".cooldown-ring") as unknown as SVGCircleElement; this.#cooldownLabel = wrapper.querySelector(".cooldown-active") as HTMLElement; const minEl = wrapper.querySelector(".min-input") as HTMLInputElement; const maxEl = wrapper.querySelector(".max-input") as HTMLInputElement; const stepEl = wrapper.querySelector(".step-input") as HTMLInputElement; const cooldownEl = wrapper.querySelector(".cooldown-input") as HTMLInputElement; const unitLabel = wrapper.querySelector(".unit-label") as HTMLElement; // Set initial values this.#titleEl.value = this.#title; this.#valueEl.value = String(this.#value); minEl.value = String(this.#min); maxEl.value = String(this.#max); stepEl.value = String(this.#step); cooldownEl.value = String(this.#cooldown); unitLabel.textContent = this.#unit; this.#updateKnob(); // Wire events const onChange = () => this.dispatchEvent(new CustomEvent("content-change")); this.#titleEl.addEventListener("input", (e) => { e.stopPropagation(); this.#title = this.#titleEl.value; onChange(); }); this.#valueEl.addEventListener("input", (e) => { e.stopPropagation(); const v = parseFloat(this.#valueEl.value); if (!isNaN(v)) this.#setValueWithCooldown(v); onChange(); }); minEl.addEventListener("input", (e) => { e.stopPropagation(); this.#min = parseFloat(minEl.value) || 0; this.#updateKnob(); onChange(); }); maxEl.addEventListener("input", (e) => { e.stopPropagation(); this.#max = parseFloat(maxEl.value) || 100; this.#updateKnob(); onChange(); }); stepEl.addEventListener("input", (e) => { e.stopPropagation(); this.#step = parseFloat(stepEl.value) || 1; onChange(); }); cooldownEl.addEventListener("input", (e) => { e.stopPropagation(); this.#cooldown = parseFloat(cooldownEl.value) || 0; onChange(); }); wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent("close")); }); // Knob drag interaction let dragging = false; this.#knobSvg.addEventListener("pointerdown", (e) => { e.stopPropagation(); dragging = true; (e.target as Element).setPointerCapture(e.pointerId); }); this.#knobSvg.addEventListener("pointermove", (e) => { if (!dragging) return; e.stopPropagation(); const rect = this.#knobSvg.getBoundingClientRect(); const cx = rect.left + rect.width / 2; const cy = rect.top + rect.height / 2; let angle = Math.atan2(e.clientY - cy, e.clientX - cx) * (180 / Math.PI); angle = (angle + 360) % 360; // Map angle to value const startA = START_ANGLE; let relAngle = (angle - startA + 360) % 360; if (relAngle > SWEEP) relAngle = relAngle > SWEEP + (360 - SWEEP) / 2 ? 0 : SWEEP; const ratio = relAngle / SWEEP; let v = this.#min + ratio * (this.#max - this.#min); // Snap to step v = Math.round(v / this.#step) * this.#step; v = Math.max(this.#min, Math.min(this.#max, v)); this.#setValueWithCooldown(v); onChange(); }); this.#knobSvg.addEventListener("pointerup", (e) => { dragging = false; e.stopPropagation(); }); // Prevent drag on all inputs for (const el of wrapper.querySelectorAll("input, button")) { el.addEventListener("pointerdown", (e) => e.stopPropagation()); } // Emit initial value this.#emitPort(); return root; } #setValueWithCooldown(v: number) { v = Math.max(this.#min, Math.min(this.#max, v)); this.#value = v; if (this.#valueEl) this.#valueEl.value = String(v); this.#updateKnob(); if (this.#cooldown > 0) { this.#pendingValue = v; if (!this.#cooldownTimer) { this.#cooldownRemaining = this.#cooldown; this.#cooldownLabel.style.display = ""; this.#cooldownLabel.textContent = `${this.#cooldownRemaining}s`; this.#updateCooldownRing(); this.#cooldownTimer = setInterval(() => { this.#cooldownRemaining--; if (this.#cooldownRemaining <= 0) { clearInterval(this.#cooldownTimer!); this.#cooldownTimer = null; this.#cooldownLabel.style.display = "none"; this.#cooldownRing.setAttribute("stroke-dashoffset", "220"); if (this.#pendingValue !== null) { this.#emitPort(); this.#pendingValue = null; } } else { this.#cooldownLabel.textContent = `${this.#cooldownRemaining}s`; this.#updateCooldownRing(); } }, 1000); } else { // Reset countdown this.#cooldownRemaining = this.#cooldown; this.#cooldownLabel.textContent = `${this.#cooldownRemaining}s`; this.#updateCooldownRing(); } } else { this.#emitPort(); } } #updateCooldownRing() { if (!this.#cooldownRing || this.#cooldown <= 0) return; const circumference = 220; // 2 * π * 35 const progress = this.#cooldownRemaining / this.#cooldown; this.#cooldownRing.setAttribute("stroke-dashoffset", String(circumference * (1 - progress))); } #updateKnob() { if (!this.#knobArc || !this.#knobDot) return; const range = this.#max - this.#min; const ratio = range > 0 ? (this.#value - this.#min) / range : 0; const endAngle = START_ANGLE + ratio * SWEEP; // Arc path const r = 35; const cx = 40; const cy = 40; const startRad = (START_ANGLE * Math.PI) / 180; const endRad = (endAngle * Math.PI) / 180; const x1 = cx + r * Math.cos(startRad); const y1 = cy + r * Math.sin(startRad); const x2 = cx + r * Math.cos(endRad); const y2 = cy + r * Math.sin(endRad); const largeArc = ratio * SWEEP > 180 ? 1 : 0; if (ratio > 0.001) { this.#knobArc.setAttribute("d", `M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2}`); } else { this.#knobArc.setAttribute("d", ""); } // Dot position this.#knobDot.setAttribute("cx", String(x2)); this.#knobDot.setAttribute("cy", String(y2)); } #emitPort() { this.setPortValue("value-out", this.#value); } override disconnectedCallback() { super.disconnectedCallback(); if (this.#cooldownTimer) { clearInterval(this.#cooldownTimer); this.#cooldownTimer = null; } } override toJSON() { return { ...super.toJSON(), type: "folk-gov-knob", title: this.#title, min: this.#min, max: this.#max, step: this.#step, value: this.#value, unit: this.#unit, cooldown: this.#cooldown, }; } static override fromData(data: Record): FolkGovKnob { const shape = FolkShape.fromData.call(this, data) as FolkGovKnob; if (data.title !== undefined) shape.title = data.title; if (data.min !== undefined) shape.min = data.min; if (data.max !== undefined) shape.max = data.max; if (data.step !== undefined) shape.step = data.step; if (data.value !== undefined) shape.value = data.value; if (data.unit !== undefined) shape.unit = data.unit; if (data.cooldown !== undefined) shape.cooldown = data.cooldown; return shape; } override applyData(data: Record): void { super.applyData(data); if (data.title !== undefined && data.title !== this.#title) this.title = data.title; if (data.min !== undefined && data.min !== this.#min) this.min = data.min; if (data.max !== undefined && data.max !== this.#max) this.max = data.max; if (data.step !== undefined && data.step !== this.#step) this.step = data.step; if (data.value !== undefined && data.value !== this.#value) this.value = data.value; if (data.unit !== undefined && data.unit !== this.#unit) this.unit = data.unit; if (data.cooldown !== undefined && data.cooldown !== this.#cooldown) this.cooldown = data.cooldown; } }