Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08f42a5f48 | ||
|
|
d5310d5a3f | ||
|
|
5217c574e9 | ||
|
|
348f4a9738 | ||
|
|
d54f1ca8b5 | ||
|
|
32ce52706f | ||
|
|
a1b75c0946 | ||
|
|
8f59da412c | ||
|
|
32ed2d1c14 | ||
|
|
6f58be6cbd | ||
|
|
4127c1e590 | ||
|
|
bdfdfca2e5 | ||
|
|
446c39b372 | ||
|
|
8df9535afa | ||
|
|
67d96a44de | ||
|
|
1c22e4e60b | ||
|
|
a5bedfbae4 | ||
|
|
b5c35eda24 | ||
|
|
164df8986f | ||
|
|
5a18c546ce | ||
|
|
7177c346ab | ||
|
|
c28e2e2eba | ||
|
|
e60f60261b | ||
|
|
744af1d8c7 | ||
|
|
54da406971 | ||
|
|
0635e99b5d | ||
|
|
da60b6af36 | ||
|
|
197c165a0a | ||
|
|
3327018a87 | ||
|
|
534c424d88 | ||
|
|
64530c20af | ||
|
|
de1524838f | ||
|
|
260bb174a2 | ||
|
|
8743f0776b | ||
|
|
1e139c16ec | ||
|
|
9bef40bcf3 | ||
|
|
8856ec7274 | ||
|
|
91889da042 | ||
|
|
747215042c | ||
|
|
f8496af3c9 | ||
|
|
0ad8577621 |
@@ -1,6 +1,6 @@
|
||||
# ThothII — Project State
|
||||
|
||||
Last updated: 2026-09-06.
|
||||
Last updated: 2026-09-24 (11-slide deck, consolidated project opening and final contacts).
|
||||
|
||||
This file is the short operational snapshot. Stable commands and the architecture mental model
|
||||
live in `AGENTS.md`; current design and runtime contracts live under `docs/architecture/`,
|
||||
@@ -12,6 +12,69 @@ Authentik, internal catalog/embedding services, and the PSD workspace repository
|
||||
`docs/operations/server-upgrade-gitea-workspace-v2.md`. Treat its operator gates and rollback
|
||||
requirements as mandatory; do not replace the running server stack in place.
|
||||
|
||||
## Presentation publishing
|
||||
|
||||
The deck now has 11 slides. The cover remains; “Where we started” is folded into
|
||||
“What we wanted to build”, now slide 2. Its speaker notes open with the four goals,
|
||||
and its existing popups include the disconnected sources and missing capabilities.
|
||||
Speaker notes now read as continuous prose without titles. On slide 2 and slides
|
||||
7–10, parenthesized cues identify the corresponding popup button at the start of
|
||||
its paragraph or dedicated popup text. The closing slide has no popup cues.
|
||||
The console reading area also omits slide and popup headings; popup controls
|
||||
remain below the preview. Edits preserve the content and keep the spoken text close
|
||||
to its previous length; actual delivery time still depends on rehearsal. The former
|
||||
penultimate “One datamart — many questions answered” slide is removed. The ThothII
|
||||
tour leads directly to “Thank you”, with both contact emails in bold 32px type.
|
||||
|
||||
Slide 8 now has four popups: Home page, Patient data (original PNG 3),
|
||||
Dashboard catalogue (PNG 6), and Brugada dashboard (PNG 7), numbered 1–4.
|
||||
Each has dedicated presenter notes; the opening explains the portal's breadth,
|
||||
the limited time, and Marco and Sara's availability for an in-person or remote
|
||||
follow-up. The overview uses a two-by-two grid; unused PNGs remain on disk.
|
||||
|
||||
Presentation PNG source (user instruction, 2026-09-23): all PNGs to import come from
|
||||
`/Users/mp/Desktop/ScreenshotPresentation/ThothIIScreens/`. This is the verified on-disk
|
||||
path the user refers to as `desktop/screenshot/presentation/thothIIscreens`.
|
||||
Copy supplied files unchanged into `presentation/deck/screenshots/thothii/`, preserving
|
||||
their embedded annotations. The 12 source PNGs retain their `01` through `12` filenames.
|
||||
The shortened slide 10 tour uses PNGs 01, 02, 05, 07, 08 and 09, numbered 1–6
|
||||
in the interface; unused images remain on disk. The 284-word presenter script
|
||||
covers all eight phases, explicitly introduces Human in the Loop, and includes
|
||||
the invitation to a longer presentation at the conference or remotely.
|
||||
The planned duration is 2:50, to be confirmed by rehearsal.
|
||||
Slide 11 prominently displays Marco Pancotti's and Sara Paratico's email addresses.
|
||||
Local preview uses `python3 -m http.server 8000 --bind 127.0.0.1 --directory presentation`.
|
||||
The first popup uses
|
||||
`01-StartingPoint.png`; popup 2 uses `02-Disambiguation01.png`. Refresh the image
|
||||
cache version when replacing a PNG. During the current editing session, refresh both
|
||||
operational browser windows after every modification, as explicitly requested.
|
||||
Popup 3 uses `05-CloseSchemaLinking.png`, popup 4 uses `07-CTE01.png`,
|
||||
popup 5 uses `08-FinalSQL.png`, and popup 6 uses `09-DatamartProduction.png`.
|
||||
The HTML references PNGs by relative URL: they are external static assets, not
|
||||
compiled or embedded. Keep `presentation/deck/screenshots/thothii/` with the deck;
|
||||
the Desktop source folder is not a runtime dependency.
|
||||
On 2026-09-23, the complete source folder was backed up to
|
||||
`/Users/mp/Desktop/ThothIIScreens-backup-20260923-FkCqV3/` and verified by SHA-256.
|
||||
The four unused PNGs (original prefixes 02, 05, 09, and 10) were removed from the
|
||||
source folder and, on 2026-09-24, from the deck after verifying backup equality.
|
||||
The backup retains all 16 original PNGs under their original names. Renumbering
|
||||
preserved each file's bytes; the existing Starting point images in the source and
|
||||
deck differ and were each retained unchanged.
|
||||
On 2026-09-24, annotated PNGs 08–12 were updated directly in the deck and verified
|
||||
visually; these deck copies are newer than the Desktop source copies. Preserve
|
||||
them when importing images. Their image cache version is `v=5`. Presenter notes
|
||||
for popups 09–12 are also updated in `presentation/deck/index.html`. The two Snagit
|
||||
comment boxes in PNG 12 share the same horizontal centre (1676.5 px in the
|
||||
3830 px source image); their text is centred within each box.
|
||||
|
||||
The user confirmed the public AritmoLab presentation and presenter console working on
|
||||
2026-09-20. Updates use the `feat/ai-etl-presentation` branch on Gitea: commit and push
|
||||
locally, then the user runs `git pull --ff-only origin feat/ai-etl-presentation` in
|
||||
`/var/www/aritmolab/presentation` on the remote server. No SSH tunnel is available;
|
||||
the user can operate an authenticated remote terminal in VS Code. Nginx routes are
|
||||
already configured, so ordinary slide updates need no Nginx reload. Exact URLs,
|
||||
paths, checks and cache behavior: [Presentation publishing](docs/operations/presentation-publishing.md).
|
||||
|
||||
## Current product shape
|
||||
|
||||
ThothII is a human-in-the-loop datamart builder with three independently built layers:
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Updating the AritmoLab presentation
|
||||
|
||||
Last confirmed by the user: 2026-09-20, after deploying commit `5217c574`.
|
||||
The user reported both the presentation and presenter console working. This is
|
||||
the static presentation deployment, separate from the ThothII application stack.
|
||||
|
||||
## Locations
|
||||
|
||||
- Local worktree: `/Users/mp/projects/ThothII-presentazione`.
|
||||
- Source: `presentation/deck/` and `presentation/presenter/`.
|
||||
- Git remote: `origin`, `https://git.tylconsulting.it/mptyl/ThothII.git`.
|
||||
- Publication branch: `feat/ai-etl-presentation`.
|
||||
- Remote checkout: `/var/www/aritmolab/presentation`.
|
||||
|
||||
Presentation:
|
||||
|
||||
```text
|
||||
https://aritmolab.policlinicosandonato.it/presentation/
|
||||
```
|
||||
|
||||
Presenter console:
|
||||
|
||||
```text
|
||||
https://aritmolab.policlinicosandonato.it/presentation/presenter/
|
||||
```
|
||||
|
||||
Give these URLs as copyable plain text when providing instructions: the user
|
||||
reported difficulty clicking Markdown links.
|
||||
|
||||
## Routine update
|
||||
|
||||
1. In the local worktree, review and validate the requested presentation changes,
|
||||
then commit and push the intended files to `origin/feat/ai-etl-presentation`.
|
||||
Include new images, styles, scripts and presenter files. A remote pull cannot
|
||||
receive changes that remain uncommitted or unpushed. Leave unrelated changes alone.
|
||||
2. The user connects to the remote server and runs:
|
||||
|
||||
```bash
|
||||
cd /var/www/aritmolab/presentation
|
||||
git branch --show-current
|
||||
git status --short
|
||||
```
|
||||
|
||||
Continue only when the branch is `feat/ai-etl-presentation` and there are no
|
||||
unexpected local changes. If either differs, inspect before updating; do not
|
||||
reset, discard files or merge the presentation branch into another branch.
|
||||
|
||||
```bash
|
||||
git pull --ff-only origin feat/ai-etl-presentation
|
||||
git log -1 --oneline
|
||||
```
|
||||
|
||||
3. Refresh both browser windows. Static responses advertise
|
||||
`Cache-Control: public, max-age=300`, so a browser may reuse assets for five
|
||||
minutes. Use a hard refresh if the old version remains visible; bump asset
|
||||
query versions when changing cached JavaScript or CSS.
|
||||
4. When network access is available, verify the expected version at both URLs,
|
||||
including screenshots and synchronization of presenter and audience popups.
|
||||
Distinguish a successful Git push from a completed remote deployment.
|
||||
|
||||
Changes to slides, notes, images, CSS and JavaScript need no build, application
|
||||
restart or Nginx reload. A reload is necessary only if Nginx configuration changes.
|
||||
|
||||
## Remote access
|
||||
|
||||
The site was reachable from the user's Mac while connected to the VPN.
|
||||
Do not use SSH tunnels for this deployment. A non-interactive direct SSH attempt
|
||||
on 2026-09-20 failed authentication. The user offered an authenticated SSH session
|
||||
inside VS Code, then chose to execute the remote commands personally. The normal
|
||||
workflow is to push locally and provide the remote pull command to the user;
|
||||
do not assume autonomous remote access or ask for passwords in chat.
|
||||
|
||||
## Nginx routing already configured
|
||||
|
||||
The existing `/presentation` route redirects to `/presentation/`.
|
||||
The user supplied the original deck alias and subsequently confirmed successful
|
||||
setup of the two additional routes for the console:
|
||||
|
||||
| URL prefix | Filesystem alias |
|
||||
| --- | --- |
|
||||
| `/presentation/` | `/var/www/aritmolab/presentation/presentation/deck/` |
|
||||
| `/presentation/deck/` | `/var/www/aritmolab/presentation/presentation/deck/` |
|
||||
| `/presentation/presenter/` | `/var/www/aritmolab/presentation/presentation/presenter/` |
|
||||
|
||||
These are sibling `location ^~` blocks within the site's `server` block. The
|
||||
specific `deck/` route supports the console's relative links to fonts and slide
|
||||
iframes. The configuration filename was not recorded.
|
||||
|
||||
The presentation is intentionally public static content, bypassing Django and
|
||||
Authentik. Preserve its existing routing and protections: directory listings off,
|
||||
`index index.html`, GET/HEAD only, five-minute cache, `nosniff`, `SAMEORIGIN`, and
|
||||
`noindex, nofollow, noarchive`. Do not expose the entire repository or change
|
||||
unrelated application routes.
|
||||
|
||||
Only if routing needs diagnosis:
|
||||
|
||||
```bash
|
||||
sudo nginx -T 2>/dev/null | grep -n -A 12 -B 3 '/presentation'
|
||||
```
|
||||
|
||||
Only after an intentional configuration edit:
|
||||
|
||||
```bash
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
@@ -0,0 +1,39 @@
|
||||
/* Artificial-brain controls and clearly separated explanatory callouts. */
|
||||
.arit.flow-slide .fcol.ai-hit { flex-basis: 62px; }
|
||||
.arit.flow-slide .brain-btn {
|
||||
width: 56px; height: 56px; border-radius: 9px;
|
||||
border: 1.5px solid var(--bordeaux); background: var(--surface);
|
||||
box-shadow: 0 3px 8px oklch(26% .02 15 / .1);
|
||||
}
|
||||
.arit.flow-slide .fcol.ai > .brain-btn { top: -54px; right: 6px; }
|
||||
.arit.flow-slide .brain-btn:hover, .arit.flow-slide .brain-btn:focus-visible {
|
||||
transform: none; background: var(--bordeaux-tint);
|
||||
box-shadow: 0 0 0 4px var(--bordeaux-tint);
|
||||
}
|
||||
.arit.flow-slide .brain-btn[aria-pressed="true"] { outline: 3px solid var(--bordeaux); outline-offset: 4px; }
|
||||
.artificial-brain { display: block; width: 46px; height: 46px; }
|
||||
.arit.flow-slide .brain-btn .ai-label {
|
||||
position: absolute; left: 50%; top: -24px; transform: translateX(-50%);
|
||||
display: grid; place-items: center; width: 42px; height: 25px;
|
||||
border: 1.5px solid var(--bordeaux); border-radius: 5px 5px 0 0;
|
||||
background: var(--bordeaux); color: var(--surface);
|
||||
font: 800 18px/1 var(--sans); letter-spacing: .04em;
|
||||
}
|
||||
.arit.flow-slide .brain-btn .ai-number {
|
||||
display: none;
|
||||
}
|
||||
/* The first AI control is in normal flow, but still anchors its AI label. */
|
||||
.arit.flow-slide .fcol.ai-hit .brain-btn { position: relative; top: auto; right: auto; }
|
||||
.ai-pop.is-ai .ai-pop-card {
|
||||
box-sizing: border-box;
|
||||
width: min(540px, calc(100vw - 48px)); max-height: calc(100vh - 48px);
|
||||
overflow-y: auto; border: 2px solid var(--bordeaux); background: var(--surface);
|
||||
transform: none; transition: none;
|
||||
}
|
||||
.ai-pop.is-ai .ai-line line { stroke: var(--bordeaux); stroke-width: 4; }
|
||||
.ai-pop.is-ai .ai-line circle { fill: var(--bordeaux); }
|
||||
.ai-pop-title .artificial-brain { width: 40px; height: 40px; }
|
||||
.ai-pop-title #aiPopBrain { flex: 0 0 44px; width: 44px; height: 44px; border: 0; background: none; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.arit.flow-slide .brain-btn { transition: none; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="#8f1204" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M29 9h-9l-8 8v8l-5 5v12l8 8h6l5 5h6V15zm6 0h9l8 8v8l5 5v12l-8 8h-6l-5 5h-6" fill="#f4dfe1"/>
|
||||
<path d="M20 17v9h12m-19 9h10v10m-7-1h7m21-27v9H32m19 9H41v10m7-1h-7M26 8V4m12 4V4M7 30H3m54 0h4M26 55v5m12-5v5"/>
|
||||
<rect x="25" y="27" width="14" height="14" rx="2" fill="#fdfcfc"/>
|
||||
<path d="M29 23v4m6-4v4m-6 14v4m6-4v4m-14-14h4m-4 6h4m14-6h4m-4 6h4"/>
|
||||
<circle cx="20" cy="17" r="2.5" fill="#fdfcfc"/><circle cx="44" cy="17" r="2.5" fill="#fdfcfc"/>
|
||||
<circle cx="23" cy="46" r="2.5" fill="#fdfcfc"/><circle cx="41" cy="46" r="2.5" fill="#fdfcfc"/>
|
||||
<path d="M30 32h4v4h-4z" fill="#8f1204" stroke="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 782 B |
@@ -44,16 +44,19 @@
|
||||
font: 600 10px/1.4 var(--sans); letter-spacing: .03em; color: var(--ink-muted);
|
||||
}
|
||||
.arit .foot span { white-space: nowrap; flex-shrink: 0; }
|
||||
.presenter-preview .arit .foot { display: none; }
|
||||
.arit .foot span:first-child { overflow: hidden; text-overflow: ellipsis; flex-shrink: 1; min-width: 0; }
|
||||
.arit .foot span:first-child { overflow: hidden; text-overflow: ellipsis; flex-shrink: 1; }
|
||||
.arit .sbody { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 30px 48px 22px; text-align: left; }
|
||||
.arit .title-slide .sbody { justify-content: center; }
|
||||
.arit.title-slide .sbody { justify-content: center; }
|
||||
.arit.center-v .sbody { justify-content: center; }
|
||||
|
||||
/* ---------- typography ---------- */
|
||||
.arit .kicker { font: 700 12px/1 var(--sans); letter-spacing: .16em; text-transform: uppercase; color: var(--bordeaux); margin-bottom: 14px; }
|
||||
.arit h1 { font: 700 46px/1.15 var(--sans); letter-spacing: -.01em; color: var(--ink); margin: 0; }
|
||||
.arit h2 { font: 700 30px/1.15 var(--sans); letter-spacing: -.01em; color: var(--ink); margin: 0 0 8px; }
|
||||
.arit .lead { font: 400 16px/1.5 var(--sans); color: var(--ink-muted); margin: 8px 0 0; }
|
||||
.arit.title-slide .lead { font-size: 19px; text-align: center; }
|
||||
.arit .hgap { height: 16px; flex: 0 0 16px; }
|
||||
|
||||
/* title slide */
|
||||
@@ -71,27 +74,83 @@
|
||||
|
||||
/* quote */
|
||||
.arit .quote {
|
||||
margin: 14px 0 20px; padding: 16px 22px; border-left: 3px solid var(--bordeaux);
|
||||
background: var(--surface-alt); border-radius: 0 8px 8px 0;
|
||||
font: italic 400 16px/1.5 var(--display); color: var(--ink);
|
||||
position: relative; margin: 14px 0 20px; padding: 20px 26px 18px 68px; border: 1px solid var(--line);
|
||||
background: var(--surface-alt); border-radius: 8px;
|
||||
font: italic 400 19px/1.55 var(--display); color: var(--ink);
|
||||
}
|
||||
.arit .quote::before { content: '\201C'; position: absolute; left: 22px; top: 10px;
|
||||
font: 700 52px/1 var(--display); color: var(--bordeaux); }
|
||||
|
||||
/* EN translations alongside Italian clinical text */
|
||||
.arit .tr-grid { display: grid; grid-template-columns: 1.12fr 1fr; gap: 24px; align-items: center; }
|
||||
.arit .tr-grid .it, .arit .tr-grid .en { margin: 0; }
|
||||
.arit .tr-grid .en { padding-left: 20px; border-left: 1px solid var(--line-strong);
|
||||
font: 400 19px/1.5 var(--sans); color: var(--bordeaux-deep); }
|
||||
.arit .en-tag { display: inline-block; margin-right: 8px; padding: 2.5px 8px; border-radius: 4px;
|
||||
background: var(--bordeaux); color: #fdfcfc; font: 700 9.5px/1 var(--sans); letter-spacing: .12em;
|
||||
vertical-align: 2px; }
|
||||
.arit .points .g { color: var(--bordeaux-deep); }
|
||||
.arit .chipc .g { font-weight: 400; opacity: .78; }
|
||||
.arit .code .en { font: 400 13px/1.5 var(--sans); color: var(--bordeaux-deep); }
|
||||
.arit .code .en::before { content: 'EN'; display: inline-block; margin-right: 8px; padding: 2px 6px;
|
||||
border-radius: 3px; background: var(--bordeaux); color: #fdfcfc;
|
||||
font: 700 8.5px/1 var(--sans); letter-spacing: .1em; }
|
||||
|
||||
/* ---------- slide "The problem": challenges + outputs rail + ThothII right ---------- */
|
||||
.arit.problem .sbody { padding-top: 20px; padding-bottom: 14px; }
|
||||
.arit.problem .kicker { margin-bottom: 10px; }
|
||||
.arit.problem .quote { margin: 0 0 10px; padding: 12px 22px 10px 64px; font-size: 16.5px; }
|
||||
.arit.problem .tr-grid .en { font-size: 16.5px; }
|
||||
.arit.problem .stage { padding: 11px 10px; }
|
||||
.arit.problem .stage b { font-size: 13px; text-transform: uppercase; }
|
||||
.arit.problem .stage span { font-size: 10.5px; text-transform: uppercase; letter-spacing: .02em; }
|
||||
.arit.problem .stage.hot { background: #fdf1f2; border-color: var(--bordeaux); }
|
||||
.arit .brain-ai { display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px;
|
||||
border-radius: 9999px; background: #fff; border: 1.5px solid var(--bordeaux); }
|
||||
.arit .brain-ai svg { width: 24px; height: 24px; color: var(--bordeaux); }
|
||||
.arit.problem .stage span.brain-ai { display: inline-flex; margin: 3px auto 0; }
|
||||
.arit.problem .midrow { display: flex; gap: 0; margin-top: 8px; align-items: stretch; }
|
||||
.arit.problem .concepts { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.arit.problem .concepts .citem { flex: 1; }
|
||||
.arit .concepts .lbl, .arit .rail .lbl { display: block; font: 700 10px/1 var(--mono); letter-spacing: .14em;
|
||||
text-transform: uppercase; color: var(--bordeaux); margin: 0 0 5px; }
|
||||
.arit .rail .lbl { padding-left: 16px; }
|
||||
.arit .citem { display: flex; gap: 12px; align-items: baseline; padding: 5px 0; border-bottom: 1px solid var(--line); }
|
||||
.arit .citem:last-child { border-bottom: none; }
|
||||
.arit .citem .n { font: 700 11px/1 var(--mono); color: var(--bordeaux); }
|
||||
.arit .citem b { font: 700 12.5px/1.3 var(--sans); color: var(--ink); text-transform: uppercase; letter-spacing: .03em; }
|
||||
.arit .citem span.d { font: 400 11px/1.4 var(--sans); color: var(--ink-muted); margin-left: 8px; }
|
||||
.arit .railwrap { position: relative; display: flex; justify-content: flex-end; }
|
||||
.arit .conn-drop, .arit .t-conn { position: absolute; width: 2px; background: var(--bordeaux); }
|
||||
.arit .conn-elbow { position: absolute; height: 2px; background: var(--bordeaux); }
|
||||
.arit .t-head { position: absolute; width: 0; height: 0; border: 5px solid transparent;
|
||||
border-bottom: 7px solid var(--bordeaux); transform: translateX(-50%); }
|
||||
.arit .rail { width: 540px; border-left: 2px solid var(--bordeaux); padding-top: 12px; }
|
||||
.arit .rail .row { display: flex; align-items: center; gap: 12px; padding: 5px 14px 5px 16px; position: relative; }
|
||||
.arit .rail .row::before { content: ''; position: absolute; left: -2px; top: 50%; width: 14px; height: 2px; background: var(--bordeaux); }
|
||||
.arit .rail .row .oicon { flex: 0 0 26px; width: 26px; height: 26px; color: var(--bordeaux); display: block; }
|
||||
.arit .rail .row b { font: 700 13px/1.2 var(--sans); color: var(--ink); text-transform: uppercase; letter-spacing: .03em; }
|
||||
.arit .rail .row span.d { font: 400 11px/1.3 var(--sans); color: var(--ink-muted); }
|
||||
.arit.problem .thoth-wrap { position: relative; justify-content: flex-end; margin-top: 10px; }
|
||||
.arit.problem .thoth-box { padding: 8px 14px; font-size: 11px; text-transform: uppercase; letter-spacing: .02em; }
|
||||
.arit.problem .thoth-box .brain-ai { margin-right: 9px; vertical-align: middle; }
|
||||
.arit .quote small { display: block; margin-top: 6px; font: 600 11px/1.4 var(--sans); font-style: normal;
|
||||
letter-spacing: .06em; text-transform: uppercase; color: var(--ink-muted); }
|
||||
|
||||
/* mini pipeline (problem / thoth slides) */
|
||||
.arit .pipeline { display: flex; align-items: stretch; gap: 10px; }
|
||||
.arit .stage { flex: 1; padding: 12px 10px; text-align: center; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-alt); }
|
||||
.arit .stage b { display: block; font: 700 13.5px/1.2 var(--sans); margin-bottom: 4px; }
|
||||
.arit .stage span { display: block; font: 400 11px/1.35 var(--sans); color: var(--ink-muted); }
|
||||
.arit .pipeline { display: flex; align-items: stretch; gap: 12px; }
|
||||
.arit .stage { flex: 1; padding: 15px 12px; text-align: center; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-alt); }
|
||||
.arit .stage b { display: block; font: 700 14px/1.2 var(--sans); margin-bottom: 4px; }
|
||||
.arit .stage span { display: block; font: 400 11.5px/1.35 var(--sans); color: var(--ink-muted); }
|
||||
.arit .stage .tag { display: inline-block; margin-top: 6px; padding: 3px 10px; border-radius: 9999px;
|
||||
background: var(--bordeaux); color: #fdfcfc; font: 700 9.5px/1 var(--sans); letter-spacing: .1em; text-transform: uppercase; }
|
||||
.arit .stage.ai { background: var(--bordeaux-tint); border-color: var(--bordeaux); }
|
||||
.arit .arrow { align-self: center; color: var(--line-strong); font: 700 18px/1 var(--sans); }
|
||||
.arit .thoth-wrap { display: flex; justify-content: flex-end; margin-top: 14px; }
|
||||
.arit .thoth-col { width: 470px; text-align: center; }
|
||||
.arit .thoth-arrow { color: var(--line-strong); font: 700 16px/1 var(--sans); margin: 2px 0; }
|
||||
.arit .thoth-box { border: 1px solid var(--bordeaux); border-radius: 8px; padding: 10px 14px;
|
||||
background: var(--surface-alt); font: 600 12.5px/1.4 var(--sans); }
|
||||
.arit .arrow { align-self: center; color: var(--line-strong); font: 700 20px/1 var(--sans); }
|
||||
.arit .thoth-wrap { display: flex; justify-content: flex-end; margin-top: 18px; }
|
||||
.arit .thoth-col { width: 540px; text-align: center; }
|
||||
.arit .thoth-arrow { color: var(--line-strong); font: 700 18px/1 var(--sans); margin: 2px 0; }
|
||||
.arit .thoth-box { border: 1px solid var(--bordeaux); border-radius: 8px; padding: 12px 16px;
|
||||
background: var(--surface-alt); font: 600 13px/1.4 var(--sans); }
|
||||
.arit .thoth-box .tag { margin-right: 8px; }
|
||||
|
||||
/* points */
|
||||
@@ -108,6 +167,8 @@
|
||||
.arit .split { display: flex; gap: 40px; flex: 1; min-height: 0; }
|
||||
.arit .split .left { flex: 0 0 430px; display: flex; flex-direction: column; }
|
||||
.arit .split .right { flex: 1; min-width: 0; display: flex; }
|
||||
.arit.nlp .split .right { align-items: center; }
|
||||
.arit.nlp .code { flex: 0 0 auto; }
|
||||
.arit .code { width: 100%; display: flex; flex-direction: column; overflow: hidden;
|
||||
border: 1px solid var(--line-strong); border-radius: 8px; background: var(--surface-sunken); }
|
||||
.arit .code-head { flex: 0 0 38px; display: flex; align-items: center; justify-content: space-between;
|
||||
@@ -117,14 +178,36 @@
|
||||
.arit .code b { font-weight: 700; color: var(--bordeaux-deep); }
|
||||
.arit .code i { font-style: normal; color: #2f6f4f; }
|
||||
|
||||
/* stats */
|
||||
.arit .stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; flex: 1; align-content: center; }
|
||||
.arit .stat { border: 1px solid var(--line); border-radius: 8px; background: var(--surface-alt);
|
||||
padding: 26px 22px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.arit .stat .num { font: 700 46px/1 var(--sans); color: var(--bordeaux); letter-spacing: -.02em; }
|
||||
.arit .stat .lbl { font: 600 13.5px/1.35 var(--sans); color: var(--ink); }
|
||||
.arit .stat .sub { font: 400 11.5px/1.4 var(--sans); color: var(--ink-muted); }
|
||||
.arit .stat-note { margin-top: 20px; font: 400 12.5px/1.5 var(--sans); color: var(--ink-muted); }
|
||||
/* stats — editorial numeral band */
|
||||
.arit .stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); }
|
||||
.arit .stat { padding: 8px 24px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.arit .stat:first-child { padding-left: 0; }
|
||||
.arit .stat + .stat { border-left: 1px solid var(--line); }
|
||||
.arit .stat .num { font: 700 62px/1 var(--sans); color: var(--bordeaux); letter-spacing: -.02em; }
|
||||
.arit .stat .lbl { font: 600 14.5px/1.35 var(--sans); color: var(--ink); }
|
||||
.arit .stat .sub { font: 400 12px/1.45 var(--sans); color: var(--ink-muted); }
|
||||
.arit .stat-note { margin-top: 26px; font: 400 13px/1.5 var(--sans); color: var(--ink-muted); }
|
||||
|
||||
/* slide "What the text miner reads": slim numbers strip + elliptical component cycle */
|
||||
.arit.stats .sbody { padding-top: 22px; }
|
||||
.arit.stats .strip { display: flex; justify-content: space-between; align-items: baseline;
|
||||
border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 9px 2px; }
|
||||
.arit.stats .sstat { display: flex; align-items: baseline; gap: 9px; }
|
||||
.arit.stats .sstat .n { font: 700 25px/1 var(--sans); color: var(--bordeaux); letter-spacing: -.02em; }
|
||||
.arit.stats .sstat .l { font: 600 11.5px/1.2 var(--sans); color: var(--ink); }
|
||||
.arit.stats .cycle { position: relative; width: 1184px; height: 270px; margin: 4px auto 0; }
|
||||
.arit.stats .cycle-svg { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.arit.stats .vcomp { position: absolute; transform: translate(-50%, -50%); width: 250px;
|
||||
text-align: center; background: var(--surface); padding: 4px 6px; }
|
||||
.arit.stats .vcomp .cicon { width: 46px; height: 46px; color: var(--bordeaux); display: block; margin: 0 auto 7px; }
|
||||
.arit.stats .vcomp b { display: block; font: 700 14.5px/1.25 var(--sans); text-transform: uppercase;
|
||||
letter-spacing: .04em; color: var(--ink); }
|
||||
.arit.stats .vcomp span.d { display: block; margin-top: 4px; font: 400 11.5px/1.4 var(--sans); color: var(--ink-muted); }
|
||||
.arit.stats .note-row { display: flex; gap: 30px; margin-top: 6px; }
|
||||
.arit.stats .nitem { flex: 1; position: relative; padding-left: 16px; font: 400 14px/1.45 var(--sans); color: var(--ink); }
|
||||
.arit.stats .nitem::before { content: ''; position: absolute; left: 0; top: 7px; width: 8px; height: 8px;
|
||||
border-radius: 9999px; background: var(--bordeaux); }
|
||||
.arit.stats .nitem b { color: var(--bordeaux-deep); }
|
||||
|
||||
/* divider */
|
||||
.arit.divider .sbody { justify-content: center; }
|
||||
@@ -132,14 +215,39 @@
|
||||
.arit .divider-h { font: 700 40px/1.15 var(--sans); margin-top: 10px; }
|
||||
.arit .cue { margin-top: 22px; font: 600 12px/1 var(--sans); letter-spacing: .12em; text-transform: uppercase; color: var(--ink-muted); }
|
||||
|
||||
/* ontology chips */
|
||||
.arit .tier { margin-bottom: 18px; }
|
||||
.arit .tier-label { display: block; font: 700 11px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase;
|
||||
color: var(--ink-muted); margin-bottom: 8px; }
|
||||
.arit .chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.arit .chipc { border: 1px solid var(--bordeaux); color: var(--bordeaux-deep); background: #fff;
|
||||
border-radius: 9999px; padding: 7px 14px; font: 700 13px/1 var(--sans); }
|
||||
.arit .chipc.t2 { border-color: var(--line-strong); color: var(--ink-muted); }
|
||||
/* ontology slide: EN-first category pills + output panel */
|
||||
.arit .onto-split { flex: 0 0 auto; display: grid; grid-template-columns: 1.25fr 1fr;
|
||||
gap: 26px; align-items: stretch; margin-top: 14px; }
|
||||
.arit .tier-h { display: flex; align-items: center; font: 700 10px/1 var(--mono); letter-spacing: .14em;
|
||||
text-transform: uppercase; color: var(--bordeaux-deep); margin: 22px 0 9px; }
|
||||
.arit .onto-left .tier-h:first-child { margin-top: 0; }
|
||||
.arit .tier-h .cnt { margin-left: auto; font: 700 9px/1 var(--mono); color: var(--bordeaux);
|
||||
border: 1px solid var(--bordeaux); border-radius: 9999px; padding: 2px 7px; }
|
||||
.arit .cats { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.arit .cat { display: inline-flex; align-items: baseline; gap: 7px; border: 1px solid var(--bordeaux);
|
||||
border-radius: 9999px; padding: 6px 13px; background: #fff; white-space: nowrap; }
|
||||
.arit .cat b { font: 700 13px/1 var(--sans); color: var(--ink); }
|
||||
.arit .cat .it { font: 400 11px/1 var(--sans); color: var(--ink-muted); }
|
||||
.arit .cat.fill { background: var(--bordeaux-tint); }
|
||||
.arit .onto-points { margin-top: 18px; }
|
||||
.arit .onto-left .onto-points:first-child { margin-top: 0; }
|
||||
.arit .onto-left { display: flex; flex-direction: column; }
|
||||
.arit .onto-left .roi { margin-top: auto; }
|
||||
.arit .onto-left .onto-points { margin-bottom: 14px; }
|
||||
.arit .oitem { display: flex; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); }
|
||||
.arit .oitem:last-child { border-bottom: none; }
|
||||
.arit .oitem .n { flex: 0 0 22px; font: 700 11px/1.6 var(--mono); color: var(--bordeaux); }
|
||||
.arit .oitem b { font: 700 13px/1.35 var(--sans); color: var(--ink); }
|
||||
.arit .oitem .d { font: 400 12.5px/1.45 var(--sans); color: var(--ink-muted); }
|
||||
.arit .out-panel { border: 1px solid var(--bordeaux); background: var(--bordeaux-tint); border-radius: 10px;
|
||||
padding: 15px 18px 8px; align-self: stretch; display: flex; flex-direction: column; }
|
||||
.arit .out-h { display: block; font: 700 10px/1 var(--mono); letter-spacing: .14em; text-transform: uppercase;
|
||||
color: var(--bordeaux-deep); margin-bottom: 10px; }
|
||||
.arit .out-item { display: flex; gap: 10px; padding: 13px 0; border-bottom: 1px solid rgba(143,18,4,.18); flex: 1; }
|
||||
.arit .out-item:last-child { border-bottom: none; }
|
||||
.arit .out-item .n { flex: 0 0 22px; font: 700 11px/1.6 var(--mono); color: var(--bordeaux-deep); }
|
||||
.arit .out-item b { font: 700 13px/1.35 var(--sans); color: var(--bordeaux-deep); }
|
||||
.arit .out-item .d { display: block; margin-top: 2px; font: 400 12.5px/1.45 var(--sans); color: var(--ink); }
|
||||
|
||||
/* flow */
|
||||
.arit .flow { margin-top: 16px; display: flex; align-items: stretch; gap: 14px; }
|
||||
@@ -179,12 +287,12 @@ button.brain-btn { cursor: pointer; padding: 0; transition: transform .15s ease,
|
||||
button.brain-btn:hover, button.brain-btn:focus-visible { transform: scale(1.18); box-shadow: 0 0 0 5px rgba(203,51,59,.14); outline: none; }
|
||||
|
||||
/* popup */
|
||||
.ai-pop { position: fixed; inset: 0; z-index: 100; background: rgba(42,36,38,.45); backdrop-filter: blur(2px);
|
||||
.ai-pop { position: fixed; inset: 0; z-index: 100; background: rgba(42,36,38,.12);
|
||||
display: block; opacity: 0; pointer-events: none; transition: opacity .2s ease; }
|
||||
.ai-pop.open { opacity: 1; pointer-events: auto; }
|
||||
.ai-line { position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; }
|
||||
.ai-line line { stroke: var(--bordeaux); stroke-width: 2.5; }
|
||||
.ai-line circle { fill: var(--bordeaux); }
|
||||
.ai-line line { stroke: var(--bordeaux-deep); stroke-width: 5; stroke-linecap: round; filter: drop-shadow(0 0 2px #fff); }
|
||||
.ai-line circle { fill: var(--bordeaux-deep); stroke: #fff; stroke-width: 1.5; }
|
||||
.ai-pop-card { position: absolute; width: min(580px, 92vw); background: #fff; border: 1px solid var(--line);
|
||||
border-radius: 12px; box-shadow: 0 18px 50px rgba(42,36,38,.28); padding: 24px 28px 22px;
|
||||
transform: translateY(14px) scale(.95); transition: transform .24s cubic-bezier(.2,.9,.3,1.15); text-align: left; }
|
||||
@@ -203,6 +311,7 @@ button.brain-btn:hover, button.brain-btn:focus-visible { transform: scale(1.18);
|
||||
.ai-pop-list .n { color: var(--bordeaux-deep); font-weight: 800; }
|
||||
.roi { margin-top: 14px; border: 1.5px solid var(--bordeaux); background: #faf4f5; border-radius: 8px;
|
||||
padding: 10px 14px; font: 400 13.5px/1.55 var(--sans); color: var(--ink); }
|
||||
.roi-quiet { border-color: var(--line-strong); border-width: 1px; background: var(--surface-alt); }
|
||||
.roi .k { display: block; margin-bottom: 4px; font: 800 10.5px/1 var(--mono); letter-spacing: .14em;
|
||||
text-transform: uppercase; color: var(--bordeaux-deep); }
|
||||
.ai-pop-x { position: absolute; top: 12px; right: 14px; width: 30px; height: 30px; border: none;
|
||||
@@ -213,20 +322,24 @@ button.brain-btn:hover, button.brain-btn:focus-visible { transform: scale(1.18);
|
||||
.arit .chip.tbd { color: var(--bordeaux-deep); border: 1px solid var(--bordeaux); background: #fff;
|
||||
border-radius: 9999px; padding: 6px 12px; font: 700 10.5px/1 var(--sans); letter-spacing: .06em; }
|
||||
|
||||
/* where we started */
|
||||
.arit .sysmap { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; flex: 1; align-content: center; }
|
||||
.arit .syscard { position: relative; display: block; width: 100%; border: 1.5px solid var(--line-strong);
|
||||
border-radius: 8px; background: var(--surface-alt); padding: 20px 16px 14px; text-align: left;
|
||||
cursor: pointer; font-family: var(--sans); transition: transform .15s ease, box-shadow .15s ease; }
|
||||
.arit button.syscard:hover, .arit button.syscard:focus-visible { transform: translateY(-3px);
|
||||
box-shadow: 0 6px 18px rgba(42,36,38,.14); outline: none; }
|
||||
.arit .syscard b { display: block; font: 700 17px/1.3 var(--sans); color: var(--ink); margin-bottom: 6px; }
|
||||
.arit .syscard .d { display: block; font: 400 12.5px/1.45 var(--sans); color: var(--ink-muted); }
|
||||
.arit .syscard .tag20 { position: absolute; top: -10px; right: 12px; background: var(--bordeaux); color: #fff;
|
||||
border-radius: 9999px; padding: 3px 10px; font: 700 9.5px/1 var(--sans); letter-spacing: .08em; }
|
||||
.arit .syscard.ghost { border: 1.5px dashed var(--bordeaux); background: #fff; cursor: default; }
|
||||
.arit .syscard.ghost b { color: var(--bordeaux-deep); }
|
||||
.arit .syscard.ghost .tag20 { background: var(--bordeaux-deep); }
|
||||
/* where we started — scattered orbit map */
|
||||
.arit .scatter { position: relative; flex: 1; min-height: 0; }
|
||||
.arit .todo-stack { position: absolute; z-index: 2; }
|
||||
.arit .todo { display: flex; align-items: center; gap: 12px; border: 2px dashed var(--bordeaux);
|
||||
border-radius: 8px; background: #fff; padding: 8px 12px; margin-bottom: 8px;
|
||||
box-shadow: 0 2px 8px rgba(42,36,38,.06); cursor: pointer; }
|
||||
.arit .todo .q { font: 800 22px/1 var(--sans); color: var(--bordeaux-deep); }
|
||||
.arit .todo .tx { display: flex; flex-direction: column; }
|
||||
.arit .todo b { font: 700 13.5px/1.3 var(--sans); color: var(--bordeaux-deep); }
|
||||
.arit .todo .tx span { font: 400 12px/1.4 var(--sans); color: var(--ink-muted); margin-top: 2px; }
|
||||
.arit .missing-title { position: absolute; font: 700 30px/1.2 var(--sans); color: var(--bordeaux-deep); }
|
||||
.arit .isle { position: absolute; display: flex; flex-direction: column; align-items: center; gap: 2px;
|
||||
cursor: pointer; transform: rotate(var(--rot, 0deg)); transition: transform .18s ease;
|
||||
border: none; background: none; font-family: var(--sans); width: 230px; }
|
||||
.arit .isle:hover, .arit .isle:focus-visible { transform: rotate(var(--rot, 0deg)) scale(1.05); outline: none; }
|
||||
.arit .isle .ico { width: var(--size, 120px); height: var(--size, 120px); color: var(--bordeaux); display: block; }
|
||||
.arit .isle .nm { font: 700 16px/1.3 var(--sans); color: var(--ink); margin-top: 4px; }
|
||||
.arit .isle .sc { font: 400 13.5px/1.4 var(--sans); color: var(--ink-muted); max-width: 240px; }
|
||||
.arit .hint { margin-top: 12px; font: italic 400 12px/1.4 var(--sans); color: var(--ink-muted); }
|
||||
|
||||
/* snapshot tours + chart placeholders */
|
||||
@@ -262,5 +375,12 @@ button.brain-btn:hover, button.brain-btn:focus-visible { transform: scale(1.18);
|
||||
.arit.flow-slide .kicker { margin-bottom: 10px; }
|
||||
.arit.flow-slide h2 { margin-bottom: 6px; }
|
||||
.arit.flow-slide .lead { margin-top: 6px; font-size: 15px; }
|
||||
.arit.flow-slide .flow { margin-top: 14px; }
|
||||
.arit.flow-slide .flow { margin-top: 10px; }
|
||||
.arit.flow-slide .zone { padding: 26px 14px 12px; }
|
||||
.arit.flow-slide .fbox { padding: 8px 10px; }
|
||||
.arit.flow-slide .fbox.end { padding: 12px 10px; }
|
||||
.arit.flow-slide .fcol { gap: 6px; }
|
||||
.arit.flow-slide .pillars { padding-top: 8px; }
|
||||
|
||||
/* popup: lead + list (missing-pieces popups) */
|
||||
.ai-pop-card p.lead { font-style: italic; color: var(--ink-muted); }
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/* Opt-in, same-browser synchronization. The regular deck has no channel or session. */
|
||||
(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const session = params.get('session');
|
||||
const role = params.get('view');
|
||||
if (!session || !['preview', 'audience'].includes(role)) return;
|
||||
const channel = new BroadcastChannel(`aritmolab-presenter:${session}`);
|
||||
const sender = crypto.randomUUID();
|
||||
const storageKey = `aritmolab-state:${session}`;
|
||||
let applying = false;
|
||||
let pending = false;
|
||||
const send = (type, extra = {}) => channel.postMessage({ type, role, sender, ...extra });
|
||||
const api = window.PresentationControls;
|
||||
const valid = state => state && Number.isInteger(state.index) && state.index >= 0 && state.index < Reveal.getTotalSlides();
|
||||
function save(state) { try { localStorage.setItem(storageKey, JSON.stringify(state)); } catch {} }
|
||||
function publish() {
|
||||
if (applying || pending) return;
|
||||
pending = true;
|
||||
queueMicrotask(() => {
|
||||
pending = false;
|
||||
const state = api.snapshot();
|
||||
save(state);
|
||||
send('state', { state });
|
||||
});
|
||||
}
|
||||
function apply(state) {
|
||||
if (!valid(state)) return;
|
||||
applying = true;
|
||||
Reveal.slide(state.index);
|
||||
api.closePopup();
|
||||
if (state.popup) api.openPopup(state.popup);
|
||||
applying = false;
|
||||
save(api.snapshot());
|
||||
}
|
||||
function command(message) {
|
||||
if (message.action === 'next') Reveal.next();
|
||||
if (message.action === 'previous') Reveal.prev();
|
||||
if (message.action === 'slide' && valid({ index: message.index })) Reveal.slide(message.index);
|
||||
if (message.action === 'popup' && message.popup) api.openPopup(message.popup);
|
||||
if (message.action === 'close') api.closePopup();
|
||||
publish();
|
||||
}
|
||||
function ready() {
|
||||
Reveal.configure({ overview: false, pause: false });
|
||||
if (role === 'preview') {
|
||||
// Remove only the presenter's footer and its 34px, preserving the slide body geometry.
|
||||
document.documentElement.classList.add('presenter-preview');
|
||||
Reveal.configure({ height: 686 });
|
||||
}
|
||||
try { apply(JSON.parse(localStorage.getItem(storageKey))); } catch {}
|
||||
addEventListener('presentationchange', publish);
|
||||
Reveal.on('slidechanged', publish);
|
||||
channel.onmessage = ({ data }) => {
|
||||
if (!data || data.sender === sender) return;
|
||||
if (data.type === 'state') apply(data.state);
|
||||
if (data.type === 'command' && role === 'preview') command(data);
|
||||
if (data.type === 'request' && role === 'preview') publish();
|
||||
};
|
||||
if (role === 'preview') publish();
|
||||
else send('request');
|
||||
send('presence');
|
||||
setInterval(() => send('presence'), 1000);
|
||||
addEventListener('pagehide', () => send('departed'));
|
||||
if (role === 'audience') {
|
||||
document.title = 'AritmoLab | Audience';
|
||||
const button = document.createElement('button');
|
||||
button.textContent = 'Full screen';
|
||||
button.setAttribute('aria-label', 'Enter audience full screen');
|
||||
Object.assign(button.style, { position: 'fixed', right: '16px', top: '16px', zIndex: '200', padding: '10px 18px', borderRadius: '6px', border: '1px solid #b9afb1', background: '#faf7f7', color: '#482d34', cursor: 'pointer', font: '15px system-ui' });
|
||||
button.onclick = () => document.documentElement.requestFullscreen().catch(() => { button.textContent = 'Use the browser full-screen command'; });
|
||||
document.addEventListener('fullscreenchange', () => { button.hidden = Boolean(document.fullscreenElement); });
|
||||
document.body.append(button);
|
||||
}
|
||||
}
|
||||
if (Reveal.isReady()) ready(); else Reveal.on('ready', ready);
|
||||
})();
|
||||
@@ -0,0 +1,49 @@
|
||||
/* Ordered tour stops; screenshot originals keep their full aspect ratio. */
|
||||
.arit .screenshot-tour {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px; flex: 1; min-height: 0; margin-top: 10px;
|
||||
}
|
||||
.arit .screenshot-tour-four { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.arit .screenshot-tour button {
|
||||
display: flex; flex-direction: column; min-width: 0; min-height: 0; padding: 0;
|
||||
overflow: hidden; border: 1px solid var(--line-strong); border-radius: 8px;
|
||||
background: var(--surface-alt); color: var(--ink); cursor: pointer; text-align: left;
|
||||
}
|
||||
.arit .screenshot-tour button:hover { border-color: var(--bordeaux); background: var(--bordeaux-tint); }
|
||||
.arit .screenshot-tour button:focus-visible { outline: 3px solid var(--bordeaux); outline-offset: 3px; }
|
||||
.arit .screenshot-tour img {
|
||||
display: block; width: 100%; height: 0; flex: 1; min-height: 0; max-width: none; max-height: none;
|
||||
margin: 0; object-fit: contain; border: 0; box-shadow: none; background: #fff;
|
||||
}
|
||||
.arit .screenshot-tour span {
|
||||
display: flex; align-items: center; gap: 10px; padding: 9px 12px;
|
||||
border-top: 1px solid var(--line); font: 600 16px/1.2 var(--sans);
|
||||
}
|
||||
.arit .screenshot-tour b { color: var(--bordeaux); font: 700 22px/1 var(--sans); }
|
||||
.arit .screenshot-tour-compact {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 10px;
|
||||
}
|
||||
.arit .screenshot-tour-compact span { gap: 8px; padding: 6px 10px; font-size: 14px; }
|
||||
.arit .screenshot-tour-compact b { font-size: 18px; }
|
||||
.screenshot-dialog {
|
||||
box-sizing: border-box; width: calc(100vw - 24px); height: calc(100vh - 24px);
|
||||
max-width: none; max-height: none; padding: 8px; margin: auto;
|
||||
border: 1px solid var(--line-strong); border-radius: 8px; background: var(--surface);
|
||||
color: var(--ink); font-family: var(--sans); overflow: hidden;
|
||||
}
|
||||
.screenshot-dialog[open] { display: flex; flex-direction: column; gap: 6px; }
|
||||
.screenshot-dialog::backdrop { background: rgb(42 36 38 / 70%); }
|
||||
.screenshot-dialog header { display: flex; align-items: center; gap: 12px; min-height: 32px; flex: 0 0 auto; }
|
||||
.screenshot-dialog h2 { margin: 0 auto 0 4px; font: 600 16px/1.2 var(--sans); }
|
||||
.screenshot-dialog button {
|
||||
border: 1px solid var(--line-strong); border-radius: 4px; background: var(--surface);
|
||||
color: var(--ink); padding: 5px 12px; font: 600 14px/1.2 var(--sans); cursor: pointer;
|
||||
}
|
||||
.screenshot-dialog button:hover { background: var(--bordeaux-tint); }
|
||||
.screenshot-dialog button:focus-visible { outline: 3px solid var(--bordeaux); outline-offset: 1px; }
|
||||
.screenshot-stage { position: relative; flex: 1; min-height: 0; }
|
||||
.screenshot-stage img { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.screenshot-stage img { object-fit: contain; }
|
||||
.screenshot-dialog .screenshot-error { margin: auto; text-align: center; }
|
||||
@media print { .screenshot-dialog { display: none !important; } }
|
||||
@@ -0,0 +1,44 @@
|
||||
/* Uses the deck's shared popup state, so preview and audience stay synchronized. */
|
||||
(() => {
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.className = 'screenshot-dialog';
|
||||
dialog.setAttribute('aria-labelledby', 'screenshot-title');
|
||||
dialog.innerHTML = `<header><h2 id="screenshot-title"></h2><button type="button" aria-label="Close screenshot">Close · Esc</button></header>
|
||||
<div class="screenshot-stage">
|
||||
<img alt="">
|
||||
</div>
|
||||
<p class="screenshot-error" hidden>Screenshot unavailable. Close and reopen to retry.</p>`;
|
||||
document.body.append(dialog);
|
||||
const title = dialog.querySelector('h2');
|
||||
const image = dialog.querySelector('img');
|
||||
const error = dialog.querySelector('.screenshot-error');
|
||||
let active = null;
|
||||
let trigger = null;
|
||||
const close = () => {
|
||||
active = null;
|
||||
if (dialog.open) dialog.close();
|
||||
if (trigger?.closest('section.present')) trigger.focus({ preventScroll: true });
|
||||
trigger = null;
|
||||
};
|
||||
const requestClose = () => window.PresentationControls.closePopup();
|
||||
dialog.querySelector('button').addEventListener('click', requestClose);
|
||||
dialog.addEventListener('cancel', event => { event.preventDefault(); requestClose(); });
|
||||
dialog.addEventListener('click', event => { if (event.target === dialog) requestClose(); });
|
||||
image.addEventListener('error', () => { image.hidden = true; error.hidden = false; });
|
||||
window.ScreenshotTour = {
|
||||
get active() { return active; },
|
||||
open(button) {
|
||||
trigger = button;
|
||||
active = { kind: 'screenshot', key: button.dataset.screenshot };
|
||||
const total = button.closest('.screenshot-tour').querySelectorAll('[data-screenshot]').length;
|
||||
title.textContent = `${button.dataset.screenshotNumber} / ${total} · ${button.dataset.screenshotTitle}`;
|
||||
image.hidden = false;
|
||||
error.hidden = true;
|
||||
image.alt = button.dataset.screenshotTitle;
|
||||
image.src = button.querySelector('img').src;
|
||||
if (!dialog.open) dialog.showModal();
|
||||
window.dispatchEvent(new Event('presentationchange'));
|
||||
},
|
||||
close,
|
||||
};
|
||||
})();
|
||||
|
After Width: | Height: | Size: 630 KiB |
|
After Width: | Height: | Size: 680 KiB |
|
After Width: | Height: | Size: 551 KiB |
|
After Width: | Height: | Size: 721 KiB |
|
After Width: | Height: | Size: 723 KiB |
|
After Width: | Height: | Size: 834 KiB |
|
After Width: | Height: | Size: 596 KiB |
|
After Width: | Height: | Size: 470 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 976 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 959 KiB |
|
After Width: | Height: | Size: 948 KiB |
|
After Width: | Height: | Size: 704 KiB |
|
After Width: | Height: | Size: 802 KiB |
|
After Width: | Height: | Size: 820 KiB |
|
After Width: | Height: | Size: 925 KiB |
|
After Width: | Height: | Size: 707 KiB |
|
After Width: | Height: | Size: 472 KiB |
@@ -0,0 +1,127 @@
|
||||
/* A rectangular optical enlargement, anchored to the original diagram element. */
|
||||
.arit .flow .lens-target {
|
||||
display: block; width: 100%; color: inherit; font: inherit; cursor: pointer;
|
||||
appearance: none;
|
||||
transition: transform 180ms cubic-bezier(.16, 1, .3, 1), box-shadow 180ms ease,
|
||||
border-color 180ms ease, opacity 220ms ease;
|
||||
}
|
||||
.arit .flow .lens-target:hover {
|
||||
transform: translateY(-2px); border-color: var(--bordeaux);
|
||||
box-shadow: 0 5px 12px oklch(26% .02 15 / .13);
|
||||
}
|
||||
.arit .flow .lens-target:focus-visible, .arit .slide-lens-close:focus-visible {
|
||||
outline: 3px solid var(--bordeaux); outline-offset: 4px;
|
||||
}
|
||||
.arit .flow .zone.built, .arit .flow > .ai-hit, .arit .flow .fbox:not(.lens-target) {
|
||||
transition: opacity 220ms ease;
|
||||
}
|
||||
.arit.lens-active .flow .zone.built:not(:has(.lens-source)), .arit.lens-active .flow > .ai-hit,
|
||||
.arit.lens-active .flow .zone.built:has(.lens-source) :is(.fbox:not(.lens-source), .farrow, .brain-btn, .fcap),
|
||||
.arit.lens-active .flow .zone.existed:not(.lens-source) { opacity: .18; }
|
||||
.arit .flow .lens-source {
|
||||
border-color: var(--bordeaux); box-shadow: 0 0 0 3px var(--bordeaux-tint);
|
||||
}
|
||||
.arit .oitem.lens-target {
|
||||
width: 100%; color: inherit; font: inherit; text-align: left;
|
||||
background: transparent; border: 0; border-bottom: 1px solid var(--line);
|
||||
appearance: none; cursor: pointer;
|
||||
transition: background 180ms ease, opacity 220ms ease;
|
||||
}
|
||||
.arit .oitem.lens-target:last-child { border-bottom: 0; }
|
||||
.arit .oitem.lens-target:hover, .arit .oitem.lens-source { background: var(--bordeaux-tint); }
|
||||
.arit .oitem.lens-target:focus-visible { outline: 3px solid var(--bordeaux); outline-offset: 3px; }
|
||||
.arit.crisis .crisis-points { margin-bottom: 4px; }
|
||||
.arit.crisis .oitem { padding: 12px 10px; gap: 14px; }
|
||||
.arit.crisis .oitem .n { flex-basis: 28px; font-size: 15px; }
|
||||
.arit.crisis .oitem b { display: block; font-size: 18px; }
|
||||
.arit.crisis .oitem .d { display: block; margin-top: 3px; font-size: 16px; }
|
||||
.arit.crisis .sbody > .roi { padding: 12px 18px; font-size: 22px; line-height: 1.3; font-weight: 600; }
|
||||
.arit.crisis .sbody > .roi .k { margin-bottom: 8px; font-size: 14px; line-height: 1.2; }
|
||||
.arit.crisis.lens-active .crisis-points .oitem:not(.lens-source),
|
||||
.arit.crisis.lens-active .sbody > .roi { opacity: .18; }
|
||||
.arit.crisis.lens-active .sbody > :is(h2, .kicker) { opacity: 0; }
|
||||
.arit.validation .onto-split :is(.oitem, .roi, .out-panel) { transition: opacity 220ms ease; }
|
||||
.arit.validation.lens-active .onto-split :is(.oitem:not(.lens-source), .roi, .out-panel:not(.lens-source)) { opacity: .18; }
|
||||
.arit.validation .out-panel.lens-target {
|
||||
appearance: none; width: 100%; color: inherit; font: inherit; text-align: left;
|
||||
cursor: pointer;
|
||||
transition: transform 180ms cubic-bezier(.16, 1, .3, 1), box-shadow 180ms ease,
|
||||
background 180ms ease, opacity 220ms ease;
|
||||
}
|
||||
.arit.validation .out-panel.lens-target:hover {
|
||||
transform: translateY(-2px); background: var(--surface-alt);
|
||||
box-shadow: 0 0 0 3px var(--bordeaux-tint), 0 6px 16px oklch(26% .02 15 / .14);
|
||||
}
|
||||
.arit.validation .out-panel.lens-target:focus-visible {
|
||||
outline: 3px solid var(--bordeaux); outline-offset: 4px;
|
||||
}
|
||||
.arit.validation .out-panel.lens-source { box-shadow: 0 0 0 3px var(--bordeaux-tint); }
|
||||
.arit.validation .out-panel.lens-target .out-item:last-of-type { border-bottom: none; }
|
||||
.arit .slide-lens-guides {
|
||||
position: absolute; inset: 0; width: 100%; height: 100%;
|
||||
pointer-events: none; z-index: 8; overflow: visible;
|
||||
}
|
||||
.arit .slide-lens-guides path {
|
||||
fill: none; stroke: var(--bordeaux); stroke-width: 1; opacity: .45;
|
||||
}
|
||||
.arit .slide-lens {
|
||||
position: absolute; z-index: 9; transform-origin: 0 0;
|
||||
border: 3px solid var(--bordeaux); border-radius: 9px;
|
||||
background: var(--surface-alt); padding: 9px;
|
||||
box-shadow: 0 22px 48px oklch(26% .02 15 / .2),
|
||||
0 4px 10px oklch(26% .02 15 / .12),
|
||||
inset 0 0 0 2px var(--surface), inset 0 0 0 3px var(--line-strong);
|
||||
}
|
||||
/* A beveled rim and a restrained reflection suggest a lens without an icon. */
|
||||
.arit .slide-lens::after {
|
||||
content: ''; position: absolute; inset: 5px; pointer-events: none;
|
||||
border-radius: 4px; border: 1px solid oklch(99% .004 20 / .85);
|
||||
background: linear-gradient(125deg, oklch(99% .004 20 / .45), transparent 26%,
|
||||
transparent 78%, oklch(99% .004 20 / .25));
|
||||
}
|
||||
.arit .slide-lens-content { overflow: hidden; width: 100%; height: 100%; border-radius: 2px; }
|
||||
.arit .slide-lens .slide-lens-copy {
|
||||
margin: 0; transform-origin: 0 0; border-radius: 1px;
|
||||
border-color: transparent; position: static;
|
||||
}
|
||||
.arit .slide-lens .slide-lens-copy.zone { position: relative; }
|
||||
.arit .slide-lens.has-details .slide-lens-content { overflow-y: auto; }
|
||||
.arit .slide-lens-detail { padding: 16px 18px; text-align: left; color: var(--ink); }
|
||||
.arit .slide-lens-detail h3 {
|
||||
margin: 0 0 12px; font: 700 30px/1.15 var(--sans); color: var(--bordeaux-deep);
|
||||
}
|
||||
.arit .slide-lens-detail p { margin: 0 0 10px; font: 400 17px/1.35 var(--sans); }
|
||||
.arit .slide-lens-detail p:last-child { margin-bottom: 0; }
|
||||
.arit .slide-lens-detail p b {
|
||||
display: block; margin-bottom: 4px; font-weight: 700; color: var(--bordeaux-deep);
|
||||
}
|
||||
.arit .slide-lens-detail .detail-intro { font-size: 19px; line-height: 1.3; }
|
||||
.arit .slide-lens-detail .detail-example { padding-top: 12px; border-top: 1px solid var(--line-strong); }
|
||||
.arit .slide-lens-caption {
|
||||
position: absolute; left: 0; top: -28px; color: var(--bordeaux-deep);
|
||||
font: 700 11px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase;
|
||||
}
|
||||
.arit .slide-lens-close {
|
||||
position: absolute; right: -15px; top: -15px; z-index: 1;
|
||||
display: grid; place-items: center; width: 30px; height: 30px;
|
||||
border: 1px solid var(--bordeaux); border-radius: 5px;
|
||||
padding: 0; background: var(--surface); color: var(--bordeaux-deep);
|
||||
font: 400 22px/1 var(--sans); cursor: pointer;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.arit.validation .out-panel.lens-target { transition: none; }
|
||||
.arit.validation .out-panel.lens-target:hover { transform: none; }
|
||||
.arit.validation .onto-split :is(.oitem, .roi, .out-panel) { transition: none; }
|
||||
.arit .flow .zone, .arit .flow > .ai-hit, .arit .flow .fbox { transition: none; }
|
||||
.arit .flow .lens-target:hover { transform: none; }
|
||||
}
|
||||
@media print {
|
||||
.arit.crisis.lens-active .sbody > :is(h2, .kicker) { opacity: 1; }
|
||||
.arit.crisis.lens-active .crisis-points .oitem:not(.lens-source),
|
||||
.arit.crisis.lens-active .sbody > .roi { opacity: 1; }
|
||||
.arit.validation.lens-active .onto-split :is(.oitem, .roi, .out-panel) { opacity: 1; }
|
||||
.arit .slide-lens, .arit .slide-lens-guides { display: none !important; }
|
||||
.arit.lens-active .flow .zone, .arit.lens-active .flow > .ai-hit,
|
||||
.arit.lens-active .flow .fbox,
|
||||
.arit.lens-active .flow .zone.built:has(.lens-source) :is(.fbox, .farrow, .brain-btn, .fcap) { opacity: 1; }
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/* Reuse with a source button carrying its id and data-lens="source-id".
|
||||
Content is cloned from the source, with optional explanatory content in a template. */
|
||||
(() => {
|
||||
let current = null;
|
||||
const changed = () => window.dispatchEvent(new Event('presentationchange'));
|
||||
const reducedMotion = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
function layout() {
|
||||
if (!current) return;
|
||||
const { source, slide, frame, copy, guides, caption, detailed } = current;
|
||||
const sr = slide.getBoundingClientRect();
|
||||
const rect = source.getBoundingClientRect();
|
||||
const scale = sr.width / slide.offsetWidth;
|
||||
const area = (slide.querySelector('.flow') || slide.querySelector('.sbody')).getBoundingClientRect();
|
||||
const centered = source.dataset.lensPlacement === 'center';
|
||||
const x = (rect.left - sr.left) / scale, y = (rect.top - sr.top) / scale;
|
||||
const w = rect.width / scale, h = rect.height / scale;
|
||||
const isGroup = source.classList.contains('zone');
|
||||
const areaTop = isGroup || detailed ? 160 : (area.top - sr.top) / scale;
|
||||
const areaHeight = isGroup || detailed ? slide.offsetHeight - areaTop - 50 : area.height / scale;
|
||||
// Choose the roomier side so the original stays visible, including middle/right nodes.
|
||||
const margin = 65, gap = 38;
|
||||
const roomLeft = x - gap - margin;
|
||||
const roomRight = slide.offsetWidth - margin - (x + w + gap);
|
||||
const onRight = roomRight >= roomLeft;
|
||||
const maxWidth = centered ? Math.min(760, slide.offsetWidth - 2 * margin)
|
||||
: Math.min(slide.offsetWidth * .58, Math.max(roomLeft, roomRight));
|
||||
const zoom = Math.min(4, (maxWidth - 24) / w, (areaHeight - 40) / h);
|
||||
let width = w * zoom + 24, height = h * zoom + 24;
|
||||
if (detailed) {
|
||||
width = maxWidth;
|
||||
Object.assign(copy.style, { width: `${width - 24}px`, height: 'auto', transform: 'none' });
|
||||
height = Math.min(copy.scrollHeight + 24, areaHeight - 16);
|
||||
}
|
||||
const left = centered ? (slide.offsetWidth - width) / 2 : onRight
|
||||
? Math.min(slide.offsetWidth - width - margin, Math.max(slide.offsetWidth * .36, x + w + gap))
|
||||
: Math.max(margin, Math.min(slide.offsetWidth * .36, x - gap - width));
|
||||
const top = areaTop + (areaHeight - height) / 2 + 10;
|
||||
Object.assign(frame.style, { left: `${left}px`, top: `${top}px`, width: `${width}px`, height: `${height}px` });
|
||||
if (!detailed) Object.assign(copy.style, { width: `${w}px`, height: `${h}px`, transform: `scale(${zoom})` });
|
||||
const zoneTitle = source.dataset.lensCaption || source.closest('.zone')?.querySelector('.zlabel')?.textContent || 'Detail';
|
||||
caption.textContent = detailed ? zoneTitle : `${zoneTitle} · ${zoom.toFixed(1)}×`;
|
||||
guides.setAttribute('viewBox', `0 0 ${slide.offsetWidth} ${slide.offsetHeight}`);
|
||||
const sourceEdge = onRight ? x + w : x;
|
||||
const lensEdge = onRight ? left : left + width;
|
||||
guides.querySelector('path').setAttribute('d', `M ${sourceEdge} ${y} L ${lensEdge} ${top} M ${sourceEdge} ${y + h} L ${lensEdge} ${top + height}`);
|
||||
current.origin = `translate(${x - left}px, ${y - top}px) scale(${w / width}, ${h / height})`;
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!current) return;
|
||||
const { slide, source, trigger, frame, guides, animation } = current;
|
||||
animation?.cancel();
|
||||
const restoreFocus = frame.contains(document.activeElement);
|
||||
frame.remove(); guides.remove();
|
||||
slide.classList.remove('lens-active'); source.classList.remove('lens-source');
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
current = null;
|
||||
if (restoreFocus && slide.classList.contains('present')) trigger.focus({ preventScroll: true });
|
||||
changed();
|
||||
}
|
||||
|
||||
function open(trigger) {
|
||||
const source = document.getElementById(trigger.dataset.lens);
|
||||
const slide = source?.closest('section');
|
||||
if (!source || !slide?.classList.contains('present')) return;
|
||||
close();
|
||||
const details = source.querySelector('template.lens-details');
|
||||
const title = source.dataset.lensTitle || source.querySelector('b').textContent;
|
||||
const copy = document.createElement('div');
|
||||
if (details) {
|
||||
copy.className = 'slide-lens-detail';
|
||||
const heading = document.createElement('h3');
|
||||
heading.textContent = title;
|
||||
copy.append(heading, details.content.cloneNode(true));
|
||||
} else {
|
||||
copy.className = source.className;
|
||||
copy.classList.remove('lens-target', 'lens-source');
|
||||
copy.innerHTML = source.innerHTML;
|
||||
}
|
||||
copy.querySelectorAll('[id]').forEach(el => el.removeAttribute('id'));
|
||||
copy.querySelectorAll('button').forEach(el => el.remove());
|
||||
copy.classList.add('slide-lens-copy');
|
||||
const frame = document.createElement('div');
|
||||
frame.id = 'slide-lens'; frame.className = 'slide-lens';
|
||||
frame.classList.toggle('has-details', Boolean(details));
|
||||
frame.setAttribute('role', 'region');
|
||||
frame.setAttribute('aria-label', details ? `${title} explained` : `${title} enlarged`);
|
||||
const caption = document.createElement('div'); caption.className = 'slide-lens-caption';
|
||||
const content = document.createElement('div'); content.className = 'slide-lens-content';
|
||||
content.append(copy);
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.className = 'slide-lens-close'; closeButton.textContent = '×';
|
||||
closeButton.setAttribute('aria-label', 'Close detail');
|
||||
frame.append(caption, content, closeButton);
|
||||
const guides = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
guides.classList.add('slide-lens-guides'); guides.setAttribute('aria-hidden', 'true');
|
||||
guides.append(document.createElementNS(guides.namespaceURI, 'path'));
|
||||
slide.append(guides, frame);
|
||||
current = { source, slide, trigger, frame, copy, guides, caption, detailed: Boolean(details) };
|
||||
layout();
|
||||
source.classList.add('lens-source'); slide.classList.add('lens-active');
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
if (!reducedMotion()) {
|
||||
current.animation = frame.animate([
|
||||
{ transform: current.origin, opacity: .5 },
|
||||
{ transform: 'none', opacity: 1 },
|
||||
], { duration: 480, easing: 'cubic-bezier(.16, 1, .3, 1)' });
|
||||
guides.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 480 });
|
||||
}
|
||||
changed();
|
||||
}
|
||||
window.SlideLens = {
|
||||
open, close, layout,
|
||||
get active() { return current ? { kind: 'lens', key: current.source.id } : null; },
|
||||
};
|
||||
addEventListener('resize', layout);
|
||||
document.addEventListener('transitionend', event => {
|
||||
if (event.target === current?.source && event.propertyName === 'transform') layout();
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,70 @@
|
||||
# Presenter console
|
||||
|
||||
For the published AritmoLab site and routine Git updates, see
|
||||
[Presentation publishing](../../docs/operations/presentation-publishing.md).
|
||||
|
||||
Serve `presentation/` using the same local server as the deck:
|
||||
|
||||
```sh
|
||||
python3 -m http.server 8000 --bind 127.0.0.1 --directory presentation
|
||||
```
|
||||
|
||||
Open <http://localhost:8000/presenter/> in Chrome or another modern desktop browser.
|
||||
Select **Open audience window**, move the new window to the HDMI display, then click
|
||||
**Full screen** there. Keep the console on the Mac display. Use extended displays,
|
||||
not mirroring. Both windows must use the same browser profile and origin.
|
||||
|
||||
The console provides interactive slide preview, notes with adjustable type size,
|
||||
slide navigation, per-slide popup buttons, a manually started timer, and a choice
|
||||
between notes beside or below the preview. Notes fill the available horizontal space.
|
||||
Up next includes a live, non-interactive thumbnail of the following slide in both
|
||||
layouts; at the last slide it is replaced by the end-of-presentation message.
|
||||
With notes beside the preview, the current preview keeps the height calculated as
|
||||
65% of the space available without popup controls. It resizes with the window,
|
||||
not with the number of popups. The next preview takes the space left after the
|
||||
current preview, navigation, popup buttons and labels, and never renders larger
|
||||
than the current one.
|
||||
Popup controls contain only numbered
|
||||
buttons and disappear entirely on slides without popups. The current preview omits
|
||||
the 34px slide footer; the audience slide remains unchanged.
|
||||
Arrow keys change slides; Escape closes
|
||||
the popup. Changing slide closes any open popup. Notes scroll independently and
|
||||
return to the beginning when the slide changes. The reading area contains only the
|
||||
spoken narrative, without slide titles or popup headings. Parenthesized numbers
|
||||
on slide 2 and slides 7–10 identify the popup button for each passage.
|
||||
Screenshot-specific notes still follow the selected popup; use the controls below
|
||||
the preview to open popups.
|
||||
|
||||
Popup controls and clickable images open only on click
|
||||
or explicit keyboard activation (Enter, Space or the numbered shortcuts).
|
||||
Hover and keyboard focus may highlight a control but never open or switch a popup.
|
||||
On slide 07,
|
||||
the five numbered controls open the corresponding Validation detail, centered on
|
||||
the slide with the shared lens effect. The fifth
|
||||
detail explains all three advantages; on the slide, the advantages panel highlights
|
||||
on hover and opens on click. A detail
|
||||
stays open when the pointer leaves, until another detail is selected, Escape or
|
||||
Close is used, or the slide changes. The preview and audience show the same detail.
|
||||
|
||||
Slide 08 has four numbered screenshot controls, in the supplied tour order.
|
||||
Click a thumbnail or a console button to open
|
||||
the original screenshot almost full screen. Keys 1–4 also select screenshots;
|
||||
Escape or Close returns to the slide. Preview and audience share the selection.
|
||||
The four English descriptions form one continuous script.
|
||||
|
||||
The preview and audience communicate over a session-specific `BroadcastChannel`.
|
||||
The session is in the console URL; the latest slide and popup are retained in local
|
||||
storage for refresh recovery. A fresh `/presenter/` URL starts a separate session.
|
||||
The normal `/deck/index.html` page does not join any session. This console is for
|
||||
windows in one browser on one computer, not remote tablet synchronization.
|
||||
|
||||
If the audience window is blocked, allow popups for localhost and click the button
|
||||
again. Closing that window leaves the console usable for rehearsal; reopen it to
|
||||
resume at the current slide. The audience also remains navigable if the console
|
||||
is closed. Keep the local server running throughout the presentation.
|
||||
|
||||
Source files:
|
||||
|
||||
- `index.html`, `presenter.css`, `presenter.js`: presenter interface.
|
||||
- `../deck/presenter-bridge.js`: optional synchronization bridge.
|
||||
- `PresentationControls` in `../deck/index.html`: slide notes and popup interface.
|
||||
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AritmoLab | Presenter console</title>
|
||||
<link rel="stylesheet" href="../deck/fonts.css">
|
||||
<link rel="stylesheet" href="presenter.css?v=6">
|
||||
<script src="presenter.js?v=23" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="toolbar">
|
||||
<div class="identity"><strong>AritmoLab</strong><span>Presenter console</span></div>
|
||||
<div class="timer"><output id="elapsed" aria-label="Elapsed time">00:00</output><button id="timer-toggle">Start timer</button><button id="timer-reset" aria-label="Reset timer">Reset</button></div>
|
||||
<button id="open-audience" class="primary">Open audience window ↗</button>
|
||||
</header>
|
||||
<div class="connection"><span id="status" role="status">Loading presentation…</span><span>Keep this window on your Mac. Move the audience window to HDMI, then choose Full screen.</span></div>
|
||||
<main>
|
||||
<section class="stage" aria-label="Slide preview and controls">
|
||||
<div class="section-heading"><h2>On screen <span id="counter">01 / 14</span></h2><label class="sr-only" for="slide-picker">Jump to slide</label><select id="slide-picker" disabled><option>Loading slides…</option></select></div>
|
||||
<div class="preview-shell"><iframe id="preview" title="Interactive slide preview" allow="fullscreen"></iframe></div>
|
||||
<div class="slide-controls">
|
||||
<div class="navigation"><button id="previous" disabled>← Previous</button><button id="next" class="primary" disabled>Next slide →</button><button id="close-popup" disabled hidden>Close popup <kbd>Esc</kbd></button></div>
|
||||
<div id="popup-controls" aria-label="Slide popups" hidden></div>
|
||||
</div>
|
||||
<div class="up-next">
|
||||
<div class="next-preview" id="next-preview-shell" inert aria-hidden="true">
|
||||
<iframe id="next-preview" title="Next slide thumbnail" tabindex="-1"></iframe>
|
||||
</div>
|
||||
<div><span>UP NEXT</span><p id="next-title">Loading…</p></div>
|
||||
</div>
|
||||
<p id="error" role="alert" hidden></p>
|
||||
</section>
|
||||
<section class="script" aria-labelledby="notes-heading">
|
||||
<div class="section-heading"><h2 id="notes-heading">Speaker notes</h2><div class="reading-controls"><button id="smaller" aria-label="Smaller notes">A−</button><button id="larger" aria-label="Larger notes">A+</button><label class="sr-only" for="layout">Notes layout</label><select id="layout"><option value="side">Notes beside preview</option><option value="below">Notes below preview</option></select></div></div>
|
||||
<div id="reading" tabindex="0" aria-label="Scrollable speaker notes"><article id="notes">The slide and its speaker notes will appear here.</article></div>
|
||||
<footer><span>Notes are visible only in this window.</span><span><kbd>←</kbd> <kbd>→</kbd> Change slide · <kbd>Esc</kbd> Close popup</span></footer>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,87 @@
|
||||
:root { color-scheme: light; --bg: oklch(96.9% .006 20); --paper: oklch(99.1% .003 20); --ink: oklch(28% .012 10); --muted: oklch(49% .015 10); --line: oklch(86% .009 10); --accent: oklch(43% .105 12); --tint: oklch(93% .025 12); --notes-size: 25px; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; width: 100%; background: var(--bg); color: var(--ink); font: 16px/1.45 'Source Sans 3', system-ui, sans-serif; height: 100dvh; display: flex; flex-direction: column; }
|
||||
button, select { font: inherit; color: inherit; background: var(--paper); border: 1px solid var(--line); border-radius: 6px; min-height: 38px; padding: 7px 12px; }
|
||||
button { cursor: pointer; } button:hover:not(:disabled), select:hover { background: var(--tint); } button:disabled { opacity: .45; cursor: default; }
|
||||
button.primary { background: var(--accent); color: var(--paper); border-color: var(--accent); font-weight: 600; } button.primary:hover { background: oklch(36% .10 12); }
|
||||
:focus-visible { outline: 3px solid oklch(62% .14 12); outline-offset: 3px; }
|
||||
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 16px 24px; background: var(--paper); border-bottom: 1px solid var(--line); }
|
||||
.identity { display: flex; gap: 16px; align-items: baseline; } .identity strong { font-size: 23px; letter-spacing: -.6px; } .identity span { color: var(--muted); }
|
||||
.timer { display: flex; align-items: center; gap: 8px; } .timer output { font-variant-numeric: tabular-nums; font-size: 24px; margin-right: 8px; } .timer button { border: 0; background: transparent; padding-inline: 7px; }
|
||||
.connection { padding: 9px 24px; display: flex; flex-wrap: wrap; gap: 6px 24px; font-size: 13px; color: var(--muted); border-bottom: 1px solid var(--line); }
|
||||
#status { font-weight: 600; color: var(--accent); } #status.connected { color: oklch(40% .075 160); }
|
||||
main { display: grid; grid-template-columns: minmax(340px, 44%) minmax(0, 1fr); flex: 1; min-height: 0; }
|
||||
.stage { padding: 16px 20px; overflow-y: auto; min-width: 0; display: grid; grid-template-rows: auto var(--current-preview-height, 0px) auto minmax(0, 1fr) auto; gap: 10px; } .section-heading { display: flex; gap: 12px; align-items: center; justify-content: space-between; margin-bottom: 0; }
|
||||
h2 { margin: 0; font-size: 16px; font-weight: 600; white-space: nowrap; } #counter { color: var(--muted); font-weight: 400; font-variant-numeric: tabular-nums; margin-left: 8px; }
|
||||
#slide-picker { min-width: 0; width: 52%; text-overflow: ellipsis; font-size: 14px; }
|
||||
.preview-shell, .next-preview { position: relative; width: 100%; aspect-ratio: 16/9; overflow: hidden; background: var(--paper); border: 1px solid var(--line); }
|
||||
.stage > .preview-shell { width: 100%; height: 100%; min-height: 0; aspect-ratio: auto; justify-self: center; border: 0; background: transparent; }
|
||||
#preview, #next-preview { position: absolute; left: 50%; top: 50%; width: 1280px; height: 720px; transform-origin: center; border: 0; display: block; }
|
||||
#next-preview { pointer-events: none; }
|
||||
#preview { height: 686px; }
|
||||
.next-preview[hidden] { display: none; }
|
||||
.navigation { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .navigation button { min-height: 36px; }
|
||||
.navigation:has(#close-popup:not([hidden])) { grid-template-columns: 1fr 1fr auto; }
|
||||
#close-popup { font-size: 12px; padding-inline: 8px; }
|
||||
#popup-controls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
#popup-controls:not([hidden]) { margin-top: 8px; }
|
||||
#popup-controls[hidden] { display: none; }
|
||||
#popup-controls button { display: flex; align-items: center; gap: 3px; text-align: left; font-size: 15px; font-weight: 700; line-height: 1.2; min-height: 38px; padding: 4px 6px; width: 100%; overflow-wrap: anywhere; }
|
||||
#popup-controls button > span:last-child { min-width: 0; }
|
||||
.popup-number { font-size: 23px; font-weight: 800; line-height: 1; flex: none; }
|
||||
#popup-controls button[aria-pressed="true"] { background: var(--tint); border-color: var(--accent); color: var(--accent); }
|
||||
.popup-group { min-width: 0; }
|
||||
.popup-buttons { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px; }
|
||||
.popup-group-lens { grid-column: 1 / -1; }
|
||||
.popup-group-lens .popup-buttons { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
.popup-group-lens .popup-buttons:has(> button:nth-child(4):last-child) { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.popup-group-screenshot { grid-column: 1 / -1; }
|
||||
.popup-group-screenshot .popup-buttons { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
.popup-group-screenshot .popup-buttons:has(> button:nth-child(12):last-child) { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
#popup-controls .popup-group-screenshot button { justify-content: center; }
|
||||
.popup-group-screenshot button > span { display: none; }
|
||||
#popup-controls .popup-group-lens button { flex-direction: column; justify-content: center; text-align: center; }
|
||||
.popup-group-lens button > span:nth-child(2) { display: none; }
|
||||
.popup-group-ai { grid-column: 1 / -1; } .popup-group-ai .popup-buttons { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.control-ai-icon { width: 27px; height: 27px; flex: 0 0 27px; margin-right: 3px; }
|
||||
.up-next { border-top: 1px solid var(--line); padding-top: 10px; display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: 6px; min-height: 0; }
|
||||
.up-next .next-preview { height: 100%; min-height: 0; aspect-ratio: auto; border: 0; background: transparent; }
|
||||
.up-next > div:last-child { display: flex; align-items: baseline; gap: 12px; min-width: 0; }
|
||||
.up-next span, .slide-label { color: var(--muted); font-size: 12px; letter-spacing: .09em; }
|
||||
.up-next span { flex: none; } .up-next p { margin: 0; font-size: 16px; overflow-wrap: anywhere; }
|
||||
.up-next:has(.next-preview[hidden]) { grid-template-rows: auto; align-content: start; }
|
||||
@media (min-width: 621px) {
|
||||
/* The current preview keeps its no-popup height; the next takes the remainder. */
|
||||
body:not([data-layout="below"]) .up-next { display: contents; }
|
||||
body:not([data-layout="below"]) .next-preview { grid-row: 4; }
|
||||
body:not([data-layout="below"]) .up-next > div:last-child { grid-row: 5; }
|
||||
body:not([data-layout="below"]) #next-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
}
|
||||
.script { background: var(--paper); border-left: 1px solid var(--line); display: flex; flex-direction: column; min-height: 0; min-width: 0; }
|
||||
.script > .section-heading { margin: 0; padding: 20px 28px 16px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
|
||||
.reading-controls { display: flex; align-items: center; gap: 6px; } .reading-controls select { font-size: 13px; max-width: 185px; } .reading-controls button { font-size: 17px; }
|
||||
#reading { overflow: auto; padding: 22px 28px 40px; flex: 1; min-width: 0; scroll-behavior: auto; } h1 { font-size: 27px; line-height: 1.2; letter-spacing: -.3px; margin: 8px 0 24px; overflow-wrap: anywhere; }
|
||||
#notes { font-size: var(--notes-size); line-height: 1.48; white-space: normal; width: 100%; overflow-wrap: anywhere; } #notes p { margin: 0 0 .85em; } #notes strong { font-weight: 600; } #notes .notes-authored { white-space: pre-line; } .notes-draft { font-size: 13px; color: var(--muted); margin-bottom: 16px; }
|
||||
#notes [data-note-lens], #notes [data-note-screenshot] {
|
||||
min-height: 0; padding: 0; border: 0; border-bottom: 1px dotted currentColor; border-radius: 0;
|
||||
background: transparent; color: var(--accent); font: inherit; font-weight: 600;
|
||||
text-align: left; cursor: pointer;
|
||||
}
|
||||
#notes [data-note-lens]:hover, #notes [data-note-screenshot]:hover { background: var(--tint); }
|
||||
#notes [data-note-lens]:focus-visible, #notes [data-note-screenshot]:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
|
||||
footer { border-top: 1px solid var(--line); padding: 12px 28px; font-size: 12px; color: var(--muted); display: flex; flex-wrap: wrap; gap: 6px 20px; justify-content: space-between; }
|
||||
kbd { font: 12px system-ui; background: var(--bg); border: 1px solid var(--line); border-radius: 3px; padding: 1px 4px; } #error { color: var(--accent); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||
body[data-layout="below"] main { grid-template-columns: 1fr; grid-template-rows: minmax(240px, 42%) minmax(0, 1fr); }
|
||||
body[data-layout="below"] .stage { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) minmax(150px, .65fr); grid-template-rows: auto minmax(0, 1fr); column-gap: 24px; padding: 14px 24px; }
|
||||
body[data-layout="below"] .stage > .section-heading { grid-column: 1; }
|
||||
body[data-layout="below"] .preview-shell { grid-column: 1; grid-row: 2; width: 100%; height: 100%; aspect-ratio: auto; min-height: 100px; }
|
||||
body[data-layout="below"] .slide-controls { grid-column: 2; grid-row: 1 / 3; overflow: auto; min-height: 0; }
|
||||
body[data-layout="below"] .navigation { margin: 0 0 16px; }
|
||||
body[data-layout="below"] .up-next { grid-column: 3; grid-row: 1 / 3; border-top: 0; padding-top: 0; overflow: auto; }
|
||||
body[data-layout="below"] .up-next > div:last-child { display: block; }
|
||||
body[data-layout="below"] .stage .hint { display: none; } body[data-layout="below"] .script { border-left: 0; border-top: 1px solid var(--line); }
|
||||
@media (max-width: 850px) { .identity span, .timer #timer-reset { display: none; } .toolbar { gap: 10px; padding: 12px; } main { grid-template-columns: minmax(280px, 42%) minmax(0,1fr); } .stage { padding: 16px; } #reading { padding: 22px; } .script > .section-heading { padding: 16px; } }
|
||||
@media (max-width: 1000px) { body[data-layout="below"] .stage { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: auto auto auto; overflow: auto; } body[data-layout="below"] .preview-shell { height: auto; aspect-ratio: 16/9; } body[data-layout="below"] .up-next { grid-column: 1; grid-row: 3; display: flex; padding-top: 14px; } body[data-layout="below"] .up-next .next-preview { margin: 0; } body[data-layout="below"] .slide-controls { grid-row: 1 / 4; overflow: visible; } }
|
||||
@media (max-width: 620px) { body { height: auto; min-height: 100dvh; } .toolbar { flex-wrap: wrap; } main, body[data-layout="below"] main { display: block; } body[data-layout="below"] .stage { display: block; } body[data-layout="below"] .navigation { margin-top: 14px; } .script { min-height: 65vh; } .connection { padding: 10px 12px; } #reading { max-height: 70vh; } .timer output { font-size: 20px; } .section-heading { flex-wrap: wrap; } .reading-controls { flex-wrap: wrap; } #slide-picker { width: 100%; } }
|
||||
@media (max-width: 620px) { .stage { display: block; } .stage > .preview-shell, body[data-layout="below"] .stage > .preview-shell { width: 100%; aspect-ratio: 1280/686; height: auto; margin: 12px 0; } .up-next, body[data-layout="below"] .up-next { display: grid; grid-template-rows: auto auto; margin-top: 12px; } .up-next .next-preview { aspect-ratio: 16/9; height: auto; } }
|
||||
@@ -0,0 +1,262 @@
|
||||
(() => {
|
||||
const $ = id => document.getElementById(id);
|
||||
const params = new URLSearchParams(location.search);
|
||||
const session = params.get('session') || crypto.randomUUID();
|
||||
params.set('session', session);
|
||||
history.replaceState(null, '', `${location.pathname}?${params}`);
|
||||
const channel = new BroadcastChannel(`aritmolab-presenter:${session}`);
|
||||
const sender = crypto.randomUUID();
|
||||
const send = (type, extra = {}) => channel.postMessage({ type, role: 'console', sender, ...extra });
|
||||
const command = (action, extra = {}) => send('command', { action, ...extra });
|
||||
const preview = $('preview');
|
||||
const nextPreview = $('next-preview');
|
||||
let nextPreviewReady = false;
|
||||
let slides = [];
|
||||
let current = null;
|
||||
let currentNoteKey = null;
|
||||
let audience = null;
|
||||
let lastAudienceSeen = 0;
|
||||
let lastPreviewSeen = 0;
|
||||
let notesSize = 25;
|
||||
let blocked = false;
|
||||
const urlFor = role => `../deck/index.html?v=19&view=${role}&session=${encodeURIComponent(session)}`;
|
||||
const pad = value => String(value).padStart(2, '0');
|
||||
function renderNotes(html) {
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = html;
|
||||
const target = $('notes');
|
||||
target.replaceChildren();
|
||||
// Preserve authored rich text when available; plain scripts get reading paragraphs.
|
||||
if (template.content.querySelector('p, ul, ol, h3, blockquote')) {
|
||||
target.append(template.content.cloneNode(true));
|
||||
return;
|
||||
}
|
||||
if (template.content.querySelector('*')) {
|
||||
const script = document.createElement('div');
|
||||
script.className = 'notes-authored';
|
||||
script.append(template.content.cloneNode(true));
|
||||
target.append(script);
|
||||
return;
|
||||
}
|
||||
let text = template.content.textContent.trim();
|
||||
const draft = text.match(/^\[DRAFT[^\]]*\]/);
|
||||
if (draft) {
|
||||
const label = document.createElement('div');
|
||||
label.className = 'notes-draft';
|
||||
label.textContent = draft[0];
|
||||
target.append(label, document.createTextNode('\n'));
|
||||
text = text.slice(draft[0].length).trim();
|
||||
}
|
||||
const segmenter = new Intl.Segmenter('en', { granularity: 'sentence' });
|
||||
const paragraphs = [];
|
||||
for (const line of text.split(/\n+/).map(line => line.trim()).filter(Boolean)) {
|
||||
let paragraph = '';
|
||||
for (const { segment } of segmenter.segment(line)) {
|
||||
if (paragraph.length >= 240 && paragraph.length + segment.length > 390) {
|
||||
paragraphs.push(paragraph.trim());
|
||||
paragraph = '';
|
||||
}
|
||||
paragraph += segment;
|
||||
}
|
||||
if (paragraph.trim()) paragraphs.push(paragraph.trim());
|
||||
}
|
||||
for (const text of paragraphs.length ? paragraphs : ['No speaker notes for this slide.']) {
|
||||
const paragraph = document.createElement('p');
|
||||
for (const part of text.split(/(\b(?:Cardioref|Omics Portal|AritmoLab|ThothII|Tier 1|Tier 2)\b)/g)) {
|
||||
if (/^(Cardioref|Omics Portal|AritmoLab|ThothII|Tier 1|Tier 2)$/.test(part)) {
|
||||
const strong = document.createElement('strong'); strong.textContent = part; paragraph.append(strong);
|
||||
} else paragraph.append(document.createTextNode(part));
|
||||
}
|
||||
target.append(paragraph, document.createTextNode('\n'));
|
||||
}
|
||||
}
|
||||
function updateNextPreview() {
|
||||
const index = (current?.index ?? 0) + 1;
|
||||
const hasNext = index < slides.length;
|
||||
$('next-preview-shell').hidden = !hasNext;
|
||||
if (hasNext && nextPreviewReady) nextPreview.contentWindow.Reveal.slide(index);
|
||||
}
|
||||
function render(state) {
|
||||
if (!state || !slides[state.index]) return;
|
||||
const changed = !current || current.index !== state.index;
|
||||
current = state;
|
||||
const slide = slides[state.index];
|
||||
$('counter').textContent = `${pad(state.index + 1)} / ${pad(slides.length)}`;
|
||||
$('slide-picker').value = state.index;
|
||||
$('previous').disabled = state.index === 0;
|
||||
$('next').disabled = state.index === slides.length - 1;
|
||||
$('close-popup').disabled = !state.popup;
|
||||
if (changed) {
|
||||
// Trusted, same-origin authored notes, never channel-provided HTML.
|
||||
if (!slide.popupNotes?.length) renderNotes(slide.notes);
|
||||
currentNoteKey = null;
|
||||
$('reading').scrollTop = 0;
|
||||
$('next-title').textContent = slides[state.index + 1]?.title || 'End of presentation';
|
||||
updateNextPreview();
|
||||
$('popup-controls').replaceChildren();
|
||||
$('popup-controls').hidden = !slide.popups.length;
|
||||
$('close-popup').hidden = !slide.popups.length;
|
||||
let popupNumber = 0;
|
||||
const groups = ['lens', 'screenshot', 'start', 'missing', 'ai'].flatMap(kind => {
|
||||
const popups = slide.popups.filter(popup => popup.kind === kind);
|
||||
return popups.length ? [{ kind, popups }] : [];
|
||||
});
|
||||
for (const { kind, popups } of groups) {
|
||||
const group = document.createElement('section');
|
||||
group.className = `popup-group popup-group-${kind}`;
|
||||
group.setAttribute('aria-label', { lens: 'Enlargements', screenshot: 'Screenshots', start: 'Source systems', missing: 'Missing pieces', ai: 'AI contributions' }[kind]);
|
||||
const buttons = document.createElement('div');
|
||||
buttons.className = 'popup-buttons';
|
||||
group.append(buttons);
|
||||
$('popup-controls').append(group);
|
||||
for (const popup of popups) {
|
||||
const button = document.createElement('button');
|
||||
const number = document.createElement('strong');
|
||||
number.className = 'popup-number';
|
||||
number.textContent = ++popupNumber;
|
||||
const separator = document.createElement('span');
|
||||
separator.textContent = ' - ';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = popup.title.split(' — ')[0];
|
||||
button.append(number, separator, label);
|
||||
if (kind === 'ai') {
|
||||
const icon = document.createElement('img');
|
||||
icon.src = '../deck/artificial-brain.svg'; icon.alt = ''; icon.className = 'control-ai-icon';
|
||||
button.insertBefore(icon, number);
|
||||
}
|
||||
button.title = popup.title;
|
||||
button.dataset.kind = popup.kind;
|
||||
button.dataset.key = popup.key;
|
||||
button.onclick = () => {
|
||||
if (kind === 'lens') { command('popup', { popup: { kind: popup.kind, key: popup.key } }); return; }
|
||||
if (current?.popup?.kind === popup.kind && current.popup.key === popup.key) command('close');
|
||||
else command('popup', { popup: { kind: popup.kind, key: popup.key } });
|
||||
};
|
||||
buttons.append(button);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (slide.popupNotes?.length) {
|
||||
// Keep the last selected notes readable when its screenshot is closed.
|
||||
const note = slide.popupNotes.find(note => state.popup?.kind === 'screenshot' && note.key === state.popup.key)
|
||||
|| slide.popupNotes.find(note => note.key === currentNoteKey)
|
||||
|| slide.popupNotes[0];
|
||||
if (note.key !== currentNoteKey) {
|
||||
currentNoteKey = note.key;
|
||||
renderNotes(note.html);
|
||||
$('reading').scrollTop = 0;
|
||||
}
|
||||
}
|
||||
$('popup-controls').querySelectorAll('button').forEach(button => {
|
||||
button.setAttribute('aria-pressed', String(state.popup?.kind === button.dataset.kind && state.popup?.key === button.dataset.key));
|
||||
});
|
||||
}
|
||||
channel.onmessage = ({ data }) => {
|
||||
if (!data || data.sender === sender) return;
|
||||
if (data.role === 'audience') lastAudienceSeen = data.type === 'departed' ? 0 : Date.now();
|
||||
if (data.role === 'preview') lastPreviewSeen = data.type === 'departed' ? 0 : Date.now();
|
||||
if (data.type === 'state') render(data.state);
|
||||
updateStatus();
|
||||
};
|
||||
function updateStatus() {
|
||||
const connected = Date.now() - lastAudienceSeen < 4500;
|
||||
const previewReady = slides.length > 0 && Date.now() - lastPreviewSeen < 4500;
|
||||
$('status').textContent = !previewReady ? 'Connecting to slide preview…' : connected ? 'Audience connected' : blocked ? 'Window blocked. Allow popups and click Open audience window again.' : 'Preview only. Open the audience window to present.';
|
||||
$('status').classList.toggle('connected', connected);
|
||||
}
|
||||
$('open-audience').onclick = () => {
|
||||
if (audience && !audience.closed) { audience.focus(); return; }
|
||||
audience = window.open(urlFor('audience'), `aritmolab-audience-${session}`, 'popup=yes,width=1280,height=760');
|
||||
blocked = !audience;
|
||||
updateStatus();
|
||||
};
|
||||
preview.addEventListener('load', async () => {
|
||||
try {
|
||||
const win = preview.contentWindow;
|
||||
if (!win.Reveal) throw new Error('Slide preview could not load. Check that the local server is running.');
|
||||
if (!win.Reveal.isReady()) await new Promise(resolve => win.Reveal.on('ready', resolve));
|
||||
slides = win.PresentationControls.slides();
|
||||
$('slide-picker').replaceChildren(...slides.map((slide, index) => new Option(`${pad(index + 1)} · ${slide.title}`, index)));
|
||||
$('slide-picker').disabled = false;
|
||||
current = null;
|
||||
render(win.PresentationControls.snapshot());
|
||||
send('request');
|
||||
} catch (error) { $('error').hidden = false; $('error').textContent = error.message; }
|
||||
});
|
||||
preview.src = urlFor('preview');
|
||||
nextPreview.addEventListener('load', async () => {
|
||||
const reveal = nextPreview.contentWindow.Reveal;
|
||||
if (!reveal) return;
|
||||
if (!reveal.isReady()) await new Promise(resolve => reveal.on('ready', resolve));
|
||||
reveal.configure({ keyboard: false, touch: false, overview: false, transition: 'none' });
|
||||
nextPreviewReady = true;
|
||||
updateNextPreview();
|
||||
});
|
||||
// No session token: the thumbnail must never navigate or publish to the audience.
|
||||
nextPreview.src = '../deck/index.html?v=19&view=thumbnail#/1';
|
||||
const stage = document.querySelector('.stage');
|
||||
function sizeCurrentPreview() {
|
||||
const style = getComputedStyle(stage);
|
||||
const navigation = getComputedStyle($('previous'));
|
||||
// Use the one-line navigation height, never the popup-dependent controls height.
|
||||
const navigationHeight = Math.max(parseFloat(navigation.minHeight),
|
||||
parseFloat(navigation.lineHeight) + parseFloat(navigation.paddingTop) +
|
||||
parseFloat(navigation.paddingBottom) + parseFloat(navigation.borderTopWidth) +
|
||||
parseFloat(navigation.borderBottomWidth));
|
||||
const available = stage.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)
|
||||
- stage.querySelector('.section-heading').getBoundingClientRect().height
|
||||
- navigationHeight - parseFloat(getComputedStyle($('next-title')).lineHeight)
|
||||
- 4 * parseFloat(style.rowGap);
|
||||
// Do not reserve empty bands when the slide is already limited by column width.
|
||||
const widthLimitedHeight = (stage.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)) * 686 / 1280;
|
||||
stage.style.setProperty('--current-preview-height', `${Math.max(0, Math.min(available * .65, widthLimitedHeight))}px`);
|
||||
}
|
||||
const stageSizes = new ResizeObserver(sizeCurrentPreview);
|
||||
stageSizes.observe(stage);
|
||||
stageSizes.observe(stage.querySelector('.section-heading'));
|
||||
stageSizes.observe($('previous'));
|
||||
sizeCurrentPreview();
|
||||
function fitPreviews() {
|
||||
const currentBox = preview.parentElement;
|
||||
const nextBox = nextPreview.parentElement;
|
||||
const currentScale = Math.min(currentBox.clientWidth / 1280, currentBox.clientHeight / 686);
|
||||
// The next slide includes its footer: cap its rendered height as well as its width.
|
||||
const nextScale = Math.min(nextBox.clientWidth / 1280, nextBox.clientHeight / 720, currentScale * 686 / 720);
|
||||
preview.style.transform = `translate(-50%, -50%) scale(${currentScale})`;
|
||||
nextPreview.style.transform = `translate(-50%, -50%) scale(${nextScale})`;
|
||||
}
|
||||
const previewSizes = new ResizeObserver(fitPreviews);
|
||||
for (const frame of [preview, nextPreview]) previewSizes.observe(frame.parentElement);
|
||||
$('previous').onclick = () => command('previous');
|
||||
$('next').onclick = () => command('next');
|
||||
$('close-popup').onclick = () => command('close');
|
||||
$('slide-picker').onchange = event => command('slide', { index: Number(event.target.value) });
|
||||
$('layout').onchange = event => { document.body.dataset.layout = event.target.value; };
|
||||
function fontSize(delta) {
|
||||
notesSize = Math.min(40, Math.max(18, notesSize + delta));
|
||||
document.documentElement.style.setProperty('--notes-size', `${notesSize}px`);
|
||||
$('smaller').disabled = notesSize === 18; $('larger').disabled = notesSize === 40;
|
||||
}
|
||||
$('smaller').onclick = () => fontSize(-2); $('larger').onclick = () => fontSize(2);
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.target.closest('input, select, textarea, [contenteditable="true"]')) return;
|
||||
if (/^[1-9]$/.test(event.key) && !event.repeat && !event.altKey && !event.ctrlKey && !event.metaKey) {
|
||||
const lens = [...$('popup-controls').querySelectorAll('[data-kind="lens"], [data-kind="screenshot"]')]
|
||||
.find(button => button.querySelector('.popup-number').textContent === event.key);
|
||||
if (lens) { event.preventDefault(); lens.click(); return; }
|
||||
}
|
||||
const action = { ArrowRight: 'next', ArrowLeft: 'previous', Escape: 'close' }[event.key];
|
||||
if (action) { event.preventDefault(); command(action); }
|
||||
});
|
||||
let elapsed = 0, started = null;
|
||||
$('timer-toggle').onclick = () => {
|
||||
if (started === null) { started = Date.now(); $('timer-toggle').textContent = 'Pause timer'; }
|
||||
else { elapsed += Date.now() - started; started = null; $('timer-toggle').textContent = 'Start timer'; }
|
||||
};
|
||||
$('timer-reset').onclick = () => { elapsed = 0; if (started !== null) started = Date.now(); };
|
||||
setInterval(() => {
|
||||
const seconds = Math.floor((elapsed + (started === null ? 0 : Date.now() - started)) / 1000);
|
||||
$('elapsed').textContent = `${pad(Math.floor(seconds / 60))}:${pad(seconds % 60)}`;
|
||||
updateStatus();
|
||||
}, 500);
|
||||
})();
|
||||
@@ -0,0 +1,58 @@
|
||||
<!doctype html>
|
||||
<!-- PROTOTYPE (throwaway) — contact sheet of the 14 deck slides, live iframes scaled down.
|
||||
Slide 04 shows the OLD deck layout: the approved new composition lives in slide05-outputs.html. -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>PROTOTYPE · Deck overview — 14 slides</title>
|
||||
<link rel="stylesheet" href="/deck/fonts.css">
|
||||
<style>
|
||||
:root { --bordeaux: #cb333b; --ink: #2a2426; --ink-muted: #6b6568; --line: #e3e0e1;
|
||||
--sans: 'Source Sans 3', system-ui, sans-serif; --mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; }
|
||||
body { margin: 0; background: #e9e7e7; font-family: var(--sans); padding: 22px 0 60px; }
|
||||
h1 { font: 700 13px/1 var(--mono); letter-spacing: .14em; text-transform: uppercase;
|
||||
color: var(--bordeaux); text-align: center; margin: 0 0 6px; }
|
||||
p.note { text-align: center; font: 400 12.5px/1.5 var(--sans); color: var(--ink-muted); margin: 0 0 22px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px 18px; max-width: 1180px; margin: 0 auto; padding: 0 16px; }
|
||||
.cell { background: #fff; box-shadow: 0 4px 16px rgba(42,36,38,.14); border-radius: 6px; overflow: hidden;
|
||||
cursor: pointer; }
|
||||
.cell:hover { outline: 3px solid var(--bordeaux); }
|
||||
.cell .bar { display: flex; align-items: baseline; gap: 8px; padding: 7px 10px 0;
|
||||
font: 600 11px/1.2 var(--sans); color: var(--ink); }
|
||||
.cell .bar .num { font: 700 10px/1 var(--mono); color: var(--bordeaux); }
|
||||
.cell .bar .st { margin-left: auto; font: 700 8.5px/1 var(--mono); letter-spacing: .1em; }
|
||||
.st.final { color: #2f6f4f; } .st.refined { color: var(--bordeaux); } .st.draft { color: #a08a3c; } .st.proto { color: var(--bordeaux-deep); }
|
||||
.shot { width: 100%; aspect-ratio: 16/9; position: relative; overflow: hidden; }
|
||||
.shot iframe { width: 1280px; height: 720px; border: 0; transform: scale(0.277); transform-origin: 0 0;
|
||||
pointer-events: none; position: absolute; top: 0; left: 0; }
|
||||
.cell.wide { grid-column: span 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Deck overview — 14 slides · AritmoLab</h1>
|
||||
<p class="note">Live previews of <b>/deck/index.html</b> · <b>click any slide to open it full-size</b>, then flip with the arrow keys.</p>
|
||||
<p class="note"><a href="/presenter/">Open presenter console</a> · Speaker notes, interactive preview and a separate audience window.</p>
|
||||
<div class="grid" id="grid"></div>
|
||||
<script>
|
||||
const TITLES = ['Title', 'Where we started', 'What we wanted to build', 'The problem',
|
||||
'What the text miner reads', 'Hard problems of clinical NLP', 'The clinical ontology',
|
||||
'Trust the text', 'AritmoLab today', 'All good? Not yet', 'ThothII', 'A tour of ThothII',
|
||||
'From datamarts to models', 'Thank you'];
|
||||
const STATUS = ['FINAL', 'FINAL', 'FINAL', 'FINAL', 'FINAL', 'FINAL', 'FINAL', 'REFINED',
|
||||
'DRAFT', 'DRAFT', 'DRAFT', 'DRAFT', 'DRAFT', 'DRAFT'];
|
||||
const CLS = ['final', 'final', 'final', 'final', 'final', 'final', 'final', 'refined',
|
||||
'draft', 'draft', 'draft', 'draft', 'draft', 'draft'];
|
||||
const grid = document.getElementById('grid');
|
||||
TITLES.forEach((t, i) => {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'cell';
|
||||
cell.title = 'Open slide ' + (i + 1) + ' full-size';
|
||||
cell.onclick = () => { location.href = '/deck/index.html?r=' + Date.now() + '#/' + i; };
|
||||
cell.innerHTML = `<div class="bar"><span class="num">${String(i + 1).padStart(2, '0')}</span><span>${t}</span>` +
|
||||
`<span class="st ${CLS[i]}">${STATUS[i]}</span></div>` +
|
||||
`<div class="shot"><iframe loading="lazy" src="/deck/index.html?v=28&ov=1#/${i}" title="${t}"></iframe></div>`;
|
||||
grid.appendChild(cell);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,229 @@
|
||||
<!doctype html>
|
||||
<!-- PROTOTYPE (throwaway) — Option C "Maturity rail", revised per user feedback:
|
||||
connector starts from the Marts stage · no live/next badges · uniform stroke
|
||||
intensity · texts inside boxes capitalized · EN translation same size as Italian.
|
||||
This is the only variant carried forward; A and B were discarded. -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>PROTOTYPE · Slide “The problem” — outputs rail from the datamarts (option C, revised)</title>
|
||||
<link rel="stylesheet" href="/deck/fonts.css">
|
||||
<style>
|
||||
:root {
|
||||
--bordeaux: #cb333b; --bordeaux-deep: #8f1204; --bordeaux-tint: #f4dfe1;
|
||||
--surface: #fdfcfc; --surface-alt: #f6f5f5; --surface-sunken: #ecebeb;
|
||||
--ink: #2a2426; --ink-muted: #6b6568;
|
||||
--line: #e3e0e1; --line-strong: #c9c5c6;
|
||||
--sans: 'Source Sans 3', system-ui, sans-serif;
|
||||
--display: 'Source Serif 4', Georgia, serif;
|
||||
--mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #e9e7e7; font-family: var(--sans); padding: 26px 0 90px; }
|
||||
.proto-head { text-align: center; margin: 0 0 14px; }
|
||||
.proto-head b { font: 700 12px/1 var(--mono); letter-spacing: .14em; text-transform: uppercase; color: var(--bordeaux); }
|
||||
.proto-head span { display: block; margin-top: 6px; font: 400 13px/1.4 var(--sans); color: var(--ink-muted); }
|
||||
|
||||
/* ---- slide frame (deck-like, 1200×690) ---- */
|
||||
.frame { width: 1200px; height: 690px; margin: 0 auto; background: var(--surface);
|
||||
display: flex; flex-direction: column; box-shadow: 0 8px 30px rgba(42,36,38,.18); overflow: hidden; }
|
||||
.head { flex: 0 0 74px; display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 40px; border-bottom: 1px solid var(--line); }
|
||||
.head img { height: 54px; display: block; }
|
||||
.head-org { font: 700 11px/1 var(--sans); letter-spacing: .14em; text-transform: uppercase; color: var(--ink-muted); }
|
||||
.band { flex: 0 0 42px; display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 40px; background: var(--bordeaux); color: var(--surface); }
|
||||
.band b { font: 700 12.5px/1 var(--sans); letter-spacing: .14em; text-transform: uppercase; }
|
||||
.sbody { flex: 1; min-height: 0; display: flex; flex-direction: column;
|
||||
padding: 20px 48px 14px; text-align: left; }
|
||||
.foot { flex: 0 0 30px; display: flex; align-items: center; justify-content: space-between; gap: 24px;
|
||||
padding: 0 40px; border-top: 1px solid var(--line);
|
||||
font: 600 9.5px/1.4 var(--sans); letter-spacing: .03em; color: var(--ink-muted); }
|
||||
.kicker { font: 700 11.5px/1 var(--sans); letter-spacing: .16em; text-transform: uppercase; color: var(--bordeaux); margin-bottom: 10px; }
|
||||
h2 { font: 700 27px/1.15 var(--sans); letter-spacing: -.01em; color: var(--ink); margin: 0 0 10px; }
|
||||
|
||||
.quote { position: relative; margin: 0 0 10px; padding: 14px 22px 11px 64px; border: 1px solid var(--line);
|
||||
background: var(--surface-alt); border-radius: 8px;
|
||||
font: italic 400 16.5px/1.5 var(--display); color: var(--ink); }
|
||||
.quote::before { content: '\201C'; position: absolute; left: 18px; top: 6px;
|
||||
font: 700 44px/1 var(--display); color: var(--bordeaux); }
|
||||
.tr-grid { display: grid; grid-template-columns: 1.12fr 1fr; gap: 22px; align-items: center; }
|
||||
.tr-grid .it, .tr-grid .en { margin: 0; }
|
||||
.tr-grid .en { padding-left: 18px; border-left: 1px solid var(--line-strong);
|
||||
font: 400 16.5px/1.5 var(--sans); color: var(--bordeaux-deep); }
|
||||
.en-tag { display: inline-block; margin-right: 7px; padding: 2px 7px; border-radius: 4px;
|
||||
background: var(--bordeaux); color: #fdfcfc; font: 700 9px/1 var(--sans); letter-spacing: .12em; vertical-align: 3px; }
|
||||
|
||||
.pipeline { display: flex; align-items: stretch; gap: 10px; }
|
||||
.stage { flex: 1; padding: 10px 8px; text-align: center; border: 1px solid var(--line); border-radius: 8px; background: var(--surface-alt); }
|
||||
.stage b { display: block; font: 700 12.5px/1.2 var(--sans); margin-bottom: 3px; text-transform: uppercase; }
|
||||
.stage span { display: block; font: 400 10.5px/1.35 var(--sans); color: var(--ink-muted); text-transform: uppercase; letter-spacing: .02em; }
|
||||
.stage .tag { display: inline-block; margin-top: 4px; padding: 2px 9px; border-radius: 9999px;
|
||||
background: var(--bordeaux); color: #fdfcfc; font: 700 8.5px/1 var(--sans); letter-spacing: .1em; text-transform: uppercase; }
|
||||
.brain { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px;
|
||||
border-radius: 9999px; background: #fff; border: 1.5px solid var(--bordeaux); }
|
||||
.brain svg { width: 26px; height: 26px; color: var(--bordeaux); }
|
||||
.stage.ai { background: var(--bordeaux-tint); border-color: var(--bordeaux); }
|
||||
.arrow { align-self: center; color: var(--line-strong); font: 700 17px/1 var(--sans); }
|
||||
.stage.hot { background: #fdf1f2; border-color: var(--bordeaux); }
|
||||
|
||||
.thoth-wrap { position: relative; display: flex; justify-content: flex-end; margin-top: 10px; }
|
||||
.thoth-col { width: 500px; text-align: center; }
|
||||
.thoth-box { border: 1px solid var(--bordeaux); border-radius: 8px; padding: 9px 14px;
|
||||
background: var(--surface-alt); font: 600 11.5px/1.4 var(--sans); text-transform: uppercase; letter-spacing: .02em; }
|
||||
.thoth-box .tag { display: inline-block; margin-right: 7px; padding: 2px 9px; border-radius: 9999px;
|
||||
background: var(--bordeaux); color: #fdfcfc; font: 700 8.5px/1 var(--sans); letter-spacing: .1em; text-transform: uppercase; }
|
||||
.t-conn { position: absolute; width: 2px; background: var(--bordeaux); }
|
||||
.t-head { position: absolute; width: 0; height: 0; border: 5px solid transparent;
|
||||
border-bottom: 7px solid var(--bordeaux); transform: translateX(-50%); }
|
||||
|
||||
/* ---- deep-dive concepts (center-left) ---- */
|
||||
.midrow { display: flex; gap: 28px; margin-top: 10px; align-items: flex-start; }
|
||||
.concepts { flex: 1; min-width: 0; }
|
||||
.concepts .lbl { font: 700 10px/1 var(--mono); letter-spacing: .14em; text-transform: uppercase;
|
||||
color: var(--bordeaux); margin: 0 0 4px; }
|
||||
.citem { display: flex; gap: 12px; align-items: baseline; padding: 6px 0; border-bottom: 1px solid var(--line); }
|
||||
.citem:last-child { border-bottom: none; }
|
||||
.citem .n { font: 700 11px/1 var(--mono); color: var(--bordeaux); }
|
||||
.citem b { font: 700 12.5px/1.3 var(--sans); color: var(--ink); text-transform: uppercase; letter-spacing: .03em; }
|
||||
.citem span.d { font: 400 11px/1.4 var(--sans); color: var(--ink-muted); margin-left: 8px; }
|
||||
|
||||
/* ---- outputs rail (option C, revised) ---- */
|
||||
.railwrap { position: relative; display: flex; justify-content: flex-end; }
|
||||
.conn-drop { position: absolute; top: -14px; width: 2px; background: var(--bordeaux); }
|
||||
.conn-elbow { position: absolute; height: 2px; background: var(--bordeaux); }
|
||||
.rail { width: 540px; border-left: 2px solid var(--bordeaux); padding-top: 14px; }
|
||||
.rail .lbl { font: 700 10px/1 var(--mono); letter-spacing: .14em; text-transform: uppercase;
|
||||
color: var(--bordeaux); margin: 0 0 6px 16px; }
|
||||
.rail .row { display: flex; align-items: center; gap: 12px; padding: 8px 14px 8px 16px; position: relative; }
|
||||
.rail .row::before { content: ''; position: absolute; left: -2px; top: 50%; width: 14px; height: 2px; background: var(--bordeaux); }
|
||||
.rail .row .oicon { flex: 0 0 26px; width: 26px; height: 26px; color: var(--bordeaux); display: block; }
|
||||
.rail .row b { font: 700 13.5px/1.2 var(--sans); color: var(--ink); text-transform: uppercase; letter-spacing: .03em; }
|
||||
.rail .row span.d { font: 400 11px/1.3 var(--sans); color: var(--ink-muted); }
|
||||
|
||||
.caption { max-width: 1200px; margin: 12px auto 0; text-align: center;
|
||||
font: 400 12.5px/1.5 var(--sans); color: var(--ink-muted); }
|
||||
.caption b { color: var(--ink); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="proto-head">
|
||||
<b>Prototype (revised) — slide “The problem” · outputs rail from the datamarts</b>
|
||||
<span>Option C only: connector from Marts · no status badges · uniform intensity · capitalized box texts.</span>
|
||||
</div>
|
||||
|
||||
<div class="frame">
|
||||
<div class="head">
|
||||
<img src="/deck/logo.png" alt="">
|
||||
<span class="head-org">AritmoLab · Policlinico San Donato</span>
|
||||
</div>
|
||||
<div class="band"><b>The problem</b><span style="font:700 11px/1 var(--sans)">04 · The problem</span></div>
|
||||
<div class="sbody">
|
||||
<div class="kicker">From free text to a research platform</div>
|
||||
<h2>The clinical truth lives in unstructured columns</h2>
|
||||
<div class="quote">
|
||||
<div class="tr-grid">
|
||||
<p class="it">“Il paziente riferisce sincope ricorrente; ECG basale con sopraslivellamento ST in V1–V3; test provocativo con flecainide positivo per pattern Brugada.”</p>
|
||||
<p class="en"><span class="en-tag">EN</span>“The patient reports recurrent syncope; baseline ECG with ST-segment elevation in V1–V3; flecainide provocation test positive for a Brugada pattern.”</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pipeline" id="pipeline">
|
||||
<div class="stage"><b>Sources</b><span>Cardioref · Genetic data<br>Omics Portal · ECG</span></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="stage"><b>Staging</b><span>raw replica</span></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="stage ai"><b>Integration</b><span>3NF · text mining</span><span class="brain" style="margin-top:5px"><svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4 C11 2 5.5 4 6 8.5 C2.5 10 2 14.5 4.5 17 C2.5 20 4 24.5 8 25 C9 28 14 29.5 16 27"/><path d="M16 4 C21 2 26.5 4 26 8.5 C29.5 10 30 14.5 27.5 17 C29.5 20 28 24.5 24 25 C23 28 18 29.5 16 27"/><path d="M16 4.5 V26.5"/><path d="M16 10 h4.5"/><circle cx="23" cy="10" r="1.7" fill="currentColor" stroke="none"/><path d="M16 15.5 h-4.5"/><circle cx="8.5" cy="15.5" r="1.7" fill="currentColor" stroke="none"/><path d="M16 21 h4.5"/><circle cx="23" cy="21" r="1.7" fill="currentColor" stroke="none"/></svg></span></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="stage"><b>Data Warehouse</b><span>star schema</span></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="stage hot" id="marts"><b>Marts</b><span>dbt · Superset</span></div>
|
||||
</div>
|
||||
<div class="midrow">
|
||||
<div class="concepts">
|
||||
<div class="lbl">four challenges to face · the answers come next</div>
|
||||
<div class="citem"><span class="n">01</span><div><b>Volume without structure</b><span class="d">twenty years of records locked in free-text columns</span></div></div>
|
||||
<div class="citem"><span class="n">02</span><div><b>Ambiguous language</b><span class="d">negation · family history · bilingual shorthand</span></div></div>
|
||||
<div class="citem"><span class="n">03</span><div><b>No shared ontology</b><span class="d">every system names conditions its own way</span></div></div>
|
||||
<div class="citem"><span class="n">04</span><div><b>Proving reliability</b><span class="d">extraction must be demonstrably correct</span></div></div>
|
||||
</div>
|
||||
<div class="railwrap" id="railwrap">
|
||||
<div class="conn-drop" id="conn-drop"></div>
|
||||
<div class="conn-elbow" id="conn-elbow"></div>
|
||||
<div class="rail" id="rail">
|
||||
<div class="lbl">outputs of the datamarts</div>
|
||||
<div class="row">
|
||||
<svg class="oicon" viewBox="0 0 34 34" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M4 29 h26"/><rect x="7" y="17" width="5" height="12" rx="1"/><rect x="15" y="9" width="5" height="20" rx="1"/><rect x="23" y="13" width="5" height="16" rx="1"/></svg>
|
||||
<b>Dashboards</b><span class="d">clinical review on Superset</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<svg class="oicon" viewBox="0 0 34 34" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="7" cy="7" r="2.6"/><circle cx="7" cy="27" r="2.6"/><circle cx="17" cy="17" r="3.4"/><circle cx="27" cy="7" r="2.6"/><circle cx="27" cy="27" r="2.6"/><path d="M9.3 8.8 L14.6 14.6"/><path d="M9.3 25.2 L14.6 19.4"/><path d="M19.4 14.6 L24.7 8.8"/><path d="M19.4 19.4 L24.7 25.2"/></svg>
|
||||
<b>Machine Learning</b><span class="d">cohort tables for model training</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<svg class="oicon" viewBox="0 0 34 34" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M3 27 L11 19 L17 22 L23 13"/><path d="M23 13 L30 6" stroke-dasharray="3 3.4"/><circle cx="30" cy="6" r="2.2" fill="currentColor" stroke="none"/><path d="M3 31 h28" opacity=".35"/></svg>
|
||||
<b>Predictive Statistics</b><span class="d">outcomes & risk analysis</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="thoth-wrap" id="thothwrap">
|
||||
<div class="t-conn" id="t-conn"></div>
|
||||
<div class="t-head" id="t-head"></div>
|
||||
<div class="thoth-col">
|
||||
<div class="thoth-box"><span class="brain" style="margin-right:9px; vertical-align:middle"><svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4 C11 2 5.5 4 6 8.5 C2.5 10 2 14.5 4.5 17 C2.5 20 4 24.5 8 25 C9 28 14 29.5 16 27"/><path d="M16 4 C21 2 26.5 4 26 8.5 C29.5 10 30 14.5 27.5 17 C29.5 20 28 24.5 24 25 C23 28 18 29.5 16 27"/><path d="M16 4.5 V26.5"/><path d="M16 10 h4.5"/><circle cx="23" cy="10" r="1.7" fill="currentColor" stroke="none"/><path d="M16 15.5 h-4.5"/><circle cx="8.5" cy="15.5" r="1.7" fill="currentColor" stroke="none"/><path d="M16 21 h4.5"/><circle cx="23" cy="21" r="1.7" fill="currentColor" stroke="none"/></svg></span>ThothII — natural-language → SQL over the DWH, builds the final datamarts</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="foot"><span>Role of AI in the Analysis of Unstructured Clinical Databases</span><span>Dr. Marco Pancotti - MultiPhysixLab</span><span>Dr. Sara Paratico - Gruppo San Donato</span><span>San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span>04 / 14</span></div>
|
||||
</div>
|
||||
|
||||
<p class="caption"><b>Option C, revised.</b> The rail drops straight from the Marts stage; the three outputs have equal
|
||||
visual weight and no status labels; texts inside the diagram boxes are capitalized; the EN translation is now the same size as the Italian.</p>
|
||||
|
||||
<script>
|
||||
// Draw the connector: vertical drop from the center of the Marts stage,
|
||||
// elbow leftward, into the top of the rail.
|
||||
function alignConnector() {
|
||||
const marts = document.getElementById('marts');
|
||||
const rail = document.getElementById('rail');
|
||||
const wrap = document.getElementById('railwrap');
|
||||
const drop = document.getElementById('conn-drop');
|
||||
const elbow = document.getElementById('conn-elbow');
|
||||
const mr = marts.getBoundingClientRect();
|
||||
const rr = rail.getBoundingClientRect();
|
||||
const wr = wrap.getBoundingClientRect();
|
||||
const cx = mr.left + mr.width / 2 - wr.left; // Marts center, wrap coords
|
||||
const railLeft = rr.left - wr.left; // rail border x, wrap coords
|
||||
const railTop = rr.top - wr.top; // rail top, wrap coords
|
||||
drop.style.left = (cx - 1) + 'px';
|
||||
drop.style.top = '-' + (rr.top - mr.bottom + 2) + 'px';
|
||||
drop.style.height = (rr.top - mr.bottom + 2 + railTop) + 'px';
|
||||
elbow.style.left = railLeft + 'px';
|
||||
elbow.style.width = Math.max(0, cx - railLeft) + 'px';
|
||||
elbow.style.top = (railTop - 1) + 'px';
|
||||
|
||||
// ThothII arrow: from the box up to the Marts stage, along the right margin
|
||||
const pipe = document.getElementById('pipeline');
|
||||
const twrap = document.getElementById('thothwrap');
|
||||
const box = twrap.querySelector('.thoth-box');
|
||||
const conn = document.getElementById('t-conn');
|
||||
const head = document.getElementById('t-head');
|
||||
const pr = pipe.getBoundingClientRect();
|
||||
const tr = twrap.getBoundingClientRect();
|
||||
const br = box.getBoundingClientRect();
|
||||
const x = tr.width - 10; // right margin, under Marts
|
||||
const top = pr.bottom - tr.top + 2; // just under the pipeline
|
||||
const boxTop = br.top - tr.top;
|
||||
conn.style.left = x + 'px';
|
||||
conn.style.top = top + 'px';
|
||||
conn.style.height = Math.max(0, boxTop - top) + 'px';
|
||||
head.style.left = x + 'px';
|
||||
head.style.top = (top - 7) + 'px';
|
||||
}
|
||||
addEventListener('load', alignConnector);
|
||||
addEventListener('resize', alignConnector);
|
||||
if (document.fonts && document.fonts.ready) document.fonts.ready.then(alignConnector);
|
||||
alignConnector();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,295 @@
|
||||
<!doctype html>
|
||||
<!-- PROTOTYPE (throwaway) — Slide 07 "The clinical ontology": three variants, switchable
|
||||
via ?variant=A|B|C + floating bar (← → keys too).
|
||||
Question: how to present 11+5 categories so the slide is more readable, livelier,
|
||||
and with ENGLISH more prominent than Italian (audience mostly foreign).
|
||||
A = three columns of 5-6 pills (user's idea) · B = editorial bands, EN-first pills
|
||||
· C = split: ontology left, "why the order matters" panel right. -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>PROTOTYPE · Slide 07 "The clinical ontology" — 4 layout variants</title>
|
||||
<link rel="stylesheet" href="/deck/fonts.css">
|
||||
<style>
|
||||
:root {
|
||||
--bordeaux: #cb333b; --bordeaux-deep: #8f1204; --bordeaux-tint: #f4dfe1;
|
||||
--surface: #fdfcfc; --surface-alt: #f6f5f5; --surface-sunken: #ecebeb;
|
||||
--ink: #2a2426; --ink-muted: #6b6568;
|
||||
--line: #e3e0e1; --line-strong: #c9c5c6;
|
||||
--sans: 'Source Sans 3', system-ui, sans-serif;
|
||||
--display: 'Source Serif 4', Georgia, serif;
|
||||
--mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #e9e7e7; font-family: var(--sans); padding: 26px 0 90px; }
|
||||
.proto-head { text-align: center; margin: 0 0 14px; }
|
||||
.proto-head b { font: 700 12px/1 var(--mono); letter-spacing: .14em; text-transform: uppercase; color: var(--bordeaux); }
|
||||
.proto-head span { display: block; margin-top: 6px; font: 400 13px/1.4 var(--sans); color: var(--ink-muted); }
|
||||
|
||||
/* ---- slide frame (deck-like, 1200×690) ---- */
|
||||
.frame { width: 1200px; height: 690px; margin: 0 auto; background: var(--surface);
|
||||
display: flex; flex-direction: column; box-shadow: 0 8px 30px rgba(42,36,38,.18); overflow: hidden; }
|
||||
.head { flex: 0 0 74px; display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 40px; border-bottom: 1px solid var(--line); }
|
||||
.head img { height: 54px; display: block; }
|
||||
.head-org { font: 700 11px/1 var(--sans); letter-spacing: .14em; text-transform: uppercase; color: var(--ink-muted); }
|
||||
.band { flex: 0 0 42px; display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 40px; background: var(--bordeaux); color: var(--surface); }
|
||||
.band b { font: 700 12.5px/1 var(--sans); letter-spacing: .14em; text-transform: uppercase; }
|
||||
.band .jump { font: 700 11px/1 var(--mono); letter-spacing: .1em; }
|
||||
.sbody { flex: 1; min-height: 0; display: flex; flex-direction: column;
|
||||
padding: 22px 48px 16px; text-align: left; }
|
||||
.vwrap { margin: auto 0; width: 100%; }
|
||||
.foot { flex: 0 0 30px; display: flex; align-items: center; justify-content: space-between; gap: 24px;
|
||||
padding: 0 40px; border-top: 1px solid var(--line);
|
||||
font: 600 9.5px/1.4 var(--sans); letter-spacing: .03em; color: var(--ink-muted); }
|
||||
.kicker { font: 700 11.5px/1 var(--sans); letter-spacing: .16em; text-transform: uppercase; color: var(--bordeaux); margin-bottom: 10px; }
|
||||
h2 { font: 700 27px/1.15 var(--sans); letter-spacing: -.01em; color: var(--ink); margin: 0 0 14px; }
|
||||
|
||||
/* ---- shared: EN-first pills (EN bold dark = primary, IT gloss = secondary) ---- */
|
||||
.pill { display: inline-flex; align-items: baseline; gap: 7px; border: 1px solid var(--bordeaux);
|
||||
border-radius: 9999px; padding: 6px 13px; background: #fff; white-space: nowrap; }
|
||||
.pill .en { font: 700 13px/1 var(--sans); color: var(--ink); }
|
||||
.pill .it { font: 400 11px/1 var(--sans); color: var(--ink-muted); }
|
||||
.pill.fill { background: var(--bordeaux-tint); }
|
||||
.col-h { display: flex; align-items: center; gap: 8px; font: 700 10px/1 var(--mono);
|
||||
letter-spacing: .14em; text-transform: uppercase; color: var(--bordeaux-deep); margin-bottom: 9px; }
|
||||
.col-h .n { margin-left: auto; font: 700 9px/1 var(--mono); color: var(--bordeaux);
|
||||
border: 1px solid var(--bordeaux); border-radius: 9999px; padding: 2px 7px; letter-spacing: .08em; }
|
||||
|
||||
/* ---- shared: numbered points ---- */
|
||||
.pt .num { flex: 0 0 22px; font: 700 11px/1.6 var(--mono); color: var(--bordeaux); }
|
||||
.pt b { font: 700 13px/1.35 var(--sans); color: var(--ink); }
|
||||
.pt span.d { font: 400 12.5px/1.45 var(--sans); color: var(--ink-muted); }
|
||||
code { font: 700 11.5px/1 var(--mono); color: var(--bordeaux-deep); background: var(--bordeaux-tint);
|
||||
padding: 2px 6px; border-radius: 4px; }
|
||||
|
||||
/* ================= VARIANT A — three columns (user's idea) ================= */
|
||||
.va-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0 26px; align-items: start; }
|
||||
.va-col + .va-col { border-left: 1px solid var(--line); padding-left: 26px; }
|
||||
.va-col .stack { display: flex; flex-direction: column; gap: 7px; align-items: stretch; }
|
||||
.va-col .pill { display: flex; justify-content: flex-start; }
|
||||
.va-points { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 26px; margin-top: 20px;
|
||||
padding-top: 14px; border-top: 1px solid var(--line); }
|
||||
.va-points .pt { display: flex; gap: 10px; }
|
||||
|
||||
/* ================= VARIANT B — editorial bands ================= */
|
||||
.vb-band { display: flex; gap: 20px; align-items: flex-start; padding: 13px 0 15px; border-bottom: 1px solid var(--line); }
|
||||
.vb-band:first-of-type { padding-top: 4px; }
|
||||
.vb-num { flex: 0 0 96px; }
|
||||
.vb-num b { display: block; font: 800 30px/1 var(--sans); letter-spacing: -.02em; color: var(--bordeaux); }
|
||||
.vb-num span { display: block; margin-top: 5px; font: 700 9px/1.4 var(--mono); letter-spacing: .12em;
|
||||
text-transform: uppercase; color: var(--ink-muted); }
|
||||
.vb-chips { display: flex; flex-wrap: wrap; gap: 8px; align-content: flex-start; }
|
||||
.vb-points { margin-top: 16px; }
|
||||
.vb-points .pt { display: flex; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--line); }
|
||||
.vb-points .pt:last-child { border-bottom: none; }
|
||||
|
||||
/* ================= VARIANT C — split: ontology / why order matters ================= */
|
||||
.vc-split { display: grid; grid-template-columns: 1.42fr 1fr; gap: 26px; align-items: start; flex: 1; min-height: 0; }
|
||||
.vc-tier { margin-bottom: 16px; }
|
||||
.vc-tier .col-h { margin-bottom: 10px; }
|
||||
.vc-chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.vc-why { border: 1px solid var(--bordeaux); background: var(--bordeaux-tint); border-radius: 10px;
|
||||
padding: 15px 18px 8px; }
|
||||
.vc-why-h { font: 700 10px/1 var(--mono); letter-spacing: .14em; text-transform: uppercase;
|
||||
color: var(--bordeaux-deep); margin-bottom: 4px; }
|
||||
.vc-why .pt { display: flex; gap: 10px; padding: 10px 0; border-bottom: 1px solid rgba(143,18,4,.18); }
|
||||
.vc-why .pt:last-child { border-bottom: none; }
|
||||
.vc-why .pt .num { color: var(--bordeaux-deep); }
|
||||
|
||||
/* ================= VARIANT D — ontology left, output panel right ================= */
|
||||
.vd-split { display: grid; grid-template-columns: 1.25fr 1fr; gap: 26px; align-items: stretch; flex: 1; min-height: 0; }
|
||||
.vd-points { margin-top: 4px; }
|
||||
.vd-points .pt { display: flex; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--line); }
|
||||
.vd-points .pt:last-child { border-bottom: none; }
|
||||
.vd-out { border: 1px solid var(--bordeaux); background: var(--bordeaux-tint); border-radius: 10px;
|
||||
padding: 16px 18px 10px; display: flex; flex-direction: column; }
|
||||
.vd-out .pt { display: flex; gap: 10px; padding: 11px 0; border-bottom: 1px solid rgba(143,18,4,.18); }
|
||||
.vd-out .pt:last-child { border-bottom: none; }
|
||||
.vd-out .pt .num { color: var(--bordeaux-deep); }
|
||||
.vd-out .pt b { color: var(--bordeaux-deep); }
|
||||
.vd-out .pt .d { font: 400 12.5px/1.45 var(--sans); color: var(--ink); }
|
||||
|
||||
/* ---- floating switcher (not part of the design) ---- */
|
||||
.switcher { position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%); z-index: 99;
|
||||
display: flex; align-items: center; gap: 10px; background: #1d1a1b; color: #fff;
|
||||
border-radius: 9999px; padding: 8px 10px; box-shadow: 0 6px 22px rgba(0,0,0,.35); }
|
||||
.switcher button { all: unset; cursor: pointer; width: 30px; height: 30px; border-radius: 9999px;
|
||||
display: grid; place-items: center; background: rgba(255,255,255,.14); font: 700 14px/1 var(--sans); }
|
||||
.switcher button:hover { background: rgba(255,255,255,.28); }
|
||||
.switcher .lbl { font: 700 12px/1 var(--mono); letter-spacing: .06em; min-width: 240px; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="proto-head">
|
||||
<b>PROTOTYPE · slide 07 — the clinical ontology</b>
|
||||
<span>Four layout variants · EN primary (bold dark) over IT gloss (secondary) · A = three columns (your idea) · B = editorial bands · C = ontology + "why order matters" panel · D = ontology + output panel (right)</span>
|
||||
</div>
|
||||
|
||||
<div class="frame">
|
||||
<header class="head">
|
||||
<img src="/deck/logo.png" alt="">
|
||||
<span class="head-org">AritmoLab · Policlinico San Donato</span>
|
||||
</header>
|
||||
<nav class="band"><b>Text analysis</b><span class="jump">07 · The clinical ontology</span></nav>
|
||||
<div class="sbody" id="sbody"></div>
|
||||
<footer class="foot">
|
||||
<span>Role of AI in the Analysis of Unstructured Clinical Databases</span>
|
||||
<span>Dr. Marco Pancotti - MultiPhysixLab</span>
|
||||
<span>Dr. Sara Paratico - Gruppo San Donato</span>
|
||||
<span>San Donato Milanese, Milan, Italy · 2–3 October 2026</span>
|
||||
<span>07 / 14</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="switcher">
|
||||
<button id="prev" aria-label="previous variant">←</button>
|
||||
<span class="lbl" id="lbl"></span>
|
||||
<button id="next" aria-label="next variant">→</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ---- data (EN-first: EN bold primary, IT gloss secondary) ----
|
||||
const T1 = [
|
||||
{ en: 'AF', it: 'FA' },
|
||||
{ en: 'Brugada', it: '' },
|
||||
{ en: 'Flutter', it: '' },
|
||||
{ en: 'AT', it: 'TA' },
|
||||
{ en: 'AVNRT', it: 'TRN' },
|
||||
{ en: 'PSVT', it: 'WPW / TPSV' },
|
||||
{ en: 'VT', it: 'TV' },
|
||||
{ en: 'VF', it: 'FV' },
|
||||
{ en: 'Ventricular ectopy', it: 'Extrasistolia V.' },
|
||||
{ en: 'Syncope', it: 'Sincope' },
|
||||
{ en: 'Long QT', it: 'QT lungo' },
|
||||
];
|
||||
const T2 = [
|
||||
{ en: 'AV block', it: 'Blocco AV' },
|
||||
{ en: 'Bundle branch block', it: 'Blocco di branca' },
|
||||
{ en: 'Cardiomyopathy', it: 'Cardiomiopatia' },
|
||||
{ en: 'Heart failure', it: 'Scompenso' },
|
||||
{ en: 'Valvular disease', it: 'Valvulopatia' },
|
||||
];
|
||||
const pill = (c, fill) =>
|
||||
`<span class="pill${fill ? ' fill' : ''}"><span class="en">${c.en}</span>${c.it ? `<span class="it">${c.it}</span>` : ''}</span>`;
|
||||
|
||||
const POINTS = [
|
||||
{ n: '01', b: 'Order encodes clinical precedence', d: '“Brugada” is matched before “TV”, so “substrato per TV” <span style="color:var(--bordeaux-deep)">("substrate for VT")</span> can\'t mask a Brugada pattern' },
|
||||
{ n: '02', b: 'Synonyms live inline', d: 'FA / f.a. / fib. atriale / atrial fibrillation → one canonical label' },
|
||||
{ n: '03', b: 'Versioned like software', d: 'semver for the pattern library, <code>pattern_version</code> on every extracted row' },
|
||||
];
|
||||
|
||||
// ---- variant A: three columns of pills (user's idea) ----
|
||||
function variantA() {
|
||||
const col = (h, count, items, fill) => `
|
||||
<div class="va-col">
|
||||
<div class="col-h">${h}<span class="n">${count}</span></div>
|
||||
<div class="stack">${items.map(c => pill(c, fill)).join('')}</div>
|
||||
</div>`;
|
||||
return `
|
||||
<div class="va-grid">
|
||||
${col('Tier 1 · arrhythmic — 1/2', '6 of 11', T1.slice(0, 6), true)}
|
||||
${col('Tier 1 · arrhythmic — 2/2', '5 of 11', T1.slice(6), true)}
|
||||
${col('Tier 2 · structural', '5 of 5', T2, false)}
|
||||
</div>
|
||||
<div class="va-points">${POINTS.map(p => `
|
||||
<div class="pt"><span class="num">${p.n}</span><div><b>${p.b}</b> <span class="d">— ${p.d}</span></div></div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---- variant B: editorial bands with big numerals ----
|
||||
function variantB() {
|
||||
const band = (num, name, count, items, fill) => `
|
||||
<div class="vb-band">
|
||||
<div class="vb-num"><b>${num}</b><span>${name} · ${count} categories</span></div>
|
||||
<div class="vb-chips">${items.map(c => pill(c, fill)).join('')}</div>
|
||||
</div>`;
|
||||
return `
|
||||
${band('01', 'Arrhythmic', 11, T1, true)}
|
||||
${band('02', 'Structural', 5, T2, false)}
|
||||
<div class="vb-points">${POINTS.map(p => `
|
||||
<div class="pt"><span class="num">${p.n}</span><div><b>${p.b}</b> <span class="d">— ${p.d}</span></div></div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---- variant C: ontology left, "why the order matters" right ----
|
||||
function variantC() {
|
||||
return `
|
||||
<div class="vc-split">
|
||||
<div>
|
||||
<div class="vc-tier">
|
||||
<div class="col-h">Tier 1 · arrhythmic<span class="n">11</span></div>
|
||||
<div class="vc-chips">${T1.map(c => pill(c, true)).join('')}</div>
|
||||
</div>
|
||||
<div class="vc-tier" style="margin-bottom:0">
|
||||
<div class="col-h">Tier 2 · structural<span class="n">5</span></div>
|
||||
<div class="vc-chips">${T2.map(c => pill(c, false)).join('')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vc-why">
|
||||
<div class="vc-why-h">Why one clinical order matters</div>
|
||||
${POINTS.map(p => `
|
||||
<div class="pt"><span class="num">${p.n}</span><div><b>${p.b}</b> <span class="d">— ${p.d}</span></div></div>`).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---- variant D: ontology left, "the output" right (user request) ----
|
||||
const OUTPUT = [
|
||||
{ n: '01', b: 'Quantitative data on the records', d: 'every procedure and implant the text analysis reads is written back as structured, quantitative values on the record that describes it' },
|
||||
{ n: '02', b: 'Straight into the DWH flow', d: 'these records enter the data-warehouse generation like any other source' },
|
||||
{ n: '03', b: 'As if typed at the visit', d: 'the data lands exactly as if clinicians had keyed it in themselves during the visits' },
|
||||
];
|
||||
function variantD() {
|
||||
return `
|
||||
<div class="vd-split">
|
||||
<div>
|
||||
<div class="vc-tier">
|
||||
<div class="col-h">Tier 1 · arrhythmic<span class="n">11</span></div>
|
||||
<div class="vc-chips">${T1.map(c => pill(c, true)).join('')}</div>
|
||||
</div>
|
||||
<div class="vc-tier">
|
||||
<div class="col-h">Tier 2 · structural<span class="n">5</span></div>
|
||||
<div class="vc-chips">${T2.map(c => pill(c, false)).join('')}</div>
|
||||
</div>
|
||||
<div class="vd-points">${POINTS.map(p => `
|
||||
<div class="pt"><span class="num">${p.n}</span><div><b>${p.b}</b> <span class="d">— ${p.d}</span></div></div>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="vd-out">
|
||||
<div class="vc-why-h" style="margin-bottom:8px">The output: text becomes recorded data</div>
|
||||
${OUTPUT.map(p => `
|
||||
<div class="pt"><span class="num">${p.n}</span><div><b>${p.b}</b><div class="d" style="margin-top:2px">${p.d}</div></div></div>`).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---- switcher ----
|
||||
const VARIANTS = [
|
||||
{ key: 'A', name: 'Three columns (5-6 per column)', render: variantA },
|
||||
{ key: 'B', name: 'Editorial bands, big numerals', render: variantB },
|
||||
{ key: 'C', name: 'Ontology + why-order panel', render: variantC },
|
||||
{ key: 'D', name: 'Ontology + output panel (right)', render: variantD },
|
||||
];
|
||||
function current() {
|
||||
const k = (location.search.match(/variant=([ABCD])/) || [])[1] || 'A';
|
||||
return Math.max(0, VARIANTS.findIndex(v => v.key === k));
|
||||
}
|
||||
function show(i) {
|
||||
const v = VARIANTS[i];
|
||||
document.getElementById('sbody').innerHTML =
|
||||
'<div class="vwrap"><div class="kicker">The clinical ontology</div><h2>Two tiers, one clinical order</h2>' + v.render() + '</div>';
|
||||
document.getElementById('lbl').textContent = `${v.key} · ${v.name}`;
|
||||
history.replaceState(null, '', location.pathname + '?variant=' + v.key);
|
||||
}
|
||||
document.getElementById('prev').onclick = () => show((current() + VARIANTS.length - 1) % VARIANTS.length);
|
||||
document.getElementById('next').onclick = () => show((current() + 1) % VARIANTS.length);
|
||||
addEventListener('keydown', e => {
|
||||
if (e.key === 'ArrowLeft') document.getElementById('prev').click();
|
||||
if (e.key === 'ArrowRight') document.getElementById('next').click();
|
||||
});
|
||||
show(current());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,44 +1,101 @@
|
||||
# Outline v3 (FINAL structure — proposal A) — Role of AI in the Analysis of Unstructured Clinical Databases
|
||||
# Outline v4 — Role of AI in the Analysis of Unstructured Clinical Databases
|
||||
|
||||
Updated 2026-09-18 against the actual deck, rather than the earlier proposed structure.
|
||||
|
||||
Deck: [`../deck/index.html`](../deck/index.html) — Reveal.js 5.1.0, **14 slides**, 16:9
|
||||
(1280 × 720), with local fonts and speaker notes on every slide.
|
||||
Review status: [`../prototype/deck-overview.html`](../prototype/deck-overview.html).
|
||||
The titles below are the actual slide headings; some overview labels are shorter or older.
|
||||
|
||||
Deck: `deck/index.html` (Reveal.js, 15 slides, fonts embedded).
|
||||
Presenters: **MP** = Dr. Marco Pancotti · **SP** = Dr.ssa Sara Paratico.
|
||||
The notes explicitly identify Sara on slides 05–08. The final handoff around slide 04
|
||||
and the remaining speaker allocation should be confirmed during rehearsal.
|
||||
Per-slide timings and total running time need a new rehearsal; the previous 15-slide
|
||||
timing estimate no longer describes this deck.
|
||||
|
||||
| # | Slide | Master | By | Time | Status |
|
||||
|----|------------------------------------------------|--------------|----|------|--------|
|
||||
| 01 | Title | title | MP | 40s | ✅ final |
|
||||
| 02 | Where we started (4 systems + 2 missing) | interactive | MP | 65s | ✅ final |
|
||||
| 03 | The project at a glance (flow + brain popups) | architecture | MP | 75s | ✅ final |
|
||||
| 04 | What the text miner reads (numbers) | stats | SP | 60s | ✅ real data |
|
||||
| 05 | The hard problems of clinical NLP | concept | SP | 75s | 🟡 draft |
|
||||
| 06 | Building the clinical ontology | concept | SP | 75s | 🟡 draft |
|
||||
| 07 | Trust the text: validation & human review | concept | SP | 75s | 🟡 draft |
|
||||
| 08 | AI as co-engineer (mapping YAMLs) | artifact | MP | 70s | ✅ real |
|
||||
| 09 | Genetic data — many sources, one patient | architecture | MP | 60s | ⚠️ wording TBD (a/b) |
|
||||
| 10 | ThothII: ask the warehouse in plain English | concept | MP | 75s | 🟡 draft |
|
||||
| 11 | From datamarts to predictive statistics & ML | concept | MP | 60s | 🟡 draft |
|
||||
| 12 | Live demo cue (star schema · Superset · ThothII)| divider | MP | ~2min live | ✅ |
|
||||
| 13 | Lessons learned | concept | MP | 60s | 🟡 draft |
|
||||
| 14 | (merged into 15) | — | — | — | — |
|
||||
| 15 | Thank you + Q&A (+ Substack pointer) | closing | MP | 25s | 🟡 draft |
|
||||
## Actual slide sequence
|
||||
|
||||
Talk ≈ 13.4 min + ~2 min demo. 15 slides = user cap reached.
|
||||
| # | Actual slide title | Content / format | Slide review status | Speaker notes |
|
||||
|---|---|---|---|---|
|
||||
| 01 | Role of AI in the Analysis of Unstructured Clinical Databases | AritmoLab introduction, clinical data platform and presenters | FINAL | Present; no DRAFT marker |
|
||||
| 02 | Four islands and four missing pieces | Interactive map: Cardioref, Genetic data, Omics Portal and ECG subsystems; Clinical Intelligence, Datawarehouse, ML-ready data and Professional Portal as missing pieces | FINAL | Present; no DRAFT marker |
|
||||
| 03 | What we wanted to build | Architecture and data flow toward the patient portal and research warehouse; clickable AI contribution cards | FINAL | Present; no DRAFT marker |
|
||||
| 04 | The clinical truth lives in unstructured columns | Italian clinical sentence with English translation; why free text needs structured interpretation | FINAL | DRAFT |
|
||||
| 05 | Deterministic AI, audited numbers | Text-mining outputs: 58,438 letters, 73,389 pathologies, 10,908 tests and 2,307 Brugada patients; extraction pipeline | FINAL | DRAFT — Sara |
|
||||
| 06 | Reading clinical text is not keyword matching | Clinical NLP challenges, including negation and distinguishing family history from the patient's conditions | FINAL | DRAFT — Sara |
|
||||
| 07 | Two tiers, one clinical order | Clinical ontology: 11 arrhythmic and 5 structural categories; text becomes recorded data | FINAL | DRAFT — Sara |
|
||||
| 08 | Trusting the text is a process, not a promise | Five centered detail lenses: four validation controls and the three advantages | REFINED | ~120 words; five hover cues |
|
||||
| 09 | AritmoLab — a quick tour | Portal tour; currently six screenshot placeholders | DRAFT | DRAFT |
|
||||
| 10 | The warehouse speaks SQL. Research needs more. | Gap between the warehouse and clinical/research use: SQL access, aggregates and research cohorts | DRAFT | DRAFT |
|
||||
| 11 | Ask the warehouse in plain English | ThothII: natural-language question, SQL proposal, human review and a research-ready datamart | DRAFT | DRAFT |
|
||||
| 12 | From question to datamart — the app | ThothII walkthrough; currently six screenshot placeholders | DRAFT | DRAFT |
|
||||
| 13 | One datamart — many questions answered | Multivariate analysis and machine learning; two chart placeholders with an illustrative-data disclosure | DRAFT | DRAFT — charts still to be created |
|
||||
| 14 | Thank you | Questions and invitation to future technical walkthroughs on Substack | DRAFT | DRAFT |
|
||||
|
||||
## Confirmed decisions
|
||||
- Project named **AritmoLab** everywhere (internal codename never appears).
|
||||
- Slide 02 (Where we started): map of legacy systems — Cardioref / Genetic data /
|
||||
Omics Portal / ECG subsystems, each clickable → popup (usage + problem box);
|
||||
two dashed "MISSING" cards (Health Intelligence, ML-ready datamarts).
|
||||
- Slide 03 flow: 3 sources + "Future sources" (dashed) → staging → integration 🧠
|
||||
→ star schema → datamarts 🧠 → AritmoLab (DWH + Portal); brain popups =
|
||||
The mappings / Reading the clinical text / Datamarts from plain English.
|
||||
- Text-analysis block (SP): slides 04–07. Scripts to be validated by SP.
|
||||
- Speaker view (S): Tall layout customized — upcoming 25%, timer bottom-left,
|
||||
notes 1.45em. Default: upcoming 20%, notes 80%.
|
||||
- Fonts embedded (Source Sans 3 + Source Serif 4, OFL) — no Google dependency.
|
||||
`FINAL` and `REFINED` reproduce the overview's recorded review status. They do not imply
|
||||
that speaker notes or clinical claims have received a separate final validation.
|
||||
Figures above describe the content currently displayed in the deck.
|
||||
|
||||
## OPEN
|
||||
- Slide 09 genetics wording: (a) deterministic reconciliation [recommended] vs
|
||||
(b) AI upstream (only if MP confirms it happens outside the ETL).
|
||||
- Draft slides 05-07, 10-11, 13-15: review one by one.
|
||||
- Dark theme of the whole deck: final pass.
|
||||
- "twenty years" (slide 01 script + slide 02): confirm real data span.
|
||||
## Current presentation behavior
|
||||
|
||||
- Slide 02 has four clickable source systems and four clickable missing pieces,
|
||||
each opening an explanatory popup.
|
||||
- Slide 03 has clickable brain icons that open AI contribution cards with a connector
|
||||
to the selected icon.
|
||||
- Slide 08 has four centered lens popups. In the presenter console, hovering over
|
||||
a numbered control or its corresponding heading in the notes opens the detail
|
||||
in both preview and audience view. Escape closes it. Its quality wording separates
|
||||
the original procedure-accuracy target from pathology coverage. The ~893
|
||||
false-positive records and v1.3.1 negation fix are confirmed in the ETL sources;
|
||||
see [source notes](08-validation-sources.md) for precise scope and limitations.
|
||||
- Its advantages panel highlights on hover and opens a fifth centered lens on
|
||||
click, explaining speed, precision and determinism. The fifth speaker-note cue
|
||||
and presenter control open the same popup.
|
||||
- Slide changes use a fade transition. Popups fade in and their cards move/scale into
|
||||
place; clickable source icons and brain buttons have hover effects.
|
||||
- A dropdown on each slide jumps to another slide. Reveal.js provides keyboard
|
||||
navigation and the overview.
|
||||
- The `S` key opens the Reveal.js speaker view when served locally. Speaker scripts
|
||||
live in each slide's `<aside class="notes">` in `deck/index.html`.
|
||||
- Source Sans 3 and Source Serif 4 are bundled under `deck/fonts/`; the deck does not
|
||||
depend on Google Fonts.
|
||||
|
||||
## Remaining work
|
||||
|
||||
- Review slide 08 for final approval and slides 09–14 individually.
|
||||
- Replace the six placeholders on slide 09 with real anonymized or demo portal
|
||||
screenshots. The in-slide authoring note still requests 6–7 screenshots; settle
|
||||
that count when inserting the images.
|
||||
- Replace the six placeholders on slide 12 with screenshots of the ThothII workflow.
|
||||
Its authoring note likewise still requests 6–7 screenshots.
|
||||
- Create the two charts on slide 13, identify the supporting literature, and preserve
|
||||
the disclosure if the data remain illustrative rather than measured results.
|
||||
- Validate the speaker scripts on slides 04–14, including Sara's clinical section
|
||||
on slides 05–08, and confirm the handoff between presenters.
|
||||
- Confirm the “twenty years” wording, clinical figures, and quality-gate claims before
|
||||
presenting them as final facts.
|
||||
- Confirm the closing Substack reference and whether the promised videos will be
|
||||
available by the talk.
|
||||
- Rehearse the actual 14-slide sequence and set timings, including popup explanations
|
||||
and any live demo chosen in addition to the screenshot walkthroughs.
|
||||
|
||||
The previous standalone slides on AI co-engineering, genetic reconciliation, lessons
|
||||
learned and a live-demo divider are not separate slides in the current sequence.
|
||||
Their old slide numbers, review tasks and timings should not be reused.
|
||||
|
||||
## Local preview
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
python3 -m http.server 8000 --bind 127.0.0.1 --directory presentation
|
||||
```
|
||||
|
||||
- Deck: <http://localhost:8000/deck/index.html>
|
||||
- Review overview: <http://localhost:8000/prototype/deck-overview.html>
|
||||
- Presenter console: <http://localhost:8000/presenter/> — notes, interactive preview,
|
||||
popup controls and a separate audience window for an extended HDMI display.
|
||||
See [`../presenter/README.md`](../presenter/README.md) for operation and scope.
|
||||
|
||||
Keep `presentation/` as the server root: the overview uses absolute `/deck/` URLs.
|
||||
Stop the server with Ctrl+C.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Slide 08: validation sources
|
||||
|
||||
Verified on 2026-09-20 against `aritmolab/chirone-etl` on the hospital Gitea,
|
||||
main snapshot `2ab00106188f298c1a0c1e2c48b3a4dbc66c45b5`. These are source-code
|
||||
and documentation checks, not a new audit of patient data or a production test run.
|
||||
|
||||
## Quality gates
|
||||
|
||||
[Extraction specification, SC-003 and SC-004](https://git-aritmolab.policlinicosandonato.it/aritmolab/chirone-etl/src/commit/2ab00106188f298c1a0c1e2c48b3a4dbc66c45b5/include/textanalysis/specs/001-cr-conclusioni-extraction/spec.md)
|
||||
defines procedure classification accuracy of at least 95% against a random sample
|
||||
of 100 manually reviewed records. The separate 85% target is coverage: at least one
|
||||
recognized arrhythmia in that proportion of records, assuming most procedures
|
||||
concern known arrhythmias. It is not diagnostic accuracy, sensitivity or precision.
|
||||
The repository search also confirms these definitions in the TextAnalysis README.
|
||||
The inspected sources do not establish measured results or a completed 100-record
|
||||
review for every release. The slide therefore calls these figures targets.
|
||||
|
||||
## Clinical criteria and cohort selection
|
||||
|
||||
[Confirmed-diagnosis mart documentation](https://git-aritmolab.policlinicosandonato.it/aritmolab/chirone-etl/src/commit/2ab00106188f298c1a0c1e2c48b3a4dbc66c45b5/docs/marts/procedure-event-marts/mart_diagnosi_lettera_confermata.md)
|
||||
identifies the Superset “Diagnosi Lettere” charts (570/571), in the “Test Provocativi
|
||||
& Diagnosi Lettere” dashboard, as consumers of this dataset.
|
||||
|
||||
[The SQL implementation](https://git-aritmolab.policlinicosandonato.it/aritmolab/chirone-etl/src/commit/2ab00106188f298c1a0c1e2c48b3a4dbc66c45b5/dbt/models/marts/mart_diagnosi_lettera_confermata.sql)
|
||||
first requires patient attribution and a non-negated finding. For Brugada and
|
||||
long QT syndrome it additionally requires a positive provocative test for that
|
||||
condition OR an ablation recorded for that pathology, at patient level. It does
|
||||
not impose that extra condition on other pathologies. These are operational
|
||||
cohort-selection rules, not a universal clinical diagnostic standard.
|
||||
|
||||
No formal sign-off or individual clinical validation of the entire Brugada cohort
|
||||
was established by the consulted sources. The point is titled “Clinical criteria”:
|
||||
the queries apply the selection rules and Superset displays their result. The
|
||||
speaker script explains that a disease mention alone is insufficient for cohort
|
||||
inclusion; the popup retains the Brugada/long QT rule as a project-specific example.
|
||||
|
||||
## Feedback loop: the approximately 893 records
|
||||
|
||||
[Known limits and version changelog](https://git-aritmolab.policlinicosandonato.it/aritmolab/chirone-etl/src/commit/2ab00106188f298c1a0c1e2c48b3a4dbc66c45b5/docs/architecture/textanalysis-known-limits.md)
|
||||
records an audit on 2026-06-11 and the v1.3.1 correction on 2026-06-12. It reports
|
||||
approximately 893 Brugada false positives in the newer letter format (8.4% of
|
||||
non-negated Brugada records). This is a record count, not a distinct-patient count.
|
||||
|
||||
[Regression tests, class TestNegationExtended](https://git-aritmolab.policlinicosandonato.it/aritmolab/chirone-etl/src/commit/2ab00106188f298c1a0c1e2c48b3a4dbc66c45b5/include/textanalysis/tests/unit/test_letter_cascade_analyzer.py)
|
||||
explain the defect: the negation patterns recognized “negato” and “negata”, but
|
||||
omitted “negativo”. The fix adds `negativ[oai]` to both pre- and post-context
|
||||
negation detection. The test sentence “Test alla flecainide negativo per sindrome
|
||||
di Brugada” must still yield a Brugada finding, now with `negato=True`; a separate
|
||||
test preserves the affirmed classification of a positive Brugada diagnosis.
|
||||
Thus “reclassified” is more accurate than “removed”. These tests were inspected,
|
||||
not executed in this presentation workspace.
|
||||
|
||||
## Retained context and limits
|
||||
|
||||
The mart documentation describes propagation of `attribuzione` and `negato`
|
||||
through integration, warehouse bridge and the mentions mart. The confirmed view
|
||||
filters them without deleting the underlying mentions dataset.
|
||||
|
||||
The known-limits document describes a context window of up to 60 characters on
|
||||
either side of a match, bounded by a full stop, semicolon or newline. Uncertainty
|
||||
phrases such as “sospetto di” and “per escludere” are not automatically definitive
|
||||
negations. The same-pathology deduplication tests show that an affirmed patient
|
||||
finding takes priority over a negated or family finding. Therefore the slide does
|
||||
not claim verbatim preservation of every repeated match or complete extraction
|
||||
of all clinical information.
|
||||
|
||||
The mart page's historical “first matching field wins” caveat predates the v1.3.0
|
||||
multi-field union. For current field handling, the later known-limits document
|
||||
and unit tests take precedence.
|
||||