diff --git a/lib/community-sync.ts b/lib/community-sync.ts index 31460af..6fc8e2a 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -982,6 +982,16 @@ export class CommunitySync extends EventTarget { if (data.hashtags !== undefined) post.hashtags = data.hashtags; if (data.stepNumber !== undefined && post.stepNumber !== data.stepNumber) post.stepNumber = data.stepNumber; } + + // Update workflow-block properties + if (data.type === "folk-workflow-block") { + const block = shape as any; + if (data.blockType !== undefined && block.blockType !== data.blockType) block.blockType = data.blockType; + if (data.label !== undefined && block.label !== data.label) block.label = data.label; + if (data.inputs !== undefined) block.inputs = data.inputs; + if (data.outputs !== undefined) block.outputs = data.outputs; + if (data.config !== undefined) block.config = data.config; + } } /** diff --git a/lib/folk-choice-rank.ts b/lib/folk-choice-rank.ts index 3da1d96..5b5c0ac 100644 --- a/lib/folk-choice-rank.ts +++ b/lib/folk-choice-rank.ts @@ -289,6 +289,32 @@ const styles = css` color: #94a3b8; font-size: 12px; } + + .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); } `; // -- Data types -- @@ -397,6 +423,92 @@ export function instantRunoff( return rounds; } +export function kendallTauB(rankings: UserRanking[], options: RankOption[]): number { + if (rankings.length < 2) return 0; + const optIds = options.map((o) => o.id); + let totalTau = 0; + let pairs = 0; + for (let i = 0; i < rankings.length; i++) { + for (let j = i + 1; j < rankings.length; j++) { + const posA = new Map(rankings[i].ordering.map((id, idx) => [id, idx])); + const posB = new Map(rankings[j].ordering.map((id, idx) => [id, idx])); + let concordant = 0; + let discordant = 0; + for (let a = 0; a < optIds.length; a++) { + for (let b = a + 1; b < optIds.length; b++) { + const dA = (posA.get(optIds[a]) ?? 0) - (posA.get(optIds[b]) ?? 0); + const dB = (posB.get(optIds[a]) ?? 0) - (posB.get(optIds[b]) ?? 0); + if (dA * dB > 0) concordant++; + else if (dA * dB < 0) discordant++; + } + } + const total = concordant + discordant; + if (total > 0) { + totalTau += (concordant - discordant) / total; + pairs++; + } + } + } + return pairs > 0 ? totalTau / pairs : 0; +} + +export function positionFrequency( + rankings: UserRanking[], + options: RankOption[], +): Map { + const result = new Map(); + const n = options.length; + for (const opt of options) result.set(opt.id, new Array(n).fill(0)); + for (const r of rankings) { + for (let i = 0; i < r.ordering.length; i++) { + const counts = result.get(r.ordering[i]); + if (counts && i < counts.length) counts[i]++; + } + } + return result; +} + +export function headToHead( + rankings: UserRanking[], + options: RankOption[], +): Map> { + const result = new Map>(); + for (const a of options) { + result.set(a.id, new Map()); + for (const b of options) { + if (a.id !== b.id) result.get(a.id)!.set(b.id, 0); + } + } + for (const r of rankings) { + const pos = new Map(r.ordering.map((id, idx) => [id, idx])); + for (const a of options) { + for (const b of options) { + if (a.id === b.id) continue; + if ((pos.get(a.id) ?? Infinity) < (pos.get(b.id) ?? Infinity)) { + result.get(a.id)!.set(b.id, result.get(a.id)!.get(b.id)! + 1); + } + } + } + } + return result; +} + +export function condorcetWinner(rankings: UserRanking[], options: RankOption[]): string | null { + const h2h = headToHead(rankings, options); + for (const a of options) { + let wins = true; + for (const b of options) { + if (a.id === b.id) continue; + if ((h2h.get(a.id)?.get(b.id) ?? 0) <= (h2h.get(b.id)?.get(a.id) ?? 0)) { + wins = false; + break; + } + } + if (wins) return a.id; + } + return null; +} + // -- Component -- declare global { @@ -424,15 +536,18 @@ export class FolkChoiceRank extends FolkShape { #userId = ""; #userName = ""; #activeTab: "rank" | "results" = "rank"; + #drawerOpen = false; // Drag state #dragIdx: number | null = null; #myOrdering: string[] = []; // DOM refs + #wrapperEl: HTMLElement | null = null; #bodyEl: HTMLElement | null = null; #rankPanel: HTMLElement | null = null; #resultsPanel: HTMLElement | null = null; + #drawerEl: HTMLElement | null = null; get title() { return this.#title; } set title(v: string) { this.#title = v; this.requestUpdate("title"); } @@ -505,6 +620,7 @@ export class FolkChoiceRank extends FolkShape { this.#syncMyOrdering(); const wrapper = document.createElement("div"); + wrapper.className = "wrapper"; wrapper.innerHTML = html`
@@ -512,6 +628,7 @@ export class FolkChoiceRank extends FolkShape { Rank
+
@@ -527,6 +644,7 @@ export class FolkChoiceRank extends FolkShape { +
+
+