Update interactive presentation and presenter console
@@ -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,6 +44,7 @@
|
||||
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; }
|
||||
@@ -286,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; }
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
<title>AritmoLab — Role of AI in the Analysis of Unstructured Clinical Databases</title>
|
||||
<link rel="stylesheet" href="vendor/reveal/dist/reveal.css">
|
||||
<link rel="stylesheet" href="fonts.css?v=8">
|
||||
<link rel="stylesheet" href="deck.css?v=40">
|
||||
<link rel="stylesheet" href="deck.css?v=41">
|
||||
<link rel="stylesheet" href="slide-lens.css?v=7">
|
||||
<link rel="stylesheet" href="ai-callouts.css?v=3">
|
||||
<link rel="stylesheet" href="screenshot-tour.css?v=1">
|
||||
</head>
|
||||
<body>
|
||||
<div class="reveal"><div class="slides">
|
||||
@@ -34,11 +37,11 @@
|
||||
<p class="event">“Multidimensional Characterization of Cardiac Arrhythmias: Role of Electrocardiology in the Artificial Intelligence Era”</p>
|
||||
<p class="event-where">San Donato Milanese, Milan, Italy · 2–3 October 2026</p>
|
||||
</div>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">01 / 14</span></footer>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">01 / 13</span></footer>
|
||||
<aside class="notes">
|
||||
Good morning everyone, and thank you for being here. I'm Marco Pancotti, and with my colleague Dr. Sara Paratico we work on the clinical data platform of AritmoLab at Policlinico San Donato.
|
||||
Today I want to talk about a problem that every hospital knows very well. The most valuable clinical information we have — the diagnosis, the patient's history, the outcome of a test — is written in plain free text, inside systems that were never designed to make that text usable.
|
||||
Over the next twelve minutes I'll show you how we used artificial intelligence, in three different roles, to transform twenty years of cardiology records into a database that researchers can actually query — and that clinicians can verify. My colleague Dr. Sara Paratico will join me to show how we read the clinical text itself.
|
||||
Good morning. My name is Marco Pancotti, and I led the part of the PAMP-FA project dedicated to building the AritmoLab portal at Policlinico San Donato, with support from Sara Paratico, who will co-present with me today.
|
||||
The scope of the project included a data warehouse and tools for machine learning and predictive statistics to support research by the Arrhythmology Unit, directed by Professor Pappone and Professor Locati.
|
||||
Today we'll focus on the use of AI to extract structured information from clinical text, followed by a brief tour of the portal and ThothII, a tool for building datamarts from natural-language requests.
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
@@ -52,7 +55,7 @@
|
||||
<span class="band-section">Where we started</span>
|
||||
</nav>
|
||||
<div class="sbody">
|
||||
<h2>Four islands and four missing pieces</h2>
|
||||
<h2>Where we started - Four islands and four missing pieces</h2>
|
||||
<div class="scatter">
|
||||
<div class="todo-stack" style="left:30%; top:22%; width:40%">
|
||||
<div class="todo" data-missing="ci" style="margin-left:0"><span class="q">?</span><span class="tx"><b>Clinical Intelligence</b><span>dashboards on the clinical history of the Unit and its patients</span></span></div>
|
||||
@@ -68,7 +71,7 @@
|
||||
<span class="nm">Genetic data</span><span class="sc">DNA instruments — results typed into Excel by hand</span></button>
|
||||
<button class="isle" data-start="omics" style="--rot:-2deg; --size:114px; left:calc(1% + 39px); top:55%; width:230px">
|
||||
<svg class="ico" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="24" cy="24" r="20" stroke-width="2.6"/><circle cx="24" cy="24" r="4" fill="currentColor" stroke="none"/><circle cx="24" cy="10" r="3.4"/><circle cx="11.5" cy="31" r="3.4"/><circle cx="36.5" cy="31" r="3.4"/><path d="M24 20.5 V13.5"/><path d="M21 26.5 L14 29.5"/><path d="M27 26.5 L34 29.5"/></svg>
|
||||
<span class="nm">Omics Portal</span><span class="sc">meant to host Cardioref + genetics — still immature</span></button>
|
||||
<span class="nm">Aritmolab Portal</span><span class="sc">meant to host Cardioref + genetics — still immature</span></button>
|
||||
<button class="isle" data-start="ecg" style="--rot:2deg; --size:114px; left:calc(73% + 39px); top:55%; width:230px">
|
||||
<svg class="ico" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="24" cy="24" r="20" stroke-width="2.6"/><path d="M9 24 h6 l3-9 5 16 3-7 h14" stroke-width="2.4"/><circle cx="24" cy="24" r="1.8" fill="currentColor" stroke="none"/></svg>
|
||||
<span class="nm">ECG</span><span class="sc">paper strip + a CSV on request</span></button>
|
||||
@@ -76,9 +79,20 @@
|
||||
<p class="hint" style="position:absolute; left:26%; width:48%; bottom:2px; margin:0; text-align:center">▸ click a system: how it was used — and what held it back · click a missing piece: what it would have given</p>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">02 / 14</span></footer>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">02 / 13</span></footer>
|
||||
<aside class="notes">
|
||||
Before I show you what we built, let me take you back twenty years. Cardioref, our electrophysiology database, had twenty years of clinical records: as a management tool for patients, daily activity and planning it was — and still is — excellent. But for research it was almost unusable, because most of the clinical knowledge was written in free text, outside the structured fields. The genetic data lived in its own system, which produced Excel sheets, with no connection to Cardioref. Our first Omics Portal was a promising idea, still embryonic and not integrated with anything. And a constellation of ECG subsystems that could only hand you a CSV file, on request. On top of that: no clinical intelligence on the historical data, no single datawarehouse as the source of truth, no ML-ready data for predictive statistics, and no professional portal to bring it all together. Four islands, four missing pieces. Click any system and I'll show you exactly what held it back. And here is what we built.
|
||||
The starting point was four disconnected systems:
|
||||
1 - <strong>Cardioref</strong> held twenty years of electrophysiology records and supported clinical operations, but much of the information needed for research was in free text.
|
||||
2 - <strong>Genetic data</strong> were manually entered into Excel, without integration with Cardioref.
|
||||
3 - The <strong>Aritmolab Portal</strong> was an early prototype, not yet integrated with the source systems.
|
||||
4 - <strong>ECG</strong> data were available as paper records or CSV exports on request.
|
||||
|
||||
Whe lacked:
|
||||
5 - some longitudinal <strong>clinical analytics</strong>
|
||||
6 - a shared <strong>data warehouse</strong>,
|
||||
7 - a set of structured datasets for model training,
|
||||
8 - and a unified <strong>clinical portal</strong>.
|
||||
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
@@ -94,37 +108,72 @@
|
||||
<div class="sbody">
|
||||
<div class="kicker">The plan</div>
|
||||
<h2>What we wanted to build</h2>
|
||||
<p class="lead">One platform, two destinations: the 360° patient portal and a research-ready warehouse. Built on open-source pillars, with AI coding agents wherever they helped. This talk follows the hardest piece — the unstructured data.</p>
|
||||
<p class="lead">One platform, two destinations: the 360° patient portal and a research-ready warehouse. Built on open-source pillars, with AI coding agents wherever they helped. Next, we’ll look at the hardest part: unstructured data.</p>
|
||||
<div class="flow">
|
||||
<div class="zone existed">
|
||||
<button type="button" class="zone existed lens-target" id="plan-existed" data-lens="plan-existed" data-lens-title="What existed" data-lens-number="1" aria-label="Explore What existed" aria-expanded="false" aria-controls="slide-lens">
|
||||
<span class="zlabel">What existed</span>
|
||||
<div class="fcol">
|
||||
<div class="fbox src"><b>Cardioref</b><span>cardiology records · procedures · letters</span></div>
|
||||
<div class="fbox src"><b>Genetic data</b><span>labs · variants · nomenclature</span></div>
|
||||
<div class="fbox src"><b>ECG</b><span>signals · device follow-up</span></div>
|
||||
<div class="fbox src future"><b><span class="plus">+</span>Future sources</b><span>new subsystems can be connected as sources</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fcol">
|
||||
<span class="fbox src"><b>Cardioref</b><span>cardiology records · procedures · letters</span></span>
|
||||
<span class="fbox src"><b>Genetic data</b><span>labs · variants · nomenclature</span></span>
|
||||
<span class="fbox src"><b>ECG</b><span>signals · device follow-up</span></span>
|
||||
<span class="fbox src future"><b><span class="plus">+</span>Future sources</b><span>new subsystems can be connected as sources</span></span>
|
||||
</span>
|
||||
<template class="lens-details">
|
||||
<p class="detail-intro">The starting material offered complementary views of the patient.</p>
|
||||
<p><b>Clinical course · Cardioref</b>Visits, procedures and reports describe the course of care. Dates and narrative details give each event its clinical context.</p>
|
||||
<p><b>Genetic findings</b>Laboratory results and variant descriptions add the genetic perspective, recorded separately from the clinical history.</p>
|
||||
<p><b>Electrical activity · ECG</b>Tracings and exported signals document cardiac electrical activity, complementing the written account of the patient’s condition.</p>
|
||||
<p><b>Different forms of evidence</b>Structured fields, free text and signals must retain their clinical meaning when connected. Future sources would extend this initial set.</p>
|
||||
</template>
|
||||
</button>
|
||||
<div class="fcol ai-hit">
|
||||
<button class="brain brain-btn" data-ai="ingestion" aria-label="AI contribution: the mappings">🧠</button>
|
||||
<button class="brain brain-btn" data-ai="ingestion" aria-label="AI contribution: the mappings" aria-pressed="false"><span class="ai-label" aria-hidden="true">AI</span><img class="artificial-brain" src="artificial-brain.svg" alt=""><span class="ai-number">6</span></button>
|
||||
<div class="farrow">→</div>
|
||||
</div>
|
||||
<div class="zone built">
|
||||
<span class="zlabel">What we built</span>
|
||||
<div style="display:flex; align-items:center; gap:10px; height:100%;">
|
||||
<div class="fcol" style="flex:0.8; justify-content:center"><div class="fbox"><b>Staging</b><span>raw replica of the sources</span></div></div>
|
||||
<div class="fcol" style="flex:0.8; justify-content:center"><button type="button" class="fbox lens-target" id="plan-staging" data-lens="plan-staging" data-lens-number="2" aria-label="Explore Staging" aria-expanded="false" aria-controls="slide-lens"><b>Staging</b><span>raw replica of the sources</span>
|
||||
<template class="lens-details">
|
||||
<p class="detail-intro">The landing area: a faithful working copy of the data brought in from each source system.</p>
|
||||
<p><b>What it contains</b>Original tables, identifiers, dates and clinical text, still in the source’s format.</p>
|
||||
<p><b>Why it matters</b>Subsequent processing works on this copy. The original clinical system continues its daily work, and transformations can be checked against the imported data.</p>
|
||||
<p class="detail-example"><b>Clinical example</b>A Cardioref letter arrives with its original wording. “No syncope” is still text; this layer does not yet turn it into a clinical variable.</p>
|
||||
</template>
|
||||
</button></div>
|
||||
<div class="farrow">→</div>
|
||||
<div class="fcol ai" style="flex:1.25">
|
||||
<button class="brain brain-btn" data-ai="integration" aria-label="AI contribution: clinical text reading">🧠</button>
|
||||
<div class="fbox"><b>Integration</b><span>cleaned, normalized, deduplicated</span></div>
|
||||
<button class="brain brain-btn" data-ai="integration" aria-label="AI contribution: clinical text reading" aria-pressed="false"><span class="ai-label" aria-hidden="true">AI</span><img class="artificial-brain" src="artificial-brain.svg" alt=""><span class="ai-number">7</span></button>
|
||||
<button type="button" class="fbox lens-target" id="plan-integration" data-lens="plan-integration" data-lens-number="3" aria-label="Explore Integration" aria-expanded="false" aria-controls="slide-lens"><b>Integration</b><span>cleaned, normalized, deduplicated</span>
|
||||
<template class="lens-details">
|
||||
<p class="detail-intro">The reconciliation layer: source records become consistent, connected clinical information.</p>
|
||||
<p><b>What happens here</b>Formats and terminology are standardized, duplicates reconciled, and records linked through patient and event identifiers.</p>
|
||||
<p><b>Where AI contributes</b>It extracts conditions, procedures and drug-challenge outcomes from clinical text. Negation and context matter; extraction quality needs validation.</p>
|
||||
<p class="detail-example"><b>Clinical example</b>“No syncope” is not a positive finding. A family history of Brugada must remain distinct from the patient’s own diagnosis.</p>
|
||||
</template>
|
||||
</button>
|
||||
<div class="fcap">AI reads the clinical text: pathologies, procedures, drug-challenge outcomes</div>
|
||||
</div>
|
||||
<div class="farrow">→</div>
|
||||
<div class="fcol" style="flex:1.05; justify-content:center"><div class="fbox"><b>Star schema</b><span>dimensional transformation</span></div></div>
|
||||
<div class="fcol" style="flex:1.05; justify-content:center"><button type="button" class="fbox lens-target" id="plan-star-schema" data-lens="plan-star-schema" data-lens-number="4" aria-label="Explore Data warehouse" aria-expanded="false" aria-controls="slide-lens"><b>Data warehouse</b><span>star schema · organized for analysis</span>
|
||||
<template class="lens-details">
|
||||
<p class="detail-intro">The shared analytical database: integrated clinical data reorganized for research across patients and over time.</p>
|
||||
<p><b>How a star schema works</b>“Facts” represent events or measurements, such as a procedure or test. “Dimensions” describe their context, such as the patient, date and procedure type.</p>
|
||||
<p><b>Why it matters</b>Researchers can filter, group and compare records using common definitions, without reconstructing every relationship from the original hospital tables.</p>
|
||||
<p class="detail-example"><b>Clinical question it can support</b>How many patients underwent a given procedure each year, and how does that distribution vary by age group?</p>
|
||||
</template>
|
||||
</button></div>
|
||||
<div class="farrow">→</div>
|
||||
<div class="fcol ai" style="flex:1.25">
|
||||
<button class="brain brain-btn" data-ai="datamarts" aria-label="AI contribution: datamart generation">🧠</button>
|
||||
<div class="fbox"><b>Datamarts</b><span>research-ready marts</span></div>
|
||||
<button class="brain brain-btn" data-ai="datamarts" aria-label="AI contribution: datamart generation" aria-pressed="false"><span class="ai-label" aria-hidden="true">AI</span><img class="artificial-brain" src="artificial-brain.svg" alt=""><span class="ai-number">8</span></button>
|
||||
<button type="button" class="fbox lens-target" id="plan-datamarts" data-lens="plan-datamarts" data-lens-number="5" aria-label="Explore Datamarts" aria-expanded="false" aria-controls="slide-lens"><b>Datamarts</b><span>research-ready marts</span>
|
||||
<template class="lens-details">
|
||||
<p class="detail-intro">Focused datasets derived from the warehouse for a specific research question, study or dashboard.</p>
|
||||
<p><b>What they define</b>The cohort, time window, variables and level of detail: for example, one row per patient or one row per procedure.</p>
|
||||
<p><b>How ThothII helps</b>A plain-English question becomes a proposed SQL query through a guided workflow with human review. The resulting dataset supports analysis and portal dashboards.</p>
|
||||
<p class="detail-example"><b>Illustrative study dataset</b>Patients who underwent a drug-challenge test, with test date, result and selected clinical characteristics. Its cohort and variable definitions must be agreed before interpreting results.</p>
|
||||
</template>
|
||||
</button>
|
||||
<div class="fcap">AI builds them on demand from plain-English questions (ThothII)</div>
|
||||
</div>
|
||||
<div class="farrow">→</div>
|
||||
@@ -143,11 +192,23 @@
|
||||
<span class="pillar"><b>Authentik</b> · auth — GSD LDAP</span>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">03 / 14</span></footer>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">03 / 13</span></footer>
|
||||
<aside class="notes">
|
||||
Here is the whole project on one slide. On the left, what already existed: three hospital data sources — Cardioref, our electrophysiology records; the genetic data coming from the labs; and the ECG signals. On the right, what we built in AritmoLab: a staging replica, an integration layer where the data is cleaned and normalized, a star-schema warehouse, and the research datamarts — delivered through the data warehouse and a management portal.
|
||||
Everywhere you see the brain symbol, that's where AI works for us — click each brain and its contribution card opens, tied to the brain by a red line. At integration, AI reads the clinical text. At the end, AI builds the datamarts on demand from plain-English questions. And the mappings that bring the sources in were themselves drafted by AI, then revised by humans.
|
||||
That was the plan. And to build it, we used AI everywhere it helped — drafting configs, reading clinical text, and at the very end, producing the datamarts themselves. This talk follows the hardest piece of that plan: the unstructured data. My colleague Dr. Paratico will show you exactly how the text reading works. But first, the first step of the climb: the mappings.
|
||||
Here is the whole project on one slide.
|
||||
|
||||
On the left, what already existed, three hospital data sources:
|
||||
(1) <strong>What existed</strong>: <strong>Cardioref</strong>, our electrophysiology records dataset, the <strong>genetic data</strong> coming from the labs, the <strong>ECG signals</strong>, and, in the future, other sources.
|
||||
|
||||
On the right, what we built in AritmoLab:
|
||||
(2) a staging copy of all relevant Cardioref data, (3) an integration layer where the data is cleaned and normalized, (4) a star-schema warehouse, where the data are reorganized as facts and dimensions, and (5) research datamarts, generated from the data warehouse and presented through dashboards embedded in the portal.
|
||||
|
||||
Everywhere you see the brain symbol, that's where AI works for us.
|
||||
6 - The <strong>mappings</strong> that bring the sources in were themselves drafted by AI, then revised by humans
|
||||
7 - At <strong>integration</strong>, AI reads the clinical text.
|
||||
8 - At the end, AI builds the <strong>datamarts</strong> on demand from plain-English questions.
|
||||
That was the plan. And to build it, we used AI everywhere it helped — drafting configs, reading clinical text, and at the very end, producing the datamarts themselves.
|
||||
|
||||
Next, we’ll look at the hardest part: unstructured data. My colleague, Sara Paratico,do will show you exactly how the text reading works. But first, the first step of the climb: the mappings.
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
@@ -207,9 +268,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">04 / 14</span></footer>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">04 / 13</span></footer>
|
||||
<aside class="notes">
|
||||
[DRAFT] Here is one real sentence from a discharge letter — in Italian, as the clinicians wrote it. In this single sentence there is a diagnosis (syncope), an ECG finding (ST elevation in V1–V3), and a drug-challenge result (flecainide positive for Brugada pattern). For a human cardiologist this is readable in two seconds. For a database, this is just a blob of text in a column.
|
||||
Here is one real sentence from a discharge letter — in Italian, as the clinicians wrote it. In this single sentence there is a diagnosis (syncope), an ECG finding (ST elevation in V1–V3), and a drug-challenge result (flecainide positive for Brugada pattern). For a human cardiologist this is readable in two seconds. For a database, this is just a blob of text in a column.
|
||||
The structured tables — demographics, procedures, dates — only tell half the story. The rest is locked inside these free-text fields, in every hospital system we have.
|
||||
</aside>
|
||||
</section>
|
||||
@@ -253,7 +314,7 @@
|
||||
<div class="nitem"><b>Audited.</b> A new rule set goes live only after it proves ≥95% accuracy on 100 records reviewed by hand.</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">05 / 14</span></footer>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">05 / 13</span></footer>
|
||||
<aside class="notes">
|
||||
[DRAFT — Sara] I'll take you inside these numbers. We read 58,438 discharge letters — every single one, every night. From them the text miner extracted 73,389 pathology records, classified into our clinical ontology. It parsed 10,908 drug-challenge tests, distinguishing the therapy from the actual test. And at the end of the chain: 2,307 patients with a confirmed Brugada pattern — a cohort nobody could have built by hand.
|
||||
This is not a one-off migration: the pipeline runs every night, and today it processes on average two hundred and forty letters a month. Four simple pieces do the work: a letter reader for the Italian and English text, a clinical matcher that recognises diagnoses, procedures and test outcomes, a context guard that keeps negations and family history apart, and the ontology sorter that files every finding into its clinical category.
|
||||
@@ -306,7 +367,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">06 / 14</span></footer>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">06 / 13</span></footer>
|
||||
<aside class="notes">
|
||||
[DRAFT — Sara] Why can't we just search for "FA"? Because clinical text lies to naive search. "Fibrillazione atriale esclusa" contains the words of a diagnosis but negates it — so every rule scans a window around the match, looking for negation cues. "Padre con FA" is real atrial fibrillation — but in the father, not the patient: we record it as family history. Letters mix Italian and English, abbreviations collide ("TA" is blood pressure, not a therapy), and one field can contain five different statements — so we split the text into clauses first.
|
||||
Each of these problems has a specific, versioned solution. Sara to expand with real corpus examples.
|
||||
@@ -349,7 +410,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">07 / 14</span></footer>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">07 / 13</span></footer>
|
||||
<aside class="notes">
|
||||
[DRAFT — Sara] Extraction needs a target vocabulary — that's the ontology. Tier 1 holds the eleven arrhythmological categories that matter most for our research; Tier 2 holds five structural conditions. The order of the rules is itself clinical knowledge: Brugada patterns are tested before TV, because "substrato per TV" often appears in Brugada reports and would otherwise mask the diagnosis.
|
||||
The ontology is versioned like software: a semantic version for the pattern library, stamped on every extracted row. When we add a synonym or fix a rule, the change is traceable — and the data can be rebuilt.
|
||||
@@ -359,7 +420,7 @@
|
||||
</section>
|
||||
|
||||
|
||||
<section class="arit center-v">
|
||||
<section class="arit center-v validation">
|
||||
<header class="head">
|
||||
<img src="logo.png" alt="">
|
||||
<span class="head-org">AritmoLab · Policlinico San Donato</span>
|
||||
@@ -368,34 +429,74 @@
|
||||
<span class="band-section">Text analysis</span>
|
||||
</nav>
|
||||
<div class="sbody">
|
||||
<!-- Verified against aritmolab/chirone-etl on Gitea, commit 2ab00106188f298c1a0c1e2c48b3a4dbc66c45b5.
|
||||
Exact sources and interpretation limits: ../slides/08-validation-sources.md.
|
||||
SC-003/004 are targets; ~893 refers to false-positive new-letter records, not distinct patients. -->
|
||||
<div class="kicker">Validation</div>
|
||||
<h2>Trusting the text is a process, not a promise</h2>
|
||||
<div class="onto-split">
|
||||
<div class="onto-left">
|
||||
<div class="onto-points">
|
||||
<div class="oitem"><span class="n">01</span><div><b>Quality gates</b> <span class="d">— ≥95% procedure classification, ≥85% pathology detection, validated on 100 manually reviewed records</span></div></div>
|
||||
<div class="oitem"><span class="n">02</span><div><b>Clinical review</b> <span class="d">— the extracted Brugada cohort was validated by clinicians on Superset dashboards</span></div></div>
|
||||
<div class="oitem"><span class="n">03</span><div><b>Feedback loop</b> <span class="d">— ~893 Brugada false positives found and removed in pattern-library v1.3.1</span></div></div>
|
||||
<div class="oitem"><span class="n">04</span><div><b>Nothing is silently dropped</b> <span class="d">— negated and family-attributed findings are stored too, filtered only at the mart layer</span></div></div>
|
||||
<button type="button" class="oitem lens-target" id="validation-quality" data-lens="validation-quality" data-lens-number="1" data-lens-placement="center" data-lens-caption="Validation · 01 / 04" aria-label="Explore Quality gates" aria-expanded="false" aria-controls="slide-lens"><span class="n">01</span><span><b>Quality gates</b> <span class="d">— targets: ≥95% procedure accuracy on 100 manual reviews; ≥85% pathology coverage</span></span>
|
||||
<template class="lens-details">
|
||||
<p class="detail-intro">We separate correct classification from how often the rules find a condition.</p>
|
||||
<p><b>Procedure classification</b>The specification sets a target of at least 95% agreement with a manual review of 100 randomly selected records: did the system assign the right procedure type?</p>
|
||||
<p><b>Pathology coverage</b>The 85% target asks whether at least one recognized arrhythmia is extracted from a record, assuming most procedures concern known arrhythmias. It does not measure whether every diagnosis is correct or every disease is found.</p>
|
||||
<p class="detail-example"><b>How to interpret the numbers</b>These are acceptance targets. Clinical review is still needed to detect incorrect labels and missed findings; coverage alone cannot establish clinical accuracy.</p>
|
||||
</template>
|
||||
</button>
|
||||
<button type="button" class="oitem lens-target" id="validation-review" data-lens="validation-review" data-lens-number="2" data-lens-placement="center" data-lens-caption="Validation · 02 / 04" aria-label="Explore Clinical criteria" aria-expanded="false" aria-controls="slide-lens"><span class="n">02</span><span><b>Clinical criteria</b> <span class="d">— explicit criteria determine which patients belong in the analysis; a disease name alone is not enough</span></span>
|
||||
<template class="lens-details">
|
||||
<p class="detail-intro">Finding a disease name is the first step. Inclusion in an analysis depends on what the text means and the criteria for that cohort.</p>
|
||||
<p><b>Read the context</b>“Father with Brugada” concerns a relative; “test negative for Brugada” reports a negative result; “suspected Brugada” expresses uncertainty. None of these phrases alone establishes a diagnosis in the patient.</p>
|
||||
<p><b>Make the selection explicit</b>Queries apply the cohort criteria to prepare the dataset. Superset displays the result. The diagnosis view first excludes negated findings and findings attributed to relatives.</p>
|
||||
<p class="detail-example"><b>A rule used in this project</b>For Brugada and long QT syndrome, that view also requires a positive provocative test or an ablation for the condition, at patient level. Other pathologies use only the first filter. These are project inclusion rules, not a universal diagnostic standard.</p>
|
||||
</template>
|
||||
</button>
|
||||
<button type="button" class="oitem lens-target" id="validation-feedback" data-lens="validation-feedback" data-lens-number="3" data-lens-placement="center" data-lens-caption="Validation · 03 / 04" aria-label="Explore Feedback loop" aria-expanded="false" aria-controls="slide-lens"><span class="n">03</span><span><b>Feedback loop</b> <span class="d">— v1.3.1 fixed missed negations behind ~893 false-positive Brugada records</span></span>
|
||||
<template class="lens-details">
|
||||
<p class="detail-intro">The 11 June 2026 audit identified about 893 false-positive Brugada records in the newer letter format.</p>
|
||||
<p><b>The error and the fix</b>“Test alla flecainide negativo per sindrome di Brugada” was treated as an affirmed finding: the rules recognized “negato”, but missed “negativo”. Version 1.3.1 added “negativo”, “negativa” and “negativi” before and after the condition.</p>
|
||||
<p><b>Make the correction repeatable</b>Regression tests require that this sentence still produces a Brugada finding, now marked as negated. A separate test checks that an affirmed Brugada diagnosis remains positive. Reprocessing applies the revised rules to historical letters.</p>
|
||||
<p class="detail-example"><b>What changed</b>The finding is reclassified, not erased. The ~893 figure counts affected records, not necessarily distinct patients; the cohort filters can now exclude those negated findings.</p>
|
||||
</template>
|
||||
</button>
|
||||
<button type="button" class="oitem lens-target" id="validation-context" data-lens="validation-context" data-lens-number="4" data-lens-placement="center" data-lens-caption="Validation · 04 / 04" aria-label="Explore Nothing is silently dropped" aria-expanded="false" aria-controls="slide-lens"><span class="n">04</span><span><b>Nothing is silently dropped</b> <span class="d">— negated and family-attributed findings are stored too, filtered only at the mart layer</span></span>
|
||||
<template class="lens-details">
|
||||
<p class="detail-intro">A recognized finding can be kept even when it is negated or refers to someone else.</p>
|
||||
<p><b>Store context alongside the finding</b>The extractor looks up to 60 characters before and after the match, stopping at a full stop, semicolon or line break. It records whether the finding is negated and whether it concerns the patient or a relative.</p>
|
||||
<p><b>Filter when building the research dataset</b>Those flags travel through integration and the warehouse to the mentions dataset. The confirmed-diagnosis view excludes negated and family findings, while the underlying dataset remains available for other analyses.</p>
|
||||
<p class="detail-example"><b>Preservation has a defined scope</b>Repeated matches for the same pathology are consolidated, preferring an affirmed patient finding. “Suspected” or “to exclude” is not automatically treated as a negation. These limits remain explicit for clinical review.</p>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
<div class="roi roi-quiet"><b class="k">Quality gate</b>An extraction is accepted only after 100 manual reviews confirm the agreed accuracy — the same standard for every new pattern release.</div>
|
||||
</div>
|
||||
<div class="out-panel">
|
||||
<div class="out-h">The advantage: speed, precision, determinism</div>
|
||||
<div class="out-item"><span class="n">01</span><div><b>Machine speed</b><span class="d">twenty years of letters are read by rules, not by hand; when a pattern improves, the whole archive is rebuilt from the letters themselves</span></div></div>
|
||||
<div class="out-item"><span class="n">02</span><div><b>Audited precision</b><span class="d">every release clears the same gate before production: ≥95% procedure classification, ≥85% pathology detection</span></div></div>
|
||||
<div class="out-item"><span class="n">03</span><div><b>Deterministic by design</b><span class="d">the same letter always yields the same values: no LLM sampling, no randomness, every result traceable to a versioned pattern</span></div></div>
|
||||
<div class="roi roi-quiet"><b class="k">Quality gate</b>Manual comparison checks procedure accuracy. Pathology coverage and clinical correctness are assessed separately.</div>
|
||||
</div>
|
||||
<button type="button" class="out-panel lens-target" id="validation-advantages" data-lens="validation-advantages" data-lens-number="5" data-lens-title="The advantages" data-lens-placement="center" data-lens-caption="Validation · Advantages" aria-label="Explore the advantages: speed, precision, determinism" aria-expanded="false" aria-controls="slide-lens">
|
||||
<span class="out-h">The advantage: speed, precision, determinism</span>
|
||||
<span class="out-item"><span class="n">01</span><span><b>Machine speed</b><span class="d">twenty years of letters are read by rules, not by hand; when a pattern improves, the whole archive can be reprocessed</span></span></span>
|
||||
<span class="out-item"><span class="n">02</span><span><b>Explicit quality targets</b><span class="d">≥95% procedure accuracy on a manual sample; ≥85% pathology coverage, with clinical review of the resulting cohort</span></span></span>
|
||||
<span class="out-item"><span class="n">03</span><span><b>Deterministic by design</b><span class="d">the same letter and rule version yield the same extraction: no LLM sampling, no randomness, results traceable to versioned rules</span></span></span>
|
||||
<template class="lens-details">
|
||||
<p class="detail-intro">Speed, precision and determinism each address a different part of making clinical text usable for research.</p>
|
||||
<p><b>01 · Speed: apply a correction across the archive</b>Once defined, the extraction rules process letters automatically. An improved rule can be applied again to historical text, without manually relabelling every record.</p>
|
||||
<p><b>02 · Precision: make errors visible and correctable</b>Explicit quality targets, clinical context and cohort filters make the results inspectable. The Brugada correction shows how an audit can identify a recurring error, and regression tests can protect the fix. Quality still needs measurement and clinical review.</p>
|
||||
<p><b>03 · Determinism: reproduce the extraction</b>With the same input, rule version and configuration, extraction produces the same findings. There is no language-model sampling at this step. Versioning explains why a result changes when the rules change.</p>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">08 / 14</span></footer>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">08 / 13</span></footer>
|
||||
<aside class="notes">
|
||||
[DRAFT — Sara] How do we know the extraction is right? Not by faith. Before any pattern release goes to production it passes a gate: on 100 manually reviewed records, procedure classification must reach 95% accuracy, pathology detection 85%. The Brugada cohort itself was reviewed by clinicians on dashboards — and that review fed back into the library: version 1.3.1 alone removed roughly 900 false positives.
|
||||
And we never throw information away: negated and family findings are stored, only filtered when the research mart needs them. Sara: adjust the numbers/wording to what you are comfortable presenting.
|
||||
<p><button type="button" data-note-lens="validation-quality">1. Quality gates.</button> We set a 95% procedure-accuracy target, against 100 manual reviews. The 85% pathology target measures coverage, not diagnostic accuracy.</p>
|
||||
<p><button type="button" data-note-lens="validation-review">2. Clinical criteria.</button> Finding a disease name in a letter is only the first step. We apply explicit clinical criteria to decide which patients belong in the analysis.</p>
|
||||
<p><button type="button" data-note-lens="validation-feedback">3. Feedback loop.</button> Version 1.3.1 corrected missed negations in roughly 900 Brugada records, with regression tests protecting the fix.</p>
|
||||
<p><button type="button" data-note-lens="validation-context">4. Nothing is silently dropped.</button> Negated and family findings retain their context. Research datasets filter them explicitly, so preserving a finding does not mean counting it as the patient’s diagnosis.</p>
|
||||
<p><button type="button" data-note-lens="validation-advantages">5. The advantages.</button> We can reprocess the archive quickly, inspect the rules behind each result, and reproduce the same extraction with the same rules.</p>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<!-- ============ 10 · ARITMOLAB TODAY ============ -->
|
||||
<!-- ============ 09 · ARITMOLAB TODAY ============ -->
|
||||
<section class="arit">
|
||||
<header class="head">
|
||||
<img src="logo.png" alt="">
|
||||
@@ -407,17 +508,30 @@
|
||||
<div class="sbody">
|
||||
<div class="kicker">The portal, today</div>
|
||||
<h2>AritmoLab — a quick tour</h2>
|
||||
<div class="shots">
|
||||
<div class="shot">screenshot</div><div class="shot">screenshot</div><div class="shot">screenshot</div>
|
||||
<div class="shot">screenshot</div><div class="shot">screenshot</div><div class="shot">screenshot</div>
|
||||
<div class="screenshot-tour" aria-label="AritmoLab tour, screenshots 1 to 7">
|
||||
<button type="button" data-screenshot="home" data-screenshot-number="1" data-screenshot-title="Home page" aria-haspopup="dialog"><img src="screenshots/1-HomePage.png" alt="" loading="lazy"><span><b>1</b> Home page</span></button>
|
||||
<button type="button" data-screenshot="patients" data-screenshot-number="2" data-screenshot-title="Patient list" aria-haspopup="dialog"><img src="screenshots/2-PatientList.png" alt="" loading="lazy"><span><b>2</b> Patient list</span></button>
|
||||
<button type="button" data-screenshot="profile" data-screenshot-number="3" data-screenshot-title="Patient profile" aria-haspopup="dialog"><img src="screenshots/3-PatientGeneralData.png" alt="" loading="lazy"><span><b>3</b> Patient profile</span></button>
|
||||
<button type="button" data-screenshot="history" data-screenshot-number="4" data-screenshot-title="Clinical history" aria-haspopup="dialog"><img src="screenshots/4-PatientDetail.png" alt="" loading="lazy"><span><b>4</b> Clinical history</span></button>
|
||||
<button type="button" data-screenshot="procedure" data-screenshot-number="5" data-screenshot-title="Procedure details" aria-haspopup="dialog"><img src="screenshots/5-PatientProcedureDetails.png" alt="" loading="lazy"><span><b>5</b> Procedure details</span></button>
|
||||
<button type="button" data-screenshot="dashboards" data-screenshot-number="6" data-screenshot-title="Dashboard catalogue" aria-haspopup="dialog"><img src="screenshots/6-DashboardsList.png" alt="" loading="lazy"><span><b>6</b> Dashboard catalogue</span></button>
|
||||
<button type="button" data-screenshot="brugada" data-screenshot-number="7" data-screenshot-title="Brugada dashboard" aria-haspopup="dialog"><img src="screenshots/7-Brugada-dashboard.png" alt="" loading="lazy"><span><b>7</b> Brugada dashboard</span></button>
|
||||
</div>
|
||||
<p class="hint">[MP: 6-7 real screenshots of the portal, anonymized or demo data]</p>
|
||||
<p class="hint">Select a screen to enlarge · Follow the tour from 1 to 7</p>
|
||||
</div>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">09 / 14</span></footer>
|
||||
<aside class="notes">[DRAFT] A quick tour of what AritmoLab looks like today — six screenshots: patient profile, genetics, dashboards, the 360-degree view. What began as four islands is now one platform the Unit uses every day. MP: replace with real screenshots and a spoken tour.</aside>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">09 / 13</span></footer>
|
||||
<aside class="notes">
|
||||
<p><button type="button" data-note-screenshot="home">1. Home page.</button> The home page summarises around 57,000 patients, by age, sex and geographical origin. It is the starting point for exploring the archive.</p>
|
||||
<p><button type="button" data-note-screenshot="patients">2. Patient list.</button> Search filters help us find a patient or study participant, open their record, or export the results.</p>
|
||||
<p><button type="button" data-note-screenshot="profile">3. Patient profile.</button> The profile brings demographic and clinical fields together, with access to procedures, devices, diagnostic examinations and genetics.</p>
|
||||
<p><button type="button" data-note-screenshot="history">4. Clinical history.</button> A dated timeline brings together clinical notes, discharge letters and procedures, retaining the source of each event.</p>
|
||||
<p><button type="button" data-note-screenshot="procedure">5. Procedure details.</button> Here, an ablation record shows the treated arrhythmias, procedural details, recorded complications and conclusions.</p>
|
||||
<p><button type="button" data-note-screenshot="dashboards">6. Dashboard catalogue.</button> We then move from individual records to dashboards covering departmental activity, procedures, devices and genetics.</p>
|
||||
<p><button type="button" data-note-screenshot="brugada">7. Brugada dashboard.</button> The funnel separates text mentions, filtered mentions, confirmed cases and ablation outcomes. Other charts describe sex, age, annual diagnoses and the timing of pre- and post-assessments.</p>
|
||||
</aside>
|
||||
</section>
|
||||
<!-- ============ 11 · CRISIS ============ -->
|
||||
<section class="arit">
|
||||
<!-- ============ 10 · CRISIS ============ -->
|
||||
<section class="arit crisis">
|
||||
<header class="head">
|
||||
<img src="logo.png" alt="">
|
||||
<span class="head-org">AritmoLab · Policlinico San Donato</span>
|
||||
@@ -429,52 +543,54 @@
|
||||
<div class="kicker">All good? Not yet.</div>
|
||||
<h2>The warehouse speaks SQL. Research needs more.</h2>
|
||||
<div class="hgap"></div>
|
||||
<ul class="points">
|
||||
<li><b>Clinicians and researchers don't write SQL</b> — a star schema is built for analysts, not for the ward</li>
|
||||
<li><b>Health Intelligence</b> — to present the past, the Unit needs ready-made aggregates: volumes, incidence, outcomes</li>
|
||||
<li><b>Predictive research</b> — multivariate analysis and ML models need clean, wide, cohort-shaped tables</li>
|
||||
<li><b>Hand-made datamarts are the bottleneck</b> — even with coding agents, each one takes hours of careful work</li>
|
||||
</ul>
|
||||
<div class="crisis-points" aria-label="Four barriers between the warehouse and research">
|
||||
<button type="button" class="oitem lens-target" id="crisis-language" data-lens="crisis-language" data-lens-number="1" data-lens-title="From clinical question to SQL" data-lens-placement="center" data-lens-caption="The research gap · 01 / 04" aria-expanded="false" aria-controls="slide-lens"><span class="n">01</span><span><b>Clinical questions need translation</b><span class="d">Clinicians define the question; a query must express it across linked tables.</span></span>
|
||||
<template class="lens-details">
|
||||
<p class="detail-intro">Knowing what to ask is different from knowing how the database stores the answer.</p>
|
||||
<p><b>The clinical question</b>“How many patients with confirmed Brugada underwent an ablation?” requires agreed definitions of the cohort and procedure.</p>
|
||||
<p><b>The engineering task</b>SQL must connect diagnoses, patients and procedures, apply the time window and count each patient once, even when several records describe the same person.</p>
|
||||
<p class="detail-example"><b>The bridge</b>Clinical expertise defines the meaning. A reviewed query turns that meaning into an explicit, checkable selection.</p>
|
||||
</template>
|
||||
</button>
|
||||
<button type="button" class="oitem lens-target" id="crisis-intelligence" data-lens="crisis-intelligence" data-lens-number="2" data-lens-title="Health Intelligence: describe what happened" data-lens-placement="center" data-lens-caption="The research gap · 02 / 04" aria-expanded="false" aria-controls="slide-lens"><span class="n">02</span><span><b>Health Intelligence needs shared definitions</b><span class="d">Volumes, diagnoses and outcomes need consistent groups, periods and denominators.</span></span>
|
||||
<template class="lens-details">
|
||||
<p class="detail-intro">A dashboard needs agreed indicators, not simply a chart drawn over raw records.</p>
|
||||
<p><b>Define what is counted</b>Patients, admissions and procedures answer different questions. For an outcome percentage, specify which patients are eligible and the observation period.</p>
|
||||
<p><b>Prepare comparable summaries</b>Aggregate by year, procedure or patient group using the same definitions. Keep missing information visible so that changes in documentation are not mistaken for changes in care.</p>
|
||||
<p class="detail-example"><b>Example</b>Annual ablation volumes describe activity. An outcome percentage also needs a defined denominator and follow-up window.</p>
|
||||
</template>
|
||||
</button>
|
||||
<button type="button" class="oitem lens-target" id="crisis-prediction" data-lens="crisis-prediction" data-lens-number="3" data-lens-title="Predictive research: build the study table" data-lens-placement="center" data-lens-caption="The research gap · 03 / 04" aria-expanded="false" aria-controls="slide-lens"><span class="n">03</span><span><b>Predictive research needs a study dataset</b><span class="d">A defined cohort, consistent variables and outcomes measured over an agreed period.</span></span>
|
||||
<template class="lens-details">
|
||||
<p class="detail-intro">Linked clinical records must become a table shaped around the study question.</p>
|
||||
<p><b>Define one row</b>Choose the unit of analysis: for example, one patient or one procedure. Place the selected characteristics in columns, with consistent units and explicit handling of missing values.</p>
|
||||
<p><b>Respect the timeline</b>Define when prediction would occur and when the outcome is assessed. Predictor variables must contain only information available at that prediction time.</p>
|
||||
<p class="detail-example"><b>Example</b>To study outcomes after ablation, separate pre-procedure characteristics from later observations. This prevents future information from leaking into the prediction.</p>
|
||||
</template>
|
||||
</button>
|
||||
<button type="button" class="oitem lens-target" id="crisis-datamarts" data-lens="crisis-datamarts" data-lens-number="4" data-lens-title="Datamarts: the preparation bottleneck" data-lens-placement="center" data-lens-caption="The research gap · 04 / 04" aria-expanded="false" aria-controls="slide-lens"><span class="n">04</span><span><b>Hand-made datamarts are the bottleneck</b><span class="d">Each question requires selection, joins, checks and a reproducible dataset.</span></span>
|
||||
<template class="lens-details">
|
||||
<p class="detail-intro">A datamart is a focused dataset prepared for a particular analysis.</p>
|
||||
<p><b>More than writing SQL</b>Someone must agree the cohort, connect the sources, resolve duplicate records and check missing values and patient counts. A query can run successfully and still answer the wrong question.</p>
|
||||
<p><b>Make the work repeatable</b>Keep the selection rules, query and checks together, so the dataset can be rebuilt when the data or study definition changes.</p>
|
||||
<p class="detail-example"><b>Where assistance helps</b>AI can draft the query. Clinicians and engineers still review its meaning and results before using the dataset.</p>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
<div class="roi"><b class="k">The gap</b>Twenty years of data, one warehouse — and no fast road from a research question to an answer.</div>
|
||||
</div>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">10 / 14</span></footer>
|
||||
<aside class="notes">[DRAFT] And here is the crisis of our story. The warehouse holds everything — but it speaks SQL. A clinician cannot query a star schema between two visits, and a researcher cannot build a study on raw tables. What research needs are datamarts: ready-made views for Health Intelligence, and clean cohort tables for multivariate analysis and machine learning. And building them by hand — even with coding agents helping — takes hours of careful, repetitive work for every single study. This is where our story got stuck.</aside>
|
||||
</section>
|
||||
|
||||
<section class="arit">
|
||||
<header class="head">
|
||||
<img src="logo.png" alt="">
|
||||
<span class="head-org">AritmoLab · Policlinico San Donato</span>
|
||||
</header>
|
||||
<nav class="band">
|
||||
<span class="band-section">ThothII</span>
|
||||
</nav>
|
||||
<div class="sbody">
|
||||
<div class="kicker">Role 3 — AI for analysis</div>
|
||||
<h2>Ask the warehouse in plain English</h2>
|
||||
<div class="hgap"></div>
|
||||
<div class="quote">“How many Brugada patients had an effective ablation?”
|
||||
<small>a researcher, in natural language</small>
|
||||
</div>
|
||||
<div class="pipeline">
|
||||
<div class="stage ai"><b>NL → SQL</b><span>AI writes the query over the star schema</span><span class="tag">AI</span></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="stage"><b>Human review</b><span>the researcher checks every proposed query</span></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="stage ai"><b>Datamart</b><span>curated, research-ready marts</span><span class="tag">AI</span></div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="stage"><b>Superset</b><span>statistics & dashboards</span></div>
|
||||
</div>
|
||||
<p class="stat-note">The session is a conversation: refine the question, iterate on the datamart — every step reviewable.</p>
|
||||
</div>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">11 / 14</span></footer>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">10 / 13</span></footer>
|
||||
<aside class="notes">
|
||||
[DRAFT] Third role: AI for the analysis itself. This is ThothII, our natural-language layer over the warehouse. A researcher asks "how many Brugada patients had an effective ablation?" in plain English; the AI writes the SQL over the star schema, proposes a datamart, and the researcher reviews the query before it runs. The result lands in Superset as statistics and dashboards.
|
||||
The human stays in the loop — the AI proposes, the researcher approves. This is where the demo will go live in a couple of slides.
|
||||
<p><strong>0. What is SQL?</strong><br>SQL means Structured Query Language. It tells a database what to select, connect and count.</p>
|
||||
<p><button type="button" data-note-lens="crisis-language">1. Clinical questions.</button><br>“How many patients with confirmed Brugada underwent an ablation?” We must define confirmation and count each patient once.</p>
|
||||
<p><button type="button" data-note-lens="crisis-intelligence">2. Health Intelligence.</button><br>Dashboards need agreed definitions, time periods and denominators to make comparisons meaningful.</p>
|
||||
<p><button type="button" data-note-lens="crisis-prediction">3. Predictive research.</button><br>Study tables separate characteristics known before prediction from outcomes observed afterwards.</p>
|
||||
<p><button type="button" data-note-lens="crisis-datamarts">4. Datamarts.</button><br>A datamart is an analysis dataset with explicit selection rules and repeatable checks.</p>
|
||||
<p><strong>5. The gap.</strong><br>The gap is between clinical meaning and database instructions: storing data does not automatically make a question answerable.</p>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<!-- ============ 13 · THOTHII TOUR ============ -->
|
||||
<!-- ============ 11 · THOTHII TOUR ============ -->
|
||||
<section class="arit">
|
||||
<header class="head">
|
||||
<img src="logo.png" alt="">
|
||||
@@ -492,10 +608,10 @@
|
||||
</div>
|
||||
<p class="hint">[MP: 6-7 real screenshots of ThothII — ask, review SQL, datamart, dashboard]</p>
|
||||
</div>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">12 / 14</span></footer>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">11 / 13</span></footer>
|
||||
<aside class="notes">[DRAFT] Let me walk you through ThothII: the researcher asks the question in plain English; the AI proposes the SQL; the query is reviewed; the datamart is assembled; the dashboard comes alive. Six screens, a few minutes. MP: real screenshots.</aside>
|
||||
</section>
|
||||
<!-- ============ 14 · HAPPY ENDING ============ -->
|
||||
<!-- ============ 12 · HAPPY ENDING ============ -->
|
||||
<section class="arit">
|
||||
<header class="head">
|
||||
<img src="logo.png" alt="">
|
||||
@@ -513,7 +629,7 @@
|
||||
</div>
|
||||
<p class="hint">[Illustrative data, modeled on published arrhythmology predictors — Brugada focus]</p>
|
||||
</div>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">13 / 14</span></footer>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">12 / 13</span></footer>
|
||||
<aside class="notes">[DRAFT — next step: two SVG charts, illustrative data modeled on real literature] And this is the happy ending. From one datamart, the Unit can run a multivariate analysis — which factors truly drive arrhythmic risk in Brugada patients — and train machine-learning models on the same table. What used to take weeks of manual data preparation now takes minutes. The AI did not replace the researcher: it gave the researcher back their time.</aside>
|
||||
</section>
|
||||
|
||||
@@ -535,7 +651,7 @@
|
||||
<span>Dr. Sara Paratico - I.R.C.C.S. Policlinico San Donato</span>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">14 / 14</span></footer>
|
||||
<footer class="foot"><span class="g">Role of AI in the Analysis of Unstructured Clinical Databases</span><span class="g">Dr. Marco Pancotti - MultiPhysixLab</span><span class="g">Dr. Sara Paratico - Gruppo San Donato</span><span class="g">San Donato Milanese, Milan, Italy · 2–3 October 2026</span><span class="g num">13 / 13</span></footer>
|
||||
<aside class="notes">
|
||||
[DRAFT] Thank you for your attention. If you want to go deeper — the text miner, the mapping agents, ThothII — we are publishing the technical walkthroughs as videos on our Substack; the link is on the final version of this deck. And now, happy to take your questions.
|
||||
</aside>
|
||||
@@ -545,17 +661,19 @@
|
||||
|
||||
<!-- AI contribution popup -->
|
||||
<div class="ai-pop" id="aiPop">
|
||||
<svg class="ai-line"><line x1="0" y1="0" x2="0" y2="0"></line><circle cx="0" cy="0" r="5"></circle></svg>
|
||||
<svg class="ai-line"><line x1="0" y1="0" x2="0" y2="0"></line><circle cx="0" cy="0" r="7"></circle></svg>
|
||||
<div class="ai-pop-card" role="dialog" aria-modal="true">
|
||||
<button class="ai-pop-x" aria-label="Close">✕</button>
|
||||
<div class="ai-pop-kicker" id="aiPopKicker">AI contribution</div>
|
||||
<div class="ai-pop-title"><span class="brain brain-btn" id="aiPopBrain">🧠</span><b id="aiPopTitle"></b></div>
|
||||
<div class="ai-pop-title"><span class="brain brain-btn" id="aiPopBrain"><img class="artificial-brain" src="artificial-brain.svg" alt=""></span><b id="aiPopTitle"></b></div>
|
||||
<div id="aiPopBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="vendor/reveal/dist/reveal.js"></script>
|
||||
<script src="vendor/reveal/plugin/notes/notes.js?v=3"></script>
|
||||
<script src="slide-lens.js?v=4"></script>
|
||||
<script src="screenshot-tour.js?v=1"></script>
|
||||
<script>
|
||||
Reveal.initialize({
|
||||
width: 1280, height: 720, margin: 0,
|
||||
@@ -567,7 +685,7 @@
|
||||
// jump dropdown — one select per slide, in the red band
|
||||
const JUMP_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',
|
||||
'Trust the text', 'AritmoLab today', 'All good? Not yet', 'A tour of ThothII',
|
||||
'From datamarts to models', 'Thank you'];
|
||||
document.querySelectorAll('.arit .band').forEach((band) => {
|
||||
const sel = document.createElement('select');
|
||||
@@ -707,10 +825,53 @@
|
||||
const card = pop.querySelector('.ai-pop-card');
|
||||
const line = pop.querySelector('.ai-line line');
|
||||
const dot = pop.querySelector('.ai-line circle');
|
||||
const closePop = () => pop.classList.remove('open');
|
||||
let activePopup = null;
|
||||
const closePop = () => {
|
||||
pop.classList.remove('open');
|
||||
document.querySelectorAll('.brain-btn[aria-pressed="true"]').forEach(button => button.setAttribute('aria-pressed', 'false'));
|
||||
activePopup = null;
|
||||
SlideLens.close();
|
||||
ScreenshotTour.close();
|
||||
window.dispatchEvent(new Event('presentationchange'));
|
||||
};
|
||||
Reveal.on('slidechanged', closePop);
|
||||
const placeNear = (brainEl) => {
|
||||
const b = brainEl.getBoundingClientRect();
|
||||
const vw = window.innerWidth, vh = window.innerHeight;
|
||||
card.style.width = '';
|
||||
card.style.maxHeight = '';
|
||||
if (pop.classList.contains('is-ai')) {
|
||||
const margin = 24, gap = Math.min(150, Math.max(80, vw * .12));
|
||||
const roomRight = vw - margin - b.right - gap;
|
||||
const roomLeft = b.left - gap - margin;
|
||||
const onRight = roomRight >= roomLeft;
|
||||
const by = b.top + b.height / 2;
|
||||
let left, top, sx, sy, ex, ey;
|
||||
if (Math.max(roomRight, roomLeft) >= 260) {
|
||||
card.style.width = `${Math.min(540, Math.max(roomRight, roomLeft))}px`;
|
||||
const cw = card.offsetWidth, ch = card.offsetHeight;
|
||||
left = onRight ? b.right + gap : b.left - gap - cw;
|
||||
top = Math.max(margin, Math.min(vh - margin - ch, by - ch / 2));
|
||||
sx = onRight ? b.right + 8 : b.left - 8; sy = by;
|
||||
ex = onRight ? left : left + cw;
|
||||
ey = Math.max(top + 24, Math.min(by, top + ch - 24));
|
||||
} else {
|
||||
// Narrow windows: use a vertical connector instead of covering the icon.
|
||||
const below = vh - b.bottom >= b.top;
|
||||
const verticalGap = 54;
|
||||
card.style.maxHeight = `${Math.max(80, (below ? vh - b.bottom : b.top) - verticalGap - margin)}px`;
|
||||
left = Math.max(margin, Math.min(vw - margin - card.offsetWidth, b.left + b.width / 2 - card.offsetWidth / 2));
|
||||
top = below ? b.bottom + verticalGap : b.top - verticalGap - card.offsetHeight;
|
||||
sx = b.left + b.width / 2; sy = below ? b.bottom + 8 : b.top - 8;
|
||||
ex = Math.max(left + 24, Math.min(sx, left + card.offsetWidth - 24));
|
||||
ey = below ? top : top + card.offsetHeight;
|
||||
}
|
||||
card.style.left = `${left}px`; card.style.top = `${top}px`;
|
||||
line.setAttribute('x1', sx); line.setAttribute('y1', sy);
|
||||
line.setAttribute('x2', ex); line.setAttribute('y2', ey);
|
||||
dot.setAttribute('cx', sx); dot.setAttribute('cy', sy);
|
||||
return;
|
||||
}
|
||||
const cw = card.offsetWidth, ch = card.offsetHeight;
|
||||
const bx = b.left + b.width / 2, by = b.top + b.height / 2;
|
||||
const gap = 52;
|
||||
@@ -776,11 +937,11 @@
|
||||
},
|
||||
omics: {
|
||||
kicker: 'The starting point · 20 years ago',
|
||||
title: 'Omics Portal — born too early',
|
||||
title: 'Aritmolab Portal — born too early',
|
||||
body: `
|
||||
<p class="lead">A 360° patient-management system, still in its infancy.</p>
|
||||
<ul class="ai-pop-list">
|
||||
<li><b>Used for</b> — the first patient-management features beyond the hospital EHR</li>
|
||||
<li><b>Used for</b> — the first patient-management features beyond the hospital electronic health record</li>
|
||||
<li><b>The catch</b> — embryonic functionality, and no integration with Cardioref</li>
|
||||
</ul>
|
||||
<div class="roi"><b class="k">Problem</b>A promising idea without a bridge: it could not see the clinical history stored elsewhere.</div>`,
|
||||
@@ -800,15 +961,36 @@
|
||||
const openPop = (key, brainEl, dict) => {
|
||||
const c = (dict || AI_CONTRIBS)[key];
|
||||
if (!c) return;
|
||||
SlideLens.close();
|
||||
ScreenshotTour.close();
|
||||
const isAI = (dict || AI_CONTRIBS) === AI_CONTRIBS;
|
||||
pop.classList.toggle('is-ai', isAI);
|
||||
document.querySelectorAll('.brain-btn[aria-pressed="true"]').forEach(button => button.setAttribute('aria-pressed', 'false'));
|
||||
if (isAI && brainEl) brainEl.setAttribute('aria-pressed', 'true');
|
||||
document.getElementById('aiPopBrain').style.display = isAI ? 'flex' : 'none';
|
||||
document.getElementById('aiPopKicker').textContent = c.kicker || 'AI contribution';
|
||||
document.getElementById('aiPopTitle').textContent = c.title;
|
||||
document.getElementById('aiPopBody').innerHTML = c.body;
|
||||
pop.classList.add('open');
|
||||
if (brainEl) placeNear(brainEl);
|
||||
activePopup = { kind: isAI ? 'ai' : dict === START_CONTRIBS ? 'start' : 'missing', key };
|
||||
window.dispatchEvent(new Event('presentationchange'));
|
||||
};
|
||||
document.addEventListener('click', (e) => {
|
||||
const screenshot = e.target.closest('[data-screenshot]');
|
||||
if (screenshot) {
|
||||
closePop();
|
||||
ScreenshotTour.open(screenshot);
|
||||
return;
|
||||
}
|
||||
const lens = e.target.closest('[data-lens]');
|
||||
if (lens) {
|
||||
const wasOpen = SlideLens.active?.key === lens.dataset.lens;
|
||||
closePop();
|
||||
if (!wasOpen) SlideLens.open(lens);
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('.slide-lens-close')) { closePop(); return; }
|
||||
const b = e.target.closest('.brain-btn[data-ai]');
|
||||
if (b) { openPop(b.dataset.ai, b, AI_CONTRIBS); return; }
|
||||
const s = e.target.closest('.isle[data-start]');
|
||||
@@ -816,11 +998,60 @@
|
||||
const m = e.target.closest('.todo[data-missing]');
|
||||
if (m) { openPop(m.dataset.missing, m, MISSING_CONTRIBS); return; }
|
||||
if (e.target.closest('.ai-pop-x') || e.target === pop) closePop();
|
||||
if (SlideLens.active && !e.target.closest('.slide-lens')) closePop();
|
||||
});
|
||||
// Escape closes the popup first (capture), without toggling Reveal's overview
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && pop.classList.contains('open')) { closePop(); e.stopPropagation(); }
|
||||
if (e.key === 'Escape' && (pop.classList.contains('open') || SlideLens.active || ScreenshotTour.active)) {
|
||||
closePop(); e.preventDefault(); e.stopPropagation();
|
||||
}
|
||||
const block = e.target.closest('button[data-lens], button[data-screenshot]');
|
||||
if (block && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
if (!e.repeat) block.click();
|
||||
return;
|
||||
}
|
||||
if (e.repeat || e.altKey || e.ctrlKey || e.metaKey || e.target.closest('input, select, textarea, [contenteditable="true"]')) return;
|
||||
const lens = [...(Reveal.getCurrentSlide()?.querySelectorAll('[data-lens-number], [data-screenshot-number]') || [])]
|
||||
.find(button => (button.dataset.lensNumber || button.dataset.screenshotNumber) === e.key);
|
||||
if (lens) { lens.click(); e.preventDefault(); e.stopPropagation(); }
|
||||
}, true);
|
||||
|
||||
// Small shared interface for the presenter console and its synchronized audience view.
|
||||
const popupSelector = '[data-lens], [data-screenshot], .brain-btn[data-ai], .isle[data-start], .todo[data-missing]';
|
||||
const popupIdentity = el => {
|
||||
const kind = ['lens', 'screenshot', 'ai', 'start', 'missing'].find(k => el.dataset[k]);
|
||||
return { kind, key: el.dataset[kind] };
|
||||
};
|
||||
const popupDictionary = kind => ({ ai: AI_CONTRIBS, start: START_CONTRIBS, missing: MISSING_CONTRIBS })[kind];
|
||||
window.PresentationControls = {
|
||||
snapshot: () => ({ index: Reveal.getIndices().h, popup: ScreenshotTour.active || SlideLens.active || activePopup }),
|
||||
slides: () => Reveal.getSlides().map(slide => ({
|
||||
title: slide.querySelector('h1, h2')?.textContent.trim() || '',
|
||||
notes: slide.querySelector('aside.notes')?.innerHTML || '',
|
||||
popups: [...slide.querySelectorAll(popupSelector)].map(el => {
|
||||
const id = popupIdentity(el);
|
||||
if (id.kind === 'screenshot') return { ...id, title: el.dataset.screenshotTitle };
|
||||
const source = id.kind === 'lens' ? document.getElementById(id.key) : null;
|
||||
return { ...id, title: source ? source.dataset.lensTitle || source.querySelector('b').textContent : popupDictionary(id.kind)[id.key].title };
|
||||
}),
|
||||
})),
|
||||
openPopup: id => {
|
||||
const el = [...Reveal.getCurrentSlide().querySelectorAll(popupSelector)]
|
||||
.find(el => { const candidate = popupIdentity(el); return candidate.kind === id.kind && candidate.key === id.key; });
|
||||
if (el && id.kind === 'screenshot') {
|
||||
if (ScreenshotTour.active?.key !== id.key) { closePop(); ScreenshotTour.open(el); }
|
||||
} else if (el && id.kind === 'lens') {
|
||||
if (SlideLens.active?.key !== id.key) { closePop(); SlideLens.open(el); }
|
||||
else SlideLens.layout();
|
||||
} else if (el) openPop(id.key, el, popupDictionary(id.kind));
|
||||
},
|
||||
closePopup: closePop,
|
||||
};
|
||||
addEventListener('resize', () => {
|
||||
if (activePopup) window.PresentationControls.openPopup(activePopup);
|
||||
});
|
||||
</script>
|
||||
<script src="presenter-bridge.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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,40 @@
|
||||
/* Seven 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 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); }
|
||||
.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-dialog img { width: 100%; height: 0; flex: 1; min-height: 0; object-fit: contain; }
|
||||
.screenshot-dialog .screenshot-error { margin: auto; text-align: center; }
|
||||
@media print { .screenshot-dialog { display: none !important; } }
|
||||
@@ -0,0 +1,39 @@
|
||||
/* 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><img alt=""><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 };
|
||||
title.textContent = `${button.dataset.screenshotNumber} / 7 · ${button.dataset.screenshotTitle}`;
|
||||
image.hidden = false;
|
||||
error.hidden = true;
|
||||
image.alt = `AritmoLab: ${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 |
@@ -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,64 @@
|
||||
# Presenter console
|
||||
|
||||
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.
|
||||
|
||||
Popup controls, clickable images and speaker-note headings 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 08,
|
||||
the five numbered headings in the speaker notes also 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 09 has seven numbered screenshot controls, in the supplied tour order.
|
||||
Click a thumbnail, a console button or a numbered heading in its notes to open
|
||||
the original screenshot almost full screen. Keys 1–7 also select screenshots;
|
||||
Escape or Close returns to the slide. Preview and audience share the selection.
|
||||
The seven English descriptions form one short script (about 90 seconds including
|
||||
brief pauses to change screens).
|
||||
|
||||
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=5">
|
||||
<script src="presenter.js?v=5" 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"><div class="slide-label" id="notes-number">SLIDE 01</div><h1 id="notes-title">Preparing your presentation</h1><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,86 @@
|
||||
: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-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,263 @@
|
||||
(() => {
|
||||
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 audience = null;
|
||||
let lastAudienceSeen = 0;
|
||||
let lastPreviewSeen = 0;
|
||||
let notesSize = 25;
|
||||
let blocked = false;
|
||||
const urlFor = role => `../deck/index.html?view=${role}&session=${encodeURIComponent(session)}`;
|
||||
const pad = value => String(value).padStart(2, '0');
|
||||
function showNoteLens(event) {
|
||||
const cue = event.target.closest('[data-note-lens]');
|
||||
if (!cue || !slides[current?.index]?.popups.some(popup => popup.kind === 'lens' && popup.key === cue.dataset.noteLens)) return;
|
||||
if (current?.popup?.key !== cue.dataset.noteLens) command('popup', { popup: { kind: 'lens', key: cue.dataset.noteLens } });
|
||||
}
|
||||
$('notes').addEventListener('click', showNoteLens);
|
||||
$('notes').addEventListener('click', event => {
|
||||
const cue = event.target.closest('[data-note-screenshot]');
|
||||
if (cue && slides[current?.index]?.popups.some(popup => popup.kind === 'screenshot' && popup.key === cue.dataset.noteScreenshot)) {
|
||||
command('popup', { popup: { kind: 'screenshot', key: cue.dataset.noteScreenshot } });
|
||||
}
|
||||
});
|
||||
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) {
|
||||
$('notes-number').textContent = `SLIDE ${pad(state.index + 1)}`;
|
||||
$('notes-title').textContent = slide.title;
|
||||
// Trusted, same-origin authored notes, never channel-provided HTML.
|
||||
renderNotes(slide.notes);
|
||||
$('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);
|
||||
}
|
||||
}
|
||||
}
|
||||
$('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?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,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.
|
||||