Files
HiActs-Conference-Poll/templates/display.html
T
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

797 lines
39 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HiActs Live Results - TXproducts</title>
<!-- D3.js library — used to draw the bubble chart -->
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
/* ── RESET: removes default browser spacing on all elements ── */
* { box-sizing: border-box; margin: 0; padding: 0; }
/* ── BODY: full screen, side-by-side layout on desktop ── */
body {
background: #f0f4f8;
color: #2d3748;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, sans-serif;
overflow: hidden;
height: 100vh;
display: flex;
flex-direction: column;
font-size: 14px;
}
/* ── MOBILE / TABLET RESPONSIVENESS ── */
@media (max-width: 768px) {
body { overflow: auto; height: auto; }
.sidebar { display: none !important; }
.right-col { height: auto !important; overflow: visible !important; }
#phaseResults { flex-direction: column; overflow: visible; height: auto; }
/* Hide the mixed bubble chart and horizontal bottom strip on mobile */
#chart-container { display: none !important; }
#bottomStrip { display: none !important; }
.analysis-btn { display: none !important; }
/* Always show analysis panel on mobile */
#analysisPanel { display: flex !important; overflow: visible; padding: 14px 14px 40px; }
.analysis-grid { grid-template-columns: 1fr !important; }
/* Top bar adjustments */
.top-bar { grid-template-columns: auto 1fr !important; }
.top-bar-right { padding-right: 12px; gap: 8px; }
.top-bar-event { padding: 10px 14px; }
.top-bar-event-title { font-size: 12px; }
.top-bar-event-meta { font-size: 8px; }
.top-bar-center h1 { font-size: 15px; }
.top-bar-center .meta { font-size: 12px; }
.counter-number { font-size: 22px !important; }
.counter-label { font-size: 9px; }
}
/* ── SIDEBAR: left panel with event branding (desktop only) ── */
.sidebar {
width: 300px; flex-shrink: 0;
background: white; border-right: 1px solid #dde5ee;
display: flex; flex-direction: column; height: 100vh;
}
/* Dark blue gradient header inside sidebar */
.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; }
/* Narrative text block inside sidebar */
.message-area { flex-shrink: 0; padding: 20px 22px 0; }
.sidebar-mid { flex: 1; display: flex; align-items: center; padding: 0 22px 20px; }
.message-narrative {
font-size: 13px; color: #334155; font-weight: 400;
line-height: 1.7; flex: 1;
}
.message-narrative p { margin-bottom: 12px; }
.message-narrative p:last-child { margin-bottom: 0; }
.message-narrative strong { color: #1a365d; font-weight: 600; }
/* Navigation links inside sidebar */
.sidebar-nav { padding: 18px 0 0; 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; }
/* ── BODY: full screen, no sidebar ── */
.right-col { flex: 1; display: flex; flex-direction: column; overflow: hidden; height: 100vh; }
/* ── TOP BAR: gradient left column, tagline centre, controls right ── */
.top-bar {
flex-shrink: 0; display: grid;
grid-template-columns: auto 1fr auto;
align-items: stretch;
background: white; border-bottom: 1px solid #dde5ee; gap: 0;
}
/* Gradient left column — same style as poll.html */
.top-bar-event {
background: linear-gradient(160deg, #0f2744 0%, #1a4a8a 60%, #1d6fa8 100%);
color: white; padding: 14px 20px; text-align: center;
display: flex; flex-direction: column; justify-content: center; gap: 4px;
}
.top-bar-event-title { font-size: 15px; font-weight: 900; line-height: 1.15; letter-spacing: -0.3px; }
.top-bar-event-title span { color: #5db8f5; }
.top-bar-event-meta { font-size: 9px; color: #f5c842; font-weight: 700; letter-spacing: 0.6px; text-transform: uppercase; line-height: 1.5; }
.top-bar-center { text-align: center; padding: 12px 20px; display: flex; flex-direction: column; justify-content: center; }
.top-bar-center h1 { font-size: 20px; font-weight: 700; color: #1a365d; }
.top-bar-center .meta { font-size: 15px; color: #8a9baf; margin-top: 4px; }
.top-bar-right { display: flex; align-items: center; justify-content: flex-end; gap: 14px; padding-right: 20px; }
/* 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} }
/* Large response counter (top right) */
.counter-block { text-align: center; }
.counter-number {
font-size: 32px; 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; }
/* Toggle button: switches between bubble chart and per-question analysis */
.analysis-btn {
background: linear-gradient(135deg, #2563eb, #7c3aed);
color: white; border: none; border-radius: 8px;
padding: 7px 16px; font-size: 12px; font-weight: 700;
cursor: pointer; font-family: inherit; display: none;
white-space: nowrap;
}
.analysis-btn.visible { display: block; }
/* ── PHASE 1: Question overview (NOT used in production — hidden automatically) ── */
#phaseQuestions {
flex: 1; overflow-y: auto;
padding: 18px 24px 80px;
display: flex; flex-direction: column; gap: 14px;
}
.qs-header { font-size: 12px; font-weight: 700; color: #8a9baf; text-transform: uppercase; letter-spacing: 1px; }
.qs-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.q-card {
background: white; border-radius: 14px; border: 1.5px solid #dde5ee;
padding: 14px 16px; display: flex; flex-direction: column; gap: 10px;
}
.q-card-header { display: flex; align-items: flex-start; gap: 10px; }
.q-badge {
width: 26px; height: 26px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 10px; font-weight: 800; color: white; flex-shrink: 0; margin-top: 1px;
}
.q-card-text { font-size: 13px; font-weight: 700; color: #1a365d; line-height: 1.4; }
.q-options { display: flex; flex-direction: column; gap: 4px; }
.q-option { font-size: 11.5px; color: #4a5568; padding: 4px 10px; background: #f7fafc; border-radius: 6px; border-left: 3px solid #e2e8f0; line-height: 1.3; }
.q-examples { display: flex; flex-wrap: wrap; gap: 6px; }
.q-example-chip { font-size: 11px; color: #2563eb; background: #eff6ff; border-radius: 20px; padding: 3px 10px; font-style: italic; }
.submit-row { display: flex; justify-content: center; padding-top: 4px; }
.big-submit-btn {
background: linear-gradient(135deg, #2563eb, #059669);
color: white; border: none; border-radius: 14px;
padding: 15px 40px; font-size: 16px; font-weight: 700;
cursor: pointer; font-family: inherit;
transition: opacity 0.15s, transform 0.08s;
}
.big-submit-btn:active { transform: scale(0.98); }
.big-submit-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* ── PHASE 2: Live results view (always shown in production) ── */
#phaseResults { display: none; flex: 1; flex-direction: column; overflow: hidden; }
#phaseResults.active { display: flex; }
/* Bubble chart area */
#chart-container { flex: 1; position: relative; overflow: hidden; background: white; }
#chart { width: 100%; height: 100%; }
/* Shown when there are no responses yet */
.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; }
/* ── BOTTOM STRIP: coloured bar showing leading answer per question ── */
#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-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; }
/* ── ANALYSIS PANEL: per-question ranked results (shown when "Show Analysis" is clicked) ── */
#analysisPanel {
display: none; flex: 1; flex-direction: column;
overflow-y: auto; padding: 18px 24px 80px;
gap: 14px; background: #f0f4f8;
}
#analysisPanel.active { display: flex; }
.analysis-header { font-size: 12px; font-weight: 700; color: #8a9baf; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px; }
.analysis-subheader { font-size: 11px; color: #a0aec0; margin-bottom: 14px; }
/* 2-column grid of question result cards (1 column on mobile) */
.analysis-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
/* Individual question result card */
.a-card {
background: white; border-radius: 14px; border: 1.5px solid #dde5ee;
padding: 16px; display: flex; flex-direction: column; gap: 12px;
}
.a-card-header { display: flex; align-items: flex-start; gap: 10px; }
.a-question { font-size: 13px; font-weight: 700; color: #1a365d; line-height: 1.4; flex: 1; }
.a-divider { height: 1px; background: #e8eef4; }
.a-answers { display: flex; flex-direction: column; gap: 8px; }
/* Each ranked answer row (rank label + answer text + bar + count) */
.a-row { display: flex; align-items: center; gap: 8px; }
.a-rank { font-size: 11px; font-weight: 800; color: #8a9baf; width: 16px; flex-shrink: 0; text-align: right; }
.a-rank-1 { color: #d97706; }
.a-rank-2 { color: #64748b; }
.a-rank-3 { color: #92400e; }
.a-text { font-size: 12.5px; color: #1a365d; flex: 1; line-height: 1.3; }
.a-bar-wrap { width: 80px; flex-shrink: 0; height: 7px; background: #e8eef4; border-radius: 4px; overflow: hidden; }
.a-bar { height: 100%; border-radius: 4px; transition: width 0.6s ease; }
.a-val { font-size: 11px; font-weight: 700; color: #4a5568; flex-shrink: 0; width: 20px; text-align: right; }
.bubble-group { cursor: default; }
/* ── EMAIL CAPTURE MODAL ── */
#emailModal {
display: none; position: fixed; inset: 0;
background: rgba(10, 20, 50, 0.6);
z-index: 999; align-items: center; justify-content: center;
backdrop-filter: blur(3px);
}
#emailModal.open { display: flex; }
.email-modal-box {
background: white; border-radius: 20px;
padding: 36px 40px; max-width: 460px; width: 90%;
box-shadow: 0 32px 80px rgba(0,0,0,0.35);
animation: modalIn 0.25s ease;
}
@keyframes modalIn {
from { transform: translateY(18px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.em-eyebrow { font-size: 11px; font-weight: 700; color: #2563eb; letter-spacing: 1.2px; text-transform: uppercase; margin-bottom: 12px; }
.em-title { font-size: 21px; font-weight: 900; color: #1a365d; line-height: 1.25; margin-bottom: 10px; }
.em-body { font-size: 14px; color: #4a5568; line-height: 1.75; margin-bottom: 24px; }
.em-input {
width: 100%; padding: 13px 16px; border: 1.5px solid #dde5ee;
border-radius: 10px; font-size: 15px; font-family: inherit;
margin-bottom: 12px; outline: none; color: #1a365d;
transition: border-color 0.15s;
}
.em-input:focus { border-color: #2563eb; }
.em-input.error { border-color: #dc2626; }
.em-submit {
width: 100%; padding: 14px; font-size: 15px; font-weight: 700;
color: white; border: none; border-radius: 10px; cursor: pointer;
font-family: inherit; margin-bottom: 14px;
background: linear-gradient(135deg, #2563eb, #7c3aed);
transition: opacity 0.15s;
}
.em-submit:hover { opacity: 0.9; }
.em-confirm { display: none; text-align: center; color: #059669; font-weight: 700; font-size: 14px; margin-bottom: 12px; }
.em-skip { display: block; text-align: center; font-size: 13px; color: #8a9baf; text-decoration: none; }
.em-skip:hover { color: #4a5568; }
</style>
</head>
<!--
Hi-Acts 3rd Network Meeting
Innovation Challenge Arena: Live Polling Display
Developed by TXproducts for DESY Hamburg, June 2026 -->
<body>
<!-- ═══════════════════════════════════════════
MAIN CONTENT AREA (full width, no sidebar)
═══════════════════════════════════════════ -->
<div class="right-col">
<!-- Top bar: gradient left column, tagline centre, controls right -->
<div class="top-bar">
<div class="top-bar-event">
<div class="top-bar-event-meta">Hi-Acts · 3rd Network Meeting</div>
<div class="top-bar-event-title">Innovation <span>Challenge</span> Arena</div>
<div class="top-bar-event-meta">Data and Software Standardization</div>
</div>
<div class="top-bar-center">
<h1>Every perspective matters.</h1>
<div class="meta">Help us gain insight across the beamline community.</div>
</div>
<div class="top-bar-right">
<!-- Button toggles between bubble chart and per-question ranked results -->
<button class="analysis-btn" id="analysisBtnEl" onclick="toggleAnalysis()">Show Analysis</button>
<div class="live-badge">
<span class="live-dot"></span>
<span class="live-text">Live</span>
</div>
<div class="counter-block">
<!-- Total number of responses received — updates automatically every 2.5 seconds -->
<div class="counter-number" id="totalCount">0</div>
<div class="counter-label">responses</div>
</div>
</div>
</div>
<!-- ── PHASE 1: Question overview ──
This section is hidden automatically on page load (see script at bottom).
It was used for demo purposes only — not shown to conference attendees. -->
<div id="phaseQuestions">
<div class="qs-header">11 questions being asked to today's participants</div>
<div class="qs-grid">
<div class="q-card">
<div class="q-card-header">
<div class="q-badge" style="background:#2563eb">Q1</div>
<div class="q-card-text">What best describes your role at the beamline?</div>
</div>
<div class="q-options">
<div class="q-option">Beamline Scientist</div>
<div class="q-option">Instrument Scientist</div>
<div class="q-option">Industry User</div>
<div class="q-option">Control System / IT</div>
<div class="q-option">Other</div>
</div>
</div>
<div class="q-card">
<div class="q-card-header">
<div class="q-badge" style="background:#059669">Q2</div>
<div class="q-card-text">In one word, describe your experiment logbook today.</div>
</div>
<div class="q-examples">
<span class="q-example-chip">chaotic</span>
<span class="q-example-chip">fragmented</span>
<span class="q-example-chip">manual</span>
<span class="q-example-chip">scattered</span>
<span class="q-example-chip">analog</span>
</div>
</div>
<div class="q-card">
<div class="q-card-header">
<div class="q-badge" style="background:#dc2626">Q3</div>
<div class="q-card-text">What is the biggest obstacle when moving data between beamlines or facilities?</div>
</div>
<div class="q-options">
<div class="q-option">Different control systems (TANGO, EPICS, SPOCK)</div>
<div class="q-option">Inconsistent logbook formats</div>
<div class="q-option">Missing or incomplete metadata</div>
<div class="q-option">No common file format standard</div>
<div class="q-option">Manual re-scripting for every beamline</div>
<div class="q-option">Other</div>
</div>
</div>
<div class="q-card">
<div class="q-card-header">
<div class="q-badge" style="background:#d97706">Q4</div>
<div class="q-card-text">How do you feel about standardising beamline workflows?</div>
</div>
<div class="q-options">
<div class="q-option">Necessary and overdue</div>
<div class="q-option">Desirable but very difficult</div>
<div class="q-option">Too rigid for our experimental needs</div>
<div class="q-option">Not our problem to solve</div>
<div class="q-option">Other</div>
</div>
</div>
<div class="q-card">
<div class="q-card-header">
<div class="q-badge" style="background:#7c3aed">Q5</div>
<div class="q-card-text">When an industry user brings a sample, how well documented is the experiment?</div>
</div>
<div class="q-options">
<div class="q-option">Fully documented, reproducible</div>
<div class="q-option">Mostly documented, some gaps</div>
<div class="q-option">Partial, depends on the scientist</div>
<div class="q-option">Barely documented</div>
<div class="q-option">Other</div>
</div>
</div>
<div class="q-card">
<div class="q-card-header">
<div class="q-badge" style="background:#ea580c">Q6</div>
<div class="q-card-text">If every beamline used the same data language, what would become possible?</div>
</div>
<div class="q-examples">
<span class="q-example-chip">Automated cross-facility comparison</span>
<span class="q-example-chip">Direct data delivery without post-processing</span>
<span class="q-example-chip">Reproducible experiments across facilities</span>
</div>
</div>
</div>
<div class="submit-row">
<button class="big-submit-btn" id="submitBtn" onclick="submitAll()">
Load 10 Participant Responses &amp; See Results &rarr;
</button>
</div>
</div>
<!-- ── PHASE 2: Live results (always shown to attendees) ── -->
<div id="phaseResults">
<!-- Bubble chart — bubbles represent answer frequencies, coloured by question -->
<div id="chart-container">
<svg id="chart"></svg>
<!-- Shown while waiting for the first responses to come in -->
<div class="empty-state" id="emptyState">
<h2>Waiting for responses&hellip;</h2>
<p>Results will appear here as people answer</p>
</div>
</div>
<!-- Bottom strip — desktop: horizontal coloured tabs. Mobile: hidden, replaced by mobileQuestionList -->
<div id="bottomStrip"></div>
<!-- Analysis panel — shows ranked results per question (toggled by the button in the top bar) -->
<div id="analysisPanel">
<!-- Back to results link — visible on mobile only -->
<a href="/q/1" id="backToResults" style="display:none; align-items:center; gap:6px; font-size:13px; font-weight:700; color:#2563eb; text-decoration:none; margin-bottom:4px;">&#8592; Back to results</a>
<div class="analysis-header">Top 5 ranked results per question</div>
<!-- Cards are built dynamically by JavaScript when the panel is opened -->
<div class="analysis-grid" id="analysisGrid"></div>
</div>
</div>
</div>
<script>
/* ── QUESTIONS LIST — loaded from the server, never hardcoded ── */
const QUESTIONS = {{ questions | tojson }};
/* ── COLOURS: one colour per question, used in bubbles, badges, and strips ── */
const COLORS = ['#2563eb','#059669','#dc2626','#d97706','#7c3aed','#ea580c','#0891b2','#be185d','#65a30d','#0e7490','#b45309'];
/* ── BOTTOM STRIP: builds the coloured tabs at the bottom showing the leading answer per question ── */
function buildBottomStrip(bubbles) {
const strip = document.getElementById('bottomStrip');
if (!strip) return;
strip.innerHTML = '';
QUESTIONS.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 : '—';
strip.innerHTML += `
<a href="/q/${q.id}" class="bs-item" style="background:${color}">
<div class="bs-q">Q${q.id}</div>
<div class="bs-body">
<span class="bs-label">${q.label}</span>
<span class="bs-text" title="${text}">${text}</span>
</div>
</a>`;
});
}
/* ── ANALYSIS TOGGLE ──
Switches the view between the bubble chart and the per-question ranked results panel.
The button text updates to show what clicking it will do next. ── */
let analysisVisible = false;
function toggleAnalysis() {
const btn = document.getElementById('analysisBtnEl');
analysisVisible = !analysisVisible;
if (analysisVisible) {
fetch('/api/results').then(r => r.json()).then(data => {
buildAnalysis(data.bubbles);
document.getElementById('chart-container').style.display = 'none';
document.getElementById('bottomStrip').style.display = 'none';
document.getElementById('analysisPanel').classList.add('active');
btn.textContent = 'Back to Chart';
showEmailModal();
});
} else {
document.getElementById('analysisPanel').classList.remove('active');
document.getElementById('chart-container').style.display = '';
document.getElementById('bottomStrip').style.display = '';
btn.textContent = 'Show Analysis';
}
}
/* ── BUILD ANALYSIS CARDS ──
Creates one card per question showing top 4 answers ranked by number of responses,
with a proportional bar and response count. ── */
function buildAnalysis(bubbles) {
const grid = document.getElementById('analysisGrid');
grid.innerHTML = '';
const rankLabels = ['1st', '2nd', '3rd', '4th', '5th'];
const rankColors = ['#d97706', '#64748b', '#92400e', '#94a3b8', '#cbd5e1'];
QUESTIONS.forEach((q, qi) => {
const color = COLORS[qi % COLORS.length];
const qBubs = bubbles.filter(b => b.q_id === q.id).sort((a, b) => b.value - a.value);
const top = qBubs.slice(0, 5);
const maxVal = top[0]?.value || 1;
const answersHTML = top.length ? top.map((b, i) => `
<div class="a-row">
<span class="a-rank" style="color:${rankColors[i]}">${rankLabels[i]}</span>
<span class="a-text">${b.text}</span>
<div class="a-bar-wrap">
<div class="a-bar" style="width:${(b.value / maxVal * 100).toFixed(0)}%;background:${color}"></div>
</div>
<span class="a-val">${b.value}</span>
</div>`).join('')
: '<div style="font-size:12px;color:#8a9baf;padding:4px 0">No responses yet</div>';
grid.innerHTML += `
<div class="a-card">
<div class="a-card-header">
<div class="q-badge" style="background:${color}">Q${q.id}</div>
<div class="a-question">${q.text}</div>
</div>
<div class="a-divider"></div>
<div class="a-answers">${answersHTML}</div>
</div>`;
});
}
/* ── BUBBLE CHART ──
Built using D3.js. Bubbles are sized by response count and coloured by question.
The simulation keeps bubbles separated and centred on screen. ── */
const svg = d3.select('#chart');
let width = 0, height = 0, simulation = null, currentNodes = [];
/* Recalculates chart dimensions when the window is resized */
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 based on response count relative to the maximum */
function rScale(v, max) {
return 24 + Math.sqrt(v / max) * (Math.min(width, height) * 0.145 - 24);
}
/* Wraps long answer text inside a bubble across multiple lines */
function wrapBubbleText(textEl, d) {
const r = d.r;
const fontSize = Math.max(9, Math.min(r * 0.30, 14));
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;
textEl.selectAll('*').remove();
textEl.attr('text-anchor','middle').attr('font-size',fontSize).attr('font-weight','700').attr('fill',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 bubble chart when new data arrives from the server */
function updateChart(bubbles) {
document.getElementById('emptyState').style.display = bubbles.length ? 'none' : 'block';
if (!bubbles.length) return;
const maxVal = d3.max(bubbles, d => d.value) || 1;
const oldMap = new Map(currentNodes.map(d => [d.text+'_'+d.q_id, d]));
currentNodes = bubbles.map(d => {
const key = d.text+'_'+d.q_id;
const exist = oldMap.get(key);
const r = rScale(d.value, maxVal);
const color = COLORS[(d.q_id-1) % COLORS.length];
if (exist) { exist.value=d.value; exist.r=r; exist.color=color; return exist; }
return { ...d, r, color,
x: width/2 + (Math.random()-.5)*100,
y: height/2 + (Math.random()-.5)*100 };
});
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();
}
/* Draws or updates the SVG bubble elements */
function render() {
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');
enter.append('text').attr('class','bubble-text');
enter.transition()
.delay((d, i) => i * 110)
.duration(800)
.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('fill-opacity', 0.10).attr('stroke', d => d.color).attr('stroke-width', 2.5);
all.select('.bubble-text').each(function(d) { wrapBubbleText(d3.select(this), d); });
}
/* Keeps bubbles within the chart boundary on each simulation tick */
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})`);
}
/* ── FETCH RESULTS ──
Calls the server every 2.5 seconds to get the latest responses.
Updates the counter, bubble chart, bottom strip, and mobile list automatically. ── */
function fetchResults() {
fetch('/api/results').then(r=>r.json()).then(data => {
document.getElementById('totalCount').textContent = data.total_responses;
updateChart(data.bubbles);
buildBottomStrip(data.bubbles);
if (analysisVisible) buildAnalysis(data.bubbles);
}).catch(console.error);
}
/* ── PAGE STARTUP ──
Hides the demo phase, shows the live results, and starts polling the server. ── */
document.getElementById('phaseQuestions').style.display = 'none';
document.getElementById('phaseResults').classList.add('active');
document.getElementById('analysisBtnEl').classList.add('visible');
/* On mobile, show analysis immediately and show the back button */
if (window.innerWidth <= 768) {
analysisVisible = true;
document.getElementById('analysisPanel').classList.add('active');
document.getElementById('backToResults').style.display = 'flex';
}
resize();
fetchResults();
setInterval(fetchResults, 2500); /* refresh every 2.5 seconds */
/* showEmailModal is defined in the script block below, after the modal HTML */
/* On mobile the analysis panel is auto-shown — show modal after a short delay */
if (window.innerWidth <= 768) {
setTimeout(() => { if (typeof showEmailModal === 'function') showEmailModal(); }, 800);
}
</script>
<!-- ── EMAIL CAPTURE MODAL ──
Must be in the HTML before the script that references its elements. -->
<div id="emailModal">
<div class="email-modal-box">
<div class="em-eyebrow">Innovation Challenge Arena</div>
<h2 class="em-title">You're viewing the top 5 ranked responses.</h2>
<p class="em-body">
For a full in-depth analysis of all results, including how responses differ across roles and facilities, leave your email below. We will send the complete report to you after the event.
</p>
<input class="em-input" id="emailInput" type="email" placeholder="your@email.com" autocomplete="email">
<div class="em-confirm" id="emailConfirm">Thank you. We will be in touch after the event.</div>
<button class="em-submit" id="emailSubmitBtn" onclick="submitEmail()">Send me the full report</button>
<a href="#" class="em-skip" onclick="closeEmailModal(); return false;">No thanks, just show the results</a>
</div>
</div>
<script>
/* ── EMAIL MODAL FUNCTIONS ──
Placed after the modal HTML so getElementById calls find the elements. */
function showEmailModal() {
if (sessionStorage.getItem('emailModalShown')) return;
setTimeout(() => {
const modal = document.getElementById('emailModal');
if (modal) modal.classList.add('open');
}, 400);
}
function closeEmailModal() {
sessionStorage.setItem('emailModalShown', '1');
const modal = document.getElementById('emailModal');
if (modal) modal.classList.remove('open');
}
function submitEmail() {
const input = document.getElementById('emailInput');
const confirm = document.getElementById('emailConfirm');
const btn = document.getElementById('emailSubmitBtn');
const email = input.value.trim();
if (!email || !email.includes('@') || !email.includes('.')) {
input.classList.add('error');
return;
}
input.classList.remove('error');
btn.disabled = true;
fetch('/api/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
}).then(() => {
input.style.display = 'none';
btn.style.display = 'none';
confirm.style.display = 'block';
setTimeout(closeEmailModal, 2500);
}).catch(() => { btn.disabled = false; });
}
document.getElementById('emailInput').addEventListener('keydown', e => {
if (e.key === 'Enter') submitEmail();
});
</script>
</body>
</html>