Files
HiActs-Conference-Poll/templates/presenter.html
T

831 lines
41 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: 10px 22px; font-size: 15px; font-weight: 900;
cursor: pointer; font-family: inherit; display: none;
white-space: nowrap; letter-spacing: 0.8px; text-transform: uppercase;
}
.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; }
/* QR code overlay — top-right corner of the chart, 20% of chart width */
.presenter-qr {
position: absolute;
top: 16px;
right: 16px;
width: 20%;
aspect-ratio: 1 / 1;
border-radius: 12px;
background: white;
padding: 8px;
box-shadow: 0 4px 24px rgba(0,0,0,0.15);
z-index: 10;
pointer-events: none;
}
/* ── 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: 28px 32px; max-width: 460px; width: 90%;
max-height: 90vh; overflow-y: auto;
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: 8px; }
.em-title { font-size: 18px; font-weight: 900; color: #1a365d; line-height: 1.25; margin-bottom: 8px; }
.em-body { font-size: 13px; color: #4a5568; line-height: 1.65; margin-bottom: 12px; }
.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; }
.em-gdpr { font-size: 11.5px; color: #718096; line-height: 1.65; margin-bottom: 14px; padding: 10px 13px; background: #f7fafc; border-radius: 8px; border: 1px solid #e2e8f0; }
.em-consent { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 14px; font-size: 12.5px; color: #4a5568; line-height: 1.5; cursor: pointer; }
.em-consent input[type="checkbox"] { margin-top: 2px; flex-shrink: 0; width: 15px; height: 15px; cursor: pointer; accent-color: #2563eb; }
.em-submit:disabled { opacity: 0.4; cursor: not-allowed; }
</style>
</head>
<!--
Hi-Acts 3rd Network Meeting
Innovation Challenge Arena: Live Polling Display — Presenter View
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>
<!-- QR code overlay — top-right corner, 20% of chart width -->
<img src="/static/attend_qr.png" alt="Scan to participate" class="presenter-qr">
</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>
<div class="em-gdpr">
<strong>Privacy Notice:</strong> Your email address will be collected by TXproducts UG solely to send you this analysis report. It will not be shared with or sold to any third party. It will be permanently deleted within 30 days of the conference.
</div>
<input class="em-input" id="emailInput" type="email" placeholder="your@email.com" autocomplete="email">
<label class="em-consent">
<input type="checkbox" id="gdprCheckbox" onchange="toggleSubmit()">
I have read the data notice above and consent to TXproducts UG storing my email address for the purpose of receiving this report.
</label>
<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()" disabled>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 toggleSubmit() {
const checked = document.getElementById('gdprCheckbox').checked;
document.getElementById('emailSubmitBtn').disabled = !checked;
}
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>