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>
This commit is contained in:
beantownbabeandClaude Sonnet 4.6 committed 2026-06-05 15:38:17 +02:00
commit 27a59671fc
15 files changed
+3967

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
# Live poll data — never commit these
answers.json
emails.json
comments.json
# Python cache
__pycache__/
*.pyc
*.pyo
# macOS
.DS_Store
# R
.Rhistory
+114
View File
@@ -0,0 +1,114 @@
# Conference Poll — Hi-Acts 3rd Network Meeting
**Developed by TXproducts | June 2026**
---
## What this is
A self-paced live polling web app for the Hi-Acts 3rd Network Meeting at DESY Hamburg (10 June 2026).
Attendees scan a QR code, answer 10 questions on their phone, and can view live results afterwards. Results update automatically every 2.5 seconds.
---
## File structure
```
conference_poll/
├── app.py Main server file (questions, routes, logic)
├── requirements.txt Python dependencies
├── static/
│ ├── attend_qr.png QR code image shown on the join slide (replace before go-live)
│ └── logo.svg TXproducts logo
└── templates/
├── poll.html Attendee poll page (phone/tablet friendly)
├── join.html QR code beamer slide (desktop, shown on projector)
├── display.html Live results page (bubble chart + per-question analysis)
├── question_view.html Single question bubble chart (linked from display page)
└── admin.html Internal raw results view (not public-facing)
```
---
## Pages
| URL | Purpose | Who sees it |
|-----|---------|-------------|
| `/` | Attendee poll | Everyone who scans the QR |
| `/join` | QR code slide | Shown on projector/beamer |
| `/display` | Live results with bubble chart | Anyone with the link |
| `/q/1` — `/q/10` | Per-question bubble chart | Linked from display page |
| `/admin` | Raw response counts | Internal only |
---
## How to run locally (for testing)
**Requirements:** Python 3.10 or higher
```bash
cd conference_poll
pip3 install -r requirements.txt
python3 app.py
```
Then open `http://localhost:5001` in a browser.
---
## Deployment instructions
### Recommended setup (Bluehost VPS or equivalent)
1. Upload the entire `conference_poll/` folder to the server
2. Install dependencies:
```bash
pip3 install -r requirements.txt
pip3 install gunicorn
```
3. Run with Gunicorn (production-grade server):
```bash
gunicorn -w 2 -b 0.0.0.0:80 app:app
```
Or on port 443 with SSL if HTTPS is configured.
4. The app will be accessible at the server's public IP or domain.
### Before go-live checklist
- [ ] Generate a new QR code pointing to the live URL (e.g. `https://poll.txproducts.de`)
- [ ] Replace `static/attend_qr.png` with the new QR code image
- [ ] Update the URL pill in `templates/join.html` (search for `poll.txproducts.de`)
- [ ] Restrict access to `/admin` so it is not publicly accessible (basic auth or IP whitelist)
- [ ] Consider adding a database for persistent storage (see note below)
---
## Important: data persistence
Currently all responses are stored in memory. If the server restarts during the event, all data is lost.
For production, the `answers` dictionary in `app.py` should be replaced with a simple SQLite or PostgreSQL database. The relevant section is clearly marked in `app.py` with the comment "IN-MEMORY DATA STORE".
---
## Security notes
- The `/admin` page has no authentication. Restrict it at the server level before deployment.
- The Flask `secret_key` in `app.py` should be changed to a long random string for production.
- The demo routes (`/demo/stream`, `/demo/status`, `/submit_demo`) are safe to leave in but can be removed if preferred.
---
## Changing questions (content owner: TXproducts)
Questions are defined in `app.py` in the `QUESTIONS` list. The web dev team does not need to touch these. The TXproducts team manages question content directly.
After any question change, the `QUESTIONS` array in `templates/display.html` (inside the `<script>` tag) must also be updated to match. Both lists are clearly commented.
---
## Contact
Developed by McKinley Black, TXproducts internship 2026.
Questions: mblack@txproducts.de
+1200
View File
File diff suppressed because it is too large. Load diff
View File
Whitespace-only changes.
+3
View File
@@ -0,0 +1,3 @@
import sys, os
sys.path.insert(0, os.path.dirname(__file__))
from app import app as application
+2
View File
@@ -0,0 +1,2 @@
Flask>=3.0.0
fpdf2>=2.7
+232
View File
@@ -0,0 +1,232 @@
"""
Demo seeder for boss presentation.
Populates 20 simulated participant responses, then prints instructions.
Usage:
python3 seed_demo.py
"""
import urllib.request
import urllib.error
import json
BASE_URL = "http://localhost:5001"
def post(path, data):
body = json.dumps(data).encode()
req = urllib.request.Request(
BASE_URL + path,
data=body,
headers={"Content-Type": "application/json"},
method="POST"
)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
# 20 simulated participants, one response set each
# Designed to produce an interesting, realistic bubble chart
SIMULATED_RESPONSES = [
# Q1: Role, Q2: Logbook word, Q3: Obstacle, Q4: Standardisation, Q5: Documentation, Q6: Vision
(
"Beamline Scientist",
"chaotic",
"Different control systems (TANGO, EPICS, SPOCK)",
"Necessary and overdue",
"Partial, depends on the scientist",
"Automated cross-facility comparison without manual conversion"
),
(
"Beamline Scientist",
"fragmented",
"Inconsistent logbook formats",
"Necessary and overdue",
"Partial, depends on the scientist",
"Shared analysis pipelines that work on any beamline data"
),
(
"Industry User",
"opaque",
"Missing or incomplete metadata",
"Necessary and overdue",
"Barely documented",
"Getting our own data back without asking the beamline scientist to translate it"
),
(
"Beamline Scientist",
"manual",
"Different control systems (TANGO, EPICS, SPOCK)",
"Desirable but very difficult",
"Mostly documented, some gaps",
"Reproducible experiments across DESY, ESRF and Diamond"
),
(
"Instrument Scientist",
"inconsistent",
"Manual re-scripting for every beamline",
"Necessary and overdue",
"Partial, depends on the scientist",
"One analysis script that runs on data from any facility worldwide"
),
(
"Industry User",
"black box",
"Missing or incomplete metadata",
"Necessary and overdue",
"Barely documented",
"Direct data delivery to our in-house systems without post-processing"
),
(
"Control System / IT",
"custom",
"Different control systems (TANGO, EPICS, SPOCK)",
"Desirable but very difficult",
"Mostly documented, some gaps",
"A single integration layer instead of ten bespoke adapters"
),
(
"Beamline Scientist",
"paper",
"Inconsistent logbook formats",
"Necessary and overdue",
"Partial, depends on the scientist",
"Pooled datasets across facilities for machine learning models"
),
(
"Industry User",
"incomplete",
"No common file format standard",
"Desirable but very difficult",
"Barely documented",
"Automated quality reports in a format our QA team actually understands"
),
(
"Beamline Scientist",
"scattered",
"Different control systems (TANGO, EPICS, SPOCK)",
"Necessary and overdue",
"Partial, depends on the scientist",
"Publishing FAIR data with zero extra effort after the experiment"
),
(
"Instrument Scientist",
"fragmented",
"Inconsistent logbook formats",
"Desirable but very difficult",
"Mostly documented, some gaps",
"Collaborative experiments where two facilities contribute data to one analysis"
),
(
"Industry User",
"slow",
"Missing or incomplete metadata",
"Too rigid for our experimental needs",
"Mostly documented, some gaps",
"Faster time from experiment to usable result for our product development"
),
(
"Beamline Scientist",
"mixed",
"Different control systems (TANGO, EPICS, SPOCK)",
"Necessary and overdue",
"Partial, depends on the scientist",
"Finding and reusing data from experiments done five years ago"
),
(
"Control System / IT",
"outdated",
"Manual re-scripting for every beamline",
"Desirable but very difficult",
"Partial, depends on the scientist",
"Plug-and-play data connectors between control systems and analysis tools"
),
(
"Instrument Scientist",
"informal",
"Inconsistent logbook formats",
"Necessary and overdue",
"Barely documented",
"Automatic FAIR compliance without a separate data curation step"
),
(
"Beamline Scientist",
"unreliable",
"Different control systems (TANGO, EPICS, SPOCK)",
"Necessary and overdue",
"Mostly documented, some gaps",
"Comparing strain measurements across different beamlines in one click"
),
(
"Industry User",
"confusing",
"No common file format standard",
"Not our problem to solve",
"Barely documented",
"Receiving data already formatted for our simulation software"
),
(
"Beamline Scientist",
"analog",
"Inconsistent logbook formats",
"Desirable but very difficult",
"Fully documented, reproducible",
"A European data commons for synchrotron experiments"
),
(
"Instrument Scientist",
"messy",
"Different control systems (TANGO, EPICS, SPOCK)",
"Necessary and overdue",
"Partial, depends on the scientist",
"Less time cleaning data, more time doing science"
),
(
"Beamline Scientist",
"evolving",
"Missing or incomplete metadata",
"Too rigid for our experimental needs",
"Mostly documented, some gaps",
"Real-time data quality feedback during the experiment itself"
),
]
def main():
print("Resetting existing data...")
post("/admin/reset", {})
print("Seeding 20 simulated participants...")
entries = []
for row in SIMULATED_RESPONSES:
q1, q2, q3, q4, q5, q6 = row
entries += [
{"question_id": 1, "answer": q1},
{"question_id": 2, "answer": q2},
{"question_id": 3, "answer": q3},
{"question_id": 4, "answer": q4},
{"question_id": 5, "answer": q5},
{"question_id": 6, "answer": q6},
]
result = post("/admin/seed", entries)
print(f"Done. {result['total']} total answers loaded.\n")
print("=" * 56)
print(" PRESENTATION READY")
print("=" * 56)
print()
print(" Display (bubble chart, fullscreen):")
print(" http://localhost:5001/display?demo=1")
print()
print(" Poll (for boss to try on phone):")
print(" http://localhost:5001")
print()
print(" Admin (reset / view raw data):")
print(" http://localhost:5001/admin")
print("=" * 56)
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+222
View File
@@ -0,0 +1,222 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="180mm"
height="200mm"
viewBox="0 0 179.99999 200.00001"
version="1.1"
id="svg8"
inkscape:version="1.4.4 (dcaf3e7, 2026-05-05)"
sodipodi:docname="letshaveago.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs id="defs1"><style>
/* Fade in: circles 0-2s, text 2-2.8s */
#layer_outer_light, #layer_outer_dark,
#layer_inner_light, #layer_inner_dark,
#layer_centre {
animation: fadeIn 2s ease-in both;
}
#Text {
animation: fadeIn 0.8s ease-in 2s both;
opacity: 0;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
#svg8 {
animation: pulse 1.5s ease-in-out 2.8s 1 both;
transform-origin: 89.40962px 79.830692px;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
</style></defs>
<sodipodi:namedview
id="base"
inkscape:document-units="mm"
showgrid="true"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.5"
inkscape:cx="133"
inkscape:cy="315"
inkscape:window-width="1573"
inkscape:window-height="992"
inkscape:window-x="1524"
inkscape:window-y="25"
inkscape:window-maximized="0"
inkscape:current-layer="svg8"
showguides="true">
<inkscape:grid
id="grid1"
units="mm"
originx="0"
originy="0"
spacingx="0.99999997"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="5"
enabled="true"
visible="true" />
</sodipodi:namedview>
<g
id="Text"
style="display:inline"
transform="translate(-80.961663,-48.121679)">
<path
d="m 238.40554,227.51814 q -1.04011,0 -2.17197,-0.37805 -1.13187,-0.40957 -1.83545,-1.2917 l 0.673,-0.97665 q 0.61182,0.6616 1.43777,1.07116 0.85654,0.37806 1.80487,0.37806 0.7036,0 1.31541,-0.18903 0.64241,-0.22053 1.0401,-0.72461 0.39768,-0.50407 0.39768,-1.35471 0,-0.88212 -0.48945,-1.3547 -0.45887,-0.47257 -1.19306,-0.75611 -0.70359,-0.31504 -1.49896,-0.56709 -1.3154,-0.44106 -2.17196,-1.19718 -0.82595,-0.75611 -0.82595,-2.14232 0,-1.00815 0.45887,-1.76426 0.48945,-0.75612 1.31541,-1.16567 0.82595,-0.44108 1.86604,-0.44108 0.94832,0 1.86606,0.31506 0.94832,0.31504 1.71309,1.07115 l -0.64241,1.03966 q -0.48946,-0.6931 -1.31541,-0.94515 -0.79536,-0.25203 -1.40718,-0.28354 -0.52005,0 -1.10129,0.22054 -0.55063,0.22054 -0.94832,0.6616 -0.36709,0.40956 -0.36709,1.10267 0,0.7246 0.39769,1.19718 0.39768,0.44106 1.0095,0.7246 0.64241,0.28354 1.31541,0.53559 0.88714,0.28354 1.65191,0.6931 0.79537,0.37805 1.28482,1.03966 0.52005,0.63009 0.52005,1.70125 0,1.07116 -0.45886,1.9533 -0.45887,0.85062 -1.37659,1.3547 -0.88714,0.47257 -2.26373,0.47257 z"
id="S"
style="font-weight:300;font-size:31.0445px;line-height:1;font-family:'Josefin Sans';-inkscape-font-specification:'Josefin Sans Light';text-align:center;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#006796;stroke-width:0.173701"
transform="translate(-1.0208333e-6)" />
<path
d="m 226.84217,209.52894 h 1.46836 v 5.57634 h 3.70152 v 1.19718 h -3.70152 v 10.96364 h -1.46836 v -10.96364 h -2.53906 v -1.19718 h 2.53906 z"
id="t"
style="font-weight:300;font-size:31.0445px;line-height:1;font-family:'Josefin Sans';-inkscape-font-specification:'Josefin Sans Light';text-align:center;letter-spacing:0px;word-spacing:0px;text-anchor:middle;display:inline;fill:#006796;stroke-width:0.173701"
transform="translate(-1.0208333e-6)" />
<path
d="m 221.64175,226.06892 q -0.82596,0.6931 -1.86606,1.07117 -1.04009,0.37805 -2.17196,0.37805 -1.83546,0 -3.27323,-0.81913 -1.43778,-0.85062 -2.26373,-2.29984 -0.82596,-1.44921 -0.82596,-3.308 0,-1.85877 0.85654,-3.30799 0.85656,-1.44922 2.26374,-2.26835 1.43778,-0.85063 3.12028,-0.85063 1.25423,0 2.32492,0.37807 1.07068,0.37805 1.86604,1.07115 l -0.76477,1.00815 q -0.64241,-0.53557 -1.49896,-0.88212 -0.85655,-0.34656 -1.83545,-0.34656 -1.34601,0 -2.47788,0.69311 -1.10127,0.6931 -1.77428,1.89028 -0.6424,1.16567 -0.6424,2.61489 0,1.41771 0.6424,2.61489 0.67301,1.16568 1.80488,1.85879 1.16245,0.6931 2.56964,0.6931 0.91773,0 1.71309,-0.28354 0.79537,-0.31505 1.43778,-0.81913 z"
id="C"
style="font-weight:300;font-size:31.0445px;line-height:1;font-family:'Josefin Sans';-inkscape-font-specification:'Josefin Sans Light';text-align:center;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#006796;stroke-width:0.173701"
transform="translate(-1.0208333e-6)" />
<path
d="m 200.59518,227.61265 q -1.6825,0 -2.69201,-1.00815 -1.0095,-1.03965 -1.07068,-2.6779 v -8.97885 h 1.43778 v 8.53778 q 0.0612,1.19718 0.73418,1.9848 0.7036,0.78762 2.08019,0.85063 1.16245,0 2.17196,-0.63009 1.0095,-0.6301 1.62132,-1.70126 0.64241,-1.10266 0.64241,-2.48888 v -6.55298 h 1.43777 v 12.31835 h -1.28481 l -0.15296,-3.87508 0.21413,0.85063 q -0.3059,0.94514 -1.10127,1.73276 -0.76477,0.75611 -1.83546,1.19718 -1.04009,0.44106 -2.20255,0.44106 z"
id="U"
style="font-weight:300;font-size:31.0445px;line-height:1;font-family:'Josefin Sans';-inkscape-font-specification:'Josefin Sans Light';text-align:center;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#006796;stroke-width:0.173701"
transform="translate(-1.0208333e-6)" />
<path
d="m 184.53496,227.51814 q -1.6825,0 -3.0591,-0.81913 -1.37659,-0.85062 -2.20255,-2.33134 -0.79537,-1.48072 -0.79537,-3.33951 0,-1.98479 0.82596,-3.43401 0.82596,-1.44923 2.23314,-2.23684 1.43778,-0.78762 3.15088,-0.78762 1.71309,0 3.24264,0.91364 1.56014,0.91364 2.26374,2.39436 l -0.24474,0.6931 V 203.3225 h 1.43778 v 23.9436 h -1.46836 v -3.78057 l 0.3059,0.6931 q -0.76477,1.48073 -2.29432,2.42587 -1.52955,0.91364 -3.3956,0.91364 z m 0.36709,-1.2917 q 1.3766,0 2.47787,-0.59858 1.10127,-0.6301 1.77428,-1.66976 0.673,-1.07115 0.76478,-2.39435 v -1.57525 q -0.21415,-1.19718 -0.94832,-2.14231 -0.73419,-0.94515 -1.77428,-1.48072 -1.0401,-0.56709 -2.29433,-0.56709 -1.37659,0 -2.50846,0.6616 -1.13187,0.66159 -1.80487,1.82727 -0.673,1.16568 -0.673,2.70941 0,1.44921 0.673,2.6464 0.673,1.16567 1.80487,1.89028 1.16246,0.6931 2.50846,0.6931 z"
id="D"
style="font-weight:300;font-size:31.0445px;line-height:1;font-family:'Josefin Sans';-inkscape-font-specification:'Josefin Sans Light';text-align:center;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#006796;stroke-width:0.173701"
transform="translate(-1.0208333e-6)" />
<path
d="m 162.60124,221.02816 q 0,-1.79576 0.82597,-3.27649 0.85654,-1.48072 2.29432,-2.33135 1.46837,-0.88213 3.27324,-0.88213 1.83546,0 3.27323,0.88213 1.43778,0.85063 2.26373,2.33135 0.82596,1.48073 0.82596,3.27649 0,1.79577 -0.82596,3.2765 -0.82595,1.48072 -2.29432,2.36285 -1.43778,0.85063 -3.24264,0.85063 -1.83546,0 -3.27324,-0.81913 -1.43778,-0.85062 -2.29432,-2.29984 -0.82597,-1.48072 -0.82597,-3.37101 z m 1.43779,0.0315 q 0,1.44921 0.64241,2.64639 0.673,1.16568 1.77427,1.85878 1.13187,0.69311 2.50846,0.69311 1.3766,0 2.47788,-0.69311 1.13186,-0.6931 1.77428,-1.85878 0.673,-1.19718 0.673,-2.64639 0,-1.44922 -0.673,-2.6149 -0.64242,-1.19718 -1.77428,-1.89028 -1.10128,-0.72461 -2.47788,-0.72461 -1.40719,0 -2.53905,0.75612 -1.10127,0.7246 -1.74368,1.92178 -0.64241,1.19718 -0.64241,2.55189 z"
id="O"
style="font-weight:300;font-size:31.0445px;line-height:1;font-family:'Josefin Sans';-inkscape-font-specification:'Josefin Sans Light';text-align:center;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#006796;stroke-width:0.173701"
transform="translate(-1.0208333e-6)" />
<path
d="m 155.04532,214.94775 0.15296,3.78056 -0.18355,-0.47256 q 0.39769,-1.13418 1.25423,-1.95329 0.85655,-0.81913 1.89664,-1.26019 1.07069,-0.44108 2.0496,-0.44108 l -0.0612,1.44922 q -1.3766,0 -2.50846,0.6301 -1.10128,0.63009 -1.77427,1.70125 -0.64242,1.03966 -0.64242,2.36286 v 6.52148 h -1.43778 v -12.31835 z"
id="R"
style="font-weight:300;font-size:31.0445px;line-height:1;font-family:'Josefin Sans';-inkscape-font-specification:'Josefin Sans Light';text-align:center;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#006796;stroke-width:0.173701"
transform="translate(-1.0208333e-6)" />
<path
d="m 143.20666,227.51814 q -1.71309,0 -3.21205,-0.91364 -1.49896,-0.94514 -2.20255,-2.42587 l 0.24472,-0.53557 v 9.54593 h -1.43777 v -18.33575 h 1.28482 l 0.15295,4.03261 -0.27532,-0.91364 q 0.79537,-1.48073 2.32493,-2.42587 1.52954,-0.97665 3.33441,-0.97665 1.7131,0 3.08969,0.85063 1.37659,0.85063 2.17196,2.33135 0.82596,1.44922 0.82596,3.3395 0,1.85879 -0.85655,3.33951 -0.82596,1.44922 -2.26374,2.26833 -1.40718,0.81913 -3.18146,0.81913 z m -0.24472,-1.22869 q 1.40719,0 2.53905,-0.6931 1.16245,-0.6931 1.83545,-1.85878 0.7036,-1.19718 0.7036,-2.6464 0,-1.48072 -0.673,-2.6464 -0.673,-1.19718 -1.80487,-1.89028 -1.10128,-0.69311 -2.50846,-0.69311 -1.34601,0 -2.44728,0.6301 -1.10128,0.63009 -1.77428,1.70126 -0.673,1.07116 -0.79537,2.42586 v 1.00816 q 0.0917,1.29169 0.76478,2.36285 0.70359,1.07116 1.77428,1.70126 1.10127,0.59858 2.3861,0.59858 z"
id="P"
style="font-weight:300;font-size:31.0445px;line-height:1;font-family:'Josefin Sans';-inkscape-font-specification:'Josefin Sans Light';text-align:center;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#006796;stroke-width:0.173701"
transform="translate(-1.0208333e-6)" />
<path
d="m 116.53135,204.89774 h 1.86606 l 14.13303,22.36836 h -1.89664 z m -0.3059,22.36836 7.34183,-11.81427 0.79536,1.3547 -6.30174,10.45957 z m 8.04542,-12.31835 6.08761,-10.05001 h 1.77428 l -7.00534,11.2787 z"
id="X"
style="font-weight:300;font-size:31.0445px;line-height:1;font-family:'Josefin Sans';-inkscape-font-specification:'Josefin Sans Light';text-align:center;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#006796;stroke-width:0.173701"
transform="translate(-1.0208333e-6)" />
<path
d="m 98.972136,204.89774 h 14.224804 v 1.48072 h -6.39351 v 20.88764 h -1.49896 v -20.88764 h -6.332334 z"
id="T"
style="font-weight:300;font-size:31.0445px;line-height:1;font-family:'Josefin Sans';-inkscape-font-specification:'Josefin Sans Light';text-align:center;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#006796;stroke-width:0.173701"
transform="translate(-1.0208333e-6)" />
</g>
<g
inkscape:label="Outer ring â #007bac"
inkscape:groupmode="layer"
id="layer_outer_light"
transform="translate(-0.24038462,-1.4423077)"
style="display:inline">
<animateTransform
attributeName="transform"
attributeType="XML"
type="rotate"
from="0 89.40962 79.830692"
to="-360 89.40962 79.830692"
dur="2.8s"
fill="freeze"
additive="sum" />
<path
style="fill:#007bac;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="M 85.731807,13.740782 A 67.646,67.646 0 0 0 22.003914,81.273094 67.646,67.646 0 0 0 23.73094,96.444261 H 33.814046 A 57.860035,57.860035 0 0 1 31.78988,81.273094 57.86,57.86 0 0 1 104.22163,25.278062 V 15.21511 A 67.646,67.646 0 0 0 85.731807,13.740782 Z"
id="OUTER_LIGHT" />
</g>
<g
inkscape:label="Outer DARK â #1a4465"
inkscape:groupmode="layer"
id="layer_outer_dark"
style="display:inline">
<animateTransform
attributeName="transform"
attributeType="XML"
type="rotate"
from="0 89.40962 79.830692"
to="-360 89.40962 79.830692"
dur="2.8s"
fill="freeze"
additive="sum" />
<path
style="display:inline;fill:#1a4465;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="M 125.99954,22.934517 V 35.009233 A 57.860003,57.860003 0 0 1 147.26956,79.830786 57.860034,57.860034 0 0 1 45.98324,118.05274 H 33.606734 A 67.646002,67.646002 0 0 0 157.05553,79.830786 67.646002,67.646002 0 0 0 125.99954,22.934517 Z"
id="path1" />
</g>
<g
inkscape:label="Inner LIGHT â #006796"
inkscape:groupmode="layer"
id="layer_inner_light"
style="display:inline">
<animateTransform
attributeName="transform"
attributeType="XML"
type="rotate"
from="0 89.40962 79.830692"
to="360 89.40962 79.830692"
dur="2.8s"
fill="freeze"
additive="sum" />
<path
style="fill:#006796;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 91.944259,27.360604 a 52.531002,52.531002 0 0 0 -6.119523,0.0615 52.531002,52.531002 0 0 0 -48.946367,52.408687 52.531002,52.531002 0 0 0 2.236039,15.162899 h 9.891903 A 43.157003,43.157003 0 0 1 46.252474,79.830786 43.157058,43.157058 0 0 1 103.99779,39.214144 V 29.365649 A 52.531002,52.531002 0 0 0 91.944259,27.360604 Z"
id="INNER_LIGHT" />
</g>
<g
inkscape:label="Inner DARK â #174d75"
inkscape:groupmode="layer"
id="layer_inner_dark"
style="display:inline">
<animateTransform
attributeName="transform"
attributeType="XML"
type="rotate"
from="0 89.40962 79.830692"
to="360 89.40962 79.830692"
dur="2.8s"
fill="freeze"
additive="sum" />
<path
style="fill:#174d75;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 125.95769,42.097689 v 14.781526 a 43.157058,43.157058 0 0 1 6.6089,22.951571 43.157003,43.157003 0 0 1 -63.308285,38.156844 H 53.304757 a 52.531002,52.531002 0 0 0 88.635933,-38.156844 52.531002,52.531002 0 0 0 -15.983,-37.733097 z"
id="path6" />
<path
style="fill:#ffffff;stroke-width:0.264583"
d="M 36.992057,94.993684 V 118.05295"
id="path2" />
</g>
<g
inkscape:label="Centre â #135581"
inkscape:groupmode="layer"
id="layer_centre"
style="display:inline">
<path
id="INNER_CIRCLE"
style="fill:#135581;fill-opacity:1;fill-rule:evenodd;stroke:none"
d="m 126.45162,79.830692 a 37.042003,37.042003 0 0 1 -74.084005,0 37.042003,37.042003 0 0 1 74.084005,0 z m -7.385,0 a 29.657003,29.657003 0 0 0 -59.314005,0 29.657003,29.657003 0 0 0 59.314005,0 z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+295
View File
@@ -0,0 +1,295 @@
<!DOCTYPE html>
<html lang="en">
<!--
admin.html — Admin / Raw Results Page
Internal use only. Not linked from any attendee-facing page.
Shows all raw responses per question with bar charts.
ACCESS: yourdomain.com/admin
WARNING: This page has no password protection.
The web dev team should restrict access to this URL in production
so that conference attendees cannot accidentally stumble onto it.
RESET BUTTON: clears ALL poll data permanently. Use with caution.
-->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin - HiActs Poll</title>
<style>
/* ── RESET ── */
* { box-sizing: border-box; margin: 0; padding: 0; }
/* ── BODY: side-by-side layout, full height ── */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f0f4f8; color: #2d3748;
display: flex; flex-direction: row;
height: 100vh; overflow: hidden;
}
/* ── SIDEBAR: event branding and navigation ── */
.sidebar {
width: 300px; flex-shrink: 0;
background: white; border-right: 1px solid #dde5ee;
display: flex; flex-direction: column; height: 100vh;
}
.event-header {
flex-shrink: 0;
background: linear-gradient(160deg, #0f2744 0%, #1a4a8a 60%, #1d6fa8 100%);
padding: 28px 22px 24px; color: white; text-align: center;
}
.event-badge { font-size: 10px; font-weight: 700; letter-spacing: 1.4px; text-transform: uppercase; opacity: 0.6; margin-bottom: 14px; }
.event-title { font-size: 28px; font-weight: 900; line-height: 1.12; margin-bottom: 6px; letter-spacing: -0.3px; }
.event-title span { color: #5db8f5; }
.event-subtitle { font-size: 11.5px; font-weight: 500; opacity: 0.7; margin-bottom: 16px; line-height: 1.4; }
.event-meta { display: flex; align-items: center; justify-content: center; gap: 6px; font-size: 10.5px; opacity: 0.55; letter-spacing: 0.3px; }
.event-meta-dot { width: 3px; height: 3px; background: white; border-radius: 50%; opacity: 0.5; }
.message-area { flex-shrink: 0; padding: 20px 22px 0; }
.message-narrative { font-size: 13px; color: #334155; line-height: 1.7; }
.message-narrative p { margin-bottom: 12px; }
.message-narrative p:last-child { margin-bottom: 0; }
.message-narrative strong { color: #1a365d; font-weight: 600; }
.sidebar-mid { flex: 1; display: flex; align-items: center; padding: 0 22px 20px; }
.sidebar-nav { width: 100%; display: flex; flex-direction: column; gap: 6px; }
.snav-btn {
display: block; text-decoration: none; font-size: 12px; font-weight: 600;
color: #4a5568; background: #f7fafc; border: 1px solid #dde5ee;
border-radius: 8px; padding: 7px 12px; text-align: center;
transition: background 0.12s, color 0.12s;
}
.snav-btn:hover { background: #eff6ff; color: #2563eb; border-color: #bfdbfe; }
.snav-btn.active { background: #eff6ff; color: #2563eb; border-color: #2563eb; font-weight: 700; }
/* ── MAIN COLUMN: scrollable content area ── */
.main-col { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* Top bar with total response counter */
.top-bar {
flex-shrink: 0; display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center; padding: 10px 24px;
background: white; border-bottom: 1px solid #dde5ee; gap: 12px;
}
.top-bar-center { text-align: 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; }
/* 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 */
.counter-block { text-align: right; }
.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; }
/* Scrollable content area below the top bar */
.content { flex: 1; overflow-y: auto; padding: 14px 20px 30px; }
/* 2-column grid of question result blocks */
.question-blocks-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
/* Section label */
.section-title {
font-size: 10px; font-weight: 700; color: #8a9baf;
text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px;
}
/* ── QUESTION BLOCK: one card per question showing answer breakdown ── */
.question-block {
background: white; border-radius: 12px;
padding: 14px 16px; margin-bottom: 10px; border: 1px solid #e8eef4;
}
.q-header { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 10px; }
.q-title { font-size: 13px; font-weight: 700; color: #1a365d; }
.q-count { font-size: 12px; color: #8a9baf; }
/* Three-column layout: answer labels | progress bars | leading answer circle */
.q-body { display: grid; grid-template-columns: 1fr 110px 116px; gap: 0 10px; align-items: center; }
.q-answers-col { display: flex; flex-direction: column; }
.q-answer-label { font-size: 12px; color: #2d3748; padding: 5px 0; border-bottom: 1px solid #f0f4f8; line-height: 1.3; min-height: 28px; display: flex; align-items: center; }
.q-answer-label:last-child { border-bottom: none; }
.q-bars-col { display: flex; flex-direction: column; }
.q-bar-row { display: flex; align-items: center; gap: 6px; padding: 5px 0; border-bottom: 1px solid #f0f4f8; min-height: 28px; }
.q-bar-row:last-child { border-bottom: none; }
.q-bar-track { flex: 1; height: 7px; background: #e8eef4; border-radius: 4px; overflow: hidden; }
.q-bar-fill { height: 100%; border-radius: 4px; }
.q-bar-num { font-size: 11px; font-weight: 700; color: #1a365d; width: 16px; text-align: right; flex-shrink: 0; }
/* Circular badge showing the top answer for this question */
.q-circle-col { display: flex; align-items: center; justify-content: center; }
.q-top-circle { width: 108px; height: 108px; border-radius: 50%; display: flex; align-items: center; justify-content: center; text-align: center; padding: 14px; }
.q-top-circle-text { font-size: 11px; font-weight: 700; line-height: 1.4; color: white; }
.no-answers { color: #a0aec0; font-size: 13px; }
</style>
</head>
<body>
<!-- ── SIDEBAR ── -->
<div class="sidebar">
<div class="event-header">
<div class="event-badge">Hi-Acts · 3rd Network Meeting</div>
<div class="event-title">Innovation<br><span>Challenge</span><br>Arena</div>
<div class="event-subtitle">Competing Ideas. Shared Solutions.</div>
<div class="event-meta">DESY Hamburg <span class="event-meta-dot"></span> 10 June 2026</div>
</div>
<div class="message-area">
<div class="message-narrative">
<p>This room brings together industry partners, researchers and innovation experts united by one goal. Turning a shared challenge into a shared solution.</p>
<p>The challenge is how to standardise <strong>software and data for high-throughput beamline experiments</strong>, enabling smooth exchange between facilities, reproducible science, and FAIR data by default.</p>
</div>
</div>
<div class="sidebar-mid">
<div class="sidebar-nav">
<a href="/display" class="snav-btn">Display / Demo</a>
<a href="/" class="snav-btn">Attendee Poll</a>
<a href="/display" class="snav-btn">Live Results</a>
<a href="/admin" class="snav-btn active">Admin</a>
</div>
</div>
</div>
<!-- ── MAIN CONTENT ── -->
<div class="main-col">
<!-- Top bar with live response counter -->
<div class="top-bar">
<div></div>
<div class="top-bar-center">
<h1>Every perspective in this room matters.</h1>
<div class="meta">Help us gain a bit more insight.</div>
</div>
<div class="top-bar-right">
<div class="live-badge">
<span class="live-dot"></span>
<span class="live-text">Live</span>
</div>
<div class="counter-block">
<!-- Updated by JavaScript every 3 seconds -->
<div class="counter-number" id="totalCount">0</div>
<div class="counter-label">responses</div>
</div>
</div>
</div>
<div class="content">
<!-- Action buttons row: CSV export + reset -->
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:14px; gap:10px;">
<div style="display:flex; gap:10px;">
<!-- Download all responses as a CSV for Tableau or Excel -->
<a href="/admin/export" style="background:#2563eb;color:white;border:none;border-radius:9px;padding:9px 20px;font-size:13px;font-weight:700;cursor:pointer;font-family:inherit;text-decoration:none;display:inline-block;">Download CSV</a>
<a href="/admin/report" style="background:#059669;color:white;border:none;border-radius:9px;padding:9px 20px;font-size:13px;font-weight:700;cursor:pointer;font-family:inherit;text-decoration:none;display:inline-block;">Download PDF Report</a>
</div>
<button onclick="resetData()" style="background:#e53e3e;color:white;border:none;border-radius:9px;padding:9px 20px;font-size:13px;font-weight:700;cursor:pointer;font-family:inherit;">Reset All Data</button>
</div>
<!-- Raw response blocks — one card per question, built by Jinja2 template engine -->
<div class="section-title">Raw Responses</div>
<div class="question-blocks-grid">
<!-- Loop through each question and its answer counts (provided by app.py) -->
{% for item in processed %}
{% set max_count = item.counts.values() | list | max if item.counts else 1 %}
<div class="question-block">
<div class="q-header">
<div class="q-title">Q{{ item.question.id }}: {{ item.question.text }}</div>
<div class="q-count">{{ item.total }} answer{{ 's' if item.total != 1 else '' }}</div>
</div>
{% if item.counts %}
{% set top_items = item.counts.items() | list %}
{% set q_colors = ['#2563eb','#059669','#dc2626','#d97706','#7c3aed','#ea580c','#0891b2','#be185d','#65a30d','#7c3aed'] %}
{% set q_color = q_colors[item.question.id - 1] %}
<div class="q-body">
<!-- Answer labels column -->
<div class="q-answers-col">
{% for text, count in top_items[:5] %}
<div class="q-answer-label">{{ text }}</div>
{% endfor %}
</div>
<!-- Progress bars column -->
<div class="q-bars-col">
{% for text, count in top_items[:5] %}
<div class="q-bar-row">
<div class="q-bar-track">
<div class="q-bar-fill" style="width:{{ (count / max_count * 100) | int }}%;background:{{ q_color }}"></div>
</div>
<span class="q-bar-num">{{ count }}</span>
</div>
{% endfor %}
</div>
<!-- Leading answer circle -->
<div class="q-circle-col">
<div class="q-top-circle" style="background:{{ q_color }}">
<span class="q-top-circle-text">{{ top_items[0][0] }}</span>
</div>
</div>
</div>
{% else %}
<div class="no-answers">No answers yet</div>
{% endif %}
</div>
{% endfor %}
</div><!-- end question-blocks-grid -->
<!-- Email list — collected from attendees who requested the full analysis -->
<div style="margin-top:28px;">
<div class="section-title">Emails collected (full report requests)</div>
<div style="background:white;border-radius:12px;border:1px solid #e8eef4;padding:16px 20px;">
{% if emails %}
<div style="font-size:13px;color:#4a5568;margin-bottom:10px;">{{ emails | length }} address{{ 'es' if emails | length != 1 else '' }} collected</div>
{% for email in emails %}
<div style="font-size:13px;color:#1a365d;padding:6px 0;border-bottom:1px solid #f0f4f8;font-family:'SF Mono','Fira Code',monospace;">{{ email }}</div>
{% endfor %}
{% else %}
<div style="font-size:13px;color:#a0aec0;">No emails collected yet. They appear here when attendees request the full report on the analysis page.</div>
{% endif %}
</div>
</div>
<!-- Attendee comments — free-text submitted at the end of the poll -->
<div style="margin-top:20px;">
<div class="section-title">Attendee comments ({{ comments | length }})</div>
<div style="background:white;border-radius:12px;border:1px solid #e8eef4;padding:16px 20px;">
{% if comments %}
{% for comment in comments %}
<div style="font-size:13px;color:#2d3748;padding:8px 0;border-bottom:1px solid #f0f4f8;line-height:1.5;">{{ comment }}</div>
{% endfor %}
{% else %}
<div style="font-size:13px;color:#a0aec0;">No comments submitted yet.</div>
{% endif %}
</div>
</div>
</div><!-- end content -->
</div><!-- end main-col -->
<script>
/* Updates the total response counter every 3 seconds without reloading the page */
function refresh() {
fetch('/api/results').then(r => r.json()).then(data => {
document.getElementById('totalCount').textContent = data.total_responses;
});
}
refresh();
setInterval(refresh, 3000);
/* Clears all poll data after a confirmation prompt — cannot be undone */
function resetData() {
if (!confirm('Reset ALL poll data? This cannot be undone.')) return;
fetch('/admin/reset', { method: 'POST' }).then(() => location.reload());
}
</script>
</body>
</html>
+796
View File
@@ -0,0 +1,796 @@
<!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>
+161
View File
@@ -0,0 +1,161 @@
<!DOCTYPE html>
<html lang="en">
<!--
join.html — QR Code Join Slide
This page is shown on the beamer/projector at the start of the session.
It displays the QR code that attendees scan to reach the poll on their phones.
IMPORTANT BEFORE THE CONFERENCE:
- Replace the QR code image in /static/attend_qr.png with one pointing to the live server URL
- Update the URL shown in the pill at the bottom to match the live server URL
- The web dev team will provide the correct URL after deployment
-->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Join the Poll – HiActs</title>
<style>
/* ── RESET ── */
* { box-sizing: border-box; margin: 0; padding: 0; }
/* ── BODY: full screen, dark blue gradient, side-by-side layout ── */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
height: 100vh; display: flex; flex-direction: row; overflow: hidden;
background: linear-gradient(160deg, #0f2744 0%, #1a4a8a 60%, #1d6fa8 100%);
color: white;
}
/* ── LEFT SIDEBAR: event title + narrative context card ── */
.sidebar {
width: 380px; flex-shrink: 0;
display: flex; flex-direction: column;
border-right: 1px solid rgba(255,255,255,0.15);
}
/* "Innovation Challenge Arena" title at the top of the sidebar */
.sidebar-header {
display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center; padding: 44px 32px 32px; flex-shrink: 0; gap: 14px;
}
.event-title { font-size: 44px; font-weight: 900; line-height: 1.08; letter-spacing: -0.5px; }
.event-title span { color: #5db8f5; } /* "Challenge" in light blue */
.event-subtitle-gold { font-size: 12px; font-weight: 700; color: #f5c842; letter-spacing: 1.2px; text-transform: uppercase; white-space: nowrap; }
/* White card with narrative text explaining the session goal */
.narrative-card { background: white; flex: 1; padding: 28px 32px; }
.narrative-card p { font-size: 16px; color: #334155; line-height: 1.75; margin-bottom: 14px; }
.narrative-card p:last-child { margin-bottom: 0; }
.narrative-card strong { color: #1a365d; font-weight: 600; }
.callout {
background: #ebf4ff; border-left: 4px solid #5db8f5;
border-radius: 8px; padding: 14px 16px;
font-size: 15px; color: #1a365d; line-height: 1.7; margin-bottom: 0;
}
.callout .fair { color: #b8860b; font-weight: 700; }
/* ── RIGHT PANEL: top bar + QR code area ── */
.right-panel { flex: 1; display: flex; flex-direction: column; }
/* Top bar showing event name, tagline, and date/location */
.top-bar {
flex-shrink: 0;
border-bottom: 1px solid rgba(255,255,255,0.15);
padding: 18px 48px;
display: flex; align-items: center; justify-content: space-between; gap: 24px;
}
.tb-event { font-size: 18px; font-weight: 700; letter-spacing: 0.3px; }
.tb-tagline { font-size: 17px; opacity: 0.65; font-style: italic; }
.tb-tagline-gold { font-size: 17px; font-weight: 700; font-style: italic; color: #f5c842; }
.tb-meta { font-size: 16px; opacity: 0.55; letter-spacing: 0.4px; white-space: nowrap; }
.gold { color: #5db8f5; font-weight: 700; }
/* ── QR AREA: centred content with headline, QR image, and URL pill ── */
.qr-area {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
padding: 40px 48px; text-align: center;
}
/* Main headline above the QR code */
.headline {
font-size: clamp(28px, 4vw, 52px); /* scales with screen size */
font-weight: 900; line-height: 1.12;
margin-bottom: 8px; letter-spacing: -0.5px;
}
.headline span { color: #5db8f5; }
/* Subtitle below the headline */
.subline { font-size: clamp(18px, 2.2vw, 28px); opacity: 0.6; margin-bottom: 44px; font-weight: 400; }
/* White box holding the QR code image — floats gently up and down */
.qr-wrap {
background: white; border-radius: 24px; padding: 24px;
box-shadow: 0 24px 60px rgba(0,0,0,0.4);
margin-bottom: 36px;
animation: float 4s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
/* The QR code image itself — scales to screen width on smaller displays */
.qr-wrap img { width: min(260px, 38vw); height: min(260px, 38vw); display: block; }
/* Pill showing the URL attendees can also type manually */
.url-pill {
background: rgba(255,255,255,0.12);
border: 1px solid rgba(255,255,255,0.25);
border-radius: 40px; padding: 12px 32px;
font-size: clamp(14px, 1.8vw, 20px); font-weight: 600;
letter-spacing: 0.3px;
font-family: 'SF Mono', 'Fira Code', monospace; /* monospace for URL readability */
}
</style>
</head>
<body>
<!-- LEFT SIDEBAR -->
<div class="sidebar">
<div class="sidebar-header">
<div class="event-title">Innovation<br><span>Challenge</span><br>Arena</div>
<div class="event-subtitle-gold">Data and Software Standardization</div>
</div>
<!-- White narrative card explaining the session to anyone who reads the screen -->
<div class="narrative-card">
<p>We invite all Hi-Acts attendees to share their perspective on a few key areas of data standardisation at beamline facilities.</p>
<p>Your input takes just a few minutes and helps us understand the real challenges across the community, wherever you work.</p>
<p class="callout">The challenge is how to standardise <strong>software and data for high-throughput beamline experiments</strong>,
enabling smooth exchange between facilities, reproducible science, and <span class="fair">FAIR data by default.</span></p>
</div>
</div>
<!-- RIGHT PANEL -->
<div class="right-panel">
<!-- Top bar with event metadata -->
<div class="top-bar">
<span class="tb-event">Hi-Acts &middot; 3rd Network Meeting</span>
<span class="tb-tagline-gold">Competing Ideas. Shared Solutions.</span>
<span class="tb-event">DESY Hamburg &middot; 10 June 2026</span>
</div>
<!-- QR code area -->
<div class="qr-area">
<div class="headline">Share your <span>perspective</span></div>
<div class="subline">Scan to take part in the poll</div>
<!-- QR code image — replace attend_qr.png with the production QR before the conference -->
<div class="qr-wrap">
<img src="/static/attend_qr.png" alt="QR code to join poll">
</div>
<!-- URL displayed below the QR — update this to match the production URL -->
<div class="url-pill">192.168.178.25:5001</div>
</div>
</div>
</body>
</html>
+437
View File
@@ -0,0 +1,437 @@
<!DOCTYPE html>
<html lang="en">
<!--
poll.html — Attendee Poll Page
This is the page attendees see when they scan the QR code.
They answer all questions one by one and reach a thank-you screen at the end.
No sidebar on this page — designed for phone and tablet use.
-->
<head>
<meta charset="UTF-8">
<!-- Prevents the phone from zooming in when tapping answer buttons -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>HiActs Innovation Challenge</title>
<style>
/* ── RESET ── */
* { box-sizing: border-box; margin: 0; padding: 0; }
/* ── BODY: full height, column layout ── */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, sans-serif;
background: #f0f4f8;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ── TOP BAR: event branding left, tagline right, no button ── */
.top-bar {
flex-shrink: 0;
background: white; border-bottom: 1px solid #dde5ee;
display: grid; grid-template-columns: auto 1fr; align-items: stretch; gap: 0;
}
.top-bar-event {
background: linear-gradient(160deg, #0f2744 0%, #1a4a8a 60%, #1d6fa8 100%);
color: white; padding: 12px 16px; text-align: center;
display: flex; flex-direction: column; justify-content: center; gap: 4px;
}
.top-bar-event-title { font-size: 13px; font-weight: 900; line-height: 1.15; letter-spacing: -0.3px; }
.top-bar-event-title span { color: #5db8f5; }
.top-bar-event-meta { font-size: 8px; color: #f5c842; font-weight: 700; letter-spacing: 0.6px; text-transform: uppercase; line-height: 1.5; }
.top-bar-center { text-align: right; padding: 10px 16px; display: flex; flex-direction: column; justify-content: center; }
.top-bar h1 { font-size: 16px; font-weight: 700; color: #1a365d; }
.top-bar .meta { font-size: 13px; color: #8a9baf; margin-top: 3px; }
/* ── MOBILE: phones and small tablets ── */
@media (max-width: 600px) {
.question-card { padding: 20px 16px 16px; }
.question-text { font-size: 19px; }
.option-btn { font-size: 15px; padding: 13px 16px; }
.submit-btn { font-size: 16px; padding: 15px; }
.thankyou h2 { font-size: 22px; }
.results-btn { font-size: 15px; padding: 14px 24px; }
}
/* Button linking to the live results page */
.live-results-btn {
background: linear-gradient(135deg, #2563eb, #7c3aed);
color: white; border: none; border-radius: 8px;
padding: 7px 14px; font-size: 12px; font-weight: 700;
cursor: pointer; font-family: inherit; text-decoration: none;
white-space: nowrap; display: inline-block;
}
/* ── PROGRESS BAR: thin coloured strip showing how far through the poll ── */
.progress-bar { height: 5px; background: #d0dae6; }
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4A9EFF, #5CDB95);
transition: width 0.5s ease;
border-radius: 0 3px 3px 0;
}
/* ── QUESTION CARD: the main content area for each question ── */
.question-card {
flex: 1; padding: 28px 20px 20px;
display: flex; flex-direction: column;
animation: fadeIn 0.3s ease;
}
/* Smooth fade-in animation when moving to the next question */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* "Question X of Y" label above the question text */
.question-number {
font-size: 11px; color: #8a9baf; font-weight: 700;
letter-spacing: 1.2px; text-transform: uppercase; margin-bottom: 10px;
}
/* The question text itself */
.question-text {
font-size: 22px; font-weight: 800; color: #1a365d;
line-height: 1.3; margin-bottom: 28px;
}
/* ── MULTIPLE CHOICE: list of answer buttons ── */
.options { display: flex; flex-direction: column; gap: 11px; }
.option-btn {
background: white; border: 2px solid #dde5ee; border-radius: 14px;
padding: 15px 18px; font-size: 16px; color: #2d3748;
text-align: left; cursor: pointer;
transition: border-color 0.12s, background 0.12s, transform 0.08s;
-webkit-tap-highlight-color: transparent; /* removes grey flash on iOS tap */
font-family: inherit; line-height: 1.3;
}
.option-btn:active { transform: scale(0.98); }
/* Highlighted state when an option is selected */
.option-btn.selected { border-color: #4A9EFF; background: #ebf4ff; color: #1a365d; font-weight: 600; }
.option-btn:disabled { cursor: default; }
/* ── FREE TEXT: typed answer input ── */
.free-text-container { display: flex; flex-direction: column; gap: 14px; }
.free-text-input {
background: white; border: 2px solid #dde5ee; border-radius: 14px;
padding: 16px 18px; font-size: 18px; color: #2d3748;
outline: none; transition: border-color 0.15s; width: 100%; font-family: inherit;
}
.free-text-input:focus { border-color: #4A9EFF; }
.spacer { flex: 1; }
/* Submit button for free-text questions */
.submit-btn {
background: linear-gradient(135deg, #4A9EFF, #3a7ee0);
color: white; border: none; border-radius: 14px;
padding: 17px 20px; font-size: 17px; font-weight: 700;
cursor: pointer; transition: opacity 0.15s, transform 0.08s;
width: 100%; font-family: inherit; margin-top: 16px; letter-spacing: 0.2px;
}
.submit-btn:active { transform: scale(0.98); }
.submit-btn:disabled { background: #a0aec0; cursor: not-allowed; }
/* ── THANK YOU SCREEN: shown after all questions are answered ── */
.thankyou {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center; padding: 40px 24px;
animation: fadeIn 0.4s ease;
}
.thankyou h2 { font-size: 26px; color: #1a365d; font-weight: 800; margin-bottom: 12px; }
.thankyou p { font-size: 15px; color: #718096; line-height: 1.6; max-width: 280px; }
/* Button to go to the live results page after completing the poll */
.results-btn {
display: inline-block; margin-top: 24px;
background: linear-gradient(135deg, #2563eb, #059669);
color: white; text-decoration: none; border-radius: 14px;
padding: 16px 32px; font-size: 17px; font-weight: 700;
transition: opacity 0.15s, transform 0.08s;
}
.results-btn:active { transform: scale(0.98); opacity: 0.9; }
/* Small event branding at the bottom of the thank-you screen */
.thankyou .brand { margin-top: 32px; font-size: 12px; color: #a0aec0; letter-spacing: 0.5px; }
/* Utility class — hides any element */
.hidden { display: none !important; }
</style>
</head>
<body>
<!-- ── POLL COLUMN: the full page content ── -->
<div class="poll-col" style="flex:1;display:flex;flex-direction:column;min-height:100vh;">
<!-- Top bar: event branding left, tagline centre, live results button 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>
<!-- Progress bar — fills from left to right as questions are answered -->
<div class="progress-bar">
<div class="progress-fill" id="progressFill" style="width: 0%"></div>
</div>
<!-- Question card — built and replaced by JavaScript for each question -->
<div id="questionCard" class="question-card"></div>
<!-- Thank-you screen — hidden until all questions are answered -->
<div id="thankYou" class="thankyou hidden">
<h2>Thank you!</h2>
<p>Your answers help us understand real data challenges across the beamline community.</p>
<!-- Takes the attendee to the live results page -->
<a href="/q/1" class="results-btn">See live results &rarr;</a>
<div class="brand">Hi-Acts · 3rd Network Meeting · DESY Hamburg</div>
</div>
</div>
<script>
/* ── QUESTIONS ──
Loaded from the server (defined in app.py).
{{ questions | tojson }} is replaced by real data when the page loads. */
const QUESTIONS = {{ questions | tojson }};
/* Tracks which question the attendee is currently on */
let currentIndex = 0;
/* ── showQuestion ──
Renders the question at the given index.
After all questions shows the comment step, then the thank-you screen. */
function showQuestion(index) {
if (index === QUESTIONS.length) {
showCommentStep();
return;
}
if (index > QUESTIONS.length) {
document.getElementById('questionCard').classList.add('hidden');
document.getElementById('thankYou').classList.remove('hidden');
document.getElementById('progressFill').style.width = '100%';
return;
}
const q = QUESTIONS[index];
const progress = (index / QUESTIONS.length) * 100;
document.getElementById('progressFill').style.width = progress + '%';
/* Build the HTML for this question */
let html = `
<div class="question-number">Question ${index + 1} of ${QUESTIONS.length}</div>
<div class="question-text">${q.text}</div>
`;
if (q.type === 'multiple_choice') {
/* Render a button for each answer option */
html += '<div class="options">';
q.options.forEach(opt => {
const safeOpt = opt.replace(/'/g, "\\'").replace(/"/g, '&quot;');
html += `<button class="option-btn" onclick="selectOption(this, '${safeOpt}', ${q.id})">${opt}</button>`;
});
html += '</div>';
} else {
/* Render a text input field for free-text questions */
html += `
<div class="free-text-container">
<input type="text" class="free-text-input" id="freeTextInput"
placeholder="${q.placeholder || 'Your answer...'}"
maxlength="200"
onkeydown="if(event.key==='Enter') submitFreeText(${q.id})">
</div>
<div class="spacer"></div>
<button class="submit-btn" id="submitBtn" onclick="submitFreeText(${q.id})">Submit &rarr;</button>
`;
}
/* Inject the HTML and replay the fade-in animation */
const card = document.getElementById('questionCard');
card.innerHTML = html;
card.style.animation = 'none';
card.offsetHeight; /* forces browser to reset animation */
card.style.animation = 'fadeIn 0.3s ease';
/* Auto-focus the text input on free-text questions */
if (q.type === 'free_text') {
setTimeout(() => {
const input = document.getElementById('freeTextInput');
if (input) input.focus();
}, 100);
}
}
/* ── selectOption ──
Called when an attendee taps a multiple-choice button.
If "Other" is selected, shows a free-text field instead of submitting immediately. */
function selectOption(btn, answer, qId) {
/* Clear any previously selected button */
document.querySelectorAll('.option-btn').forEach(b => {
b.classList.remove('selected');
b.disabled = false;
});
btn.classList.add('selected');
/* Remove any existing "Other" text field */
const existing = document.getElementById('otherInputWrap');
if (existing) existing.remove();
if (answer === 'Other') {
/* Show a free-text input with a 10-word limit */
const wrap = document.createElement('div');
wrap.id = 'otherInputWrap';
wrap.style.marginTop = '14px';
wrap.innerHTML = `
<input type="text" class="free-text-input" id="otherTextField"
placeholder="Max 10 words..."
onkeydown="if(event.key==='Enter') submitOther(${qId})"
oninput="enforceWordLimit(this, 10)">
<div id="wordCount" style="font-size:11px;color:#8a9baf;text-align:right;margin-top:4px">0 / 10 words</div>
<button class="submit-btn" id="otherSubmitBtn" onclick="submitOther(${qId})" style="margin-top:10px">Submit &rarr;</button>
`;
document.querySelector('.options').appendChild(wrap);
setTimeout(() => document.getElementById('otherTextField').focus(), 50);
return;
}
/* For any other option, disable all buttons and submit immediately */
document.querySelectorAll('.option-btn').forEach(b => b.disabled = true);
submitAnswer(qId, answer);
}
/* ── enforceWordLimit ──
Prevents the "Other" text field from exceeding the word limit.
Updates the live word counter below the input. */
function enforceWordLimit(input, max) {
const words = input.value.trim().split(/\s+/).filter(w => w.length > 0);
const counter = document.getElementById('wordCount');
if (words.length > max) {
input.value = words.slice(0, max).join(' ');
}
const current = input.value.trim() === '' ? 0 : input.value.trim().split(/\s+/).filter(w => w.length > 0).length;
if (counter) {
counter.textContent = `${current} / ${max} words`;
counter.style.color = current >= max ? '#FC8181' : '#8a9baf';
}
}
/* ── submitOther ──
Called when the attendee submits an "Other" free-text answer.
Validates that the field is not empty before submitting. */
function submitOther(qId) {
const input = document.getElementById('otherTextField');
const btn = document.getElementById('otherSubmitBtn');
const answer = input ? input.value.trim() : '';
if (!answer) {
if (input) input.style.borderColor = '#FC8181'; /* flash red if empty */
return;
}
document.querySelectorAll('.option-btn').forEach(b => b.disabled = true);
if (input) input.disabled = true;
if (btn) btn.disabled = true;
submitAnswer(qId, answer);
}
/* ── submitFreeText ──
Called when the attendee submits a free-text question answer.
Validates that the field is not empty before submitting. */
function submitFreeText(qId) {
const input = document.getElementById('freeTextInput');
const btn = document.getElementById('submitBtn');
const answer = input ? input.value.trim() : '';
if (!answer) {
if (input) input.style.borderColor = '#FC8181';
return;
}
if (input) input.disabled = true;
if (btn) btn.disabled = true;
submitAnswer(qId, answer);
}
/* ── submitAnswer ──
Sends the answer to the server, then moves to the next question after a short delay. */
function submitAnswer(qId, answer) {
fetch('/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question_id: qId, answer: answer })
})
.then(r => r.json())
.then(() => {
setTimeout(() => {
currentIndex++;
showQuestion(currentIndex);
}, 420); /* short pause before showing the next question */
})
.catch(err => console.error(err));
}
/* ── showCommentStep ──
Shown after all questions are answered. Optional 150-char comment box.
Attendee can submit a comment or skip straight to the thank-you screen. */
function showCommentStep() {
const progress = (QUESTIONS.length / QUESTIONS.length) * 100;
document.getElementById('progressFill').style.width = progress + '%';
const card = document.getElementById('questionCard');
card.classList.remove('hidden');
card.innerHTML = `
<div class="question-number">Almost done</div>
<div class="question-text">Any final thoughts?</div>
<div class="free-text-container">
<textarea id="commentInput" class="free-text-input"
placeholder="Optional — max 150 characters..."
maxlength="150" rows="3"
style="resize:none;font-size:16px;"
oninput="updateCharCount(this)"></textarea>
<div id="charCount" style="font-size:11px;color:#8a9baf;text-align:right;margin-top:4px">0 / 150</div>
</div>
<div class="spacer"></div>
<button class="submit-btn" onclick="submitComment()">Submit comment &rarr;</button>
<button onclick="skipComment()" style="background:none;border:none;color:#8a9baf;font-size:14px;margin-top:12px;cursor:pointer;font-family:inherit;text-decoration:underline;">Skip</button>
`;
card.style.animation = 'none';
card.offsetHeight;
card.style.animation = 'fadeIn 0.3s ease';
}
function updateCharCount(el) {
const count = el.value.length;
const counter = document.getElementById('charCount');
if (counter) {
counter.textContent = count + ' / 150';
counter.style.color = count >= 140 ? '#FC8181' : '#8a9baf';
}
}
function submitComment() {
const input = document.getElementById('commentInput');
const text = input ? input.value.trim() : '';
if (text) {
fetch('/submit_comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ comment: text })
}).catch(err => console.error(err));
}
currentIndex++;
showQuestion(currentIndex);
}
function skipComment() {
currentIndex++;
showQuestion(currentIndex);
}
/* ── START: load the first question when the page opens ── */
showQuestion(0);
</script>
</body>
</html>
+490
View File
@@ -0,0 +1,490 @@
<!DOCTYPE html>
<html lang="en">
<!--
question_view.html — Single Question Bubble Chart
Shows the bubble chart for one specific question.
Accessed by clicking any coloured tab in the bottom strip on the display page.
URL format: /q/1, /q/2, /q/3, etc.
The leading answer bubble is highlighted in solid colour.
All other bubbles are outlined only.
The bottom strip lets users navigate between questions.
-->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Page title uses the first 40 characters of the question text -->
<title>{{ question.text[:40] }}… – HiActs</title>
<!-- D3.js library used to draw and animate the bubble chart -->
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
/* ── RESET ── */
* { box-sizing: border-box; margin: 0; padding: 0; }
/* ── BODY: full height, side-by-side layout ── */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, sans-serif;
background: #f0f4f8; color: #2d3748;
height: 100vh; display: flex; flex-direction: row; overflow: hidden;
}
/* ── SIDEBAR: event branding and navigation ── */
.sidebar {
width: 300px; flex-shrink: 0;
background: white; border-right: 1px solid #dde5ee;
display: flex; flex-direction: column; height: 100vh;
}
.event-header {
flex-shrink: 0;
background: linear-gradient(160deg, #0f2744 0%, #1a4a8a 60%, #1d6fa8 100%);
padding: 28px 22px 24px; color: white; text-align: center;
}
.event-badge { font-size: 10px; font-weight: 700; letter-spacing: 1.4px; text-transform: uppercase; opacity: 0.6; margin-bottom: 14px; }
.event-title { font-size: 28px; font-weight: 900; line-height: 1.12; margin-bottom: 6px; letter-spacing: -0.3px; }
.event-title span { color: #5db8f5; }
.event-subtitle { font-size: 11.5px; font-weight: 500; opacity: 0.7; margin-bottom: 16px; line-height: 1.4; }
.event-meta { display: flex; align-items: center; justify-content: center; gap: 6px; font-size: 10.5px; opacity: 0.55; letter-spacing: 0.3px; }
.event-meta-dot { width: 3px; height: 3px; background: white; border-radius: 50%; opacity: 0.5; }
.message-area { flex-shrink: 0; padding: 20px 22px 0; }
.message-narrative { font-size: 13px; color: #334155; line-height: 1.7; }
.message-narrative p { margin-bottom: 12px; }
.message-narrative p:last-child { margin-bottom: 0; }
.message-narrative strong { color: #1a365d; font-weight: 600; }
.sidebar-mid { flex: 1; display: flex; align-items: center; padding: 0 22px 20px; }
.sidebar-nav { width: 100%; display: flex; flex-direction: column; gap: 6px; }
.snav-btn {
display: block; text-decoration: none; font-size: 12px; font-weight: 600;
color: #4a5568; background: #f7fafc; border: 1px solid #dde5ee;
border-radius: 8px; padding: 7px 12px; text-align: center;
transition: background 0.12s, color 0.12s;
}
.snav-btn:hover { background: #eff6ff; color: #2563eb; border-color: #bfdbfe; }
.snav-btn.active { background: #eff6ff; color: #2563eb; border-color: #2563eb; font-weight: 700; }
/* ── MOBILE ── */
@media (max-width: 768px) {
#rankedAnswers { display: none !important; }
.sidebar { display: none !important; }
body { flex-direction: column; height: auto; overflow: auto; }
.right-col { height: auto !important; overflow: visible !important; }
#chart-container { height: 72vw !important; min-height: 260px; }
#bottomStrip { display: none !important; }
.mobile-nav { display: flex !important; }
/* Simplify top bar — hide label text, shrink everything */
.top-bar { padding: 8px 12px; gap: 8px; flex-wrap: nowrap; }
.back-btn { font-size: 11px; padding: 5px 8px; }
.q-label-text { display: none; }
.live-badge { padding: 3px 8px; }
.live-text { display: none; }
.counter-number { font-size: 20px !important; }
.counter-label { font-size: 8px; }
.q-text-band { padding: 12px 16px; }
.q-text-band h2 { font-size: 17px; }
}
/* Mobile prev/next navigation bar — hidden on desktop */
.mobile-nav {
display: none;
align-items: center; justify-content: space-between;
padding: 12px 16px; background: white; border-top: 1px solid #dde5ee;
gap: 12px;
}
.nav-btn {
display: inline-flex; align-items: center; gap: 6px;
background: #f0f4f8; border: 1.5px solid #dde5ee; border-radius: 10px;
padding: 10px 18px; font-size: 14px; font-weight: 700; color: #1a365d;
text-decoration: none;
}
.nav-btn.disabled { opacity: 0.3; pointer-events: none; }
.nav-q-indicator { font-size: 13px; font-weight: 700; color: #8a9baf; }
/* ── RIGHT COLUMN: chart and question content ── */
.right-col { flex: 1; display: flex; flex-direction: column; overflow: hidden; height: 100vh; }
/* Top bar: back button, question label, live badge, response counter */
.top-bar {
flex-shrink: 0; display: flex; align-items: center; justify-content: space-between;
padding: 10px 24px; background: white; border-bottom: 1px solid #dde5ee; gap: 16px;
}
/* Back button returns to the main display page */
.back-btn {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; font-weight: 700; color: #4a5568;
background: #f7fafc; border: 1px solid #dde5ee; border-radius: 8px;
padding: 6px 12px; text-decoration: none; white-space: nowrap;
transition: background 0.12s, color 0.12s;
}
.back-btn:hover { background: #eff6ff; color: #2563eb; border-color: #bfdbfe; }
/* Coloured badge showing which question this is (e.g. "Q3") */
.q-label-badge { display: inline-flex; align-items: center; gap: 8px; }
.q-num { width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 800; color: white; flex-shrink: 0; }
.q-label-text { font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.8px; }
.top-bar-right { display: flex; align-items: center; gap: 14px; }
/* Green pulsing live badge */
.live-badge { display: flex; align-items: center; gap: 6px; background: #f0fdf4; border: 1px solid #86efac; border-radius: 20px; padding: 4px 12px; }
.live-dot { width: 7px; height: 7px; background: #16a34a; border-radius: 50%; animation: blink 1.4s infinite; }
.live-text { font-size: 11px; font-weight: 700; color: #16a34a; letter-spacing: 0.8px; text-transform: uppercase; }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.3} }
/* Response counter for this specific question */
.counter-block { text-align: right; }
.counter-number { font-size: 28px; font-weight: 900; line-height: 1; background: linear-gradient(135deg, #2563eb, #059669); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.counter-label { font-size: 10px; color: #8a9baf; text-transform: uppercase; letter-spacing: 1px; }
/* Band showing the full question text below the top bar */
.q-text-band { flex-shrink: 0; padding: 18px 28px; background: white; border-bottom: 1px solid #e8eef4; }
.q-text-band h2 { font-size: 22px; font-weight: 800; color: #1a365d; line-height: 1.3; }
/* ── BUBBLE CHART ── */
#chart-container { flex: 1; position: relative; overflow: hidden; background: #f0f4f8; }
#chart { width: 100%; height: 100%; }
/* Shown when no responses have been received yet for this question */
.empty-state { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); text-align: center; pointer-events: none; }
.empty-state h2 { font-size: 16px; font-weight: 700; color: #c5d2de; margin-bottom: 6px; }
.empty-state p { font-size: 14px; color: #d6e0ea; }
.bubble-group { cursor: default; }
/* ── RANKED ANSWERS: shown below the bubble chart for this question ── */
#rankedAnswers { padding: 16px 20px 30px; background: #f0f4f8; }
.ranked-header { font-size: 11px; font-weight: 700; color: #8a9baf; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; }
.ranked-card { background: white; border-radius: 12px; border: 1.5px solid #dde5ee; padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; }
.r-row { display: flex; align-items: center; gap: 8px; }
.r-rank { font-size: 11px; font-weight: 800; width: 28px; flex-shrink: 0; text-align: right; }
.r-text { font-size: 13px; color: #1a365d; flex: 1; line-height: 1.3; }
.r-bar-wrap { width: 80px; flex-shrink: 0; height: 7px; background: #e8eef4; border-radius: 4px; overflow: hidden; }
.r-bar { height: 100%; border-radius: 4px; transition: width 0.6s ease; }
.r-val { font-size: 11px; font-weight: 700; color: #4a5568; flex-shrink: 0; width: 24px; text-align: right; }
/* ── BOTTOM STRIP: coloured tabs for navigating between questions ── */
#bottomStrip { flex-shrink: 0; background: #f0f4f8; border-top: 1px solid #dde5ee; padding: 8px 16px; display: flex; gap: 8px; align-items: stretch; }
.bs-item { display: flex; align-items: center; gap: 8px; border-radius: 10px; padding: 8px 12px; flex: 1; min-width: 0; text-decoration: none; cursor: pointer; transition: filter 0.15s, transform 0.1s; }
.bs-item:hover { filter: brightness(1.12); transform: translateY(-1px); }
.bs-item.current { outline: 3px solid white; } /* highlights the currently active question */
.bs-q { width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 9px; font-weight: 800; color: white; flex-shrink: 0; background: rgba(255,255,255,0.25); }
.bs-body { display: flex; flex-direction: column; min-width: 0; }
.bs-label { font-size: 9px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.7px; margin-bottom: 2px; color: rgba(255,255,255,0.7); }
.bs-text { font-size: 11.5px; font-weight: 700; color: white; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
</style>
</head>
<body>
<!-- ── SIDEBAR ── -->
<div class="sidebar">
<div class="event-header">
<div class="event-badge">Hi-Acts · 3rd Network Meeting</div>
<div class="event-title">Innovation<br><span>Challenge</span><br>Arena</div>
<div class="event-subtitle">Competing Ideas. Shared Solutions.</div>
<div class="event-meta">DESY Hamburg <span class="event-meta-dot"></span> 10 June 2026</div>
</div>
<div class="message-area">
<div class="message-narrative">
<p>This room brings together industry partners, researchers and innovation experts united by one goal. Turning a shared challenge into a shared solution.</p>
<p>The challenge is how to standardise <strong>software and data for high-throughput beamline experiments</strong>, enabling smooth exchange between facilities, reproducible science, and FAIR data by default.</p>
</div>
</div>
<div class="sidebar-mid">
<div class="sidebar-nav">
<a href="/display" class="snav-btn">Display / Demo</a>
<a href="/" class="snav-btn">Attendee Poll</a>
<a href="/display" class="snav-btn active">Live Results</a>
<a href="/admin" class="snav-btn">Admin</a>
</div>
</div>
</div>
<!-- ── RIGHT COLUMN ── -->
<div class="right-col">
<!-- Top bar: navigation + question identifier + response count -->
<div class="top-bar">
<a href="/display" class="back-btn">&#8592; All Results</a>
<div class="q-label-badge">
<!-- Coloured badge set by JavaScript using question ID and colour -->
<div class="q-num" id="qNumBadge"></div>
<span class="q-label-text" id="qLabelText"></span>
</div>
<div class="top-bar-right">
<div class="live-badge">
<span class="live-dot"></span>
<span class="live-text">Live</span>
</div>
<div class="counter-block">
<!-- Shows the number of responses for this specific question only -->
<div class="counter-number" id="qCount">0</div>
<div class="counter-label">responses</div>
</div>
</div>
</div>
<!-- Full question text band below the top bar -->
<div class="q-text-band">
<h2 id="qText"></h2>
</div>
<!-- Bubble chart — fills all remaining vertical space -->
<div id="chart-container">
<svg id="chart"></svg>
<div class="empty-state" id="emptyState">
<h2>Waiting for responses&hellip;</h2>
<p>No votes yet for this question</p>
</div>
</div>
<!-- Bottom strip — desktop navigation between questions -->
<div id="bottomStrip"></div>
<!-- Ranked answers for this question — shown below the chart on all devices -->
<div id="rankedAnswers"></div>
<!-- Mobile prev/next navigation — hidden on desktop, built by JavaScript -->
<div class="mobile-nav" id="mobileNav"></div>
</div>
<script>
/* ── QUESTION DATA ──
Q = this specific question (passed from app.py via Jinja2)
ALL_QS = all questions (used to build the bottom strip) */
const Q = {{ question | tojson }};
const QI = {{ question.id }} - 1; /* zero-based index for this question */
/* Colour assigned to this question — cycles through 10 colours */
const COLORS = ['#2563eb','#059669','#dc2626','#d97706','#7c3aed','#ea580c','#0891b2','#be185d','#65a30d','#7c3aed'];
const COLOR = COLORS[QI % COLORS.length];
/* Short labels shown in the bottom strip for each question */
const LABELS = ['Your Role','Logbook Word','Biggest Obstacle','On Standardisation','Documentation Level','Shared Data Vision','Data Turnaround','Data Ownership','Repeat Experiments','Data Readiness'];
const ALL_QS = {{ questions | tojson }};
/* Set the question badge, label, and full text from the loaded data */
document.getElementById('qNumBadge').textContent = 'Q' + Q.id;
document.getElementById('qNumBadge').style.background = COLOR;
document.getElementById('qLabelText').textContent = LABELS[QI] || ('Q' + Q.id);
document.getElementById('qLabelText').style.color = COLOR;
document.getElementById('qText').textContent = Q.text;
/* ── BUBBLE CHART ── */
const svg = d3.select('#chart');
let width = 0, height = 0, simulation = null, currentNodes = [];
let leadKey = null, prevLeadKey = null, displayedLeadKey = null;
/* Recalculates chart dimensions on window resize */
function resize() {
const el = document.getElementById('chart-container');
width = el.clientWidth;
height = el.clientHeight;
svg.attr('width', width).attr('height', height);
if (simulation) simulation.force('center', d3.forceCenter(width/2, height/2)).alpha(0.3).restart();
}
window.addEventListener('resize', resize);
/* Calculates bubble radius proportional to response count */
function rScale(v, max) {
return 28 + Math.sqrt(v / max) * (Math.min(width, height) * 0.18 - 28);
}
/* Wraps long answer text inside a bubble over multiple lines */
function wrapBubbleText(textEl, d) {
const r = d.r;
const fontSize = Math.max(9, Math.min(r * 0.30, 15));
const maxWidth = r * 1.65;
const charW = fontSize * 0.54;
const maxChars = Math.max(5, Math.floor(maxWidth / charW));
const words = d.text.split(/\s+/);
const lines = [];
let line = '';
for (const word of words) {
const test = line ? line + ' ' + word : word;
if (test.length > maxChars && line) { lines.push(line); line = word; }
else line = test;
}
if (line) lines.push(line);
const hasCount = r > 34 && d.value > 1;
const lineH = fontSize * 1.22;
const countSize = Math.max(8, Math.min(r * 0.20, 11));
const totalH = (lines.length - 1) * lineH + (hasCount ? lineH * 1.05 : 0);
const startY = -(totalH / 2) + fontSize * 0.35;
const isLeader = (d.text + '_' + d.q_id) === displayedLeadKey;
textEl.selectAll('*').remove();
/* Leader bubble text is white; all others use the question colour */
textEl.attr('text-anchor','middle').attr('font-size',fontSize).attr('font-weight','700').attr('fill', isLeader ? 'white' : d.color);
lines.forEach((ln, i) => {
textEl.append('tspan').attr('x',0).attr('y', startY + i * lineH).text(ln);
});
if (hasCount) {
textEl.append('tspan')
.attr('x',0).attr('y', startY + lines.length * lineH)
.attr('fill-opacity',0.55).attr('font-weight','400').attr('font-size',countSize)
.text(d.value === 1 ? '1 response' : `${d.value} responses`);
}
}
/* Updates the chart when new data arrives */
function updateChart(bubbles) {
const qBubbles = bubbles.filter(b => b.q_id === Q.id);
document.getElementById('emptyState').style.display = qBubbles.length ? 'none' : 'block';
if (!qBubbles.length) return;
const maxVal = d3.max(qBubbles, d => d.value) || 1;
const oldMap = new Map(currentNodes.map(d => [d.text + '_' + d.q_id, d]));
const isFirstRender = currentNodes.length === 0;
prevLeadKey = leadKey;
leadKey = qBubbles.reduce((a, b) => b.value > a.value ? b : a).text + '_' + Q.id;
const leaderChanged = leadKey !== prevLeadKey;
currentNodes = qBubbles.map(d => {
const key = d.text + '_' + d.q_id;
const exist = oldMap.get(key);
const r = rScale(d.value, maxVal);
if (exist) { exist.value = d.value; exist.r = r; exist.color = COLOR; return exist; }
return { ...d, r, color: COLOR,
x: width/2 + (Math.random()-.5)*80,
y: height/2 + (Math.random()-.5)*80 };
});
if (simulation) simulation.stop();
simulation = d3.forceSimulation(currentNodes)
.force('center', d3.forceCenter(width/2, height/2).strength(0.04))
.force('charge', d3.forceManyBody().strength(6))
.force('collision', d3.forceCollide().radius(d => d.r + 4).strength(0.85))
.alphaDecay(0.008)
.on('tick', ticked);
render(isFirstRender, leaderChanged);
}
/* Applies solid fill to the leading bubble and outlined style to all others */
function applyLeaderStyle() {
displayedLeadKey = leadKey;
svg.selectAll('.bubble-group').each(function(d) {
const isLeader = (d.text + '_' + d.q_id) === leadKey;
d3.select(this).select('.bubble-circle')
.transition().duration(1400).ease(d3.easeSinInOut)
.attr('fill-opacity', isLeader ? 0.85 : 0.10);
d3.select(this).select('.bubble-text')
.transition().duration(1400).ease(d3.easeSinInOut)
.attr('fill', isLeader ? 'white' : d.color);
});
}
/* Draws or updates the SVG bubble elements */
function render(isFirst, leaderChanged) {
const groups = svg.selectAll('.bubble-group').data(currentNodes, d => d.text + '_' + d.q_id);
groups.exit().transition().duration(300).style('opacity', 0).remove();
const enter = groups.enter().append('g').attr('class','bubble-group').style('opacity', 0);
enter.append('circle').attr('class','bubble-circle').attr('fill-opacity', 0.10);
enter.append('text').attr('class','bubble-text');
const staggerEnd = currentNodes.length * 130 + 900;
enter.transition()
.delay((d, i) => i * 130)
.duration(900)
.ease(d3.easeCubicOut)
.style('opacity', 1);
const all = enter.merge(groups);
all.select('.bubble-circle').attr('r', d => d.r).attr('fill', d => d.color).attr('stroke', d => d.color).attr('stroke-width', 2.5);
enter.select('.bubble-circle').attr('fill-opacity', 0.10);
all.select('.bubble-text').each(function(d) { wrapBubbleText(d3.select(this), d); });
/* Apply leader highlight after bubbles have faded in on first load */
if (isFirst) {
setTimeout(applyLeaderStyle, staggerEnd + 300);
} else if (leaderChanged) {
applyLeaderStyle();
}
}
/* Keeps bubbles within the chart boundary */
function ticked() {
currentNodes.forEach(d => {
d.x = Math.max(d.r+4, Math.min(width - d.r-4, d.x));
d.y = Math.max(d.r+4, Math.min(height - d.r-4, d.y));
});
svg.selectAll('.bubble-group').attr('transform', d => `translate(${d.x},${d.y})`);
}
/* Builds the bottom strip of coloured question navigation tabs */
function buildBottomStrip(bubbles) {
const strip = document.getElementById('bottomStrip');
strip.innerHTML = '';
ALL_QS.forEach((q, qi) => {
const color = COLORS[qi % COLORS.length];
const best = bubbles.filter(b => b.q_id === q.id).sort((a, b) => b.value - a.value)[0];
const text = best ? best.text : '—';
const isCurrent = q.id === Q.id;
strip.innerHTML += `
<a href="/q/${q.id}" class="bs-item${isCurrent ? ' current' : ''}" style="background:${color}${isCurrent ? ';outline:3px solid white' : ''}">
<div class="bs-q">Q${q.id}</div>
<div class="bs-body">
<span class="bs-label">${LABELS[qi] || 'Q'+q.id}</span>
<span class="bs-text" title="${text}">${text}</span>
</div>
</a>`;
});
}
/* Fetches the latest results and refreshes the chart, ranked answers, and strip */
function fetchResults() {
fetch('/api/results').then(r => r.json()).then(data => {
const qAnswers = data.bubbles.filter(b => b.q_id === Q.id);
const total = qAnswers.reduce((s, b) => s + b.value, 0);
document.getElementById('qCount').textContent = total;
updateChart(data.bubbles);
buildBottomStrip(data.bubbles);
buildRankedAnswers(data.bubbles);
}).catch(console.error);
}
/* ── RANKED ANSWERS: top 5 results for this question shown below the chart ── */
function buildRankedAnswers(bubbles) {
const container = document.getElementById('rankedAnswers');
if (!container) return;
const qBubs = bubbles.filter(b => b.q_id === Q.id).sort((a, b) => b.value - a.value).slice(0, 5);
if (!qBubs.length) { container.innerHTML = ''; return; }
const maxVal = qBubs[0].value;
const rankLabels = ['1st','2nd','3rd','4th','5th'];
const rankColors = ['#d97706','#64748b','#92400e','#94a3b8','#cbd5e1'];
const rows = qBubs.map((b, i) => `
<div class="r-row">
<span class="r-rank" style="color:${rankColors[i]}">${rankLabels[i]}</span>
<span class="r-text">${b.text}</span>
<div class="r-bar-wrap"><div class="r-bar" style="width:${(b.value/maxVal*100).toFixed(0)}%;background:${COLOR}"></div></div>
<span class="r-val">${b.value}</span>
</div>`).join('');
container.innerHTML = `
<div class="ranked-header">Top ranked answers</div>
<div class="ranked-card">${rows}</div>`;
}
/* ── MOBILE NAV: build prev/next arrows ── */
function buildMobileNav() {
const nav = document.getElementById('mobileNav');
if (!nav) return;
const prevQ = ALL_QS.find(q => q.id === Q.id - 1);
const nextQ = ALL_QS.find(q => q.id === Q.id + 1);
nav.innerHTML = `
<a href="${prevQ ? '/q/'+prevQ.id : '#'}" class="nav-btn${prevQ ? '' : ' disabled'}">&#8592; Q${Q.id - 1 > 0 ? Q.id - 1 : ''}</a>
<a href="/display" class="nav-btn" style="font-size:12px;">Analysis</a>
<span class="nav-q-indicator">Q${Q.id} of ${ALL_QS.length}</span>
<a href="${nextQ ? '/q/'+nextQ.id : '#'}" class="nav-btn${nextQ ? '' : ' disabled'}">Q${Q.id + 1 <= ALL_QS.length ? Q.id + 1 : ''} &#8594;</a>
`;
}
/* ── START ── */
resize();
buildMobileNav();
fetchResults();
/* Fetch quickly on first load then settle to normal interval */
setTimeout(fetchResults, 500);
setInterval(fetchResults, 2500);
</script>
</body>
</html>