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

648 lines
16 KiB
Text
Raw Normal View History

---
/**
* VotingResultsChart - Interactive visualization for voting results
* Supports all voting methods with method-specific visualizations
*/
interface Props {
proposalId;
}
const { proposalId } = Astro.props;
---
<div class="results-chart" data-proposal-id={proposalId}>
<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;
private data;
constructor(container: HTMLElement) {
this.container = container;
this.proposalId = container.dataset.proposalId || '';
this.init();
}
async init() {
try {
const res = await fetch(`/api/proposals/${this.proposalId}/results`);
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) {
const loading = this.container.querySelector('#results-loading');
if (loading) {
loading.innerHTML = `<span class="error">${message}</span>`;
}
}
render() {
const loading = this.container.querySelector('#results-loading');
const content = this.container.querySelector('#results-content');
if (loading) loading.style.display = 'none';
if (content) content.style.display = 'block';
this.renderStats();
this.renderChart();
this.renderDetails();
}
renderStats() {
const statsRow = this.container.querySelector('#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">${winner.label || 'Winner'}</div>
<div class="stat-label">Winner</div>
</div>
` : ''}
`;
}
renderChart() {
const chartArea = this.container.querySelector('#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) => r.votes || r.score || 0));
let barsHtml = results.map((result, index) => {
const score = result.votes || result.score || 0;
const pct = maxScore > 0 ? (score / maxScore) * 100 : 0;
const isWinner = index === 0;
const methodIcon = this.getMethodIcon(method);
return `
<div class="result-bar ${isWinner ? 'winner' : ''}" style="--bar-delay: ${index * 0.1}s">
<div class="bar-header">
<span class="bar-rank">#${result.rank || index + 1}</span>
<span class="bar-label">${result.label}</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>
`;
// Trigger animation
setTimeout(() => {
chartArea.querySelectorAll('.bar-fill').forEach(bar => {
(bar).classList.add('animate');
});
}, 100);
}
renderDetails() {
const detailsArea = this.container.querySelector('#details-area');
const roundsArea = this.container.querySelector('#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: Element, roundsArea: Element | null, details) {
if (!details.rounds || !roundsArea) return;
(roundsArea).style.display = 'block';
const roundsHtml = details.rounds.map((round, i) => {
const countsHtml = round.vote_counts.map(([id, count]: [string, number]) => {
const label = this.data.results?.find((r) => r.option_id === id)?.label || 'Option';
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: Element, details) {
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: Element, details) {
if (!details.finalists) return;
const finalist1 = this.data.results?.find((r) => r.option_id === details.finalists[0])?.label || 'Option A';
const finalist2 = this.data.results?.find((r) => r.option_id === details.finalists[1])?.label || 'Option B';
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: Element, details) {
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) {
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) {
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, method) {
if (method === 'quadratic') return `${score} effective votes`;
if (method === 'star') return `${score} points`;
return `${score} votes`;
}
}
// Initialize all charts on page
document.querySelectorAll('.results-chart').forEach(el => {
new VotingResultsChart(el);
});
</script>
<style>
.results-chart {
background: var(--color-surface);
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;
}
.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;
}
.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;
margin-bottom: 1.25rem;
}
.chart-header svg {
color: var(--color-primary);
}
.chart-header h3 {
margin: 0;
font-size: 1.125rem;
}
.bars-container {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.result-bar {
animation: fadeInUp 0.3s ease forwards;
animation-delay: var(--bar-delay, 0s);
opacity: 0;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.bar-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.375rem;
}
.bar-rank {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted);
min-width: 24px;
}
.bar-label {
font-weight: 500;
color: var(--color-text);
flex: 1;
}
.winner-icon {
color: var(--color-warning);
width: 1.25rem;
height: 1.25rem;
}
.bar-track {
height: 32px;
background: var(--color-bg);
border-radius: 8px;
overflow: hidden;
position: relative;
}
.bar-fill {
height: 100%;
width: 0;
background: linear-gradient(90deg, var(--color-primary) 0%, var(--color-primary-hover) 100%);
border-radius: 8px;
display: flex;
align-items: center;
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%);
}
.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>