Files
beantownbabeandClaude Sonnet 4.6 27a59671fc Initial commit: HiActs Conference Poll app
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>
2026-06-05 15:38:17 +02:00

491 lines
26 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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">&#8592; 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&hellip;</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'}">&#8592; 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 : ''} &#8594;</a>
`;
}
/* ── START ── */
resize();
buildMobileNav();
fetchResults();
/* Fetch quickly on first load then settle to normal interval */
setTimeout(fetchResults, 500);
setInterval(fetchResults, 2500);
</script>
</body>
</html>