ui: polish voting results chart

This commit is contained in:
Marco Allegretti 2026-01-29 13:08:46 +01:00
parent 5adce4a066
commit 826ffd9022

View file

@ -41,6 +41,7 @@ const { proposalId, apiBase = '' } = Astro.props;
private data: any; private data: any;
private apiBase: string; private apiBase: string;
private enabled = false; private enabled = false;
private tooltip?: HTMLElement;
constructor(container: HTMLElement) { constructor(container: HTMLElement) {
this.container = container; this.container = container;
@ -161,9 +162,10 @@ const { proposalId, apiBase = '' } = Astro.props;
const isWinner = index === 0; const isWinner = index === 0;
const safeLabel = this.escapeHtml(result.label); const safeLabel = this.escapeHtml(result.label);
const safeValue = this.escapeHtml(this.formatScore(score, method));
return ` return `
<div class="result-bar ${isWinner ? 'winner' : ''}" style="--bar-delay: ${index * 0.1}s"> <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"> <div class="bar-header">
<span class="bar-rank">#${result.rank || index + 1}</span> <span class="bar-rank">#${result.rank || index + 1}</span>
<span class="bar-label">${safeLabel}</span> <span class="bar-label">${safeLabel}</span>
@ -187,8 +189,11 @@ const { proposalId, apiBase = '' } = Astro.props;
<div class="bars-container"> <div class="bars-container">
${barsHtml} ${barsHtml}
</div> </div>
<div class="chart-tooltip" id="chart-tooltip" aria-hidden="true"></div>
`; `;
this.attachChartInteractions(chartArea);
// Trigger animation // Trigger animation
setTimeout(() => { setTimeout(() => {
chartArea.querySelectorAll<HTMLElement>('.bar-fill').forEach((bar) => { chartArea.querySelectorAll<HTMLElement>('.bar-fill').forEach((bar) => {
@ -197,6 +202,54 @@ const { proposalId, apiBase = '' } = Astro.props;
}, 100); }, 100);
} }
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 { renderDetails(): void {
const detailsArea = this.container.querySelector<HTMLElement>('#details-area'); const detailsArea = this.container.querySelector<HTMLElement>('#details-area');
const roundsArea = this.container.querySelector<HTMLElement>('#rounds-area'); const roundsArea = this.container.querySelector<HTMLElement>('#rounds-area');
@ -366,7 +419,7 @@ const { proposalId, apiBase = '' } = Astro.props;
<style> <style>
.results-chart { .results-chart {
background: var(--color-surface); background: rgba(255, 255, 255, 0.03);
border-radius: 16px; border-radius: 16px;
padding: 1.5rem; padding: 1.5rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@ -411,6 +464,7 @@ const { proposalId, apiBase = '' } = Astro.props;
border-radius: 12px; border-radius: 12px;
min-width: 100px; min-width: 100px;
gap: 0.25rem; gap: 0.25rem;
border: 1px solid rgba(255, 255, 255, 0.04);
} }
.stat-card svg { .stat-card svg {
@ -421,6 +475,7 @@ const { proposalId, apiBase = '' } = Astro.props;
.stat-card.winner { .stat-card.winner {
background: linear-gradient(135deg, var(--color-warning) 0%, #f97316 100%); background: linear-gradient(135deg, var(--color-warning) 0%, #f97316 100%);
color: white; color: white;
border-color: rgba(255, 255, 255, 0.15);
} }
.stat-card.winner svg { .stat-card.winner svg {
@ -443,82 +498,31 @@ const { proposalId, apiBase = '' } = Astro.props;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
margin-bottom: 1.25rem; margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--color-border);
} }
.chart-header svg { .chart-header svg {
color: var(--color-primary); 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 { .bar-track {
height: 32px; height: 32px;
background: var(--color-bg); background: var(--color-bg);
border-radius: 8px; border-radius: 16px;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.04);
position: relative; position: relative;
} }
.bar-fill { .bar-fill {
height: 100%; height: 100%;
background: linear-gradient(90deg, var(--color-primary) 0%, var(--color-secondary) 100%);
border-radius: 16px;
width: 0; width: 0;
background: linear-gradient(90deg, var(--color-primary) 0%, var(--color-primary-hover) 100%); transition: width 0.8s ease;
border-radius: 8px; box-shadow: 0 10px 24px rgba(59, 130, 246, 0.14);
display: flex;
align-items: center;
justify-content: flex-end; justify-content: flex-end;
padding-right: 0.75rem; padding-right: 0.75rem;
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1); transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
@ -530,6 +534,48 @@ const { proposalId, apiBase = '' } = Astro.props;
.result-bar.winner .bar-fill { .result-bar.winner .bar-fill {
background: linear-gradient(90deg, var(--color-warning) 0%, #f97316 100%); background: linear-gradient(90deg, var(--color-warning) 0%, #f97316 100%);
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 { .bar-value {