Flask poll app for the Hi-Acts 3rd Network Meeting at DESY Hamburg, 10 June 2026. Includes 11-question poll, live D3 bubble chart display, 7-page PDF analysis report, admin dashboard, and CSV export. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
491 lines
26 KiB
HTML
491 lines
26 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<!--
|
||
question_view.html — Single Question Bubble Chart
|
||
Shows the bubble chart for one specific question.
|
||
Accessed by clicking any coloured tab in the bottom strip on the display page.
|
||
URL format: /q/1, /q/2, /q/3, etc.
|
||
|
||
The leading answer bubble is highlighted in solid colour.
|
||
All other bubbles are outlined only.
|
||
The bottom strip lets users navigate between questions.
|
||
-->
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<!-- Page title uses the first 40 characters of the question text -->
|
||
<title>{{ question.text[:40] }}… – HiActs</title>
|
||
|
||
<!-- D3.js library used to draw and animate the bubble chart -->
|
||
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||
|
||
<style>
|
||
/* ── RESET ── */
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
/* ── BODY: full height, side-by-side layout ── */
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, sans-serif;
|
||
background: #f0f4f8; color: #2d3748;
|
||
height: 100vh; display: flex; flex-direction: row; overflow: hidden;
|
||
}
|
||
|
||
/* ── SIDEBAR: event branding and navigation ── */
|
||
.sidebar {
|
||
width: 300px; flex-shrink: 0;
|
||
background: white; border-right: 1px solid #dde5ee;
|
||
display: flex; flex-direction: column; height: 100vh;
|
||
}
|
||
.event-header {
|
||
flex-shrink: 0;
|
||
background: linear-gradient(160deg, #0f2744 0%, #1a4a8a 60%, #1d6fa8 100%);
|
||
padding: 28px 22px 24px; color: white; text-align: center;
|
||
}
|
||
.event-badge { font-size: 10px; font-weight: 700; letter-spacing: 1.4px; text-transform: uppercase; opacity: 0.6; margin-bottom: 14px; }
|
||
.event-title { font-size: 28px; font-weight: 900; line-height: 1.12; margin-bottom: 6px; letter-spacing: -0.3px; }
|
||
.event-title span { color: #5db8f5; }
|
||
.event-subtitle { font-size: 11.5px; font-weight: 500; opacity: 0.7; margin-bottom: 16px; line-height: 1.4; }
|
||
.event-meta { display: flex; align-items: center; justify-content: center; gap: 6px; font-size: 10.5px; opacity: 0.55; letter-spacing: 0.3px; }
|
||
.event-meta-dot { width: 3px; height: 3px; background: white; border-radius: 50%; opacity: 0.5; }
|
||
.message-area { flex-shrink: 0; padding: 20px 22px 0; }
|
||
.message-narrative { font-size: 13px; color: #334155; line-height: 1.7; }
|
||
.message-narrative p { margin-bottom: 12px; }
|
||
.message-narrative p:last-child { margin-bottom: 0; }
|
||
.message-narrative strong { color: #1a365d; font-weight: 600; }
|
||
.sidebar-mid { flex: 1; display: flex; align-items: center; padding: 0 22px 20px; }
|
||
.sidebar-nav { width: 100%; display: flex; flex-direction: column; gap: 6px; }
|
||
.snav-btn {
|
||
display: block; text-decoration: none; font-size: 12px; font-weight: 600;
|
||
color: #4a5568; background: #f7fafc; border: 1px solid #dde5ee;
|
||
border-radius: 8px; padding: 7px 12px; text-align: center;
|
||
transition: background 0.12s, color 0.12s;
|
||
}
|
||
.snav-btn:hover { background: #eff6ff; color: #2563eb; border-color: #bfdbfe; }
|
||
.snav-btn.active { background: #eff6ff; color: #2563eb; border-color: #2563eb; font-weight: 700; }
|
||
|
||
/* ── MOBILE ── */
|
||
@media (max-width: 768px) {
|
||
#rankedAnswers { display: none !important; }
|
||
.sidebar { display: none !important; }
|
||
body { flex-direction: column; height: auto; overflow: auto; }
|
||
.right-col { height: auto !important; overflow: visible !important; }
|
||
#chart-container { height: 72vw !important; min-height: 260px; }
|
||
#bottomStrip { display: none !important; }
|
||
.mobile-nav { display: flex !important; }
|
||
/* Simplify top bar — hide label text, shrink everything */
|
||
.top-bar { padding: 8px 12px; gap: 8px; flex-wrap: nowrap; }
|
||
.back-btn { font-size: 11px; padding: 5px 8px; }
|
||
.q-label-text { display: none; }
|
||
.live-badge { padding: 3px 8px; }
|
||
.live-text { display: none; }
|
||
.counter-number { font-size: 20px !important; }
|
||
.counter-label { font-size: 8px; }
|
||
.q-text-band { padding: 12px 16px; }
|
||
.q-text-band h2 { font-size: 17px; }
|
||
}
|
||
|
||
/* Mobile prev/next navigation bar — hidden on desktop */
|
||
.mobile-nav {
|
||
display: none;
|
||
align-items: center; justify-content: space-between;
|
||
padding: 12px 16px; background: white; border-top: 1px solid #dde5ee;
|
||
gap: 12px;
|
||
}
|
||
.nav-btn {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
background: #f0f4f8; border: 1.5px solid #dde5ee; border-radius: 10px;
|
||
padding: 10px 18px; font-size: 14px; font-weight: 700; color: #1a365d;
|
||
text-decoration: none;
|
||
}
|
||
.nav-btn.disabled { opacity: 0.3; pointer-events: none; }
|
||
.nav-q-indicator { font-size: 13px; font-weight: 700; color: #8a9baf; }
|
||
|
||
/* ── RIGHT COLUMN: chart and question content ── */
|
||
.right-col { flex: 1; display: flex; flex-direction: column; overflow: hidden; height: 100vh; }
|
||
|
||
/* Top bar: back button, question label, live badge, response counter */
|
||
.top-bar {
|
||
flex-shrink: 0; display: flex; align-items: center; justify-content: space-between;
|
||
padding: 10px 24px; background: white; border-bottom: 1px solid #dde5ee; gap: 16px;
|
||
}
|
||
/* Back button returns to the main display page */
|
||
.back-btn {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
font-size: 12px; font-weight: 700; color: #4a5568;
|
||
background: #f7fafc; border: 1px solid #dde5ee; border-radius: 8px;
|
||
padding: 6px 12px; text-decoration: none; white-space: nowrap;
|
||
transition: background 0.12s, color 0.12s;
|
||
}
|
||
.back-btn:hover { background: #eff6ff; color: #2563eb; border-color: #bfdbfe; }
|
||
|
||
/* Coloured badge showing which question this is (e.g. "Q3") */
|
||
.q-label-badge { display: inline-flex; align-items: center; gap: 8px; }
|
||
.q-num { width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 800; color: white; flex-shrink: 0; }
|
||
.q-label-text { font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.8px; }
|
||
.top-bar-right { display: flex; align-items: center; gap: 14px; }
|
||
|
||
/* Green pulsing live badge */
|
||
.live-badge { display: flex; align-items: center; gap: 6px; background: #f0fdf4; border: 1px solid #86efac; border-radius: 20px; padding: 4px 12px; }
|
||
.live-dot { width: 7px; height: 7px; background: #16a34a; border-radius: 50%; animation: blink 1.4s infinite; }
|
||
.live-text { font-size: 11px; font-weight: 700; color: #16a34a; letter-spacing: 0.8px; text-transform: uppercase; }
|
||
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.3} }
|
||
|
||
/* Response counter for this specific question */
|
||
.counter-block { text-align: right; }
|
||
.counter-number { font-size: 28px; font-weight: 900; line-height: 1; background: linear-gradient(135deg, #2563eb, #059669); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
|
||
.counter-label { font-size: 10px; color: #8a9baf; text-transform: uppercase; letter-spacing: 1px; }
|
||
|
||
/* Band showing the full question text below the top bar */
|
||
.q-text-band { flex-shrink: 0; padding: 18px 28px; background: white; border-bottom: 1px solid #e8eef4; }
|
||
.q-text-band h2 { font-size: 22px; font-weight: 800; color: #1a365d; line-height: 1.3; }
|
||
|
||
/* ── BUBBLE CHART ── */
|
||
#chart-container { flex: 1; position: relative; overflow: hidden; background: #f0f4f8; }
|
||
#chart { width: 100%; height: 100%; }
|
||
|
||
/* Shown when no responses have been received yet for this question */
|
||
.empty-state { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); text-align: center; pointer-events: none; }
|
||
.empty-state h2 { font-size: 16px; font-weight: 700; color: #c5d2de; margin-bottom: 6px; }
|
||
.empty-state p { font-size: 14px; color: #d6e0ea; }
|
||
.bubble-group { cursor: default; }
|
||
|
||
/* ── RANKED ANSWERS: shown below the bubble chart for this question ── */
|
||
#rankedAnswers { padding: 16px 20px 30px; background: #f0f4f8; }
|
||
.ranked-header { font-size: 11px; font-weight: 700; color: #8a9baf; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; }
|
||
.ranked-card { background: white; border-radius: 12px; border: 1.5px solid #dde5ee; padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; }
|
||
.r-row { display: flex; align-items: center; gap: 8px; }
|
||
.r-rank { font-size: 11px; font-weight: 800; width: 28px; flex-shrink: 0; text-align: right; }
|
||
.r-text { font-size: 13px; color: #1a365d; flex: 1; line-height: 1.3; }
|
||
.r-bar-wrap { width: 80px; flex-shrink: 0; height: 7px; background: #e8eef4; border-radius: 4px; overflow: hidden; }
|
||
.r-bar { height: 100%; border-radius: 4px; transition: width 0.6s ease; }
|
||
.r-val { font-size: 11px; font-weight: 700; color: #4a5568; flex-shrink: 0; width: 24px; text-align: right; }
|
||
|
||
/* ── BOTTOM STRIP: coloured tabs for navigating between questions ── */
|
||
#bottomStrip { flex-shrink: 0; background: #f0f4f8; border-top: 1px solid #dde5ee; padding: 8px 16px; display: flex; gap: 8px; align-items: stretch; }
|
||
.bs-item { display: flex; align-items: center; gap: 8px; border-radius: 10px; padding: 8px 12px; flex: 1; min-width: 0; text-decoration: none; cursor: pointer; transition: filter 0.15s, transform 0.1s; }
|
||
.bs-item:hover { filter: brightness(1.12); transform: translateY(-1px); }
|
||
.bs-item.current { outline: 3px solid white; } /* highlights the currently active question */
|
||
.bs-q { width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 9px; font-weight: 800; color: white; flex-shrink: 0; background: rgba(255,255,255,0.25); }
|
||
.bs-body { display: flex; flex-direction: column; min-width: 0; }
|
||
.bs-label { font-size: 9px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.7px; margin-bottom: 2px; color: rgba(255,255,255,0.7); }
|
||
.bs-text { font-size: 11.5px; font-weight: 700; color: white; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ── SIDEBAR ── -->
|
||
<div class="sidebar">
|
||
<div class="event-header">
|
||
<div class="event-badge">Hi-Acts · 3rd Network Meeting</div>
|
||
<div class="event-title">Innovation<br><span>Challenge</span><br>Arena</div>
|
||
<div class="event-subtitle">Competing Ideas. Shared Solutions.</div>
|
||
<div class="event-meta">DESY Hamburg <span class="event-meta-dot"></span> 10 June 2026</div>
|
||
</div>
|
||
<div class="message-area">
|
||
<div class="message-narrative">
|
||
<p>This room brings together industry partners, researchers and innovation experts united by one goal. Turning a shared challenge into a shared solution.</p>
|
||
<p>The challenge is how to standardise <strong>software and data for high-throughput beamline experiments</strong>, enabling smooth exchange between facilities, reproducible science, and FAIR data by default.</p>
|
||
</div>
|
||
</div>
|
||
<div class="sidebar-mid">
|
||
<div class="sidebar-nav">
|
||
<a href="/display" class="snav-btn">Display / Demo</a>
|
||
<a href="/" class="snav-btn">Attendee Poll</a>
|
||
<a href="/display" class="snav-btn active">Live Results</a>
|
||
<a href="/admin" class="snav-btn">Admin</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── RIGHT COLUMN ── -->
|
||
<div class="right-col">
|
||
|
||
<!-- Top bar: navigation + question identifier + response count -->
|
||
<div class="top-bar">
|
||
<a href="/display" class="back-btn">← All Results</a>
|
||
<div class="q-label-badge">
|
||
<!-- Coloured badge set by JavaScript using question ID and colour -->
|
||
<div class="q-num" id="qNumBadge"></div>
|
||
<span class="q-label-text" id="qLabelText"></span>
|
||
</div>
|
||
<div class="top-bar-right">
|
||
<div class="live-badge">
|
||
<span class="live-dot"></span>
|
||
<span class="live-text">Live</span>
|
||
</div>
|
||
<div class="counter-block">
|
||
<!-- Shows the number of responses for this specific question only -->
|
||
<div class="counter-number" id="qCount">0</div>
|
||
<div class="counter-label">responses</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Full question text band below the top bar -->
|
||
<div class="q-text-band">
|
||
<h2 id="qText"></h2>
|
||
</div>
|
||
|
||
<!-- Bubble chart — fills all remaining vertical space -->
|
||
<div id="chart-container">
|
||
<svg id="chart"></svg>
|
||
<div class="empty-state" id="emptyState">
|
||
<h2>Waiting for responses…</h2>
|
||
<p>No votes yet for this question</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bottom strip — desktop navigation between questions -->
|
||
<div id="bottomStrip"></div>
|
||
|
||
<!-- Ranked answers for this question — shown below the chart on all devices -->
|
||
<div id="rankedAnswers"></div>
|
||
|
||
<!-- Mobile prev/next navigation — hidden on desktop, built by JavaScript -->
|
||
<div class="mobile-nav" id="mobileNav"></div>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
/* ── QUESTION DATA ──
|
||
Q = this specific question (passed from app.py via Jinja2)
|
||
ALL_QS = all questions (used to build the bottom strip) */
|
||
const Q = {{ question | tojson }};
|
||
const QI = {{ question.id }} - 1; /* zero-based index for this question */
|
||
|
||
/* Colour assigned to this question — cycles through 10 colours */
|
||
const COLORS = ['#2563eb','#059669','#dc2626','#d97706','#7c3aed','#ea580c','#0891b2','#be185d','#65a30d','#7c3aed'];
|
||
const COLOR = COLORS[QI % COLORS.length];
|
||
|
||
/* Short labels shown in the bottom strip for each question */
|
||
const LABELS = ['Your Role','Logbook Word','Biggest Obstacle','On Standardisation','Documentation Level','Shared Data Vision','Data Turnaround','Data Ownership','Repeat Experiments','Data Readiness'];
|
||
|
||
const ALL_QS = {{ questions | tojson }};
|
||
|
||
/* Set the question badge, label, and full text from the loaded data */
|
||
document.getElementById('qNumBadge').textContent = 'Q' + Q.id;
|
||
document.getElementById('qNumBadge').style.background = COLOR;
|
||
document.getElementById('qLabelText').textContent = LABELS[QI] || ('Q' + Q.id);
|
||
document.getElementById('qLabelText').style.color = COLOR;
|
||
document.getElementById('qText').textContent = Q.text;
|
||
|
||
/* ── BUBBLE CHART ── */
|
||
const svg = d3.select('#chart');
|
||
let width = 0, height = 0, simulation = null, currentNodes = [];
|
||
let leadKey = null, prevLeadKey = null, displayedLeadKey = null;
|
||
|
||
/* Recalculates chart dimensions on window resize */
|
||
function resize() {
|
||
const el = document.getElementById('chart-container');
|
||
width = el.clientWidth;
|
||
height = el.clientHeight;
|
||
svg.attr('width', width).attr('height', height);
|
||
if (simulation) simulation.force('center', d3.forceCenter(width/2, height/2)).alpha(0.3).restart();
|
||
}
|
||
window.addEventListener('resize', resize);
|
||
|
||
/* Calculates bubble radius proportional to response count */
|
||
function rScale(v, max) {
|
||
return 28 + Math.sqrt(v / max) * (Math.min(width, height) * 0.18 - 28);
|
||
}
|
||
|
||
/* Wraps long answer text inside a bubble over multiple lines */
|
||
function wrapBubbleText(textEl, d) {
|
||
const r = d.r;
|
||
const fontSize = Math.max(9, Math.min(r * 0.30, 15));
|
||
const maxWidth = r * 1.65;
|
||
const charW = fontSize * 0.54;
|
||
const maxChars = Math.max(5, Math.floor(maxWidth / charW));
|
||
const words = d.text.split(/\s+/);
|
||
const lines = [];
|
||
let line = '';
|
||
for (const word of words) {
|
||
const test = line ? line + ' ' + word : word;
|
||
if (test.length > maxChars && line) { lines.push(line); line = word; }
|
||
else line = test;
|
||
}
|
||
if (line) lines.push(line);
|
||
const hasCount = r > 34 && d.value > 1;
|
||
const lineH = fontSize * 1.22;
|
||
const countSize = Math.max(8, Math.min(r * 0.20, 11));
|
||
const totalH = (lines.length - 1) * lineH + (hasCount ? lineH * 1.05 : 0);
|
||
const startY = -(totalH / 2) + fontSize * 0.35;
|
||
const isLeader = (d.text + '_' + d.q_id) === displayedLeadKey;
|
||
textEl.selectAll('*').remove();
|
||
/* Leader bubble text is white; all others use the question colour */
|
||
textEl.attr('text-anchor','middle').attr('font-size',fontSize).attr('font-weight','700').attr('fill', isLeader ? 'white' : d.color);
|
||
lines.forEach((ln, i) => {
|
||
textEl.append('tspan').attr('x',0).attr('y', startY + i * lineH).text(ln);
|
||
});
|
||
if (hasCount) {
|
||
textEl.append('tspan')
|
||
.attr('x',0).attr('y', startY + lines.length * lineH)
|
||
.attr('fill-opacity',0.55).attr('font-weight','400').attr('font-size',countSize)
|
||
.text(d.value === 1 ? '1 response' : `${d.value} responses`);
|
||
}
|
||
}
|
||
|
||
/* Updates the chart when new data arrives */
|
||
function updateChart(bubbles) {
|
||
const qBubbles = bubbles.filter(b => b.q_id === Q.id);
|
||
document.getElementById('emptyState').style.display = qBubbles.length ? 'none' : 'block';
|
||
if (!qBubbles.length) return;
|
||
|
||
const maxVal = d3.max(qBubbles, d => d.value) || 1;
|
||
const oldMap = new Map(currentNodes.map(d => [d.text + '_' + d.q_id, d]));
|
||
const isFirstRender = currentNodes.length === 0;
|
||
|
||
prevLeadKey = leadKey;
|
||
leadKey = qBubbles.reduce((a, b) => b.value > a.value ? b : a).text + '_' + Q.id;
|
||
const leaderChanged = leadKey !== prevLeadKey;
|
||
|
||
currentNodes = qBubbles.map(d => {
|
||
const key = d.text + '_' + d.q_id;
|
||
const exist = oldMap.get(key);
|
||
const r = rScale(d.value, maxVal);
|
||
if (exist) { exist.value = d.value; exist.r = r; exist.color = COLOR; return exist; }
|
||
return { ...d, r, color: COLOR,
|
||
x: width/2 + (Math.random()-.5)*80,
|
||
y: height/2 + (Math.random()-.5)*80 };
|
||
});
|
||
|
||
if (simulation) simulation.stop();
|
||
simulation = d3.forceSimulation(currentNodes)
|
||
.force('center', d3.forceCenter(width/2, height/2).strength(0.04))
|
||
.force('charge', d3.forceManyBody().strength(6))
|
||
.force('collision', d3.forceCollide().radius(d => d.r + 4).strength(0.85))
|
||
.alphaDecay(0.008)
|
||
.on('tick', ticked);
|
||
|
||
render(isFirstRender, leaderChanged);
|
||
}
|
||
|
||
/* Applies solid fill to the leading bubble and outlined style to all others */
|
||
function applyLeaderStyle() {
|
||
displayedLeadKey = leadKey;
|
||
svg.selectAll('.bubble-group').each(function(d) {
|
||
const isLeader = (d.text + '_' + d.q_id) === leadKey;
|
||
d3.select(this).select('.bubble-circle')
|
||
.transition().duration(1400).ease(d3.easeSinInOut)
|
||
.attr('fill-opacity', isLeader ? 0.85 : 0.10);
|
||
d3.select(this).select('.bubble-text')
|
||
.transition().duration(1400).ease(d3.easeSinInOut)
|
||
.attr('fill', isLeader ? 'white' : d.color);
|
||
});
|
||
}
|
||
|
||
/* Draws or updates the SVG bubble elements */
|
||
function render(isFirst, leaderChanged) {
|
||
const groups = svg.selectAll('.bubble-group').data(currentNodes, d => d.text + '_' + d.q_id);
|
||
groups.exit().transition().duration(300).style('opacity', 0).remove();
|
||
const enter = groups.enter().append('g').attr('class','bubble-group').style('opacity', 0);
|
||
enter.append('circle').attr('class','bubble-circle').attr('fill-opacity', 0.10);
|
||
enter.append('text').attr('class','bubble-text');
|
||
|
||
const staggerEnd = currentNodes.length * 130 + 900;
|
||
enter.transition()
|
||
.delay((d, i) => i * 130)
|
||
.duration(900)
|
||
.ease(d3.easeCubicOut)
|
||
.style('opacity', 1);
|
||
|
||
const all = enter.merge(groups);
|
||
all.select('.bubble-circle').attr('r', d => d.r).attr('fill', d => d.color).attr('stroke', d => d.color).attr('stroke-width', 2.5);
|
||
enter.select('.bubble-circle').attr('fill-opacity', 0.10);
|
||
all.select('.bubble-text').each(function(d) { wrapBubbleText(d3.select(this), d); });
|
||
|
||
/* Apply leader highlight after bubbles have faded in on first load */
|
||
if (isFirst) {
|
||
setTimeout(applyLeaderStyle, staggerEnd + 300);
|
||
} else if (leaderChanged) {
|
||
applyLeaderStyle();
|
||
}
|
||
}
|
||
|
||
/* Keeps bubbles within the chart boundary */
|
||
function ticked() {
|
||
currentNodes.forEach(d => {
|
||
d.x = Math.max(d.r+4, Math.min(width - d.r-4, d.x));
|
||
d.y = Math.max(d.r+4, Math.min(height - d.r-4, d.y));
|
||
});
|
||
svg.selectAll('.bubble-group').attr('transform', d => `translate(${d.x},${d.y})`);
|
||
}
|
||
|
||
/* Builds the bottom strip of coloured question navigation tabs */
|
||
function buildBottomStrip(bubbles) {
|
||
const strip = document.getElementById('bottomStrip');
|
||
strip.innerHTML = '';
|
||
ALL_QS.forEach((q, qi) => {
|
||
const color = COLORS[qi % COLORS.length];
|
||
const best = bubbles.filter(b => b.q_id === q.id).sort((a, b) => b.value - a.value)[0];
|
||
const text = best ? best.text : '—';
|
||
const isCurrent = q.id === Q.id;
|
||
strip.innerHTML += `
|
||
<a href="/q/${q.id}" class="bs-item${isCurrent ? ' current' : ''}" style="background:${color}${isCurrent ? ';outline:3px solid white' : ''}">
|
||
<div class="bs-q">Q${q.id}</div>
|
||
<div class="bs-body">
|
||
<span class="bs-label">${LABELS[qi] || 'Q'+q.id}</span>
|
||
<span class="bs-text" title="${text}">${text}</span>
|
||
</div>
|
||
</a>`;
|
||
});
|
||
}
|
||
|
||
/* Fetches the latest results and refreshes the chart, ranked answers, and strip */
|
||
function fetchResults() {
|
||
fetch('/api/results').then(r => r.json()).then(data => {
|
||
const qAnswers = data.bubbles.filter(b => b.q_id === Q.id);
|
||
const total = qAnswers.reduce((s, b) => s + b.value, 0);
|
||
document.getElementById('qCount').textContent = total;
|
||
updateChart(data.bubbles);
|
||
buildBottomStrip(data.bubbles);
|
||
buildRankedAnswers(data.bubbles);
|
||
}).catch(console.error);
|
||
}
|
||
|
||
/* ── RANKED ANSWERS: top 5 results for this question shown below the chart ── */
|
||
function buildRankedAnswers(bubbles) {
|
||
const container = document.getElementById('rankedAnswers');
|
||
if (!container) return;
|
||
const qBubs = bubbles.filter(b => b.q_id === Q.id).sort((a, b) => b.value - a.value).slice(0, 5);
|
||
if (!qBubs.length) { container.innerHTML = ''; return; }
|
||
const maxVal = qBubs[0].value;
|
||
const rankLabels = ['1st','2nd','3rd','4th','5th'];
|
||
const rankColors = ['#d97706','#64748b','#92400e','#94a3b8','#cbd5e1'];
|
||
const rows = qBubs.map((b, i) => `
|
||
<div class="r-row">
|
||
<span class="r-rank" style="color:${rankColors[i]}">${rankLabels[i]}</span>
|
||
<span class="r-text">${b.text}</span>
|
||
<div class="r-bar-wrap"><div class="r-bar" style="width:${(b.value/maxVal*100).toFixed(0)}%;background:${COLOR}"></div></div>
|
||
<span class="r-val">${b.value}</span>
|
||
</div>`).join('');
|
||
container.innerHTML = `
|
||
<div class="ranked-header">Top ranked answers</div>
|
||
<div class="ranked-card">${rows}</div>`;
|
||
}
|
||
|
||
/* ── MOBILE NAV: build prev/next arrows ── */
|
||
function buildMobileNav() {
|
||
const nav = document.getElementById('mobileNav');
|
||
if (!nav) return;
|
||
const prevQ = ALL_QS.find(q => q.id === Q.id - 1);
|
||
const nextQ = ALL_QS.find(q => q.id === Q.id + 1);
|
||
nav.innerHTML = `
|
||
<a href="${prevQ ? '/q/'+prevQ.id : '#'}" class="nav-btn${prevQ ? '' : ' disabled'}">← Q${Q.id - 1 > 0 ? Q.id - 1 : ''}</a>
|
||
<a href="/display" class="nav-btn" style="font-size:12px;">Analysis</a>
|
||
<span class="nav-q-indicator">Q${Q.id} of ${ALL_QS.length}</span>
|
||
<a href="${nextQ ? '/q/'+nextQ.id : '#'}" class="nav-btn${nextQ ? '' : ' disabled'}">Q${Q.id + 1 <= ALL_QS.length ? Q.id + 1 : ''} →</a>
|
||
`;
|
||
}
|
||
|
||
/* ── START ── */
|
||
resize();
|
||
buildMobileNav();
|
||
fetchResults();
|
||
/* Fetch quickly on first load then settle to normal interval */
|
||
setTimeout(fetchResults, 500);
|
||
setInterval(fetchResults, 2500);
|
||
</script>
|
||
</body>
|
||
</html>
|