519 lines
14 KiB
TypeScript
519 lines
14 KiB
TypeScript
/**
|
|
* 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<typeof setInterval> | 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`
|
|
<div class="header" data-drag>
|
|
<span class="header-title">🎛️ Knob</span>
|
|
<span class="header-actions">
|
|
<button class="close-btn">×</button>
|
|
</span>
|
|
</div>
|
|
<div class="body">
|
|
<input class="title-input" type="text" placeholder="Parameter name..." />
|
|
<svg class="knob-svg" width="80" height="80" viewBox="0 0 80 80">
|
|
<circle cx="40" cy="40" r="35" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="6" />
|
|
<circle class="cooldown-ring" cx="40" cy="40" r="35" fill="none" stroke="#f59e0b"
|
|
stroke-width="6" stroke-dasharray="220" stroke-dashoffset="220"
|
|
transform="rotate(-90 40 40)" opacity="0.5" />
|
|
<path class="knob-arc" fill="none" stroke="${HEADER_COLOR}" stroke-width="6" stroke-linecap="round" d="" />
|
|
<circle class="knob-dot" cx="40" cy="40" r="5" fill="white" />
|
|
</svg>
|
|
<div class="value-row">
|
|
<input class="value-input" type="number" />
|
|
<span class="unit-label"></span>
|
|
</div>
|
|
<div class="range-row">
|
|
<span>min</span>
|
|
<input class="range-input min-input" type="number" />
|
|
<span>max</span>
|
|
<input class="range-input max-input" type="number" />
|
|
<span>step</span>
|
|
<input class="range-input step-input" type="number" min="0.01" />
|
|
</div>
|
|
<div class="cooldown-row">
|
|
<span>cooldown</span>
|
|
<input class="cooldown-input" type="number" min="0" />
|
|
<span>s</span>
|
|
<span class="cooldown-active" style="display:none"></span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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<string, any>): 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<string, any>): 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;
|
|
}
|
|
}
|