791 lines
20 KiB
TypeScript
791 lines
20 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: 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,
|
|
};
|
|
}
|
|
}
|