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:
commit
27a59671fc
15 files changed
+3967
No files matched your search
+15
@@ -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
|
||||
@@ -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
|
||||
Whitespace-only changes.
@@ -0,0 +1,3 @@
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from app import app as application
|
||||
@@ -0,0 +1,2 @@
|
||||
Flask>=3.0.0
|
||||
fpdf2>=2.7
|
||||
+232
@@ -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
@@ -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 |
@@ -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>
|
||||
@@ -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 & See Results →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── PHASE 2: Live results (always shown to attendees) ── -->
|
||||
<div id="phaseResults">
|
||||
|
||||
<!-- Bubble chart — bubbles represent answer frequencies, coloured by question -->
|
||||
<div id="chart-container">
|
||||
<svg id="chart"></svg>
|
||||
<!-- Shown while waiting for the first responses to come in -->
|
||||
<div class="empty-state" id="emptyState">
|
||||
<h2>Waiting for responses…</h2>
|
||||
<p>Results will appear here as people answer</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom strip — desktop: horizontal coloured tabs. Mobile: hidden, replaced by mobileQuestionList -->
|
||||
<div id="bottomStrip"></div>
|
||||
|
||||
|
||||
<!-- Analysis panel — shows ranked results per question (toggled by the button in the top bar) -->
|
||||
<div id="analysisPanel">
|
||||
<!-- Back to results link — visible on mobile only -->
|
||||
<a href="/q/1" id="backToResults" style="display:none; align-items:center; gap:6px; font-size:13px; font-weight:700; color:#2563eb; text-decoration:none; margin-bottom:4px;">← Back to results</a>
|
||||
<div class="analysis-header">Top 5 ranked results per question</div>
|
||||
<!-- Cards are built dynamically by JavaScript when the panel is opened -->
|
||||
<div class="analysis-grid" id="analysisGrid"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
/* ── QUESTIONS LIST — loaded from the server, never hardcoded ── */
|
||||
const QUESTIONS = {{ questions | tojson }};
|
||||
|
||||
/* ── COLOURS: one colour per question, used in bubbles, badges, and strips ── */
|
||||
const COLORS = ['#2563eb','#059669','#dc2626','#d97706','#7c3aed','#ea580c','#0891b2','#be185d','#65a30d','#0e7490','#b45309'];
|
||||
|
||||
/* ── BOTTOM STRIP: builds the coloured tabs at the bottom showing the leading answer per question ── */
|
||||
function buildBottomStrip(bubbles) {
|
||||
const strip = document.getElementById('bottomStrip');
|
||||
if (!strip) return;
|
||||
strip.innerHTML = '';
|
||||
QUESTIONS.forEach((q, qi) => {
|
||||
const color = COLORS[qi % COLORS.length];
|
||||
const best = bubbles.filter(b => b.q_id === q.id).sort((a, b) => b.value - a.value)[0];
|
||||
const text = best ? best.text : '—';
|
||||
strip.innerHTML += `
|
||||
<a href="/q/${q.id}" class="bs-item" style="background:${color}">
|
||||
<div class="bs-q">Q${q.id}</div>
|
||||
<div class="bs-body">
|
||||
<span class="bs-label">${q.label}</span>
|
||||
<span class="bs-text" title="${text}">${text}</span>
|
||||
</div>
|
||||
</a>`;
|
||||
});
|
||||
}
|
||||
|
||||
/* ── ANALYSIS TOGGLE ──
|
||||
Switches the view between the bubble chart and the per-question ranked results panel.
|
||||
The button text updates to show what clicking it will do next. ── */
|
||||
let analysisVisible = false;
|
||||
function toggleAnalysis() {
|
||||
const btn = document.getElementById('analysisBtnEl');
|
||||
analysisVisible = !analysisVisible;
|
||||
if (analysisVisible) {
|
||||
fetch('/api/results').then(r => r.json()).then(data => {
|
||||
buildAnalysis(data.bubbles);
|
||||
document.getElementById('chart-container').style.display = 'none';
|
||||
document.getElementById('bottomStrip').style.display = 'none';
|
||||
document.getElementById('analysisPanel').classList.add('active');
|
||||
btn.textContent = 'Back to Chart';
|
||||
showEmailModal();
|
||||
});
|
||||
} else {
|
||||
document.getElementById('analysisPanel').classList.remove('active');
|
||||
document.getElementById('chart-container').style.display = '';
|
||||
document.getElementById('bottomStrip').style.display = '';
|
||||
btn.textContent = 'Show Analysis';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── BUILD ANALYSIS CARDS ──
|
||||
Creates one card per question showing top 4 answers ranked by number of responses,
|
||||
with a proportional bar and response count. ── */
|
||||
function buildAnalysis(bubbles) {
|
||||
const grid = document.getElementById('analysisGrid');
|
||||
grid.innerHTML = '';
|
||||
const rankLabels = ['1st', '2nd', '3rd', '4th', '5th'];
|
||||
const rankColors = ['#d97706', '#64748b', '#92400e', '#94a3b8', '#cbd5e1'];
|
||||
|
||||
QUESTIONS.forEach((q, qi) => {
|
||||
const color = COLORS[qi % COLORS.length];
|
||||
const qBubs = bubbles.filter(b => b.q_id === q.id).sort((a, b) => b.value - a.value);
|
||||
const top = qBubs.slice(0, 5);
|
||||
const maxVal = top[0]?.value || 1;
|
||||
|
||||
const answersHTML = top.length ? top.map((b, i) => `
|
||||
<div class="a-row">
|
||||
<span class="a-rank" style="color:${rankColors[i]}">${rankLabels[i]}</span>
|
||||
<span class="a-text">${b.text}</span>
|
||||
<div class="a-bar-wrap">
|
||||
<div class="a-bar" style="width:${(b.value / maxVal * 100).toFixed(0)}%;background:${color}"></div>
|
||||
</div>
|
||||
<span class="a-val">${b.value}</span>
|
||||
</div>`).join('')
|
||||
: '<div style="font-size:12px;color:#8a9baf;padding:4px 0">No responses yet</div>';
|
||||
|
||||
grid.innerHTML += `
|
||||
<div class="a-card">
|
||||
<div class="a-card-header">
|
||||
<div class="q-badge" style="background:${color}">Q${q.id}</div>
|
||||
<div class="a-question">${q.text}</div>
|
||||
</div>
|
||||
<div class="a-divider"></div>
|
||||
<div class="a-answers">${answersHTML}</div>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
/* ── BUBBLE CHART ──
|
||||
Built using D3.js. Bubbles are sized by response count and coloured by question.
|
||||
The simulation keeps bubbles separated and centred on screen. ── */
|
||||
const svg = d3.select('#chart');
|
||||
let width = 0, height = 0, simulation = null, currentNodes = [];
|
||||
|
||||
/* Recalculates chart dimensions when the window is resized */
|
||||
function resize() {
|
||||
const el = document.getElementById('chart-container');
|
||||
width = el.clientWidth;
|
||||
height = el.clientHeight;
|
||||
svg.attr('width', width).attr('height', height);
|
||||
if (simulation) simulation.force('center', d3.forceCenter(width/2, height/2)).alpha(0.3).restart();
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
/* Calculates bubble radius based on response count relative to the maximum */
|
||||
function rScale(v, max) {
|
||||
return 24 + Math.sqrt(v / max) * (Math.min(width, height) * 0.145 - 24);
|
||||
}
|
||||
|
||||
/* Wraps long answer text inside a bubble across multiple lines */
|
||||
function wrapBubbleText(textEl, d) {
|
||||
const r = d.r;
|
||||
const fontSize = Math.max(9, Math.min(r * 0.30, 14));
|
||||
const maxWidth = r * 1.65;
|
||||
const charW = fontSize * 0.54;
|
||||
const maxChars = Math.max(5, Math.floor(maxWidth / charW));
|
||||
|
||||
const words = d.text.split(/\s+/);
|
||||
const lines = [];
|
||||
let line = '';
|
||||
for (const word of words) {
|
||||
const test = line ? line + ' ' + word : word;
|
||||
if (test.length > maxChars && line) { lines.push(line); line = word; }
|
||||
else { line = test; }
|
||||
}
|
||||
if (line) lines.push(line);
|
||||
|
||||
const hasCount = r > 34 && d.value > 1;
|
||||
const lineH = fontSize * 1.22;
|
||||
const countSize = Math.max(8, Math.min(r * 0.20, 11));
|
||||
const totalH = (lines.length - 1) * lineH + (hasCount ? lineH * 1.05 : 0);
|
||||
const startY = -(totalH / 2) + fontSize * 0.35;
|
||||
|
||||
textEl.selectAll('*').remove();
|
||||
textEl.attr('text-anchor','middle').attr('font-size',fontSize).attr('font-weight','700').attr('fill',d.color);
|
||||
|
||||
lines.forEach((ln, i) => {
|
||||
textEl.append('tspan').attr('x',0).attr('y', startY + i * lineH).text(ln);
|
||||
});
|
||||
|
||||
if (hasCount) {
|
||||
textEl.append('tspan')
|
||||
.attr('x',0).attr('y', startY + lines.length * lineH)
|
||||
.attr('fill-opacity',0.55).attr('font-weight','400').attr('font-size',countSize)
|
||||
.text(d.value === 1 ? '1 response' : `${d.value} responses`);
|
||||
}
|
||||
}
|
||||
|
||||
/* Updates the bubble chart when new data arrives from the server */
|
||||
function updateChart(bubbles) {
|
||||
document.getElementById('emptyState').style.display = bubbles.length ? 'none' : 'block';
|
||||
if (!bubbles.length) return;
|
||||
|
||||
const maxVal = d3.max(bubbles, d => d.value) || 1;
|
||||
const oldMap = new Map(currentNodes.map(d => [d.text+'_'+d.q_id, d]));
|
||||
|
||||
currentNodes = bubbles.map(d => {
|
||||
const key = d.text+'_'+d.q_id;
|
||||
const exist = oldMap.get(key);
|
||||
const r = rScale(d.value, maxVal);
|
||||
const color = COLORS[(d.q_id-1) % COLORS.length];
|
||||
if (exist) { exist.value=d.value; exist.r=r; exist.color=color; return exist; }
|
||||
return { ...d, r, color,
|
||||
x: width/2 + (Math.random()-.5)*100,
|
||||
y: height/2 + (Math.random()-.5)*100 };
|
||||
});
|
||||
|
||||
if (simulation) simulation.stop();
|
||||
simulation = d3.forceSimulation(currentNodes)
|
||||
.force('center', d3.forceCenter(width/2, height/2).strength(0.04))
|
||||
.force('charge', d3.forceManyBody().strength(6))
|
||||
.force('collision', d3.forceCollide().radius(d => d.r + 4).strength(0.85))
|
||||
.alphaDecay(0.008)
|
||||
.on('tick', ticked);
|
||||
|
||||
render();
|
||||
}
|
||||
|
||||
/* Draws or updates the SVG bubble elements */
|
||||
function render() {
|
||||
const groups = svg.selectAll('.bubble-group').data(currentNodes, d => d.text+'_'+d.q_id);
|
||||
groups.exit().transition().duration(300).style('opacity', 0).remove();
|
||||
const enter = groups.enter().append('g').attr('class','bubble-group').style('opacity', 0);
|
||||
enter.append('circle').attr('class','bubble-circle');
|
||||
enter.append('text').attr('class','bubble-text');
|
||||
|
||||
enter.transition()
|
||||
.delay((d, i) => i * 110)
|
||||
.duration(800)
|
||||
.ease(d3.easeCubicOut)
|
||||
.style('opacity', 1);
|
||||
|
||||
const all = enter.merge(groups);
|
||||
all.select('.bubble-circle')
|
||||
.attr('r', d => d.r).attr('fill', d => d.color)
|
||||
.attr('fill-opacity', 0.10).attr('stroke', d => d.color).attr('stroke-width', 2.5);
|
||||
all.select('.bubble-text').each(function(d) { wrapBubbleText(d3.select(this), d); });
|
||||
}
|
||||
|
||||
/* Keeps bubbles within the chart boundary on each simulation tick */
|
||||
function ticked() {
|
||||
currentNodes.forEach(d => {
|
||||
d.x = Math.max(d.r+4, Math.min(width - d.r-4, d.x));
|
||||
d.y = Math.max(d.r+4, Math.min(height - d.r-4, d.y));
|
||||
});
|
||||
svg.selectAll('.bubble-group').attr('transform', d => `translate(${d.x},${d.y})`);
|
||||
}
|
||||
|
||||
/* ── FETCH RESULTS ──
|
||||
Calls the server every 2.5 seconds to get the latest responses.
|
||||
Updates the counter, bubble chart, bottom strip, and mobile list automatically. ── */
|
||||
function fetchResults() {
|
||||
fetch('/api/results').then(r=>r.json()).then(data => {
|
||||
document.getElementById('totalCount').textContent = data.total_responses;
|
||||
updateChart(data.bubbles);
|
||||
buildBottomStrip(data.bubbles);
|
||||
if (analysisVisible) buildAnalysis(data.bubbles);
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
/* ── PAGE STARTUP ──
|
||||
Hides the demo phase, shows the live results, and starts polling the server. ── */
|
||||
document.getElementById('phaseQuestions').style.display = 'none';
|
||||
document.getElementById('phaseResults').classList.add('active');
|
||||
document.getElementById('analysisBtnEl').classList.add('visible');
|
||||
|
||||
/* On mobile, show analysis immediately and show the back button */
|
||||
if (window.innerWidth <= 768) {
|
||||
analysisVisible = true;
|
||||
document.getElementById('analysisPanel').classList.add('active');
|
||||
document.getElementById('backToResults').style.display = 'flex';
|
||||
}
|
||||
|
||||
resize();
|
||||
fetchResults();
|
||||
setInterval(fetchResults, 2500); /* refresh every 2.5 seconds */
|
||||
|
||||
/* showEmailModal is defined in the script block below, after the modal HTML */
|
||||
|
||||
/* On mobile the analysis panel is auto-shown — show modal after a short delay */
|
||||
if (window.innerWidth <= 768) {
|
||||
setTimeout(() => { if (typeof showEmailModal === 'function') showEmailModal(); }, 800);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<!-- ── EMAIL CAPTURE MODAL ──
|
||||
Must be in the HTML before the script that references its elements. -->
|
||||
<div id="emailModal">
|
||||
<div class="email-modal-box">
|
||||
<div class="em-eyebrow">Innovation Challenge Arena</div>
|
||||
<h2 class="em-title">You're viewing the top 5 ranked responses.</h2>
|
||||
<p class="em-body">
|
||||
For a full in-depth analysis of all results, including how responses differ across roles and facilities, leave your email below. We will send the complete report to you after the event.
|
||||
</p>
|
||||
<input class="em-input" id="emailInput" type="email" placeholder="your@email.com" autocomplete="email">
|
||||
<div class="em-confirm" id="emailConfirm">Thank you. We will be in touch after the event.</div>
|
||||
<button class="em-submit" id="emailSubmitBtn" onclick="submitEmail()">Send me the full report</button>
|
||||
<a href="#" class="em-skip" onclick="closeEmailModal(); return false;">No thanks, just show the results</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ── EMAIL MODAL FUNCTIONS ──
|
||||
Placed after the modal HTML so getElementById calls find the elements. */
|
||||
|
||||
function showEmailModal() {
|
||||
if (sessionStorage.getItem('emailModalShown')) return;
|
||||
setTimeout(() => {
|
||||
const modal = document.getElementById('emailModal');
|
||||
if (modal) modal.classList.add('open');
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function closeEmailModal() {
|
||||
sessionStorage.setItem('emailModalShown', '1');
|
||||
const modal = document.getElementById('emailModal');
|
||||
if (modal) modal.classList.remove('open');
|
||||
}
|
||||
|
||||
function submitEmail() {
|
||||
const input = document.getElementById('emailInput');
|
||||
const confirm = document.getElementById('emailConfirm');
|
||||
const btn = document.getElementById('emailSubmitBtn');
|
||||
const email = input.value.trim();
|
||||
|
||||
if (!email || !email.includes('@') || !email.includes('.')) {
|
||||
input.classList.add('error');
|
||||
return;
|
||||
}
|
||||
input.classList.remove('error');
|
||||
btn.disabled = true;
|
||||
|
||||
fetch('/api/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
}).then(() => {
|
||||
input.style.display = 'none';
|
||||
btn.style.display = 'none';
|
||||
confirm.style.display = 'block';
|
||||
setTimeout(closeEmailModal, 2500);
|
||||
}).catch(() => { btn.disabled = false; });
|
||||
}
|
||||
|
||||
document.getElementById('emailInput').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') submitEmail();
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 · 3rd Network Meeting</span>
|
||||
<span class="tb-tagline-gold">Competing Ideas. Shared Solutions.</span>
|
||||
<span class="tb-event">DESY Hamburg · 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>
|
||||
@@ -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 →</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, '"');
|
||||
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 →</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 →</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 →</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>
|
||||
@@ -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">← 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…</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'}">← 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 : ''} →</a>
|
||||
`;
|
||||
}
|
||||
|
||||
/* ── START ── */
|
||||
resize();
|
||||
buildMobileNav();
|
||||
fetchResults();
|
||||
/* Fetch quickly on first load then settle to normal interval */
|
||||
setTimeout(fetchResults, 500);
|
||||
setInterval(fetchResults, 2500);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user