Add folk-choice-* collaborative decision components
Three new canvas shapes for small-group decisions: - folk-choice-vote: live polling with plurality, approval, and quadratic voting modes - folk-choice-rank: drag-to-reorder with Borda count and instant-runoff aggregation - folk-choice-spider: multi-criteria scoring with SVG radar chart, per-user polygon overlays, and weighted mean aggregation All sync via rSpace's existing Automerge CRDT infrastructure. Aggregation algorithms are exported as pure functions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0489319e15
commit
20a0796a4d
|
|
@ -533,6 +533,33 @@ export class CommunitySync extends EventTarget {
|
|||
if (data.mintId !== undefined && ledger.mintId !== data.mintId) ledger.mintId = data.mintId;
|
||||
if (data.entries !== undefined) ledger.entries = data.entries;
|
||||
}
|
||||
|
||||
// Update choice-vote properties
|
||||
if (data.type === "folk-choice-vote") {
|
||||
const vote = shape as any;
|
||||
if (data.title !== undefined && vote.title !== data.title) vote.title = data.title;
|
||||
if (data.options !== undefined) vote.options = data.options;
|
||||
if (data.mode !== undefined && vote.mode !== data.mode) vote.mode = data.mode;
|
||||
if (data.budget !== undefined && vote.budget !== data.budget) vote.budget = data.budget;
|
||||
if (data.votes !== undefined) vote.votes = data.votes;
|
||||
}
|
||||
|
||||
// Update choice-rank properties
|
||||
if (data.type === "folk-choice-rank") {
|
||||
const rank = shape as any;
|
||||
if (data.title !== undefined && rank.title !== data.title) rank.title = data.title;
|
||||
if (data.options !== undefined) rank.options = data.options;
|
||||
if (data.rankings !== undefined) rank.rankings = data.rankings;
|
||||
}
|
||||
|
||||
// Update choice-spider properties
|
||||
if (data.type === "folk-choice-spider") {
|
||||
const spider = shape as any;
|
||||
if (data.title !== undefined && spider.title !== data.title) spider.title = data.title;
|
||||
if (data.options !== undefined) spider.options = data.options;
|
||||
if (data.criteria !== undefined) spider.criteria = data.criteria;
|
||||
if (data.scores !== undefined) spider.scores = data.scores;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,774 @@
|
|||
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: #4f46e5;
|
||||
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;
|
||||
}
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #4f46e5;
|
||||
border-bottom: 2px solid #4f46e5;
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.panel.hidden { display: none; }
|
||||
|
||||
/* Rank list */
|
||||
.rank-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 4px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.rank-item:active { cursor: grabbing; }
|
||||
|
||||
.rank-item.dragging {
|
||||
opacity: 0.5;
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.rank-item.drag-over {
|
||||
border-color: #4f46e5;
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 2px 8px rgba(79, 70, 229, 0.15);
|
||||
}
|
||||
|
||||
.grip {
|
||||
color: #94a3b8;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rank-num {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rank-label {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* Results */
|
||||
.results-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.results-heading {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.borda-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.borda-label {
|
||||
font-size: 12px;
|
||||
min-width: 80px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.borda-bar-bg {
|
||||
flex: 1;
|
||||
height: 14px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.borda-bar-fill {
|
||||
height: 100%;
|
||||
background: #4f46e5;
|
||||
border-radius: 3px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.borda-pts {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #4f46e5;
|
||||
min-width: 24px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.irv-round {
|
||||
font-size: 11px;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.irv-round .round-num {
|
||||
font-weight: 600;
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.irv-round .eliminated {
|
||||
color: #ef4444;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.irv-round .winner {
|
||||
color: #22c55e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.voters-count {
|
||||
padding: 4px 0;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.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: #4f46e5; }
|
||||
|
||||
.add-btn {
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.add-btn:hover { background: #4338ca; }
|
||||
|
||||
.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: #4f46e5;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 24px 12px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
// -- Data types --
|
||||
|
||||
export interface RankOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface UserRanking {
|
||||
userId: string;
|
||||
userName: string;
|
||||
ordering: string[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface IRVRound {
|
||||
round: number;
|
||||
counts: Record<string, number>;
|
||||
eliminated: string | null;
|
||||
}
|
||||
|
||||
// -- Pure aggregation functions --
|
||||
|
||||
export function bordaCount(
|
||||
rankings: UserRanking[],
|
||||
options: RankOption[],
|
||||
): Map<string, number> {
|
||||
const n = options.length;
|
||||
const scores = new Map<string, number>();
|
||||
for (const opt of options) scores.set(opt.id, 0);
|
||||
for (const r of rankings) {
|
||||
for (let i = 0; i < r.ordering.length; i++) {
|
||||
const points = n - 1 - i;
|
||||
const optId = r.ordering[i];
|
||||
if (scores.has(optId)) {
|
||||
scores.set(optId, scores.get(optId)! + points);
|
||||
}
|
||||
}
|
||||
}
|
||||
return scores;
|
||||
}
|
||||
|
||||
export function instantRunoff(
|
||||
rankings: UserRanking[],
|
||||
options: RankOption[],
|
||||
): IRVRound[] {
|
||||
if (rankings.length === 0 || options.length === 0) return [];
|
||||
|
||||
const rounds: IRVRound[] = [];
|
||||
const remaining = new Set(options.map((o) => o.id));
|
||||
let round = 1;
|
||||
|
||||
while (remaining.size > 1) {
|
||||
// Count first-place votes among remaining options
|
||||
const counts: Record<string, number> = {};
|
||||
for (const id of remaining) counts[id] = 0;
|
||||
|
||||
for (const r of rankings) {
|
||||
const top = r.ordering.find((id) => remaining.has(id));
|
||||
if (top) counts[top]++;
|
||||
}
|
||||
|
||||
const totalVotes = rankings.length;
|
||||
|
||||
// Check for majority
|
||||
let majorityWinner: string | null = null;
|
||||
for (const [id, count] of Object.entries(counts)) {
|
||||
if (count > totalVotes / 2) {
|
||||
majorityWinner = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (majorityWinner) {
|
||||
rounds.push({ round, counts, eliminated: null });
|
||||
break;
|
||||
}
|
||||
|
||||
// Find minimum vote count
|
||||
let minCount = Infinity;
|
||||
for (const count of Object.values(counts)) {
|
||||
if (count < minCount) minCount = count;
|
||||
}
|
||||
|
||||
// Eliminate first option with minimum count
|
||||
let eliminated: string | null = null;
|
||||
for (const [id, count] of Object.entries(counts)) {
|
||||
if (count === minCount) {
|
||||
eliminated = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
rounds.push({ round, counts, eliminated });
|
||||
if (eliminated) remaining.delete(eliminated);
|
||||
round++;
|
||||
}
|
||||
|
||||
// If eliminated down to 1
|
||||
if (remaining.size === 1 && (rounds.length === 0 || rounds[rounds.length - 1].eliminated !== null)) {
|
||||
const lastId = [...remaining][0];
|
||||
rounds.push({ round, counts: { [lastId]: rankings.length }, eliminated: null });
|
||||
}
|
||||
|
||||
return rounds;
|
||||
}
|
||||
|
||||
// -- Component --
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"folk-choice-rank": FolkChoiceRank;
|
||||
}
|
||||
}
|
||||
|
||||
const MEDAL_COLORS = ["#f59e0b", "#94a3b8", "#cd7f32", "#64748b", "#64748b"];
|
||||
|
||||
export class FolkChoiceRank extends FolkShape {
|
||||
static override tagName = "folk-choice-rank";
|
||||
|
||||
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 = "Rank Choices";
|
||||
#options: RankOption[] = [];
|
||||
#rankings: UserRanking[] = [];
|
||||
#userId = "";
|
||||
#userName = "";
|
||||
#activeTab: "rank" | "results" = "rank";
|
||||
|
||||
// Drag state
|
||||
#dragIdx: number | null = null;
|
||||
#myOrdering: string[] = [];
|
||||
|
||||
// DOM refs
|
||||
#bodyEl: HTMLElement | null = null;
|
||||
#rankPanel: HTMLElement | null = null;
|
||||
#resultsPanel: 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: RankOption[]) {
|
||||
this.#options = v;
|
||||
this.#syncMyOrdering();
|
||||
this.#render();
|
||||
this.requestUpdate("options");
|
||||
}
|
||||
|
||||
get rankings() { return this.#rankings; }
|
||||
set rankings(v: UserRanking[]) {
|
||||
this.#rankings = v;
|
||||
this.#syncMyOrdering();
|
||||
this.#render();
|
||||
this.requestUpdate("rankings");
|
||||
}
|
||||
|
||||
#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);
|
||||
}
|
||||
|
||||
#syncMyOrdering() {
|
||||
const mine = this.#rankings.find((r) => r.userId === this.#userId);
|
||||
if (mine) {
|
||||
// Keep existing ordering, add new options at end
|
||||
const existing = new Set(mine.ordering);
|
||||
const extra = this.#options.filter((o) => !existing.has(o.id)).map((o) => o.id);
|
||||
this.#myOrdering = [...mine.ordering.filter((id) => this.#options.some((o) => o.id === id)), ...extra];
|
||||
} else {
|
||||
this.#myOrdering = this.#options.map((o) => o.id);
|
||||
}
|
||||
}
|
||||
|
||||
#saveMyRanking() {
|
||||
if (!this.#ensureIdentity()) return;
|
||||
const idx = this.#rankings.findIndex((r) => r.userId === this.#userId);
|
||||
const entry: UserRanking = {
|
||||
userId: this.#userId,
|
||||
userName: this.#userName,
|
||||
ordering: [...this.#myOrdering],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
if (idx >= 0) {
|
||||
this.#rankings[idx] = entry;
|
||||
} else {
|
||||
this.#rankings.push(entry);
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
}
|
||||
|
||||
override createRenderRoot() {
|
||||
const root = super.createRenderRoot();
|
||||
this.#ensureIdentity();
|
||||
this.#syncMyOrdering();
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = html`
|
||||
<div class="header">
|
||||
<span class="header-title">
|
||||
<span>📊</span>
|
||||
<span class="title-text">Rank</span>
|
||||
</span>
|
||||
<div class="header-actions">
|
||||
<button class="close-btn" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="tab-bar">
|
||||
<button class="tab active" data-tab="rank">My Ranking</button>
|
||||
<button class="tab" data-tab="results">Results</button>
|
||||
</div>
|
||||
<div class="panel rank-panel"></div>
|
||||
<div class="panel results-panel hidden"></div>
|
||||
<div class="add-form">
|
||||
<input type="text" class="add-input" placeholder="Add option..." />
|
||||
<button class="add-btn">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="username-prompt" style="display: none;">
|
||||
<p>Enter your name to rank:</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.#bodyEl = wrapper.querySelector(".body") as HTMLElement;
|
||||
this.#rankPanel = wrapper.querySelector(".rank-panel") as HTMLElement;
|
||||
this.#resultsPanel = wrapper.querySelector(".results-panel") as HTMLElement;
|
||||
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.#saveMyRanking();
|
||||
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(); });
|
||||
|
||||
// Tabs
|
||||
wrapper.querySelectorAll(".tab").forEach((tab) => {
|
||||
tab.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const t = (tab as HTMLElement).dataset.tab as "rank" | "results";
|
||||
this.#activeTab = t;
|
||||
wrapper.querySelectorAll(".tab").forEach((tb) => tb.classList.remove("active"));
|
||||
tab.classList.add("active");
|
||||
this.#rankPanel!.classList.toggle("hidden", t !== "rank");
|
||||
this.#resultsPanel!.classList.toggle("hidden", t !== "results");
|
||||
this.#render();
|
||||
});
|
||||
});
|
||||
|
||||
// Add option
|
||||
const addOption = () => {
|
||||
const label = addInput.value.trim();
|
||||
if (!label) return;
|
||||
const id = `opt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
this.#options.push({ id, label });
|
||||
this.#myOrdering.push(id);
|
||||
addInput.value = "";
|
||||
this.#saveMyRanking();
|
||||
this.#render();
|
||||
};
|
||||
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"));
|
||||
});
|
||||
|
||||
this.#render();
|
||||
return root;
|
||||
}
|
||||
|
||||
#render() {
|
||||
if (this.#activeTab === "rank") this.#renderRankList();
|
||||
else this.#renderResults();
|
||||
}
|
||||
|
||||
#renderRankList() {
|
||||
if (!this.#rankPanel) return;
|
||||
|
||||
if (this.#myOrdering.length === 0) {
|
||||
this.#rankPanel.innerHTML = '<div class="empty-state">Add options below to start ranking</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const optMap = new Map(this.#options.map((o) => [o.id, o]));
|
||||
|
||||
this.#rankPanel.innerHTML = this.#myOrdering
|
||||
.map((id, i) => {
|
||||
const opt = optMap.get(id);
|
||||
if (!opt) return "";
|
||||
const color = MEDAL_COLORS[Math.min(i, MEDAL_COLORS.length - 1)];
|
||||
return `
|
||||
<div class="rank-item" data-idx="${i}">
|
||||
<span class="grip">≡≡</span>
|
||||
<span class="rank-num" style="color:${color}">${i + 1}</span>
|
||||
<span class="rank-label">${this.#escapeHtml(opt.label)}</span>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
// Wire pointer-based drag
|
||||
this.#rankPanel.querySelectorAll(".rank-item").forEach((item) => {
|
||||
const el = item as HTMLElement;
|
||||
el.addEventListener("pointerdown", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#dragIdx = parseInt(el.dataset.idx!);
|
||||
el.classList.add("dragging");
|
||||
el.setPointerCapture(e.pointerId);
|
||||
|
||||
const onMove = (me: PointerEvent) => {
|
||||
me.stopPropagation();
|
||||
const target = this.#rankPanel!.querySelector(
|
||||
`.rank-item:not(.dragging):hover`
|
||||
) as HTMLElement;
|
||||
// Clear previous drag-over states
|
||||
this.#rankPanel!.querySelectorAll(".drag-over").forEach((d) => d.classList.remove("drag-over"));
|
||||
|
||||
if (target) target.classList.add("drag-over");
|
||||
};
|
||||
|
||||
const onUp = (ue: PointerEvent) => {
|
||||
ue.stopPropagation();
|
||||
el.classList.remove("dragging");
|
||||
el.releasePointerCapture(ue.pointerId);
|
||||
el.removeEventListener("pointermove", onMove);
|
||||
el.removeEventListener("pointerup", onUp);
|
||||
|
||||
// Find drop target
|
||||
const overEl = this.#rankPanel!.querySelector(".drag-over") as HTMLElement;
|
||||
if (overEl && this.#dragIdx !== null) {
|
||||
const targetIdx = parseInt(overEl.dataset.idx!);
|
||||
if (targetIdx !== this.#dragIdx) {
|
||||
const [moved] = this.#myOrdering.splice(this.#dragIdx, 1);
|
||||
this.#myOrdering.splice(targetIdx, 0, moved);
|
||||
this.#saveMyRanking();
|
||||
}
|
||||
}
|
||||
|
||||
this.#rankPanel!.querySelectorAll(".drag-over").forEach((d) => d.classList.remove("drag-over"));
|
||||
this.#dragIdx = null;
|
||||
this.#renderRankList();
|
||||
};
|
||||
|
||||
el.addEventListener("pointermove", onMove);
|
||||
el.addEventListener("pointerup", onUp);
|
||||
});
|
||||
|
||||
// Keyboard reorder
|
||||
el.tabIndex = 0;
|
||||
el.addEventListener("keydown", (e) => {
|
||||
e.stopPropagation();
|
||||
const idx = parseInt(el.dataset.idx!);
|
||||
if (e.key === "ArrowUp" && idx > 0) {
|
||||
[this.#myOrdering[idx - 1], this.#myOrdering[idx]] = [this.#myOrdering[idx], this.#myOrdering[idx - 1]];
|
||||
this.#saveMyRanking();
|
||||
this.#renderRankList();
|
||||
(this.#rankPanel!.querySelector(`[data-idx="${idx - 1}"]`) as HTMLElement)?.focus();
|
||||
} else if (e.key === "ArrowDown" && idx < this.#myOrdering.length - 1) {
|
||||
[this.#myOrdering[idx], this.#myOrdering[idx + 1]] = [this.#myOrdering[idx + 1], this.#myOrdering[idx]];
|
||||
this.#saveMyRanking();
|
||||
this.#renderRankList();
|
||||
(this.#rankPanel!.querySelector(`[data-idx="${idx + 1}"]`) as HTMLElement)?.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#renderResults() {
|
||||
if (!this.#resultsPanel) return;
|
||||
|
||||
const uniqueVoters = new Set(this.#rankings.map((r) => r.userId)).size;
|
||||
|
||||
if (this.#rankings.length === 0) {
|
||||
this.#resultsPanel.innerHTML = '<div class="empty-state">No rankings submitted yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const optMap = new Map(this.#options.map((o) => [o.id, o]));
|
||||
|
||||
// Borda count
|
||||
const borda = bordaCount(this.#rankings, this.#options);
|
||||
const maxBorda = Math.max(1, ...borda.values());
|
||||
const bordaSorted = [...borda.entries()].sort((a, b) => b[1] - a[1]);
|
||||
|
||||
let bordaHtml = '<div class="results-section"><div class="results-heading">Borda Count</div>';
|
||||
for (const [optId, pts] of bordaSorted) {
|
||||
const opt = optMap.get(optId);
|
||||
if (!opt) continue;
|
||||
const pct = (pts / maxBorda) * 100;
|
||||
bordaHtml += `
|
||||
<div class="borda-row">
|
||||
<span class="borda-label">${this.#escapeHtml(opt.label)}</span>
|
||||
<div class="borda-bar-bg"><div class="borda-bar-fill" style="width:${pct}%"></div></div>
|
||||
<span class="borda-pts">${pts}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
bordaHtml += "</div>";
|
||||
|
||||
// IRV
|
||||
const irvRounds = instantRunoff(this.#rankings, this.#options);
|
||||
let irvHtml = '<div class="results-section"><div class="results-heading">Instant-Runoff</div>';
|
||||
for (const r of irvRounds) {
|
||||
const counts = Object.entries(r.counts)
|
||||
.map(([id, c]) => `${optMap.get(id)?.label || id}: ${c}`)
|
||||
.join(", ");
|
||||
if (r.eliminated) {
|
||||
const elimLabel = optMap.get(r.eliminated)?.label || r.eliminated;
|
||||
irvHtml += `<div class="irv-round"><span class="round-num">Round ${r.round}:</span> ${counts} — <span class="eliminated">${this.#escapeHtml(elimLabel)} eliminated</span></div>`;
|
||||
} else {
|
||||
// Winner round
|
||||
const winnerId = Object.keys(r.counts)[0];
|
||||
const winLabel = optMap.get(winnerId)?.label || winnerId;
|
||||
irvHtml += `<div class="irv-round"><span class="round-num">Round ${r.round}:</span> ${counts} — <span class="winner">${this.#escapeHtml(winLabel)} wins!</span></div>`;
|
||||
}
|
||||
}
|
||||
irvHtml += "</div>";
|
||||
|
||||
this.#resultsPanel.innerHTML = bordaHtml + irvHtml +
|
||||
`<div class="voters-count">${uniqueVoters} voter${uniqueVoters !== 1 ? "s" : ""}</div>`;
|
||||
}
|
||||
|
||||
#escapeHtml(text: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
override toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
type: "folk-choice-rank",
|
||||
title: this.#title,
|
||||
options: this.#options,
|
||||
rankings: this.#rankings,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,790 @@
|
|||
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: 400px;
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: #059669;
|
||||
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;
|
||||
}
|
||||
|
||||
.option-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.option-tab {
|
||||
padding: 6px 12px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.option-tab.active {
|
||||
color: #059669;
|
||||
border-bottom: 2px solid #059669;
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
.chart-area {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chart-area svg {
|
||||
max-width: 260px;
|
||||
max-height: 240px;
|
||||
}
|
||||
|
||||
.sliders {
|
||||
padding: 4px 12px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.slider-label {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.slider-input {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: #e2e8f0;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider-input::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #059669;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slider-val {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #059669;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.score-summary {
|
||||
padding: 4px 12px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.score-summary .best {
|
||||
font-weight: 600;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.add-forms {
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.add-forms .add-group {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.add-forms input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
font-size: 11px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.add-forms input:focus { border-color: #059669; }
|
||||
|
||||
.add-forms button {
|
||||
background: #059669;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.add-forms button:hover { background: #047857; }
|
||||
|
||||
.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: #059669;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 24px 12px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
`;
|
||||
|
||||
// -- Data types --
|
||||
|
||||
export interface SpiderOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SpiderCriterion {
|
||||
id: string;
|
||||
label: string;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface SpiderScore {
|
||||
userId: string;
|
||||
userName: string;
|
||||
optionId: string;
|
||||
criterionId: string;
|
||||
value: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// -- Pure aggregation functions --
|
||||
|
||||
export function weightedMeanScore(
|
||||
scores: SpiderScore[],
|
||||
criteria: SpiderCriterion[],
|
||||
optionId: string,
|
||||
): number {
|
||||
const byCriterion = new Map<string, number[]>();
|
||||
for (const s of scores) {
|
||||
if (s.optionId !== optionId) continue;
|
||||
if (!byCriterion.has(s.criterionId)) byCriterion.set(s.criterionId, []);
|
||||
byCriterion.get(s.criterionId)!.push(s.value);
|
||||
}
|
||||
|
||||
let weightedSum = 0;
|
||||
let totalWeight = 0;
|
||||
for (const c of criteria) {
|
||||
const vals = byCriterion.get(c.id);
|
||||
if (!vals || vals.length === 0) continue;
|
||||
const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
|
||||
weightedSum += avg * c.weight;
|
||||
totalWeight += c.weight;
|
||||
}
|
||||
return totalWeight > 0 ? weightedSum / totalWeight : 0;
|
||||
}
|
||||
|
||||
export function getRadarVertices(
|
||||
scores: SpiderScore[],
|
||||
criteria: SpiderCriterion[],
|
||||
optionId: string,
|
||||
userId: string,
|
||||
cx: number,
|
||||
cy: number,
|
||||
radius: number,
|
||||
): { x: number; y: number }[] {
|
||||
const n = criteria.length;
|
||||
if (n === 0) return [];
|
||||
const angleStep = (2 * Math.PI) / n;
|
||||
|
||||
return criteria.map((c, i) => {
|
||||
const score = scores.find(
|
||||
(s) => s.optionId === optionId && s.criterionId === c.id && s.userId === userId,
|
||||
);
|
||||
const val = score ? score.value / 10 : 0;
|
||||
const angle = i * angleStep - Math.PI / 2;
|
||||
return {
|
||||
x: cx + radius * val * Math.cos(angle),
|
||||
y: cy + radius * val * Math.sin(angle),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getAverageRadarVertices(
|
||||
scores: SpiderScore[],
|
||||
criteria: SpiderCriterion[],
|
||||
optionId: string,
|
||||
cx: number,
|
||||
cy: number,
|
||||
radius: number,
|
||||
): { x: number; y: number }[] {
|
||||
const n = criteria.length;
|
||||
if (n === 0) return [];
|
||||
const angleStep = (2 * Math.PI) / n;
|
||||
|
||||
const byCriterion = new Map<string, number[]>();
|
||||
for (const s of scores) {
|
||||
if (s.optionId !== optionId) continue;
|
||||
if (!byCriterion.has(s.criterionId)) byCriterion.set(s.criterionId, []);
|
||||
byCriterion.get(s.criterionId)!.push(s.value);
|
||||
}
|
||||
|
||||
return criteria.map((c, i) => {
|
||||
const vals = byCriterion.get(c.id) || [];
|
||||
const avg = vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
|
||||
const val = avg / 10;
|
||||
const angle = i * angleStep - Math.PI / 2;
|
||||
return {
|
||||
x: cx + radius * val * Math.cos(angle),
|
||||
y: cy + radius * val * Math.sin(angle),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function polygonArea(vertices: { x: number; y: number }[]): number {
|
||||
const n = vertices.length;
|
||||
if (n < 3) return 0;
|
||||
let area = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n;
|
||||
area += vertices[i].x * vertices[j].y;
|
||||
area -= vertices[j].x * vertices[i].y;
|
||||
}
|
||||
return Math.abs(area) / 2;
|
||||
}
|
||||
|
||||
// -- Component --
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"folk-choice-spider": FolkChoiceSpider;
|
||||
}
|
||||
}
|
||||
|
||||
const USER_COLORS = ["#7c5bf5", "#f59e0b", "#10b981", "#ef4444", "#06b6d4", "#ec4899", "#8b5cf6", "#f97316"];
|
||||
|
||||
function userColor(userId: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < userId.length; i++) {
|
||||
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0;
|
||||
}
|
||||
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
|
||||
}
|
||||
|
||||
export class FolkChoiceSpider extends FolkShape {
|
||||
static override tagName = "folk-choice-spider";
|
||||
|
||||
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 = "Score Options";
|
||||
#options: SpiderOption[] = [];
|
||||
#criteria: SpiderCriterion[] = [];
|
||||
#scores: SpiderScore[] = [];
|
||||
#userId = "";
|
||||
#userName = "";
|
||||
#selectedOptionId = "";
|
||||
|
||||
// DOM refs
|
||||
#bodyEl: HTMLElement | null = null;
|
||||
#chartArea: HTMLElement | null = null;
|
||||
#slidersEl: HTMLElement | null = null;
|
||||
#legendEl: HTMLElement | null = null;
|
||||
#summaryEl: HTMLElement | null = null;
|
||||
#optionTabsEl: 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: SpiderOption[]) {
|
||||
this.#options = v;
|
||||
if (v.length > 0 && !v.some((o) => o.id === this.#selectedOptionId)) {
|
||||
this.#selectedOptionId = v[0].id;
|
||||
}
|
||||
this.#render();
|
||||
this.requestUpdate("options");
|
||||
}
|
||||
|
||||
get criteria() { return this.#criteria; }
|
||||
set criteria(v: SpiderCriterion[]) {
|
||||
this.#criteria = v;
|
||||
this.#render();
|
||||
this.requestUpdate("criteria");
|
||||
}
|
||||
|
||||
get scores() { return this.#scores; }
|
||||
set scores(v: SpiderScore[]) {
|
||||
this.#scores = v;
|
||||
this.#render();
|
||||
this.requestUpdate("scores");
|
||||
}
|
||||
|
||||
#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);
|
||||
}
|
||||
|
||||
#setScore(criterionId: string, value: number) {
|
||||
if (!this.#ensureIdentity()) return;
|
||||
const optionId = this.#selectedOptionId;
|
||||
|
||||
// Remove existing score for this user/option/criterion
|
||||
this.#scores = this.#scores.filter(
|
||||
(s) => !(s.userId === this.#userId && s.optionId === optionId && s.criterionId === criterionId),
|
||||
);
|
||||
this.#scores.push({
|
||||
userId: this.#userId,
|
||||
userName: this.#userName,
|
||||
optionId,
|
||||
criterionId,
|
||||
value,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
}
|
||||
|
||||
override createRenderRoot() {
|
||||
const root = super.createRenderRoot();
|
||||
this.#ensureIdentity();
|
||||
if (this.#options.length > 0) this.#selectedOptionId = this.#options[0].id;
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = html`
|
||||
<div class="header">
|
||||
<span class="header-title">
|
||||
<span>🕸</span>
|
||||
<span class="title-text">Spider</span>
|
||||
</span>
|
||||
<div class="header-actions">
|
||||
<button class="close-btn" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="option-tabs"></div>
|
||||
<div class="chart-area"></div>
|
||||
<div class="sliders"></div>
|
||||
<div class="legend"></div>
|
||||
<div class="score-summary"></div>
|
||||
<div class="add-forms">
|
||||
<div class="add-group">
|
||||
<input type="text" class="add-opt-input" placeholder="+ option" />
|
||||
<button class="add-opt-btn">Add</button>
|
||||
</div>
|
||||
<div class="add-group">
|
||||
<input type="text" class="add-crit-input" placeholder="+ criterion" />
|
||||
<button class="add-crit-btn">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="username-prompt" style="display: none;">
|
||||
<p>Enter your name to score:</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.#bodyEl = wrapper.querySelector(".body") as HTMLElement;
|
||||
this.#chartArea = wrapper.querySelector(".chart-area") as HTMLElement;
|
||||
this.#slidersEl = wrapper.querySelector(".sliders") as HTMLElement;
|
||||
this.#legendEl = wrapper.querySelector(".legend") as HTMLElement;
|
||||
this.#summaryEl = wrapper.querySelector(".score-summary") as HTMLElement;
|
||||
this.#optionTabsEl = wrapper.querySelector(".option-tabs") as HTMLElement;
|
||||
|
||||
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 addOptInput = wrapper.querySelector(".add-opt-input") as HTMLInputElement;
|
||||
const addOptBtn = wrapper.querySelector(".add-opt-btn") as HTMLButtonElement;
|
||||
const addCritInput = wrapper.querySelector(".add-crit-input") as HTMLInputElement;
|
||||
const addCritBtn = wrapper.querySelector(".add-crit-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 addOpt = () => {
|
||||
const label = addOptInput.value.trim();
|
||||
if (!label) return;
|
||||
const id = `opt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
this.#options.push({ id, label });
|
||||
if (!this.#selectedOptionId) this.#selectedOptionId = id;
|
||||
addOptInput.value = "";
|
||||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
};
|
||||
addOptBtn.addEventListener("click", (e) => { e.stopPropagation(); addOpt(); });
|
||||
addOptInput.addEventListener("click", (e) => e.stopPropagation());
|
||||
addOptInput.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") addOpt(); });
|
||||
|
||||
// Add criterion
|
||||
const addCrit = () => {
|
||||
const label = addCritInput.value.trim();
|
||||
if (!label) return;
|
||||
const id = `crit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
this.#criteria.push({ id, label, weight: 1 });
|
||||
addCritInput.value = "";
|
||||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
};
|
||||
addCritBtn.addEventListener("click", (e) => { e.stopPropagation(); addCrit(); });
|
||||
addCritInput.addEventListener("click", (e) => e.stopPropagation());
|
||||
addCritInput.addEventListener("keydown", (e) => { e.stopPropagation(); if (e.key === "Enter") addCrit(); });
|
||||
|
||||
wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
});
|
||||
|
||||
this.#render();
|
||||
return root;
|
||||
}
|
||||
|
||||
#render() {
|
||||
this.#renderOptionTabs();
|
||||
this.#renderChart();
|
||||
this.#renderSliders();
|
||||
this.#renderLegend();
|
||||
this.#renderSummary();
|
||||
}
|
||||
|
||||
#renderOptionTabs() {
|
||||
if (!this.#optionTabsEl) return;
|
||||
this.#optionTabsEl.innerHTML = this.#options
|
||||
.map((opt) => `<button class="option-tab ${opt.id === this.#selectedOptionId ? "active" : ""}" data-opt="${opt.id}">${this.#escapeHtml(opt.label)}</button>`)
|
||||
.join("");
|
||||
|
||||
this.#optionTabsEl.querySelectorAll(".option-tab").forEach((tab) => {
|
||||
tab.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#selectedOptionId = (tab as HTMLElement).dataset.opt!;
|
||||
this.#render();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#renderChart() {
|
||||
if (!this.#chartArea) return;
|
||||
const n = this.#criteria.length;
|
||||
if (n < 3) {
|
||||
this.#chartArea.innerHTML = '<div class="empty-state">Add at least 3 criteria</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const CX = 130;
|
||||
const CY = 120;
|
||||
const R = 90;
|
||||
const RINGS = 5;
|
||||
const angleStep = (2 * Math.PI) / n;
|
||||
|
||||
const polar = (angle: number, r: number) => {
|
||||
const a = angle - Math.PI / 2;
|
||||
return { x: CX + r * Math.cos(a), y: CY + r * Math.sin(a) };
|
||||
};
|
||||
|
||||
let svg = `<svg viewBox="0 0 260 250" xmlns="http://www.w3.org/2000/svg">`;
|
||||
|
||||
// Grid rings
|
||||
for (let ring = 1; ring <= RINGS; ring++) {
|
||||
const r = (R / RINGS) * ring;
|
||||
const pts = Array.from({ length: n }, (_, i) => {
|
||||
const p = polar(i * angleStep, r);
|
||||
return `${p.x},${p.y}`;
|
||||
}).join(" ");
|
||||
svg += `<polygon points="${pts}" fill="none" stroke="#e2e8f0" stroke-width="${ring === RINGS ? 1.5 : 0.5}"/>`;
|
||||
}
|
||||
|
||||
// Axis lines + labels
|
||||
for (let i = 0; i < n; i++) {
|
||||
const end = polar(i * angleStep, R);
|
||||
const lbl = polar(i * angleStep, R + 16);
|
||||
svg += `<line x1="${CX}" y1="${CY}" x2="${end.x}" y2="${end.y}" stroke="#e2e8f0" stroke-width="0.5"/>`;
|
||||
svg += `<text x="${lbl.x}" y="${lbl.y}" text-anchor="middle" dominant-baseline="central" fill="#94a3b8" font-size="9">${this.#escapeHtml(this.#criteria[i].label)}</text>`;
|
||||
}
|
||||
|
||||
// Get unique users who scored the selected option
|
||||
const optId = this.#selectedOptionId;
|
||||
const userIds = [...new Set(this.#scores.filter((s) => s.optionId === optId).map((s) => s.userId))];
|
||||
|
||||
// Per-user polygons
|
||||
for (const uid of userIds) {
|
||||
const verts = getRadarVertices(this.#scores, this.#criteria, optId, uid, CX, CY, R);
|
||||
if (verts.length >= 3) {
|
||||
const pts = verts.map((v) => `${v.x},${v.y}`).join(" ");
|
||||
const color = userColor(uid);
|
||||
svg += `<polygon points="${pts}" fill="${color}" fill-opacity="0.15" stroke="${color}" stroke-width="1.5" stroke-opacity="0.6"/>`;
|
||||
for (const v of verts) {
|
||||
svg += `<circle cx="${v.x}" cy="${v.y}" r="3" fill="${color}"/>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Average polygon (dashed)
|
||||
if (userIds.length > 0) {
|
||||
const avgVerts = getAverageRadarVertices(this.#scores, this.#criteria, optId, CX, CY, R);
|
||||
if (avgVerts.length >= 3) {
|
||||
const pts = avgVerts.map((v) => `${v.x},${v.y}`).join(" ");
|
||||
svg += `<polygon points="${pts}" fill="none" stroke="#1e293b" stroke-width="2" stroke-dasharray="4 3" opacity="0.5"/>`;
|
||||
}
|
||||
}
|
||||
|
||||
svg += `</svg>`;
|
||||
this.#chartArea.innerHTML = svg;
|
||||
}
|
||||
|
||||
#renderSliders() {
|
||||
if (!this.#slidersEl) return;
|
||||
const optId = this.#selectedOptionId;
|
||||
|
||||
if (this.#criteria.length === 0) {
|
||||
this.#slidersEl.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
this.#slidersEl.innerHTML = this.#criteria
|
||||
.map((c) => {
|
||||
const myScore = this.#scores.find(
|
||||
(s) => s.userId === this.#userId && s.optionId === optId && s.criterionId === c.id,
|
||||
);
|
||||
const val = myScore ? myScore.value : 5;
|
||||
return `
|
||||
<div class="slider-row">
|
||||
<span class="slider-label">${this.#escapeHtml(c.label)}</span>
|
||||
<input type="range" class="slider-input" min="1" max="10" value="${val}" data-crit="${c.id}" />
|
||||
<span class="slider-val">${val}</span>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
this.#slidersEl.querySelectorAll(".slider-input").forEach((slider) => {
|
||||
const input = slider as HTMLInputElement;
|
||||
input.addEventListener("click", (e) => e.stopPropagation());
|
||||
input.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||||
input.addEventListener("input", (e) => {
|
||||
e.stopPropagation();
|
||||
const critId = input.dataset.crit!;
|
||||
const val = parseInt(input.value);
|
||||
const valEl = input.parentElement!.querySelector(".slider-val") as HTMLElement;
|
||||
valEl.textContent = String(val);
|
||||
this.#setScore(critId, val);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#renderLegend() {
|
||||
if (!this.#legendEl) return;
|
||||
const optId = this.#selectedOptionId;
|
||||
const users = new Map<string, string>();
|
||||
for (const s of this.#scores) {
|
||||
if (s.optionId === optId) users.set(s.userId, s.userName);
|
||||
}
|
||||
|
||||
this.#legendEl.innerHTML = [...users.entries()]
|
||||
.map(([uid, name]) => `<span class="legend-item"><span class="legend-dot" style="background:${userColor(uid)}"></span>${this.#escapeHtml(name)}</span>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
#renderSummary() {
|
||||
if (!this.#summaryEl) return;
|
||||
|
||||
if (this.#options.length === 0 || this.#criteria.length === 0) {
|
||||
this.#summaryEl.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const results = this.#options.map((opt) => ({
|
||||
label: opt.label,
|
||||
score: weightedMeanScore(this.#scores, this.#criteria, opt.id),
|
||||
}));
|
||||
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
const best = results[0];
|
||||
|
||||
if (best && best.score > 0) {
|
||||
const summary = results.map((r) => `${this.#escapeHtml(r.label)}: ${r.score.toFixed(1)}`).join(" | ");
|
||||
this.#summaryEl.innerHTML = `<span class="best">${this.#escapeHtml(best.label)}</span> leads — ${summary}`;
|
||||
} else {
|
||||
this.#summaryEl.innerHTML = "Score options to see results";
|
||||
}
|
||||
}
|
||||
|
||||
#escapeHtml(text: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
override toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
type: "folk-choice-spider",
|
||||
title: this.#title,
|
||||
options: this.#options,
|
||||
criteria: this.#criteria,
|
||||
scores: this.#scores,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,652 @@
|
|||
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: 320px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: #0d9488;
|
||||
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;
|
||||
}
|
||||
|
||||
.mode-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.mode-tab {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.mode-tab.active {
|
||||
color: #0d9488;
|
||||
border-bottom: 2px solid #0d9488;
|
||||
background: #f0fdfa;
|
||||
}
|
||||
|
||||
.options-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.option-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.option-row:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.option-row.voted {
|
||||
background: #f0fdfa;
|
||||
}
|
||||
|
||||
.bar-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;
|
||||
z-index: 1;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.option-count {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-variant-numeric: tabular-nums;
|
||||
z-index: 1;
|
||||
min-width: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.option-pct {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
z-index: 1;
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.qv-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.qv-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.qv-btn:hover { background: #f1f5f9; }
|
||||
.qv-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.qv-count {
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.budget-bar {
|
||||
padding: 4px 12px;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.budget-bar .used {
|
||||
font-weight: 600;
|
||||
color: #0d9488;
|
||||
}
|
||||
|
||||
.voters-count {
|
||||
padding: 4px 12px;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.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: #0d9488; }
|
||||
|
||||
.add-btn {
|
||||
background: #0d9488;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.add-btn:hover { background: #0f766e; }
|
||||
|
||||
.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: #0d9488;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
`;
|
||||
|
||||
// -- Data types --
|
||||
|
||||
export interface VoteOption {
|
||||
id: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface UserVote {
|
||||
userId: string;
|
||||
userName: string;
|
||||
allocations: Record<string, number>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export type VoteMode = "plurality" | "approval" | "quadratic";
|
||||
|
||||
// -- Pure aggregation functions --
|
||||
|
||||
export function tallyVotes(
|
||||
votes: UserVote[],
|
||||
options: VoteOption[],
|
||||
): Map<string, number> {
|
||||
const tally = new Map<string, number>();
|
||||
for (const opt of options) tally.set(opt.id, 0);
|
||||
for (const v of votes) {
|
||||
for (const [optId, count] of Object.entries(v.allocations)) {
|
||||
if (tally.has(optId)) {
|
||||
tally.set(optId, tally.get(optId)! + count);
|
||||
}
|
||||
}
|
||||
}
|
||||
return tally;
|
||||
}
|
||||
|
||||
export function quadraticCost(allocations: Record<string, number>): number {
|
||||
let total = 0;
|
||||
for (const k of Object.values(allocations)) {
|
||||
total += k * k;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// -- Component --
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"folk-choice-vote": FolkChoiceVote;
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_COLORS = ["#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"];
|
||||
|
||||
export class FolkChoiceVote extends FolkShape {
|
||||
static override tagName = "folk-choice-vote";
|
||||
|
||||
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 = "Quick Poll";
|
||||
#options: VoteOption[] = [];
|
||||
#mode: VoteMode = "plurality";
|
||||
#budget = 100;
|
||||
#votes: UserVote[] = [];
|
||||
#userId = "";
|
||||
#userName = "";
|
||||
|
||||
// DOM refs
|
||||
#bodyEl: HTMLElement | null = null;
|
||||
#optionsEl: HTMLElement | null = null;
|
||||
#budgetEl: HTMLElement | null = null;
|
||||
#votersEl: 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: VoteOption[]) {
|
||||
this.#options = v;
|
||||
this.#render();
|
||||
this.requestUpdate("options");
|
||||
}
|
||||
|
||||
get mode() { return this.#mode; }
|
||||
set mode(v: VoteMode) {
|
||||
this.#mode = v;
|
||||
this.#render();
|
||||
this.requestUpdate("mode");
|
||||
}
|
||||
|
||||
get budget() { return this.#budget; }
|
||||
set budget(v: number) {
|
||||
this.#budget = v;
|
||||
this.#render();
|
||||
this.requestUpdate("budget");
|
||||
}
|
||||
|
||||
get votes() { return this.#votes; }
|
||||
set votes(v: UserVote[]) {
|
||||
this.#votes = v;
|
||||
this.#render();
|
||||
this.requestUpdate("votes");
|
||||
}
|
||||
|
||||
#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);
|
||||
}
|
||||
|
||||
#getUserVote(): UserVote | undefined {
|
||||
return this.#votes.find((v) => v.userId === this.#userId);
|
||||
}
|
||||
|
||||
override createRenderRoot() {
|
||||
const root = super.createRenderRoot();
|
||||
this.#ensureIdentity();
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = html`
|
||||
<div class="header">
|
||||
<span class="header-title">
|
||||
<span>☑</span>
|
||||
<span class="title-text">Poll</span>
|
||||
</span>
|
||||
<div class="header-actions">
|
||||
<button class="close-btn" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="mode-tabs">
|
||||
<button class="mode-tab active" data-mode="plurality">Plurality</button>
|
||||
<button class="mode-tab" data-mode="approval">Approval</button>
|
||||
<button class="mode-tab" data-mode="quadratic">Quadratic</button>
|
||||
</div>
|
||||
<div class="options-list"></div>
|
||||
<div class="budget-bar" style="display:none;"></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="username-prompt" style="display: none;">
|
||||
<p>Enter your name to vote:</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.#bodyEl = wrapper.querySelector(".body") as HTMLElement;
|
||||
this.#optionsEl = wrapper.querySelector(".options-list") as HTMLElement;
|
||||
this.#budgetEl = wrapper.querySelector(".budget-bar") as HTMLElement;
|
||||
this.#votersEl = wrapper.querySelector(".voters-count") as HTMLElement;
|
||||
const titleEl = wrapper.querySelector(".title-text") as HTMLElement;
|
||||
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;
|
||||
|
||||
// Show username prompt if needed
|
||||
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(); });
|
||||
|
||||
// Mode tabs
|
||||
wrapper.querySelectorAll(".mode-tab").forEach((tab) => {
|
||||
tab.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const m = (tab as HTMLElement).dataset.mode as VoteMode;
|
||||
this.#mode = m;
|
||||
wrapper.querySelectorAll(".mode-tab").forEach((t) => t.classList.remove("active"));
|
||||
tab.classList.add("active");
|
||||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
});
|
||||
});
|
||||
|
||||
// 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(); });
|
||||
|
||||
// Close
|
||||
wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
});
|
||||
|
||||
// Title
|
||||
if (this.#title) titleEl.textContent = this.#title;
|
||||
|
||||
this.#render();
|
||||
return root;
|
||||
}
|
||||
|
||||
#castVote(optionId: string, delta: number) {
|
||||
if (!this.#ensureIdentity()) return;
|
||||
|
||||
let vote = this.#getUserVote();
|
||||
if (!vote) {
|
||||
vote = { userId: this.#userId, userName: this.#userName, allocations: {}, timestamp: Date.now() };
|
||||
this.#votes.push(vote);
|
||||
}
|
||||
|
||||
const current = vote.allocations[optionId] || 0;
|
||||
|
||||
if (this.#mode === "plurality") {
|
||||
// Toggle: clear all, then set this one (or clear if already voted)
|
||||
const wasVoted = current > 0;
|
||||
for (const key of Object.keys(vote.allocations)) vote.allocations[key] = 0;
|
||||
if (!wasVoted) vote.allocations[optionId] = 1;
|
||||
} else if (this.#mode === "approval") {
|
||||
// Toggle this option
|
||||
vote.allocations[optionId] = current > 0 ? 0 : 1;
|
||||
} else {
|
||||
// Quadratic: increment/decrement
|
||||
const next = Math.max(0, current + delta);
|
||||
const testAlloc = { ...vote.allocations, [optionId]: next };
|
||||
if (quadraticCost(testAlloc) <= this.#budget) {
|
||||
vote.allocations[optionId] = next;
|
||||
}
|
||||
}
|
||||
|
||||
vote.timestamp = Date.now();
|
||||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
}
|
||||
|
||||
#render() {
|
||||
if (!this.#optionsEl) return;
|
||||
|
||||
const tally = tallyVotes(this.#votes, this.#options);
|
||||
const maxVotes = Math.max(1, ...tally.values());
|
||||
const totalVotes = [...tally.values()].reduce((a, b) => a + b, 0);
|
||||
const myVote = this.#getUserVote();
|
||||
const uniqueVoters = new Set(this.#votes.map((v) => v.userId)).size;
|
||||
|
||||
this.#optionsEl.innerHTML = this.#options
|
||||
.map((opt) => {
|
||||
const count = tally.get(opt.id) || 0;
|
||||
const pct = totalVotes > 0 ? (count / totalVotes) * 100 : 0;
|
||||
const barWidth = (count / maxVotes) * 100;
|
||||
const myAlloc = myVote?.allocations[opt.id] || 0;
|
||||
const isVoted = myAlloc > 0;
|
||||
|
||||
let controls = "";
|
||||
if (this.#mode === "quadratic") {
|
||||
controls = `
|
||||
<div class="qv-controls">
|
||||
<button class="qv-btn qv-minus" data-opt="${opt.id}">−</button>
|
||||
<span class="qv-count">${myAlloc}</span>
|
||||
<button class="qv-btn qv-plus" data-opt="${opt.id}">+</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="option-row ${isVoted ? "voted" : ""}" data-opt="${opt.id}">
|
||||
<div class="bar-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>
|
||||
${controls}
|
||||
<span class="option-count">${count}</span>
|
||||
<span class="option-pct" style="color:${opt.color}">${pct.toFixed(0)}%</span>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
// Budget display for quadratic
|
||||
if (this.#budgetEl) {
|
||||
if (this.#mode === "quadratic") {
|
||||
const used = myVote ? quadraticCost(myVote.allocations) : 0;
|
||||
this.#budgetEl.style.display = "block";
|
||||
this.#budgetEl.innerHTML = `Credits: <span class="used">${used}</span> / ${this.#budget}`;
|
||||
} else {
|
||||
this.#budgetEl.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// Voter count
|
||||
if (this.#votersEl) {
|
||||
this.#votersEl.textContent = uniqueVoters === 0
|
||||
? "No votes yet"
|
||||
: `${uniqueVoters} voter${uniqueVoters !== 1 ? "s" : ""}`;
|
||||
}
|
||||
|
||||
// Wire click events
|
||||
if (this.#mode !== "quadratic") {
|
||||
this.#optionsEl.querySelectorAll(".option-row").forEach((row) => {
|
||||
row.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const optId = (row as HTMLElement).dataset.opt!;
|
||||
this.#castVote(optId, 1);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
this.#optionsEl.querySelectorAll(".qv-plus").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#castVote((btn as HTMLElement).dataset.opt!, 1);
|
||||
});
|
||||
});
|
||||
this.#optionsEl.querySelectorAll(".qv-minus").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#castVote((btn as HTMLElement).dataset.opt!, -1);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#escapeHtml(text: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
override toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
type: "folk-choice-vote",
|
||||
title: this.#title,
|
||||
options: this.#options,
|
||||
mode: this.#mode,
|
||||
budget: this.#budget,
|
||||
votes: this.#votes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -55,6 +55,11 @@ export * from "./folk-booking";
|
|||
export * from "./folk-token-mint";
|
||||
export * from "./folk-token-ledger";
|
||||
|
||||
// Decision/Choice Shapes
|
||||
export * from "./folk-choice-vote";
|
||||
export * from "./folk-choice-rank";
|
||||
export * from "./folk-choice-spider";
|
||||
|
||||
// Sync
|
||||
export * from "./community-sync";
|
||||
export * from "./presence";
|
||||
|
|
|
|||
|
|
@ -169,7 +169,10 @@
|
|||
folk-packing-list,
|
||||
folk-booking,
|
||||
folk-token-mint,
|
||||
folk-token-ledger {
|
||||
folk-token-ledger,
|
||||
folk-choice-vote,
|
||||
folk-choice-rank,
|
||||
folk-choice-spider {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
|
@ -178,7 +181,8 @@
|
|||
folk-image-gen, folk-video-gen, folk-prompt, folk-transcription,
|
||||
folk-video-chat, folk-obs-note, folk-workflow-block,
|
||||
folk-itinerary, folk-destination, folk-budget, folk-packing-list,
|
||||
folk-booking, folk-token-mint, folk-token-ledger) {
|
||||
folk-booking, folk-token-mint, folk-token-ledger,
|
||||
folk-choice-vote, folk-choice-rank, folk-choice-spider) {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
|
|
@ -187,7 +191,8 @@
|
|||
folk-image-gen, folk-video-gen, folk-prompt, folk-transcription,
|
||||
folk-video-chat, folk-obs-note, folk-workflow-block,
|
||||
folk-itinerary, folk-destination, folk-budget, folk-packing-list,
|
||||
folk-booking, folk-token-mint, folk-token-ledger):hover {
|
||||
folk-booking, folk-token-mint, folk-token-ledger,
|
||||
folk-choice-vote, folk-choice-rank, folk-choice-spider):hover {
|
||||
outline: 2px dashed #3b82f6;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
|
@ -227,6 +232,9 @@
|
|||
<button id="add-packing-list" title="Packing List">🎒 Packing</button>
|
||||
<button id="add-booking" title="Trip Booking">✈️ Booking</button>
|
||||
<button id="add-token" title="Create Token">🪙 Token</button>
|
||||
<button id="add-choice-vote" title="Live Poll">☑ Poll</button>
|
||||
<button id="add-choice-rank" title="Rank Choices">📊 Rank</button>
|
||||
<button id="add-choice-spider" title="Score Matrix">🕸 Spider</button>
|
||||
<button id="add-arrow" title="Connect Shapes">↗️ Connect</button>
|
||||
<button id="zoom-in" title="Zoom In">+</button>
|
||||
<button id="zoom-out" title="Zoom Out">-</button>
|
||||
|
|
@ -267,6 +275,9 @@
|
|||
FolkBooking,
|
||||
FolkTokenMint,
|
||||
FolkTokenLedger,
|
||||
FolkChoiceVote,
|
||||
FolkChoiceRank,
|
||||
FolkChoiceSpider,
|
||||
CommunitySync,
|
||||
PresenceManager,
|
||||
generatePeerId
|
||||
|
|
@ -302,6 +313,9 @@
|
|||
FolkBooking.define();
|
||||
FolkTokenMint.define();
|
||||
FolkTokenLedger.define();
|
||||
FolkChoiceVote.define();
|
||||
FolkChoiceRank.define();
|
||||
FolkChoiceSpider.define();
|
||||
|
||||
// Get community info from URL
|
||||
const hostname = window.location.hostname;
|
||||
|
|
@ -326,7 +340,8 @@
|
|||
"folk-transcription", "folk-video-chat", "folk-obs-note",
|
||||
"folk-workflow-block", "folk-itinerary", "folk-destination",
|
||||
"folk-budget", "folk-packing-list", "folk-booking",
|
||||
"folk-token-mint", "folk-token-ledger"
|
||||
"folk-token-mint", "folk-token-ledger",
|
||||
"folk-choice-vote", "folk-choice-rank", "folk-choice-spider"
|
||||
].join(", ");
|
||||
|
||||
// Initialize CommunitySync
|
||||
|
|
@ -578,6 +593,27 @@
|
|||
if (data.mintId) shape.mintId = data.mintId;
|
||||
if (data.entries) shape.entries = data.entries;
|
||||
break;
|
||||
case "folk-choice-vote":
|
||||
shape = document.createElement("folk-choice-vote");
|
||||
if (data.title) shape.title = data.title;
|
||||
if (data.options) shape.options = data.options;
|
||||
if (data.mode) shape.mode = data.mode;
|
||||
if (data.budget != null) shape.budget = data.budget;
|
||||
if (data.votes) shape.votes = data.votes;
|
||||
break;
|
||||
case "folk-choice-rank":
|
||||
shape = document.createElement("folk-choice-rank");
|
||||
if (data.title) shape.title = data.title;
|
||||
if (data.options) shape.options = data.options;
|
||||
if (data.rankings) shape.rankings = data.rankings;
|
||||
break;
|
||||
case "folk-choice-spider":
|
||||
shape = document.createElement("folk-choice-spider");
|
||||
if (data.title) shape.title = data.title;
|
||||
if (data.options) shape.options = data.options;
|
||||
if (data.criteria) shape.criteria = data.criteria;
|
||||
if (data.scores) shape.scores = data.scores;
|
||||
break;
|
||||
case "folk-markdown":
|
||||
default:
|
||||
shape = document.createElement("folk-markdown");
|
||||
|
|
@ -643,6 +679,9 @@
|
|||
"folk-booking": { width: 300, height: 240 },
|
||||
"folk-token-mint": { width: 320, height: 280 },
|
||||
"folk-token-ledger": { width: 380, height: 400 },
|
||||
"folk-choice-vote": { width: 360, height: 400 },
|
||||
"folk-choice-rank": { width: 380, height: 480 },
|
||||
"folk-choice-spider": { width: 440, height: 540 },
|
||||
};
|
||||
|
||||
// Get the center of the current viewport in canvas coordinates
|
||||
|
|
@ -776,6 +815,50 @@
|
|||
}
|
||||
});
|
||||
|
||||
// Decision/choice components
|
||||
document.getElementById("add-choice-vote").addEventListener("click", () => {
|
||||
createAndAddShape("folk-choice-vote", {
|
||||
title: "Quick Poll",
|
||||
options: [
|
||||
{ id: "opt-1", label: "Option A", color: "#3b82f6" },
|
||||
{ id: "opt-2", label: "Option B", color: "#22c55e" },
|
||||
{ id: "opt-3", label: "Option C", color: "#f59e0b" },
|
||||
],
|
||||
mode: "plurality",
|
||||
budget: 100,
|
||||
votes: [],
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("add-choice-rank").addEventListener("click", () => {
|
||||
createAndAddShape("folk-choice-rank", {
|
||||
title: "Rank These",
|
||||
options: [
|
||||
{ id: "opt-1", label: "Option A" },
|
||||
{ id: "opt-2", label: "Option B" },
|
||||
{ id: "opt-3", label: "Option C" },
|
||||
],
|
||||
rankings: [],
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("add-choice-spider").addEventListener("click", () => {
|
||||
createAndAddShape("folk-choice-spider", {
|
||||
title: "Evaluate Options",
|
||||
options: [
|
||||
{ id: "opt-1", label: "Option A" },
|
||||
{ id: "opt-2", label: "Option B" },
|
||||
],
|
||||
criteria: [
|
||||
{ id: "crit-1", label: "Quality", weight: 1 },
|
||||
{ id: "crit-2", label: "Cost", weight: 1 },
|
||||
{ id: "crit-3", label: "Speed", weight: 1 },
|
||||
{ id: "crit-4", label: "Risk", weight: 1 },
|
||||
],
|
||||
scores: [],
|
||||
});
|
||||
});
|
||||
|
||||
// Arrow connection mode
|
||||
let connectMode = false;
|
||||
let connectSource = null;
|
||||
|
|
|
|||
Loading…
Reference in New Issue