Habilitation Slides, Replication Packages, and RevealJS Workflows
Last year, I submitted my habilitation thesis to the Faculty of Environmental Sciences at TUD, titled:
Geovisualizing Collective Perception: Integrating Opportunistic Data into the Planning Information Cycle
The remaining part to do was the habilitation defense, on October 15, 2026. This post documents the slides, archival links, some of the discussion points, and the technical workflows used to build and publish the presentations.
1. Scientific Lecture
Title: Geovisual Analytics for Democratic Resilience: Methodological Frontiers in Measuring Social Cohesion
- Web Slides: https://kartographie.geo.tu-dresden.de/ad/social-cohesion/
- copyrighted images have been removed from the above web-slides. Use this link for internal view of the original presentation slides.
- Zenodo Archive (PDF & Markdown): placeholder
- Repository: gitlab.hrz.tu-chemnitz.de/ad/habilitation-social-cohesion (private)
2. Trial Lecture
Title: Principles of Good Scientific Practice in Geoinformatics: From Data Management to Reproducible Code
- Web Slides: https://kartographie.geo.tu-dresden.de/ad/reproducible-geoinformatics/
- copyrighted images have been removed from the above web-slides. Use this link for internal view of the original presentation slides.
- Zenodo Archive (PDF & Markdown): https://doi.org/10.5281/zenodo.23335010
- Repository: gitlab.hrz.tu-chemnitz.de/ad/reproducibility-geoinformatics (private)
Colloquium & Discussion Notes
For my own reference, a brief summary of the main themes discussed during the colloquium:
- Bridging Qualitative and Quantitative Approaches: Using large-scale opportunistic geosocial data not as an absolute “ground truth,” but as a spatial baseline and conversation starter to provoke and inform local, qualitative neighborhood assessments.
- Accessibility: Addressing the complexity gap between raw geosocial data pipelines and municipal planning practice; structuring outputs so non-programmers can interpret and critique them.
- Platform Bias: Dealing with positive curation bias on social media (what users choose not to post) and the “homogenizing” effect of generative AI on future digital traces.
- Geoprivacy: Combining statistical modeling (e.g.,
SynPop-DE) and probabilistic data structures (HyperLogLog) to enable high-resolution spatial analysis without exposing personal data. - Reproducibility Conventions: Treating rules like “no binary files in Git” or “100% open data” as a spectrum with real-world trade-offs (e.g., GeoPackage tolerances, FAIR vs. ELSA restrictions).
Technical Notes & Slide Workflows
Both presentations were built with Jekyll and Reveal.js. This allowed the slides to function simultaneously as live web presentations, speaker documentation, and Markdown raw archival. Some of the new things I experimented with:
1. iframe-scrolling via Reveal.js Fragments
In the second lecture, I embedded full HTML exports of Jupyter notebooks (such as the wind turbine viewshed analysis and static AI conversation transcripts) as background iframes.
To avoid switching to the mouse and manually scrolling during the talk, I linked Reveal.js fragmentshown and fragmenthidden events to scroll the iframe to specific anchor IDs or pixel offsets.
Slide content
<!-- .slide: data-header="clean" style="top: 0 !important;" -->
<!-- Titel -->
<div style="text-align: left; margin-left: 235px; margin-top: 15px; margin-bottom: 15px;">
<h2 style="color: var(--tud-blue, #00008C); margin: 0; font-size: 1.25em;">
Case Study: 1,500 Wind Turbine Viewsheds
</h2>
<div style="color: var(--tud-text-muted, #566371); margin-top: 3px; font-size: 0.78em;">
Automated batch processing in JupyterLab with legal requirements
</div>
</div>
<div style="position: relative; width: 1080px; height: 475px; margin: 0 auto;">
<!-- Das interaktive Jupyter-Notebook Iframe -->
<iframe id="viewshed-frame" src="assets/01_viewshedanalysis.html"
style="width: 100%; height: 100%; border: 1px solid #d0d5dd; border-radius: 6px;
box-shadow: 0 4px 18px rgba(0,0,0,0.08); background: white;">
</iframe>
<!-- Green boxes -->
<!-- Fragment 1: Prepare environment -->
<div class="fragment hud-ioer" data-fragment-index="1" data-target="Prepare-environment">
<h4 class="hud-ioer-title">01 • Definition of Dependencies</h4>
<p class="hud-ioer-body">Code as documentation & execution. Carto-Lab container pinned in the header.</p>
</div>
<!-- Fragment 2: Documenting the Environment (RAM, Cores) -->
<div class="fragment hud-ioer" data-fragment-index="2" data-target="Hardware-specs">
<h4 class="hud-ioer-title">02 • Documenting the Environment</h4>
<p class="hud-ioer-body">Programmatic hardware auditing: 64-bit architecture, 128 GB RAM VM, and 16 CPU cores.</p>
</div>
<!-- Fragment 3: Parameters -->
<div class="fragment hud-ioer" data-fragment-index="3" data-target="Parameters">
<h4 class="hud-ioer-title">03 • Explicit Definition of Parameters</h4>
<p class="hud-ioer-body">Observer height (1.8 m), turbine hub heights, and atmospheric refraction declared as immutable constants.</p>
</div>
<!-- Fragment 4: Create grass project -->
<div class="fragment hud-ioer" data-fragment-index="4" data-target="Create-grass-project-(a-Location)">
<h4 class="hud-ioer-title">04 • Parametric Project Generation</h4>
<p class="hud-ioer-body">Headless GRASS project and projection (EPSG:25833) initialized via code — no manual GUI clicks.</p>
</div>
<!-- Fragment 5: Data visualization -->
<div class="fragment hud-ioer" data-fragment-index="5" data-target="Data-visualization">
<h4 class="hud-ioer-title">05 • Input Validation</h4>
<p class="hud-ioer-body">Automated reprojection checks and boundary verification on municipal shapefiles prior to batch execution.</p>
</div>
<!-- Fragment 6: Viewshed Preview -->
<div class="fragment hud-ioer" data-fragment-index="6" data-target="Viewshed-preview">
<h4 class="hud-ioer-title">06 • Visual Verification & Preview</h4>
<p class="hud-ioer-body">Included preview map and visual verification directly inside the notebook pipeline.</p>
</div>
<!-- Fragment 7: Cumulative Preview / Final Archive -->
<div class="fragment hud-ioer" data-fragment-index="7" data-target="cumulative-preview">
<h4 class="hud-ioer-title">07 • Automated Archive Generation</h4>
<p class="hud-ioer-body">Output packaged into timestamped ZIP archives. Static HTML report compiled and published via CI/CD.</p>
</div>
</div>
<!-- CSS: No jumping -->
<style>
.hud-ioer {
display: none;
position: absolute;
width: 44%;
right: -60px;
top: 220px;
box-shadow: 0 1px 4px rgba(0,0,0,0.5), 0 5px 25px rgba(0,0,0,0.2);
background-color: rgba(112, 164, 1, 0.88);
color: #ffffff;
padding: 16px 22px;
font-size: 18px;
text-align: left;
border-radius: 4px;
z-index: 50;
}
/* Nur die jeweils aktive Box einblenden */
.hud-ioer.current-fragment {
display: block !important;
}
/* Unsichtbares Dummy-Fragment für den leisen RAM-Scroll */
.hud-silent {
display: none !important;
}
.hud-ioer-title {
color: #ffffff !important;
font-size: 1.05em;
font-weight: 700;
margin: 0 0 6px 0;
}
.hud-ioer-body {
font-size: 0.82em;
line-height: 1.45;
color: #ffffff;
margin: 0;
}
</style>
<!-- Steuerung für Box 1 während Fragment 2 (kein Wackeln) -->
<script>
(function() {
function updateHUDState() {
const b1 = document.querySelector('.hud-step-1');
const silent = document.querySelector('.hud-silent');
if (!b1 || !silent) return;
// Wenn das leise Fragment 2 aktiv ist, Box 1 stabil sichtbar halten:
if (silent.classList.contains('current-fragment')) {
b1.style.display = 'block';
} else if (!b1.classList.contains('current-fragment')) {
b1.style.display = '';
}
}
if (window.parent && window.parent.Reveal) {
window.parent.Reveal.on('fragmentshown', updateHUDState);
window.parent.Reveal.on('fragmenthidden', updateHUDState);
}
})();
</script>
Note:
- 1,500 wind turbine viewsheds for state legal area enforcement.
- Legal court scrutiny required exact reproducibility.
- Automated batch processing using headless GRASS.
- Parameters, seeds, and hardware programmatic audited.
- Transition: Zero-friction execution via the cloud.
JavaScript listener inside iframe-HTML for fragment-based iframe scrolling
Here I had to fix a number of Leaflet-archival issues, too - the code can definitly be improved.
<script>
// 1. Sanftes Gleit-Scrollen (700ms)
function smoothScrollToY(targetY, duration = 700) {
const startY = window.pageYOffset || document.documentElement.scrollTop;
const diff = targetY - startY;
let startTime = null;
function step(timestamp) {
if (!startTime) startTime = timestamp;
const progress = Math.min((timestamp - startTime) / duration, 1);
const ease = progress < 0.5
? 4 * progress * progress * progress
: 1 - Math.pow(-2 * progress + 2, 3) / 2;
window.scrollTo(0, startY + diff * ease);
if (progress < 1) {
requestAnimationFrame(step);
}
}
requestAnimationFrame(step);
}
// 2. Automatischer Heiler für Leaflet-Icons und 10km-Sichtfeld-Zoom
window.repairLeafletMaps = function () {
try {
// Absolute URL zur Asset-Basis berechnen (funktioniert überall: localhost & Nginx!)
const icon2xUrl = new URL('vendor/images/marker-icon-2x.png', window.location.href).href;
const iconShadowUrl = new URL('vendor/images/marker-shadow.png', window.location.href).href;
const nestedIframes = document.querySelectorAll('iframe');
nestedIframes.forEach(f => {
try {
const w = f.contentWindow;
const doc = f.contentDocument || (w && w.document);
if (!doc) return;
// A) Marker-Icons & Pfade mit absolutem Pfad versorgen:
if (w && w.L && w.L.Icon && w.L.Icon.Default) {
w.L.Icon.Default.mergeOptions({
iconUrl: icon2xUrl,
iconRetinaUrl: icon2xUrl,
shadowUrl: iconShadowUrl
});
}
// B) Bereits im DOM erzeugte Marker-Bilder direkt überschreiben:
const brokenMarkers = doc.querySelectorAll('img.leaflet-marker-icon');
brokenMarkers.forEach(img => {
img.src = icon2xUrl;
});
const brokenShadows = doc.querySelectorAll('img.leaflet-shadow');
brokenShadows.forEach(img => {
img.src = iconShadowUrl;
});
// C) Leaflet-Karte finden & auf die perfekte 10-km-Übersicht einstellen:
if (w) {
for (let k in w) {
if (k.startsWith('map_') && w[k] && typeof w[k].invalidateSize === 'function') {
const map = w[k];
map.invalidateSize();
// Alle Layer nach Punkten absuchen
const pts = [];
map.eachLayer(layer => {
if (layer.getLatLng) pts.push(layer.getLatLng());
});
if (pts.length > 0 && w.L) {
const bounds = w.L.latLngBounds(pts);
// WICHTIG: maxZoom: 11 verhindert das Überzoomen!
// So bleibt das 10-km-Umland um die Windräder vollständig sichtbar.
map.fitBounds(bounds, {
padding: [80, 80],
maxZoom: 11
});
}
}
}
}
} catch (e) { }
});
} catch (err) { }
};
// 3. Anker-Ansteuerung
window.goToAnchor = function (anchorId) {
if (!anchorId || anchorId === 'top') {
smoothScrollToY(0, 600);
return;
}
const cleanId = anchorId.replace(/^#/, '');
const el = document.getElementById(cleanId) ||
document.querySelector('[name="' + cleanId + '"]') ||
document.querySelector(`[data-toc-modified-id^="${cleanId}"]`);
if (el) {
const y = el.getBoundingClientRect().top + (window.pageYOffset || document.documentElement.scrollTop) - 25;
smoothScrollToY(y, 700);
// Beim Scrollen zur Kartenvorschau: Karte reparieren & korrekt einpassen
if (cleanId === 'Viewshed-preview') {
setTimeout(window.repairLeafletMaps, 350);
setTimeout(window.repairLeafletMaps, 800);
}
}
};
// TOC-Klicks abfangen (verhindert Sprünge)
document.addEventListener('click', function (e) {
const link = e.target.closest('a[href^="#"]');
if (link) {
e.preventDefault();
window.goToAnchor(link.getAttribute('href'));
}
});
// 4. Reveal Parent Verbindung
function connectToReveal() {
try {
const parentReveal = window.parent && window.parent.Reveal;
if (!parentReveal) {
setTimeout(connectToReveal, 150);
return;
}
parentReveal.on('slidechanged', function (event) {
if (event.currentSlide && event.currentSlide.querySelector('#viewshed-frame')) {
window.goToAnchor('top');
}
});
parentReveal.on('fragmentshown', function (event) {
const target = event.fragment.getAttribute('data-target');
if (target) window.goToAnchor(target);
});
parentReveal.on('fragmenthidden', function (event) {
const hasPrev = typeof parentReveal.hasPreviousFragment === 'function'
? parentReveal.hasPreviousFragment()
: false;
const activeFragments = window.parent.document.querySelectorAll('.fragment.visible');
if (hasPrev && activeFragments.length > 0) {
const lastActive = activeFragments[activeFragments.length - 1];
const target = lastActive.getAttribute('data-target');
if (target) window.goToAnchor(target);
} else {
window.goToAnchor('top');
}
});
} catch (err) { }
}
connectToReveal();
// Initialer Fix beim Laden
window.addEventListener('load', () => setTimeout(window.repairLeafletMaps, 1000));
</script>
This block is placed just before the cloding </body>-element. Then, whereever I needed the fragments to scroll to, I added anchors as html-ids:
<h1 id="Prepare-environment">Prepare environment<a class="anchor-link" href="#Prepare-environment">¶</a>
2. GitLab CI/CD: PDF Rendering, Copyright Filtering & Zenodo Upload
Every commit to the slide repository triggers a multi-stage GitLab CI/CD pipeline:
- Build: Compiles the Jekyll/Reveal.js site into static HTML.
- Export PDF: Uses a headless Chromium / Decktape container to render a static 16:9 PDF backup (
presentation.pdf) with all fragments expanded or collapsed as needed. - Copyright / Asset Sanitization: Strips or replaces fair-use imagery (§ 51 UrhG citations used for classroom/examination teaching) before pushing the public replication package.
- Zenodo Archival: Manually triggered, it pushes the compiled PDF, the raw Markdown source files, and
cite.bibmetadata to Zenodo via its REST API. This creates a new versioned DOI.
Copyrighted workflow:
- Inline Images: Add
class="restricted"to the<img>tag:<img class="restricted" src="images/restricted/figure.png" style="height: 420px;" alt="..."> - Background Images: Add
data-state="restricted"to the slide comment:<!-- .slide: data-background="images/restricted/photo.jpg" data-state="restricted" --> - Asset Storage: Store copyrighted files in
images/restricted/(automatically excluded from the Zenodo source code ZIP archive).
.gitlab-ci.yml (Slide build, PDF export & Zenodo stage)
# This file is a template, and might need editing before it works on your project.
# Template project: https://gitlab.com/pages/jekyll
# Docs: https://docs.gitlab.com/ce/pages/
image: ruby:3.2
variables:
JEKYLL_ENV: production
before_script:
- bundle install
stages:
- test
# - pages
- build
- deploy
- export
- export_redacted
test:
stage: test
script:
- bundle exec jekyll build -d test
artifacts:
paths:
- test
except:
- main
build:
stage: build
script:
- bundle exec jekyll build -d _site --config _config.yml
artifacts:
name: _site
paths:
- _site
only:
- main
deploy:
stage: deploy
image: "registry.gitlab.vgiscience.org/tud_ifk/rsync-ssh-alpine:alpine3.17-r0-v4"
before_script:
- eval $(ssh-agent -s)
- echo "${SSH_PRIVKEY}" | ssh-add -
- mkdir -p ~/.ssh
- '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\tLogLevel QUIET\n\n" > ~/.ssh/config'
script:
- rsync -avu -zz --no-perms --omit-dir-times --chown=www-data:www-data --chmod=D775,F664 _site/ "${PRODUCTION_DESTINATION}"ad/reproducible-geoinformatics/
only:
- main
# only triggers on manual builds
export_pdf:
stage: export
image:
name: astefanutti/decktape:latest
entrypoint: [""]
before_script: []
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
allow_failure: true
script:
- |
SLIDE_PATH=$(grep '^baseurl:' _config.yml | cut -d ':' -f 2 | tr -d ' "'\' | tr -d '\r')
node /decktape/decktape.js reveal "https://kartographie.geo.tu-dresden.de${SLIDE_PATH}/" presentation.pdf \
--size 1920x1080 \
--pause 5500 \
--load-pause 3000 \
--chrome-path chromium-browser \
--chrome-arg=--no-sandbox \
--chrome-arg=--blink-settings=preferredColorScheme=0 \
--chrome-arg=--disable-features=WebContentsForceDark
artifacts:
name: "presentation-pdf-${CI_COMMIT_SHORT_SHA}"
paths:
- presentation.pdf
expire_in: 3 months
# only triggers on manual builds
export_pdf_redacted:
stage: export_redacted
image:
name: astefanutti/decktape:latest
entrypoint: [""]
before_script: []
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
allow_failure: true
script:
- |
SLIDE_PATH=$(grep '^baseurl:' _config.yml | cut -d ':' -f 2 | tr -d ' "'\' | tr -d '\r')
node /decktape/decktape.js reveal "https://kartographie.geo.tu-dresden.de${SLIDE_PATH}//?redact=true" presentation.pdf \
--size 1920x1080 \
--pause 5500 \
--load-pause 3000 \
--chrome-path chromium-browser \
--chrome-arg=--no-sandbox \
--chrome-arg=--blink-settings=preferredColorScheme=0 \
--chrome-arg=--disable-features=WebContentsForceDark
artifacts:
name: "presentation-pdf-${CI_COMMIT_SHORT_SHA}"
paths:
- presentation.pdf
expire_in: 3 months