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>
797 lines
39 KiB
HTML
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 & See Results →
|
|
</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…</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;">← 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>
|