2026-01-27 16:21:58 +00:00
|
|
|
---
|
|
|
|
|
/**
|
|
|
|
|
* VotingResultsChart - Interactive visualization for voting results
|
|
|
|
|
* Supports all voting methods with method-specific visualizations
|
|
|
|
|
*/
|
|
|
|
|
interface Props {
|
2026-01-29 12:03:20 +00:00
|
|
|
proposalId: string | number;
|
|
|
|
|
apiBase?: string;
|
2026-01-27 16:21:58 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:03:20 +00:00
|
|
|
const { proposalId, apiBase = '' } = Astro.props;
|
2026-01-27 16:21:58 +00:00
|
|
|
---
|
|
|
|
|
|
2026-01-29 12:03:20 +00:00
|
|
|
<div class="results-chart" data-proposal-id={proposalId} data-api-base={apiBase} data-enabled="0">
|
2026-01-27 16:21:58 +00:00
|
|
|
<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;
|
2026-01-29 12:03:20 +00:00
|
|
|
private proposalId: string;
|
|
|
|
|
private data: any;
|
|
|
|
|
private apiBase: string;
|
|
|
|
|
private enabled = false;
|
2026-01-27 16:21:58 +00:00
|
|
|
|
|
|
|
|
constructor(container: HTMLElement) {
|
|
|
|
|
this.container = container;
|
|
|
|
|
this.proposalId = container.dataset.proposalId || '';
|
2026-01-29 12:03:20 +00:00
|
|
|
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 '&';
|
|
|
|
|
case '<': return '<';
|
|
|
|
|
case '>': return '>';
|
|
|
|
|
case '"': return '"';
|
|
|
|
|
case "'": return ''';
|
|
|
|
|
default: return ch;
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-01-27 16:21:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async init() {
|
|
|
|
|
try {
|
2026-01-29 12:03:20 +00:00
|
|
|
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}` },
|
|
|
|
|
});
|
2026-01-27 16:21:58 +00:00
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:03:20 +00:00
|
|
|
showError(message: string): void {
|
|
|
|
|
const loading = this.container.querySelector<HTMLElement>('#results-loading');
|
2026-01-27 16:21:58 +00:00
|
|
|
if (loading) {
|
|
|
|
|
loading.innerHTML = `<span class="error">${message}</span>`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:03:20 +00:00
|
|
|
render(): void {
|
|
|
|
|
const loading = this.container.querySelector<HTMLElement>('#results-loading');
|
|
|
|
|
const content = this.container.querySelector<HTMLElement>('#results-content');
|
2026-01-27 16:21:58 +00:00
|
|
|
|
|
|
|
|
if (loading) loading.style.display = 'none';
|
|
|
|
|
if (content) content.style.display = 'block';
|
|
|
|
|
|
|
|
|
|
this.renderStats();
|
|
|
|
|
this.renderChart();
|
|
|
|
|
this.renderDetails();
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:03:20 +00:00
|
|
|
renderStats(): void {
|
|
|
|
|
const statsRow = this.container.querySelector<HTMLElement>('#stats-row');
|
2026-01-27 16:21:58 +00:00
|
|
|
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>
|
2026-01-29 12:03:20 +00:00
|
|
|
<div class="stat-value">${this.escapeHtml(winner.label || 'Winner')}</div>
|
2026-01-27 16:21:58 +00:00
|
|
|
<div class="stat-label">Winner</div>
|
|
|
|
|
</div>
|
|
|
|
|
` : ''}
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:03:20 +00:00
|
|
|
renderChart(): void {
|
|
|
|
|
const chartArea = this.container.querySelector<HTMLElement>('#chart-area');
|
2026-01-27 16:21:58 +00:00
|
|
|
if (!chartArea || !this.data.results) return;
|
|
|
|
|
|
|
|
|
|
const method = this.data.voting_method;
|
|
|
|
|
const results = this.data.results;
|
2026-01-29 12:03:20 +00:00
|
|
|
const maxScore = Math.max(...results.map((r: any) => r.votes || r.score || 0));
|
2026-01-27 16:21:58 +00:00
|
|
|
|
2026-01-29 12:03:20 +00:00
|
|
|
let barsHtml = results.map((result: any, index: number) => {
|
2026-01-27 16:21:58 +00:00
|
|
|
const score = result.votes || result.score || 0;
|
|
|
|
|
const pct = maxScore > 0 ? (score / maxScore) * 100 : 0;
|
|
|
|
|
const isWinner = index === 0;
|
2026-01-29 12:03:20 +00:00
|
|
|
|
|
|
|
|
const safeLabel = this.escapeHtml(result.label);
|
2026-01-27 16:21:58 +00:00
|
|
|
|
|
|
|
|
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>
|
2026-01-29 12:03:20 +00:00
|
|
|
<span class="bar-label">${safeLabel}</span>
|
2026-01-27 16:21:58 +00:00
|
|
|
${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(() => {
|
2026-01-29 12:03:20 +00:00
|
|
|
chartArea.querySelectorAll<HTMLElement>('.bar-fill').forEach((bar) => {
|
|
|
|
|
bar.classList.add('animate');
|
2026-01-27 16:21:58 +00:00
|
|
|
});
|
|
|
|
|
}, 100);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:03:20 +00:00
|
|
|
renderDetails(): void {
|
|
|
|
|
const detailsArea = this.container.querySelector<HTMLElement>('#details-area');
|
|
|
|
|
const roundsArea = this.container.querySelector<HTMLElement>('#rounds-area');
|
2026-01-27 16:21:58 +00:00
|
|
|
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 = '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:03:20 +00:00
|
|
|
renderRankedChoiceDetails(detailsArea: HTMLElement, roundsArea: HTMLElement | null, details: any): void {
|
2026-01-27 16:21:58 +00:00
|
|
|
if (!details.rounds || !roundsArea) return;
|
|
|
|
|
|
2026-01-29 12:03:20 +00:00
|
|
|
roundsArea.style.display = 'block';
|
2026-01-27 16:21:58 +00:00
|
|
|
|
2026-01-29 12:03:20 +00:00
|
|
|
const roundsHtml = details.rounds.map((round: any, i: number) => {
|
2026-01-27 16:21:58 +00:00
|
|
|
const countsHtml = round.vote_counts.map(([id, count]: [string, number]) => {
|
2026-01-29 12:03:20 +00:00
|
|
|
const labelRaw = this.data.results?.find((r: any) => r.option_id === id)?.label || 'Option';
|
|
|
|
|
const label = this.escapeHtml(labelRaw);
|
2026-01-27 16:21:58 +00:00
|
|
|
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>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:03:20 +00:00
|
|
|
renderSchulzeDetails(detailsArea: HTMLElement, details: any): void {
|
2026-01-27 16:21:58 +00:00
|
|
|
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>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:03:20 +00:00
|
|
|
renderStarDetails(detailsArea: HTMLElement, details: any): void {
|
2026-01-27 16:21:58 +00:00
|
|
|
if (!details.finalists) return;
|
|
|
|
|
|
2026-01-29 12:03:20 +00:00
|
|
|
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);
|
2026-01-27 16:21:58 +00:00
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:03:20 +00:00
|
|
|
renderQuadraticDetails(detailsArea: HTMLElement, details: any): void {
|
2026-01-27 16:21:58 +00:00
|
|
|
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>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:03:20 +00:00
|
|
|
getMethodIcon(method: string): string {
|
2026-01-27 16:21:58 +00:00
|
|
|
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';
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:03:20 +00:00
|
|
|
getMethodName(method: string): string {
|
2026-01-27 16:21:58 +00:00
|
|
|
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';
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 12:03:20 +00:00
|
|
|
formatScore(score: any, method: string): string {
|
2026-01-27 16:21:58 +00:00
|
|
|
if (method === 'quadratic') return `${score} effective votes`;
|
|
|
|
|
if (method === 'star') return `${score} points`;
|
|
|
|
|
return `${score} votes`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Initialize all charts on page
|
2026-01-29 12:03:20 +00:00
|
|
|
document.querySelectorAll<HTMLElement>('.results-chart').forEach((el) => {
|
2026-01-27 16:21:58 +00:00
|
|
|
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>
|