likwid/frontend/src/components/voting/VotingResultsChart.astro

741 lines
20 KiB
Text
Raw Normal View History

---
/**
* VotingResultsChart - Interactive visualization for voting results
* Supports all voting methods with method-specific visualizations
*/
interface Props {
proposalId: string | number;
apiBase?: string;
}
const { proposalId, apiBase = '' } = Astro.props;
---
<div class="results-chart" data-proposal-id={proposalId} data-api-base={apiBase} data-enabled="0">
<div class="chart-container">
<div id="results-loading" class="loading-state">
<div class="spinner"></div>
<span>Loading results...</span>
</div>
<div id="results-content" class="results-content" style="display: none;">
<!-- Summary Stats -->
<div class="stats-row" id="stats-row"></div>
<!-- Main Chart Area -->
<div class="chart-area" id="chart-area"></div>
<!-- Method-specific Details -->
<div class="details-area" id="details-area"></div>
<!-- Timeline/Rounds (for RCV) -->
<div class="rounds-area" id="rounds-area" style="display: none;"></div>
</div>
</div>
</div>
<script>
class VotingResultsChart {
private container: HTMLElement;
private proposalId: string;
private data: any;
private apiBase: string;
private enabled = false;
2026-01-29 12:08:46 +00:00
private tooltip?: HTMLElement;
constructor(container: HTMLElement) {
this.container = container;
this.proposalId = container.dataset.proposalId || '';
this.apiBase = container.dataset.apiBase || '';
this.enabled = container.dataset.enabled === '1';
if (this.enabled) {
this.init();
} else {
this.watchEnable();
}
}
watchEnable() {
const obs = new MutationObserver(() => {
if (this.enabled) return;
if (this.container.dataset.enabled === '1') {
this.enabled = true;
obs.disconnect();
this.init();
}
});
obs.observe(this.container, { attributes: true, attributeFilter: ['data-enabled'] });
}
escapeHtml(value: unknown): string {
return String(value || '').replace(/[&<>"']/g, function(ch) {
switch (ch) {
case '&': return '&amp;';
case '<': return '&lt;';
case '>': return '&gt;';
case '"': return '&quot;';
case "'": return '&#39;';
default: return ch;
}
});
}
async init() {
try {
const token = localStorage.getItem('token');
if (!token) {
this.showError('Login required to view voting results');
return;
}
const res = await fetch(`${this.apiBase}/api/proposals/${this.proposalId}/results`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) throw new Error('Failed to load results');
this.data = await res.json();
this.render();
} catch (e) {
this.showError('Could not load voting results');
}
}
showError(message: string): void {
const loading = this.container.querySelector<HTMLElement>('#results-loading');
if (loading) {
loading.innerHTML = `<span class="error">${message}</span>`;
}
}
render(): void {
const loading = this.container.querySelector<HTMLElement>('#results-loading');
const content = this.container.querySelector<HTMLElement>('#results-content');
if (loading) loading.style.display = 'none';
if (content) content.style.display = 'block';
this.renderStats();
this.renderChart();
this.renderDetails();
}
renderStats(): void {
const statsRow = this.container.querySelector<HTMLElement>('#stats-row');
if (!statsRow) return;
const winner = this.data.winner;
const totalVotes = this.data.total_votes || 0;
const totalVoters = this.data.total_voters || this.data.results?.length || 0;
statsRow.innerHTML = `
<div class="stat-card">
<svg class="icon icon-md"><use href="#icon-users"/></svg>
<div class="stat-value">${totalVoters}</div>
<div class="stat-label">Participants</div>
</div>
<div class="stat-card">
<svg class="icon icon-md"><use href="#icon-vote-cast"/></svg>
<div class="stat-value">${totalVotes}</div>
<div class="stat-label">Total Votes</div>
</div>
${winner ? `
<div class="stat-card winner">
<svg class="icon icon-md"><use href="#icon-winner"/></svg>
<div class="stat-value">${this.escapeHtml(winner.label || 'Winner')}</div>
<div class="stat-label">Winner</div>
</div>
` : ''}
`;
}
renderChart(): void {
const chartArea = this.container.querySelector<HTMLElement>('#chart-area');
if (!chartArea || !this.data.results) return;
const method = this.data.voting_method;
const results = this.data.results;
const maxScore = Math.max(...results.map((r: any) => r.votes || r.score || 0));
let barsHtml = results.map((result: any, index: number) => {
const score = result.votes || result.score || 0;
const pct = maxScore > 0 ? (score / maxScore) * 100 : 0;
const isWinner = index === 0;
const safeLabel = this.escapeHtml(result.label);
2026-01-29 12:08:46 +00:00
const safeValue = this.escapeHtml(this.formatScore(score, method));
return `
2026-01-29 12:08:46 +00:00
<div class="result-bar ${isWinner ? 'winner' : ''}" style="--bar-delay: ${index * 0.1}s" tabindex="0" data-label="${safeLabel}" data-value="${safeValue}">
<div class="bar-header">
<span class="bar-rank">#${result.rank || index + 1}</span>
<span class="bar-label">${safeLabel}</span>
${isWinner ? '<svg class="icon winner-icon"><use href="#icon-winner"/></svg>' : ''}
</div>
<div class="bar-track">
<div class="bar-fill" style="--bar-width: ${pct}%">
<span class="bar-value">${this.formatScore(score, method)}</span>
</div>
</div>
${result.percentage ? `<div class="bar-pct">${result.percentage.toFixed(1)}%</div>` : ''}
</div>
`;
}).join('');
chartArea.innerHTML = `
<div class="chart-header">
<svg class="icon icon-md"><use href="#${this.getMethodIcon(method)}"/></svg>
<h3>${this.getMethodName(method)} Results</h3>
</div>
<div class="bars-container">
${barsHtml}
</div>
2026-01-29 12:08:46 +00:00
<div class="chart-tooltip" id="chart-tooltip" aria-hidden="true"></div>
`;
2026-01-29 12:08:46 +00:00
this.attachChartInteractions(chartArea);
// Trigger animation
setTimeout(() => {
chartArea.querySelectorAll<HTMLElement>('.bar-fill').forEach((bar) => {
bar.classList.add('animate');
});
}, 100);
}
2026-01-29 12:08:46 +00:00
attachChartInteractions(chartArea: HTMLElement): void {
const tooltip = chartArea.querySelector<HTMLElement>('#chart-tooltip');
if (!tooltip) return;
this.tooltip = tooltip;
const show = (bar: HTMLElement) => {
if (!this.tooltip) return;
const label = bar.dataset.label || '';
const value = bar.dataset.value || '';
this.tooltip.textContent = `${label} • ${value}`;
this.tooltip.setAttribute('aria-hidden', 'false');
this.tooltip.classList.add('is-visible');
const rect = bar.getBoundingClientRect();
const left = Math.max(12, Math.min(window.innerWidth - 12, rect.left + rect.width * 0.35));
const top = Math.max(12, rect.top - 10);
this.tooltip.style.left = `${left}px`;
this.tooltip.style.top = `${top}px`;
};
const hide = () => {
if (!this.tooltip) return;
this.tooltip.setAttribute('aria-hidden', 'true');
this.tooltip.classList.remove('is-visible');
};
chartArea.addEventListener('mouseover', (e) => {
const target = e.target as HTMLElement | null;
const bar = target?.closest?.('.result-bar') as HTMLElement | null;
if (bar) show(bar);
});
chartArea.addEventListener('mouseout', (e) => {
const related = (e as MouseEvent).relatedTarget as HTMLElement | null;
if (!related || !related.closest?.('.result-bar')) hide();
});
chartArea.addEventListener('focusin', (e) => {
const target = e.target as HTMLElement | null;
const bar = target?.closest?.('.result-bar') as HTMLElement | null;
if (bar) show(bar);
});
chartArea.addEventListener('focusout', () => {
hide();
});
}
renderDetails(): void {
const detailsArea = this.container.querySelector<HTMLElement>('#details-area');
const roundsArea = this.container.querySelector<HTMLElement>('#rounds-area');
if (!detailsArea) return;
const method = this.data.voting_method;
const details = this.data.details;
if (!details) {
detailsArea.innerHTML = '';
return;
}
switch (method) {
case 'ranked_choice':
this.renderRankedChoiceDetails(detailsArea, roundsArea, details);
break;
case 'schulze':
this.renderSchulzeDetails(detailsArea, details);
break;
case 'star':
this.renderStarDetails(detailsArea, details);
break;
case 'quadratic':
this.renderQuadraticDetails(detailsArea, details);
break;
default:
detailsArea.innerHTML = '';
}
}
renderRankedChoiceDetails(detailsArea: HTMLElement, roundsArea: HTMLElement | null, details: any): void {
if (!details.rounds || !roundsArea) return;
roundsArea.style.display = 'block';
const roundsHtml = details.rounds.map((round: any, i: number) => {
const countsHtml = round.vote_counts.map(([id, count]: [string, number]) => {
const labelRaw = this.data.results?.find((r: any) => r.option_id === id)?.label || 'Option';
const label = this.escapeHtml(labelRaw);
const isEliminated = round.eliminated === id;
return `
<div class="round-option ${isEliminated ? 'eliminated' : ''}">
<span class="option-label">${label}</span>
<span class="option-count">${count}</span>
${isEliminated ? '<span class="eliminated-badge">Eliminated</span>' : ''}
</div>
`;
}).join('');
return `
<div class="round-card">
<div class="round-header">
<span class="round-number">Round ${round.round}</span>
${round.eliminated ? '' : '<span class="final-round">Final</span>'}
</div>
<div class="round-options">${countsHtml}</div>
</div>
`;
}).join('');
roundsArea.innerHTML = `
<h4>
<svg class="icon"><use href="#icon-history"/></svg>
Elimination Rounds
</h4>
<div class="rounds-timeline">${roundsHtml}</div>
`;
}
renderSchulzeDetails(detailsArea: HTMLElement, details: any): void {
if (!details.pairwise_matrix) return;
detailsArea.innerHTML = `
<div class="schulze-details">
<h4>
<svg class="icon"><use href="#icon-network"/></svg>
Pairwise Comparison Matrix
</h4>
<p class="method-note">Shows head-to-head victories. Schulze finds strongest paths through comparisons.</p>
<div class="matrix-container">
<div class="matrix-note">Matrix visualization available in detailed view</div>
</div>
</div>
`;
}
renderStarDetails(detailsArea: HTMLElement, details: any): void {
if (!details.finalists) return;
const finalist1Raw = this.data.results?.find((r: any) => r.option_id === details.finalists[0])?.label || 'Option A';
const finalist2Raw = this.data.results?.find((r: any) => r.option_id === details.finalists[1])?.label || 'Option B';
const finalist1 = this.escapeHtml(finalist1Raw);
const finalist2 = this.escapeHtml(finalist2Raw);
detailsArea.innerHTML = `
<div class="star-details">
<h4>
<svg class="icon"><use href="#icon-star"/></svg>
Automatic Runoff
</h4>
<div class="runoff-matchup">
<div class="finalist ${details.runoff_votes[0] >= details.runoff_votes[1] ? 'winner' : ''}">
<span class="finalist-name">${finalist1}</span>
<span class="finalist-votes">${details.runoff_votes[0]} preferred</span>
</div>
<div class="vs">VS</div>
<div class="finalist ${details.runoff_votes[1] > details.runoff_votes[0] ? 'winner' : ''}">
<span class="finalist-name">${finalist2}</span>
<span class="finalist-votes">${details.runoff_votes[1]} preferred</span>
</div>
</div>
</div>
`;
}
renderQuadraticDetails(detailsArea: HTMLElement, details: any): void {
detailsArea.innerHTML = `
<div class="quadratic-details">
<h4>
<svg class="icon"><use href="#icon-quadratic"/></svg>
Credits Analysis
</h4>
<div class="credits-stat">
<span class="credits-label">Total Credits Spent:</span>
<span class="credits-value">${details.total_credits_spent || 0}</span>
</div>
<p class="method-note">In quadratic voting, the cost grows as votes²: 1 vote = 1 credit, 2 votes = 4 credits, 3 votes = 9 credits.</p>
</div>
`;
}
getMethodIcon(method: string): string {
const icons: Record<string, string> = {
'approval': 'icon-approval',
'ranked_choice': 'icon-ranked-choice',
'schulze': 'icon-schulze',
'star': 'icon-star',
'quadratic': 'icon-quadratic',
};
return icons[method] || 'icon-results';
}
getMethodName(method: string): string {
const names: Record<string, string> = {
'approval': 'Approval Voting',
'ranked_choice': 'Ranked Choice',
'schulze': 'Schulze Method',
'star': 'STAR Voting',
'quadratic': 'Quadratic Voting',
};
return names[method] || 'Voting';
}
formatScore(score: any, method: string): string {
if (method === 'quadratic') return `${score} effective votes`;
if (method === 'star') return `${score} points`;
return `${score} votes`;
}
}
// Initialize all charts on page
document.querySelectorAll<HTMLElement>('.results-chart').forEach((el) => {
new VotingResultsChart(el);
});
</script>
<style>
.results-chart {
2026-01-29 12:08:46 +00:00
background: rgba(255, 255, 255, 0.03);
border-radius: 16px;
padding: 1.5rem;
border: 1px solid var(--color-border);
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: var(--color-text-muted);
gap: 1rem;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.stats-row {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.stat-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem 1.5rem;
background: var(--color-bg);
border-radius: 12px;
min-width: 100px;
gap: 0.25rem;
2026-01-29 12:08:46 +00:00
border: 1px solid rgba(255, 255, 255, 0.04);
}
.stat-card svg {
color: var(--color-text-muted);
margin-bottom: 0.25rem;
}
.stat-card.winner {
background: linear-gradient(135deg, var(--color-warning) 0%, #f97316 100%);
color: white;
2026-01-29 12:08:46 +00:00
border-color: rgba(255, 255, 255, 0.15);
}
.stat-card.winner svg {
color: white;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
}
.stat-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.8;
}
.chart-header {
display: flex;
align-items: center;
gap: 0.75rem;
2026-01-29 12:08:46 +00:00
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--color-border);
}
.chart-header svg {
color: var(--color-primary);
}
.bar-track {
height: 32px;
background: var(--color-bg);
2026-01-29 12:08:46 +00:00
border-radius: 16px;
overflow: hidden;
2026-01-29 12:08:46 +00:00
border: 1px solid rgba(255, 255, 255, 0.04);
position: relative;
}
.bar-fill {
height: 100%;
2026-01-29 12:08:46 +00:00
background: linear-gradient(90deg, var(--color-primary) 0%, var(--color-secondary) 100%);
border-radius: 16px;
width: 0;
2026-01-29 12:08:46 +00:00
transition: width 0.8s ease;
box-shadow: 0 10px 24px rgba(59, 130, 246, 0.14);
justify-content: flex-end;
padding-right: 0.75rem;
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.bar-fill.animate {
width: var(--bar-width, 0%);
}
.result-bar.winner .bar-fill {
background: linear-gradient(90deg, var(--color-warning) 0%, #f97316 100%);
2026-01-29 12:08:46 +00:00
box-shadow: 0 10px 24px rgba(249, 115, 22, 0.18);
}
.chart-tooltip {
position: fixed;
z-index: 50;
left: 0;
top: 0;
transform: translate(-50%, -100%);
padding: 0.5rem 0.75rem;
border-radius: 999px;
background: rgba(0, 0, 0, 0.72);
color: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(255, 255, 255, 0.12);
font-size: 0.8125rem;
letter-spacing: 0.01em;
pointer-events: none;
opacity: 0;
transition: opacity 120ms ease;
}
.chart-tooltip.is-visible {
opacity: 1;
}
@media (max-width: 640px) {
.results-chart {
padding: 1rem;
}
.chart-header {
margin-bottom: 1rem;
}
.bar-track {
height: 28px;
}
.bar-header {
gap: 0.5rem;
flex-wrap: wrap;
}
}
.bar-value {
font-size: 0.8125rem;
font-weight: 600;
color: white;
white-space: nowrap;
}
.bar-pct {
font-size: 0.75rem;
color: var(--color-text-muted);
margin-top: 0.25rem;
text-align: right;
}
/* Rounds Timeline */
.rounds-area h4 {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 1.5rem 0 1rem;
font-size: 1rem;
}
.rounds-timeline {
display: flex;
gap: 1rem;
overflow-x: auto;
padding-bottom: 0.5rem;
}
.round-card {
min-width: 200px;
background: var(--color-bg);
border-radius: 12px;
padding: 1rem;
border: 1px solid var(--color-border);
}
.round-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.round-number {
font-weight: 600;
color: var(--color-primary);
}
.final-round {
font-size: 0.6875rem;
padding: 0.125rem 0.5rem;
background: var(--color-success);
color: white;
border-radius: 4px;
text-transform: uppercase;
}
.round-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.round-option {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.875rem;
}
.round-option.eliminated {
opacity: 0.5;
text-decoration: line-through;
}
.eliminated-badge {
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
background: var(--color-error);
color: white;
border-radius: 4px;
margin-left: 0.5rem;
}
/* STAR Runoff */
.runoff-matchup {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 1.5rem;
background: var(--color-bg);
border-radius: 12px;
margin-top: 1rem;
}
.finalist {
text-align: center;
padding: 1rem;
border-radius: 8px;
min-width: 120px;
}
.finalist.winner {
background: linear-gradient(135deg, var(--color-warning) 0%, #f97316 100%);
color: white;
}
.finalist-name {
display: block;
font-weight: 600;
margin-bottom: 0.25rem;
}
.finalist-votes {
font-size: 0.8125rem;
opacity: 0.8;
}
.vs {
font-weight: 700;
color: var(--color-text-muted);
font-size: 0.875rem;
}
/* Details sections */
.details-area h4 {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 1.5rem 0 0.75rem;
font-size: 1rem;
}
.method-note {
font-size: 0.875rem;
color: var(--color-text-muted);
margin: 0.5rem 0;
}
.credits-stat {
display: flex;
gap: 0.5rem;
padding: 1rem;
background: var(--color-bg);
border-radius: 8px;
margin-top: 0.75rem;
}
.credits-value {
font-weight: 700;
color: var(--color-primary);
}
.error {
color: var(--color-error);
}
</style>