410 lines
11 KiB
TypeScript
410 lines
11 KiB
TypeScript
/**
|
|
* folk-gov-quadratic — Weight Transformer
|
|
*
|
|
* Inline weight transform GovMod. Accepts raw weight on input port,
|
|
* applies sqrt/log/linear transform, and emits effective weight on output.
|
|
* Always passes (gate-out = satisfied). Visualizes raw vs effective in a bar chart.
|
|
*/
|
|
|
|
import { FolkShape } from "./folk-shape";
|
|
import { css, html } from "./tags";
|
|
import type { PortDescriptor } from "./data-types";
|
|
|
|
const HEADER_COLOR = "#14b8a6";
|
|
|
|
type TransformMode = "sqrt" | "log" | "linear";
|
|
|
|
interface WeightEntry {
|
|
who: string;
|
|
raw: number;
|
|
effective: number;
|
|
}
|
|
|
|
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: 240px;
|
|
min-height: 140px;
|
|
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;
|
|
padding: 12px;
|
|
gap: 8px;
|
|
}
|
|
|
|
.title-input {
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--rs-text-primary, #e2e8f0);
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
width: 100%;
|
|
outline: none;
|
|
}
|
|
|
|
.title-input::placeholder {
|
|
color: var(--rs-text-muted, #64748b);
|
|
}
|
|
|
|
.mode-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 11px;
|
|
color: var(--rs-text-muted, #94a3b8);
|
|
}
|
|
|
|
.mode-select {
|
|
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: 11px;
|
|
padding: 2px 6px;
|
|
outline: none;
|
|
}
|
|
|
|
.chart-area {
|
|
min-height: 60px;
|
|
}
|
|
|
|
.chart-area svg {
|
|
width: 100%;
|
|
display: block;
|
|
}
|
|
|
|
.entries-list {
|
|
max-height: 80px;
|
|
overflow-y: auto;
|
|
font-size: 10px;
|
|
color: var(--rs-text-muted, #94a3b8);
|
|
}
|
|
|
|
.entry-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 2px 0;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
|
}
|
|
|
|
.status-label {
|
|
font-size: 10px;
|
|
font-weight: 500;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
text-align: center;
|
|
color: #22c55e;
|
|
}
|
|
`;
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
"folk-gov-quadratic": FolkGovQuadratic;
|
|
}
|
|
}
|
|
|
|
export class FolkGovQuadratic extends FolkShape {
|
|
static override tagName = "folk-gov-quadratic";
|
|
|
|
static override portDescriptors: PortDescriptor[] = [
|
|
{ name: "weight-in", type: "json", direction: "input" },
|
|
{ name: "weight-out", type: "json", direction: "output" },
|
|
{ name: "gate-out", type: "json", 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 = "Weight Transform";
|
|
#mode: TransformMode = "sqrt";
|
|
#entries: WeightEntry[] = [];
|
|
|
|
// DOM refs
|
|
#titleEl!: HTMLInputElement;
|
|
#modeEl!: HTMLSelectElement;
|
|
#chartEl!: HTMLElement;
|
|
#listEl!: HTMLElement;
|
|
|
|
get title() { return this.#title; }
|
|
set title(v: string) {
|
|
this.#title = v;
|
|
if (this.#titleEl) this.#titleEl.value = v;
|
|
}
|
|
|
|
get mode() { return this.#mode; }
|
|
set mode(v: TransformMode) {
|
|
this.#mode = v;
|
|
if (this.#modeEl) this.#modeEl.value = v;
|
|
this.#recalc();
|
|
}
|
|
|
|
get entries(): WeightEntry[] { return [...this.#entries]; }
|
|
set entries(v: WeightEntry[]) {
|
|
this.#entries = v;
|
|
this.#updateVisuals();
|
|
this.#emitPorts();
|
|
}
|
|
|
|
#transform(raw: number): number {
|
|
if (raw <= 0) return 0;
|
|
switch (this.#mode) {
|
|
case "sqrt": return Math.sqrt(raw);
|
|
case "log": return Math.log1p(raw);
|
|
case "linear": return raw;
|
|
}
|
|
}
|
|
|
|
#recalc() {
|
|
for (const e of this.#entries) {
|
|
e.effective = this.#transform(e.raw);
|
|
}
|
|
this.#updateVisuals();
|
|
this.#emitPorts();
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
}
|
|
|
|
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">√ Quadratic</span>
|
|
<span class="header-actions">
|
|
<button class="close-btn">×</button>
|
|
</span>
|
|
</div>
|
|
<div class="body">
|
|
<input class="title-input" type="text" placeholder="Transform title..." />
|
|
<div class="mode-row">
|
|
<span>Mode:</span>
|
|
<select class="mode-select">
|
|
<option value="sqrt">√ Sqrt</option>
|
|
<option value="log">log(1+x)</option>
|
|
<option value="linear">Linear</option>
|
|
</select>
|
|
</div>
|
|
<div class="chart-area"></div>
|
|
<div class="entries-list"></div>
|
|
<span class="status-label">PASSTHROUGH</span>
|
|
</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.#modeEl = wrapper.querySelector(".mode-select") as HTMLSelectElement;
|
|
this.#chartEl = wrapper.querySelector(".chart-area") as HTMLElement;
|
|
this.#listEl = wrapper.querySelector(".entries-list") as HTMLElement;
|
|
|
|
// Set initial values
|
|
this.#titleEl.value = this.#title;
|
|
this.#modeEl.value = this.#mode;
|
|
this.#updateVisuals();
|
|
|
|
// Wire events
|
|
this.#titleEl.addEventListener("input", (e) => {
|
|
e.stopPropagation();
|
|
this.#title = this.#titleEl.value;
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
});
|
|
|
|
this.#modeEl.addEventListener("change", (e) => {
|
|
e.stopPropagation();
|
|
this.#mode = this.#modeEl.value as TransformMode;
|
|
this.#recalc();
|
|
});
|
|
|
|
wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.dispatchEvent(new CustomEvent("close"));
|
|
});
|
|
|
|
// Prevent drag on inputs
|
|
for (const el of wrapper.querySelectorAll("input, select, button")) {
|
|
el.addEventListener("pointerdown", (e) => e.stopPropagation());
|
|
}
|
|
|
|
// Handle input port
|
|
this.addEventListener("port-value-changed", ((e: CustomEvent) => {
|
|
const { name, value } = e.detail;
|
|
if (name === "weight-in" && value && typeof value === "object") {
|
|
const v = value as any;
|
|
// Accept { who, weight } or { who, raw }
|
|
const who = v.who || v.memberName || "anonymous";
|
|
const raw = v.weight || v.raw || v.amount || 0;
|
|
// Update or add
|
|
const existing = this.#entries.find(e => e.who === who);
|
|
if (existing) {
|
|
existing.raw = raw;
|
|
existing.effective = this.#transform(raw);
|
|
} else {
|
|
this.#entries.push({ who, raw, effective: this.#transform(raw) });
|
|
}
|
|
this.#updateVisuals();
|
|
this.#emitPorts();
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
}
|
|
}) as EventListener);
|
|
|
|
return root;
|
|
}
|
|
|
|
#updateVisuals() {
|
|
this.#renderChart();
|
|
this.#renderList();
|
|
}
|
|
|
|
#renderChart() {
|
|
if (!this.#chartEl) return;
|
|
if (this.#entries.length === 0) {
|
|
this.#chartEl.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
const W = 220;
|
|
const H = 70;
|
|
const PAD = { top: 6, right: 8, bottom: 16, left: 8 };
|
|
const plotW = W - PAD.left - PAD.right;
|
|
const plotH = H - PAD.top - PAD.bottom;
|
|
|
|
const maxRaw = Math.max(1, ...this.#entries.map(e => e.raw));
|
|
const maxEff = Math.max(1, ...this.#entries.map(e => e.effective));
|
|
const maxVal = Math.max(maxRaw, maxEff);
|
|
const barW = Math.max(6, Math.min(20, plotW / (this.#entries.length * 2.5)));
|
|
|
|
let svg = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">`;
|
|
|
|
// Grid line
|
|
svg += `<line x1="${PAD.left}" y1="${PAD.top + plotH}" x2="${W - PAD.right}" y2="${PAD.top + plotH}" stroke="rgba(255,255,255,0.1)" stroke-width="0.5"/>`;
|
|
|
|
const entries = this.#entries.slice(0, 8); // max 8 bars
|
|
const groupW = plotW / entries.length;
|
|
|
|
for (let i = 0; i < entries.length; i++) {
|
|
const e = entries[i];
|
|
const cx = PAD.left + groupW * i + groupW / 2;
|
|
const rawH = (e.raw / maxVal) * plotH;
|
|
const effH = (e.effective / maxVal) * plotH;
|
|
|
|
// Raw bar (dimmed)
|
|
svg += `<rect x="${cx - barW - 1}" y="${PAD.top + plotH - rawH}" width="${barW}" height="${rawH}" rx="2" fill="rgba(255,255,255,0.15)"/>`;
|
|
// Effective bar (teal)
|
|
svg += `<rect x="${cx + 1}" y="${PAD.top + plotH - effH}" width="${barW}" height="${effH}" rx="2" fill="${HEADER_COLOR}"/>`;
|
|
|
|
// Label
|
|
const label = e.who.length > 5 ? e.who.slice(0, 5) : e.who;
|
|
svg += `<text x="${cx}" y="${H - 2}" text-anchor="middle" font-size="7" fill="#94a3b8" font-family="system-ui">${label}</text>`;
|
|
}
|
|
|
|
// Legend
|
|
svg += `<rect x="${W - 60}" y="2" width="6" height="6" rx="1" fill="rgba(255,255,255,0.15)"/>`;
|
|
svg += `<text x="${W - 52}" y="7.5" font-size="6" fill="#94a3b8" font-family="system-ui">raw</text>`;
|
|
svg += `<rect x="${W - 34}" y="2" width="6" height="6" rx="1" fill="${HEADER_COLOR}"/>`;
|
|
svg += `<text x="${W - 26}" y="7.5" font-size="6" fill="#94a3b8" font-family="system-ui">eff</text>`;
|
|
|
|
svg += "</svg>";
|
|
this.#chartEl.innerHTML = svg;
|
|
}
|
|
|
|
#renderList() {
|
|
if (!this.#listEl) return;
|
|
this.#listEl.innerHTML = this.#entries.map(e =>
|
|
`<div class="entry-item"><span>${e.who}</span><span>${e.raw.toFixed(1)} → ${e.effective.toFixed(2)}</span></div>`
|
|
).join("");
|
|
}
|
|
|
|
#emitPorts() {
|
|
const totalRaw = this.#entries.reduce((s, e) => s + e.raw, 0);
|
|
const totalEffective = this.#entries.reduce((s, e) => s + e.effective, 0);
|
|
|
|
this.setPortValue("weight-out", {
|
|
totalRaw,
|
|
totalEffective,
|
|
mode: this.#mode,
|
|
entries: this.#entries.map(e => ({ who: e.who, raw: e.raw, effective: e.effective })),
|
|
});
|
|
|
|
// Always satisfied — this is a passthrough transform
|
|
this.setPortValue("gate-out", {
|
|
satisfied: true,
|
|
totalRaw,
|
|
totalEffective,
|
|
mode: this.#mode,
|
|
});
|
|
}
|
|
|
|
override toJSON() {
|
|
return {
|
|
...super.toJSON(),
|
|
type: "folk-gov-quadratic",
|
|
title: this.#title,
|
|
mode: this.#mode,
|
|
entries: this.#entries,
|
|
};
|
|
}
|
|
|
|
static override fromData(data: Record<string, any>): FolkGovQuadratic {
|
|
const shape = FolkShape.fromData.call(this, data) as FolkGovQuadratic;
|
|
if (data.title !== undefined) shape.title = data.title;
|
|
if (data.mode !== undefined) shape.mode = data.mode;
|
|
if (data.entries !== undefined) shape.entries = data.entries;
|
|
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.mode !== undefined && data.mode !== this.#mode) this.mode = data.mode;
|
|
if (data.entries !== undefined && JSON.stringify(data.entries) !== JSON.stringify(this.#entries)) this.entries = data.entries;
|
|
}
|
|
}
|