957 lines
31 KiB
TypeScript
957 lines
31 KiB
TypeScript
import { FolkShape } from "./folk-shape";
|
|
import { css, html } from "./tags";
|
|
|
|
const USER_ID_KEY = "folk-choice-userid";
|
|
const USER_NAME_KEY = "folk-choice-username";
|
|
|
|
const styles = css`
|
|
:host {
|
|
background: white;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
min-width: 340px;
|
|
min-height: 380px;
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 12px;
|
|
background: #d97706;
|
|
color: white;
|
|
border-radius: 8px 8px 0 0;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
cursor: move;
|
|
}
|
|
|
|
.header-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
|
|
.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;
|
|
height: calc(100% - 36px);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.options-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 8px 12px;
|
|
}
|
|
|
|
.option-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 8px;
|
|
border-radius: 6px;
|
|
margin-bottom: 6px;
|
|
background: #fffbeb;
|
|
border: 1px solid #fde68a;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.conviction-bg {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
bottom: 0;
|
|
border-radius: 6px;
|
|
opacity: 0.12;
|
|
transition: width 0.4s ease;
|
|
}
|
|
|
|
.option-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
z-index: 1;
|
|
}
|
|
|
|
.option-label {
|
|
flex: 1;
|
|
font-size: 13px;
|
|
color: #1e293b;
|
|
z-index: 1;
|
|
}
|
|
|
|
.stake-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
z-index: 1;
|
|
}
|
|
|
|
.stake-btn {
|
|
width: 22px;
|
|
height: 22px;
|
|
border: 1px solid #fbbf24;
|
|
border-radius: 4px;
|
|
background: white;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #92400e;
|
|
}
|
|
|
|
.stake-btn:hover { background: #fef3c7; }
|
|
.stake-btn:disabled { opacity: 0.3; cursor: default; }
|
|
|
|
.stake-count {
|
|
font-size: 12px;
|
|
font-variant-numeric: tabular-nums;
|
|
min-width: 14px;
|
|
text-align: center;
|
|
color: #92400e;
|
|
font-weight: 600;
|
|
z-index: 1;
|
|
}
|
|
|
|
.option-conviction {
|
|
font-size: 11px;
|
|
color: #d97706;
|
|
font-weight: 600;
|
|
font-variant-numeric: tabular-nums;
|
|
min-width: 40px;
|
|
text-align: right;
|
|
z-index: 1;
|
|
}
|
|
|
|
.option-time {
|
|
font-size: 10px;
|
|
color: #94a3b8;
|
|
min-width: 32px;
|
|
text-align: right;
|
|
z-index: 1;
|
|
}
|
|
|
|
.weight-bar {
|
|
padding: 4px 12px;
|
|
font-size: 11px;
|
|
color: #64748b;
|
|
border-top: 1px solid #e2e8f0;
|
|
text-align: center;
|
|
}
|
|
|
|
.weight-bar .used {
|
|
font-weight: 600;
|
|
color: #d97706;
|
|
}
|
|
|
|
.voters-count {
|
|
padding: 4px 12px;
|
|
font-size: 11px;
|
|
color: #94a3b8;
|
|
text-align: center;
|
|
}
|
|
|
|
.conviction-chart {
|
|
padding: 4px 8px 0;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.conviction-chart svg {
|
|
width: 100%;
|
|
display: block;
|
|
}
|
|
|
|
.conviction-chart:empty {
|
|
display: none;
|
|
}
|
|
|
|
.add-form {
|
|
display: flex;
|
|
gap: 6px;
|
|
padding: 8px 12px;
|
|
border-top: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.add-input {
|
|
flex: 1;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 6px;
|
|
padding: 6px 10px;
|
|
font-size: 12px;
|
|
outline: none;
|
|
}
|
|
|
|
.add-input:focus { border-color: #d97706; }
|
|
|
|
.add-btn {
|
|
background: #d97706;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 6px;
|
|
padding: 6px 12px;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.add-btn:hover { background: #b45309; }
|
|
|
|
.username-prompt {
|
|
padding: 16px;
|
|
text-align: center;
|
|
}
|
|
|
|
.username-prompt p {
|
|
font-size: 13px;
|
|
color: #64748b;
|
|
margin: 0 0 8px;
|
|
}
|
|
|
|
.username-input {
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 6px;
|
|
padding: 8px 12px;
|
|
font-size: 13px;
|
|
outline: none;
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.username-btn {
|
|
background: #d97706;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 6px;
|
|
padding: 8px 16px;
|
|
cursor: pointer;
|
|
font-weight: 500;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.wrapper { position: relative; height: 100%; }
|
|
.results-drawer {
|
|
position: absolute; top: 0; left: 100%; width: 300px; height: 100%;
|
|
background: white; border-radius: 0 8px 8px 0;
|
|
box-shadow: 4px 0 12px rgba(0,0,0,0.08);
|
|
overflow-y: auto; display: none; flex-direction: column;
|
|
font-size: 12px; z-index: 10;
|
|
}
|
|
.drawer-open .results-drawer { display: flex; }
|
|
.drawer-section { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; }
|
|
.drawer-heading {
|
|
font-size: 10px; font-weight: 600; text-transform: uppercase;
|
|
letter-spacing: 0.5px; color: #94a3b8; margin-bottom: 6px;
|
|
}
|
|
.stat-row { display: flex; justify-content: space-between; padding: 3px 0; font-size: 11px; }
|
|
.stat-label { color: #64748b; }
|
|
.stat-value { font-weight: 600; color: #1e293b; font-variant-numeric: tabular-nums; }
|
|
.drawer-bar-row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
|
|
.drawer-bar-label { font-size: 11px; min-width: 60px; color: #1e293b; }
|
|
.drawer-bar-bg { flex: 1; height: 12px; background: #f1f5f9; border-radius: 3px; overflow: hidden; }
|
|
.drawer-bar-fill { height: 100%; border-radius: 3px; transition: width 0.3s; }
|
|
.drawer-bar-val { font-size: 10px; font-weight: 600; min-width: 24px; text-align: right; font-variant-numeric: tabular-nums; }
|
|
.participant-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 11px; color: #475569; }
|
|
.participant-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
.drawer-toggle.active { background: rgba(255,255,255,0.3); }
|
|
.settings-toggle.active { background: rgba(255,255,255,0.3); }
|
|
.settings-panel { display: none; flex-direction: column; gap: 12px; padding: 12px; overflow-y: auto; height: calc(100% - 36px); }
|
|
.settings-open .settings-panel { display: flex; }
|
|
.settings-open .body { display: none !important; }
|
|
.settings-open .results-drawer { display: none !important; }
|
|
.settings-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #94a3b8; margin-bottom: 6px; }
|
|
.settings-input { width: 100%; box-sizing: border-box; border: 1px solid #e2e8f0; border-radius: 6px; padding: 6px 10px; font-size: 12px; outline: none; }
|
|
.settings-input:focus { border-color: #d97706; }
|
|
.settings-item { display: flex; align-items: center; gap: 6px; padding: 4px 0; }
|
|
.settings-item input[type="text"] { flex: 1; border: 1px solid #e2e8f0; border-radius: 4px; padding: 4px 6px; font-size: 11px; outline: none; min-width: 0; }
|
|
.settings-item input[type="color"] { width: 24px; height: 24px; border: none; border-radius: 4px; padding: 0; cursor: pointer; background: none; }
|
|
.settings-item .remove-btn { background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 14px; padding: 2px 4px; border-radius: 4px; flex-shrink: 0; }
|
|
.settings-item .remove-btn:hover { color: #ef4444; background: #fef2f2; }
|
|
.settings-danger { background: none; border: 1px solid #fca5a5; color: #ef4444; border-radius: 6px; padding: 6px 12px; cursor: pointer; font-size: 11px; width: 100%; margin-top: 4px; }
|
|
.settings-danger:hover { background: #fef2f2; }
|
|
.settings-done { background: #d97706; color: white; border: none; border-radius: 6px; padding: 8px 16px; cursor: pointer; font-size: 12px; font-weight: 500; width: 100%; margin-top: auto; }
|
|
.settings-done:hover { background: #b45309; }
|
|
`;
|
|
|
|
// -- Data types --
|
|
|
|
export interface ConvictionOption {
|
|
id: string;
|
|
label: string;
|
|
color: string;
|
|
}
|
|
|
|
export interface ConvictionStake {
|
|
userId: string;
|
|
userName: string;
|
|
optionId: string;
|
|
weight: number;
|
|
since: number;
|
|
}
|
|
|
|
// -- Pure aggregation functions --
|
|
|
|
export function convictionScore(
|
|
stakes: ConvictionStake[],
|
|
optionId: string,
|
|
now: number,
|
|
): number {
|
|
let total = 0;
|
|
for (const s of stakes) {
|
|
if (s.optionId !== optionId) continue;
|
|
total += s.weight * Math.max(0, now - s.since) / 3600000;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
export function convictionVelocity(
|
|
stakes: ConvictionStake[],
|
|
optionId: string,
|
|
): number {
|
|
let total = 0;
|
|
for (const s of stakes) {
|
|
if (s.optionId !== optionId) continue;
|
|
total += s.weight;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
// -- Component --
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
"folk-choice-conviction": FolkChoiceConviction;
|
|
}
|
|
}
|
|
|
|
const DEFAULT_COLORS = ["#f59e0b", "#3b82f6", "#22c55e", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"];
|
|
|
|
export class FolkChoiceConviction extends FolkShape {
|
|
static override tagName = "folk-choice-conviction";
|
|
|
|
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 = "Conviction Ranking";
|
|
#options: ConvictionOption[] = [];
|
|
#stakes: ConvictionStake[] = [];
|
|
#userId = "";
|
|
#userName = "";
|
|
#drawerOpen = false;
|
|
#settingsOpen = false;
|
|
#tickInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
// DOM refs
|
|
#wrapperEl: HTMLElement | null = null;
|
|
#bodyEl: HTMLElement | null = null;
|
|
#optionsEl: HTMLElement | null = null;
|
|
#weightEl: HTMLElement | null = null;
|
|
#votersEl: HTMLElement | null = null;
|
|
#drawerEl: HTMLElement | null = null;
|
|
#chartEl: HTMLElement | null = null;
|
|
#settingsEl: HTMLElement | null = null;
|
|
|
|
get title() { return this.#title; }
|
|
set title(v: string) { this.#title = v; this.requestUpdate("title"); }
|
|
|
|
get options() { return this.#options; }
|
|
set options(v: ConvictionOption[]) {
|
|
this.#options = v;
|
|
this.#render();
|
|
this.requestUpdate("options");
|
|
}
|
|
|
|
get stakes() { return this.#stakes; }
|
|
set stakes(v: ConvictionStake[]) {
|
|
this.#stakes = v;
|
|
this.#render();
|
|
this.requestUpdate("stakes");
|
|
}
|
|
|
|
#ensureIdentity(): boolean {
|
|
if (this.#userId && this.#userName) return true;
|
|
this.#userId = localStorage.getItem(USER_ID_KEY) || "";
|
|
this.#userName = localStorage.getItem(USER_NAME_KEY) || localStorage.getItem("rspace-username") || "";
|
|
if (!this.#userId) {
|
|
this.#userId = crypto.randomUUID().slice(0, 8);
|
|
localStorage.setItem(USER_ID_KEY, this.#userId);
|
|
}
|
|
return !!this.#userName;
|
|
}
|
|
|
|
#setUserName(name: string) {
|
|
this.#userName = name;
|
|
localStorage.setItem(USER_NAME_KEY, name);
|
|
localStorage.setItem("rspace-username", name);
|
|
}
|
|
|
|
#getMyStake(optionId: string): ConvictionStake | undefined {
|
|
return this.#stakes.find((s) => s.userId === this.#userId && s.optionId === optionId);
|
|
}
|
|
|
|
#setStake(optionId: string, delta: number) {
|
|
if (!this.#ensureIdentity()) return;
|
|
|
|
const existing = this.#getMyStake(optionId);
|
|
const currentWeight = existing ? existing.weight : 0;
|
|
const newWeight = Math.max(0, Math.min(10, currentWeight + delta));
|
|
|
|
if (newWeight === 0) {
|
|
// Remove stake
|
|
this.#stakes = this.#stakes.filter(
|
|
(s) => !(s.userId === this.#userId && s.optionId === optionId),
|
|
);
|
|
} else if (existing) {
|
|
// Update — reset conviction timer on weight change
|
|
existing.weight = newWeight;
|
|
existing.since = Date.now();
|
|
} else {
|
|
// New stake
|
|
this.#stakes.push({
|
|
userId: this.#userId,
|
|
userName: this.#userName,
|
|
optionId,
|
|
weight: newWeight,
|
|
since: Date.now(),
|
|
});
|
|
}
|
|
|
|
this.#render();
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
}
|
|
|
|
override createRenderRoot() {
|
|
const root = super.createRenderRoot();
|
|
this.#ensureIdentity();
|
|
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "wrapper";
|
|
wrapper.innerHTML = html`
|
|
<div class="header">
|
|
<span class="header-title">
|
|
<span>⏳</span>
|
|
<span class="title-text">Conviction</span>
|
|
</span>
|
|
<div class="header-actions">
|
|
<button class="settings-toggle" title="Settings">⚙</button>
|
|
<button class="drawer-toggle" title="Results & Stats">📊</button>
|
|
<button class="close-btn" title="Close">×</button>
|
|
</div>
|
|
</div>
|
|
<div class="body">
|
|
<div class="conviction-chart"></div>
|
|
<div class="options-list"></div>
|
|
<div class="weight-bar"></div>
|
|
<div class="voters-count"></div>
|
|
<div class="add-form">
|
|
<input type="text" class="add-input" placeholder="Add option..." />
|
|
<button class="add-btn">Add</button>
|
|
</div>
|
|
</div>
|
|
<div class="results-drawer"></div>
|
|
<div class="settings-panel"></div>
|
|
<div class="username-prompt" style="display: none;">
|
|
<p>Enter your name to stake:</p>
|
|
<input type="text" class="username-input" placeholder="Your name..." />
|
|
<button class="username-btn">Join</button>
|
|
</div>
|
|
`;
|
|
|
|
const slot = root.querySelector("slot");
|
|
const containerDiv = slot?.parentElement as HTMLElement;
|
|
if (containerDiv) containerDiv.replaceWith(wrapper);
|
|
|
|
this.#wrapperEl = wrapper;
|
|
this.#bodyEl = wrapper.querySelector(".body") as HTMLElement;
|
|
this.#optionsEl = wrapper.querySelector(".options-list") as HTMLElement;
|
|
this.#weightEl = wrapper.querySelector(".weight-bar") as HTMLElement;
|
|
this.#votersEl = wrapper.querySelector(".voters-count") as HTMLElement;
|
|
this.#drawerEl = wrapper.querySelector(".results-drawer") as HTMLElement;
|
|
this.#chartEl = wrapper.querySelector(".conviction-chart") as HTMLElement;
|
|
const titleEl = wrapper.querySelector(".title-text") as HTMLElement;
|
|
|
|
const drawerToggle = wrapper.querySelector(".drawer-toggle") as HTMLButtonElement;
|
|
drawerToggle.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.#drawerOpen = !this.#drawerOpen;
|
|
this.#wrapperEl!.classList.toggle("drawer-open", this.#drawerOpen);
|
|
drawerToggle.classList.toggle("active", this.#drawerOpen);
|
|
if (this.#drawerOpen) this.#renderDrawer();
|
|
});
|
|
|
|
this.#settingsEl = wrapper.querySelector(".settings-panel") as HTMLElement;
|
|
const settingsToggle = wrapper.querySelector(".settings-toggle") as HTMLButtonElement;
|
|
settingsToggle.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.#settingsOpen = !this.#settingsOpen;
|
|
this.#wrapperEl!.classList.toggle("settings-open", this.#settingsOpen);
|
|
settingsToggle.classList.toggle("active", this.#settingsOpen);
|
|
if (this.#settingsOpen) this.#renderSettings();
|
|
else this.#render();
|
|
});
|
|
|
|
const usernamePrompt = wrapper.querySelector(".username-prompt") as HTMLElement;
|
|
const usernameInput = wrapper.querySelector(".username-input") as HTMLInputElement;
|
|
const usernameBtn = wrapper.querySelector(".username-btn") as HTMLButtonElement;
|
|
const addInput = wrapper.querySelector(".add-input") as HTMLInputElement;
|
|
const addBtn = wrapper.querySelector(".add-btn") as HTMLButtonElement;
|
|
|
|
if (!this.#userName) {
|
|
this.#bodyEl.style.display = "none";
|
|
usernamePrompt.style.display = "block";
|
|
}
|
|
|
|
const submitName = () => {
|
|
const name = usernameInput.value.trim();
|
|
if (name) {
|
|
this.#setUserName(name);
|
|
this.#bodyEl!.style.display = "flex";
|
|
usernamePrompt.style.display = "none";
|
|
this.#render();
|
|
}
|
|
};
|
|
usernameBtn.addEventListener("click", (e) => { e.stopPropagation(); submitName(); });
|
|
usernameInput.addEventListener("click", (e) => e.stopPropagation());
|
|
usernameInput.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") submitName(); });
|
|
|
|
// Add option
|
|
const addOption = () => {
|
|
const label = addInput.value.trim();
|
|
if (!label) return;
|
|
this.#options.push({
|
|
id: `opt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
label,
|
|
color: DEFAULT_COLORS[this.#options.length % DEFAULT_COLORS.length],
|
|
});
|
|
addInput.value = "";
|
|
this.#render();
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
};
|
|
addBtn.addEventListener("click", (e) => { e.stopPropagation(); addOption(); });
|
|
addInput.addEventListener("click", (e) => e.stopPropagation());
|
|
addInput.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") addOption(); });
|
|
|
|
wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.dispatchEvent(new CustomEvent("close"));
|
|
});
|
|
|
|
if (this.#title) titleEl.textContent = this.#title;
|
|
|
|
// Live conviction update
|
|
this.#tickInterval = setInterval(() => this.#render(), 10000);
|
|
|
|
this.#render();
|
|
return root;
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
if (this.#tickInterval) {
|
|
clearInterval(this.#tickInterval);
|
|
this.#tickInterval = null;
|
|
}
|
|
}
|
|
|
|
#render() {
|
|
this.#renderOptions();
|
|
this.#renderChart();
|
|
if (this.#drawerOpen) this.#renderDrawer();
|
|
}
|
|
|
|
#renderOptions() {
|
|
if (!this.#optionsEl) return;
|
|
const now = Date.now();
|
|
const convictions = this.#options.map((opt) => ({
|
|
id: opt.id,
|
|
score: convictionScore(this.#stakes, opt.id, now),
|
|
}));
|
|
const maxConv = Math.max(1, ...convictions.map((c) => c.score));
|
|
const uniqueParticipants = new Set(this.#stakes.map((s) => s.userId)).size;
|
|
|
|
// Sort options by conviction score (highest first)
|
|
const sortedOptions = [...this.#options].sort((a, b) => {
|
|
const scoreA = convictions.find((c) => c.id === a.id)!.score;
|
|
const scoreB = convictions.find((c) => c.id === b.id)!.score;
|
|
return scoreB - scoreA;
|
|
});
|
|
|
|
this.#optionsEl.innerHTML = sortedOptions
|
|
.map((opt) => {
|
|
const conv = convictions.find((c) => c.id === opt.id)!;
|
|
const barWidth = (conv.score / maxConv) * 100;
|
|
const myStake = this.#getMyStake(opt.id);
|
|
const myWeight = myStake ? myStake.weight : 0;
|
|
const timeHeld = myStake ? this.#formatDuration(now - myStake.since) : "";
|
|
|
|
return `
|
|
<div class="option-row" data-opt="${opt.id}">
|
|
<div class="conviction-bg" style="width:${barWidth}%;background:${opt.color};"></div>
|
|
<span class="option-dot" style="background:${opt.color}"></span>
|
|
<span class="option-label">${this.#escapeHtml(opt.label)}</span>
|
|
<div class="stake-controls">
|
|
<button class="stake-btn stake-minus" data-opt="${opt.id}">−</button>
|
|
<span class="stake-count">${myWeight}</span>
|
|
<button class="stake-btn stake-plus" data-opt="${opt.id}">+</button>
|
|
</div>
|
|
<span class="option-time">${timeHeld}</span>
|
|
<span class="option-conviction">${this.#formatConviction(conv.score)}</span>
|
|
</div>
|
|
`;
|
|
})
|
|
.join("");
|
|
|
|
// Wire buttons
|
|
this.#optionsEl.querySelectorAll(".stake-plus").forEach((btn) => {
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.#setStake((btn as HTMLElement).dataset.opt!, 1);
|
|
});
|
|
});
|
|
this.#optionsEl.querySelectorAll(".stake-minus").forEach((btn) => {
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.#setStake((btn as HTMLElement).dataset.opt!, -1);
|
|
});
|
|
});
|
|
|
|
// Weight bar
|
|
if (this.#weightEl) {
|
|
const totalWeight = this.#stakes
|
|
.filter((s) => s.userId === this.#userId)
|
|
.reduce((sum, s) => sum + s.weight, 0);
|
|
this.#weightEl.innerHTML = `Your weight: <span class="used">${totalWeight}</span> across ${this.#stakes.filter((s) => s.userId === this.#userId).length} option${this.#stakes.filter((s) => s.userId === this.#userId).length !== 1 ? "s" : ""}`;
|
|
}
|
|
|
|
// Voters count
|
|
if (this.#votersEl) {
|
|
this.#votersEl.textContent = uniqueParticipants === 0
|
|
? "No stakes yet"
|
|
: `${uniqueParticipants} participant${uniqueParticipants !== 1 ? "s" : ""}`;
|
|
}
|
|
}
|
|
|
|
#renderChart() {
|
|
if (!this.#chartEl) return;
|
|
if (this.#options.length === 0 || this.#stakes.length === 0) {
|
|
this.#chartEl.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const W = 280;
|
|
const H = 100;
|
|
const PAD = { top: 10, right: 10, bottom: 18, left: 34 };
|
|
const plotW = W - PAD.left - PAD.right;
|
|
const plotH = H - PAD.top - PAD.bottom;
|
|
|
|
// Collect all inflection time points from stakes
|
|
const timeSet = new Set<number>();
|
|
for (const s of this.#stakes) timeSet.add(s.since);
|
|
timeSet.add(now);
|
|
const sortedTimes = [...timeSet].sort((a, b) => a - b);
|
|
|
|
const earliest = sortedTimes[0];
|
|
const timeRange = Math.max(now - earliest, 60000); // at least 1 minute
|
|
|
|
// Compute conviction curve for each option
|
|
const curves: { id: string; color: string; points: { t: number; v: number }[] }[] = [];
|
|
let maxV = 0;
|
|
|
|
for (const opt of this.#options) {
|
|
const optStakes = this.#stakes.filter((s) => s.optionId === opt.id);
|
|
if (optStakes.length === 0) continue;
|
|
|
|
const points: { t: number; v: number }[] = [];
|
|
const optEarliest = Math.min(...optStakes.map((s) => s.since));
|
|
|
|
// Start at zero
|
|
if (optEarliest > earliest) points.push({ t: earliest, v: 0 });
|
|
points.push({ t: optEarliest, v: 0 });
|
|
|
|
// Compute conviction at each time point after this option's first stake
|
|
for (const t of sortedTimes) {
|
|
if (t <= optEarliest) continue;
|
|
let score = 0;
|
|
for (const s of optStakes) {
|
|
if (s.since <= t) score += s.weight * (t - s.since) / 3600000;
|
|
}
|
|
points.push({ t, v: score });
|
|
maxV = Math.max(maxV, score);
|
|
}
|
|
|
|
curves.push({ id: opt.id, color: opt.color, points });
|
|
}
|
|
|
|
if (maxV === 0) maxV = 1;
|
|
|
|
const x = (t: number) => PAD.left + ((t - earliest) / timeRange) * plotW;
|
|
const y = (v: number) => PAD.top + (1 - v / maxV) * plotH;
|
|
|
|
let svg = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">`;
|
|
|
|
// Grid
|
|
svg += `<line x1="${PAD.left}" y1="${y(0)}" x2="${W - PAD.right}" y2="${y(0)}" stroke="#e2e8f0" stroke-width="0.5"/>`;
|
|
svg += `<line x1="${PAD.left}" y1="${y(maxV)}" x2="${W - PAD.right}" y2="${y(maxV)}" stroke="#e2e8f0" stroke-width="0.5" stroke-dasharray="3,3"/>`;
|
|
if (maxV > 2) {
|
|
const mid = maxV / 2;
|
|
svg += `<line x1="${PAD.left}" y1="${y(mid)}" x2="${W - PAD.right}" y2="${y(mid)}" stroke="#f1f5f9" stroke-width="0.5" stroke-dasharray="2,4"/>`;
|
|
}
|
|
|
|
// Area fill + line + end dot for each option
|
|
for (const curve of curves) {
|
|
if (curve.points.length < 2) continue;
|
|
|
|
// Area
|
|
const areaD = `M${x(curve.points[0].t)},${y(0)} ` +
|
|
curve.points.map((p) => `L${x(p.t)},${y(p.v)}`).join(" ") +
|
|
` L${x(curve.points[curve.points.length - 1].t)},${y(0)} Z`;
|
|
svg += `<path d="${areaD}" fill="${curve.color}" opacity="0.1"/>`;
|
|
|
|
// Line
|
|
const lineD = curve.points.map((p, i) => `${i === 0 ? "M" : "L"}${x(p.t)},${y(p.v)}`).join(" ");
|
|
svg += `<path d="${lineD}" fill="none" stroke="${curve.color}" stroke-width="1.5" stroke-linejoin="round"/>`;
|
|
|
|
// End dot
|
|
const last = curve.points[curve.points.length - 1];
|
|
svg += `<circle cx="${x(last.t)}" cy="${y(last.v)}" r="2.5" fill="${curve.color}"/>`;
|
|
}
|
|
|
|
// Y axis labels
|
|
svg += `<text x="${PAD.left - 4}" y="${PAD.top + 4}" text-anchor="end" font-size="8" fill="#94a3b8" font-family="system-ui">${this.#formatConviction(maxV)}</text>`;
|
|
svg += `<text x="${PAD.left - 4}" y="${y(0)}" text-anchor="end" font-size="8" fill="#94a3b8" font-family="system-ui">0</text>`;
|
|
|
|
// X axis labels
|
|
const fmtRange = (ms: number) => {
|
|
if (ms < 60000) return "<1m ago";
|
|
if (ms < 3600000) return `${Math.floor(ms / 60000)}m ago`;
|
|
if (ms < 86400000) return `${Math.floor(ms / 3600000)}h ago`;
|
|
return `${Math.floor(ms / 86400000)}d ago`;
|
|
};
|
|
svg += `<text x="${PAD.left}" y="${H - 3}" font-size="8" fill="#94a3b8" font-family="system-ui">${fmtRange(timeRange)}</text>`;
|
|
svg += `<text x="${W - PAD.right}" y="${H - 3}" text-anchor="end" font-size="8" fill="#94a3b8" font-family="system-ui">now</text>`;
|
|
|
|
svg += "</svg>";
|
|
this.#chartEl.innerHTML = svg;
|
|
}
|
|
|
|
#renderDrawer() {
|
|
if (!this.#drawerEl) return;
|
|
const now = Date.now();
|
|
const optMap = new Map(this.#options.map((o) => [o.id, o]));
|
|
|
|
// Group Results: conviction leaderboard
|
|
const convictions = this.#options.map((opt) => ({
|
|
id: opt.id,
|
|
label: opt.label,
|
|
color: opt.color,
|
|
score: convictionScore(this.#stakes, opt.id, now),
|
|
velocity: convictionVelocity(this.#stakes, opt.id),
|
|
rawWeight: this.#stakes.filter((s) => s.optionId === opt.id).reduce((sum, s) => sum + s.weight, 0),
|
|
}));
|
|
convictions.sort((a, b) => b.score - a.score);
|
|
const maxConv = Math.max(1, ...convictions.map((c) => c.score));
|
|
|
|
let resultsHtml = '<div class="drawer-section"><div class="drawer-heading">Conviction Leaderboard</div>';
|
|
for (const c of convictions) {
|
|
const pct = (c.score / maxConv) * 100;
|
|
resultsHtml += `<div class="drawer-bar-row">
|
|
<span class="drawer-bar-label">${this.#escapeHtml(c.label)}</span>
|
|
<div class="drawer-bar-bg"><div class="drawer-bar-fill" style="width:${pct}%;background:${c.color}"></div></div>
|
|
<span class="drawer-bar-val">${this.#formatConviction(c.score)}</span>
|
|
</div>`;
|
|
}
|
|
resultsHtml += "</div>";
|
|
|
|
// Statistics
|
|
const uniqueParticipants = new Set(this.#stakes.map((s) => s.userId)).size;
|
|
const totalConv = convictions.reduce((sum, c) => sum + c.score, 0);
|
|
const totalVelocity = convictions.reduce((sum, c) => sum + c.velocity, 0);
|
|
|
|
let statsHtml = '<div class="drawer-section"><div class="drawer-heading">Statistics</div>';
|
|
statsHtml += `<div class="stat-row"><span class="stat-label">Participants</span><span class="stat-value">${uniqueParticipants}</span></div>`;
|
|
statsHtml += `<div class="stat-row"><span class="stat-label">Total conviction</span><span class="stat-value">${this.#formatConviction(totalConv)}</span></div>`;
|
|
statsHtml += `<div class="stat-row"><span class="stat-label">Velocity (wt/hr)</span><span class="stat-value">${totalVelocity.toFixed(1)}</span></div>`;
|
|
|
|
// Raw weight vs conviction rank comparison
|
|
const byRawWeight = [...convictions].sort((a, b) => b.rawWeight - a.rawWeight);
|
|
const byConviction = convictions; // already sorted
|
|
let rankDiff = false;
|
|
for (let i = 0; i < convictions.length; i++) {
|
|
if (byRawWeight[i]?.id !== byConviction[i]?.id) { rankDiff = true; break; }
|
|
}
|
|
if (rankDiff && convictions.length >= 2) {
|
|
statsHtml += '<div style="margin-top:6px"><div class="drawer-heading">Raw Weight vs Conviction</div>';
|
|
for (const c of convictions) {
|
|
const rawRank = byRawWeight.findIndex((r) => r.id === c.id) + 1;
|
|
const convRank = byConviction.findIndex((r) => r.id === c.id) + 1;
|
|
const diff = rawRank - convRank;
|
|
const arrow = diff > 0 ? `↑${diff}` : diff < 0 ? `↓${Math.abs(diff)}` : "=";
|
|
statsHtml += `<div class="stat-row"><span class="stat-label">${this.#escapeHtml(c.label)}</span><span class="stat-value">#${convRank} (${arrow})</span></div>`;
|
|
}
|
|
statsHtml += "</div>";
|
|
}
|
|
statsHtml += "</div>";
|
|
|
|
// Participants
|
|
const userStakes = new Map<string, { name: string; totalWeight: number; totalConv: number; optCount: number }>();
|
|
for (const s of this.#stakes) {
|
|
const u = userStakes.get(s.userId) || { name: s.userName, totalWeight: 0, totalConv: 0, optCount: 0 };
|
|
u.name = s.userName;
|
|
u.totalWeight += s.weight;
|
|
u.totalConv += s.weight * Math.max(0, now - s.since) / 3600000;
|
|
u.optCount++;
|
|
userStakes.set(s.userId, u);
|
|
}
|
|
|
|
let participantsHtml = '<div class="drawer-section"><div class="drawer-heading">Participants</div>';
|
|
for (const [, u] of userStakes) {
|
|
participantsHtml += `<div class="participant-row">
|
|
<span class="participant-dot" style="background:#d97706"></span>
|
|
<span>${this.#escapeHtml(u.name)}</span>
|
|
<span style="margin-left:auto;color:#94a3b8;font-size:10px">wt:${u.totalWeight} conv:${this.#formatConviction(u.totalConv)}</span>
|
|
</div>`;
|
|
}
|
|
participantsHtml += "</div>";
|
|
|
|
this.#drawerEl.innerHTML = resultsHtml + statsHtml + participantsHtml;
|
|
}
|
|
|
|
#formatConviction(score: number): string {
|
|
if (score < 1) return score.toFixed(2);
|
|
if (score < 100) return score.toFixed(1);
|
|
return Math.round(score).toString();
|
|
}
|
|
|
|
#formatDuration(ms: number): string {
|
|
if (ms < 60000) return "<1m";
|
|
if (ms < 3600000) return `${Math.floor(ms / 60000)}m`;
|
|
if (ms < 86400000) return `${Math.floor(ms / 3600000)}h`;
|
|
return `${Math.floor(ms / 86400000)}d`;
|
|
}
|
|
|
|
#timeAgo(ts: number): string {
|
|
const diff = Date.now() - ts;
|
|
if (diff < 60000) return "just now";
|
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
|
return `${Math.floor(diff / 86400000)}d ago`;
|
|
}
|
|
|
|
#escapeHtml(text: string): string {
|
|
const div = document.createElement("div");
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
#renderSettings() {
|
|
if (!this.#settingsEl) return;
|
|
const esc = (s: string) => this.#escapeHtml(s);
|
|
let h = '<div><div class="settings-label">Title</div>';
|
|
h += `<input type="text" class="settings-input settings-title" value="${esc(this.#title)}" /></div>`;
|
|
h += '<div><div class="settings-label">Options</div>';
|
|
for (let i = 0; i < this.#options.length; i++) {
|
|
const opt = this.#options[i];
|
|
h += `<div class="settings-item" data-idx="${i}">`;
|
|
h += `<input type="color" class="opt-color" value="${opt.color}" />`;
|
|
h += `<input type="text" class="opt-label" value="${esc(opt.label)}" />`;
|
|
h += `<button class="remove-btn" title="Remove">×</button></div>`;
|
|
}
|
|
h += '</div>';
|
|
const uniqueStakers = new Set(this.#stakes.map((s) => s.userId)).size;
|
|
h += '<div><div class="settings-label">Danger Zone</div>';
|
|
h += `<button class="settings-danger clear-data-btn">Reset all stakes (${uniqueStakers} participant${uniqueStakers !== 1 ? "s" : ""})</button></div>`;
|
|
h += '<button class="settings-done">Done</button>';
|
|
this.#settingsEl.innerHTML = h;
|
|
|
|
const stop = (e: Event) => e.stopPropagation();
|
|
const titleInput = this.#settingsEl.querySelector(".settings-title") as HTMLInputElement;
|
|
titleInput.addEventListener("click", stop);
|
|
titleInput.addEventListener("input", () => {
|
|
this.#title = titleInput.value;
|
|
this.#wrapperEl!.querySelector(".title-text")!.textContent = this.#title;
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
});
|
|
this.#settingsEl.querySelectorAll(".opt-label").forEach((el) => {
|
|
const input = el as HTMLInputElement;
|
|
input.addEventListener("click", stop);
|
|
input.addEventListener("input", () => {
|
|
const idx = parseInt(input.closest(".settings-item")!.getAttribute("data-idx")!);
|
|
this.#options[idx].label = input.value;
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
});
|
|
});
|
|
this.#settingsEl.querySelectorAll(".opt-color").forEach((el) => {
|
|
const input = el as HTMLInputElement;
|
|
input.addEventListener("click", stop);
|
|
input.addEventListener("input", () => {
|
|
const idx = parseInt(input.closest(".settings-item")!.getAttribute("data-idx")!);
|
|
this.#options[idx].color = input.value;
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
});
|
|
});
|
|
this.#settingsEl.querySelectorAll(".remove-btn").forEach((btn) => {
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const idx = parseInt((btn as HTMLElement).closest(".settings-item")!.getAttribute("data-idx")!);
|
|
const removedId = this.#options[idx].id;
|
|
this.#options.splice(idx, 1);
|
|
this.#stakes = this.#stakes.filter((s) => s.optionId !== removedId);
|
|
this.#renderSettings();
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
});
|
|
});
|
|
this.#settingsEl.querySelector(".clear-data-btn")!.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.#stakes = [];
|
|
this.#renderSettings();
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
});
|
|
this.#settingsEl.querySelector(".settings-done")!.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.#settingsOpen = false;
|
|
this.#wrapperEl!.classList.remove("settings-open");
|
|
this.#wrapperEl!.querySelector(".settings-toggle")!.classList.remove("active");
|
|
this.#render();
|
|
});
|
|
}
|
|
|
|
override toJSON() {
|
|
return {
|
|
...super.toJSON(),
|
|
type: "folk-choice-conviction",
|
|
title: this.#title,
|
|
options: this.#options,
|
|
stakes: this.#stakes,
|
|
};
|
|
}
|
|
}
|