stationen = FileAttachment("data/cv_stationen.csv").csv({typed: true})
skills = FileAttachment("data/cv_skills.csv").csv({typed: true})
gruppenNamen = ["Arbeit", "Uni", "Lehre", "Studium"]
// Kategorische Palette, für beide Hintergründe geprüft (dataviz-Validator)
farben = dunkel
? ["#3987e5", "#d95926", "#199e70", "#c98500"]
: ["#2a78d6", "#eb6834", "#1baf7a", "#eda100"]
// reagiert auf den Dark-Mode-Toggle von Quarto (Klasse quarto-dark am body)
dunkel = Generators.observe(notify => {
const pruefen = () => notify(document.body.classList.contains("quarto-dark"));
pruefen();
const beobachter = new MutationObserver(pruefen);
beobachter.observe(document.body, {attributes: true, attributeFilter: ["class"]});
return () => beobachter.disconnect();
})
heute = new Date()
jahr = d => d.getFullYear()
zeitraum = d => `${jahr(d.start)} – ${d.ende ? jahr(d.ende) : "heute"}`
viewof gruppen = Inputs.checkbox(gruppenNamen, {value: gruppenNamen, label: "Zeige"})
auswahl = stationen
.filter(d => gruppen.includes(d.gruppe))
.map((d, i) => ({
...d,
id: `${d.titel} · ${d.org} · ${jahr(d.start)}`,
ende_plot: d.ende ?? heute,
laufend: d.ende == null,
label: `${d.titel} · ${d.org}`
}))
.sort((a, b) => gruppenNamen.indexOf(a.gruppe) - gruppenNamen.indexOf(b.gruppe) || a.start - b.start)
// Zeitleiste: gleiche Gruppe, Titel und Organisation mit zeitlicher Überschneidung
// werden zu einem Balken zusammengefasst, die Einzelteile stehen im Tooltip
zeitleiste = d3.groups(auswahl, d => `${d.gruppe}|${d.titel}|${d.org}`)
.flatMap(([, eintraege]) => {
const balken = [];
for (const d of eintraege) {
const letzter = balken.at(-1);
if (letzter && d.start <= letzter.ende_plot) {
letzter.teile.push(d);
letzter.ende_plot = d3.max([letzter.ende_plot, d.ende_plot]);
letzter.laufend ||= d.laufend;
letzter.ende = letzter.laufend ? null : letzter.ende_plot;
} else {
balken.push({...d, teile: [d]});
}
}
return balken;
})
.sort((a, b) => gruppenNamen.indexOf(a.gruppe) - gruppenNamen.indexOf(b.gruppe) || a.start - b.start)
mitte = new Date((d3.min(stationen, d => d.start).getTime() + heute.getTime()) / 2)CV
Data Scientist bei Statistik Austria, Lektor an der Universität Wien und seit fast zwanzig Jahren mit R, Item Response Theory und Rasch-Modellen unterwegs. Die Stationen unten kommen aus derselben Tabelle wie das PDF.
Stationen
Plot.plot({
width,
height: zeitleiste.length * 46 + 40,
marginLeft: 8,
marginRight: 8,
style: {fontFamily: "var(--bs-body-font-family)", fontSize: "13px", color: "currentColor", background: "transparent"},
x: {type: "utc", label: null, grid: true, domain: [d3.min(stationen, d => d.start), heute]},
y: {domain: zeitleiste.map(d => d.id), axis: null, paddingInner: 0.1},
color: {domain: gruppenNamen, range: farben, legend: true},
marks: [
// Balken im unteren Teil jeder Zeile, oben ist Platz für die Beschriftung
Plot.barX(zeitleiste, {x1: "start", x2: "ende_plot", y: "id", fill: "gruppe", insetTop: 22, rx: 4}),
// laufende Stationen: Pfeil am rechten Ende
Plot.text(zeitleiste.filter(d => d.laufend), {x: "ende_plot", y: "id", text: () => "▶", dy: 11, dx: -2, textAnchor: "end", fill: "white", fontSize: 10}),
// Beschriftung links- bzw. rechtsbündig, damit sie nicht aus dem Bild läuft;
// am Handy reicht der Platz dafür nicht, dort stehen alle Beschriftungen am linken Rand
...(width < 576
? [Plot.text(zeitleiste, {y: "id", text: "label", frameAnchor: "left", textAnchor: "start", dy: -8, fill: "currentColor", fontSize: 11})]
: [Plot.text(zeitleiste.filter(d => d.start < mitte), {x: "start", y: "id", text: "label", textAnchor: "start", dy: -8, fill: "currentColor"}),
Plot.text(zeitleiste.filter(d => d.start >= mitte), {x: "ende_plot", y: "id", text: "label", textAnchor: "end", dy: -8, fill: "currentColor"})]),
Plot.tip(zeitleiste, Plot.pointerY({
x1: "start", x2: "ende_plot", y: "id",
title: d => `${d.titel}\n${d.org} · ${zeitraum(d)}\n\n${
d.teile.length > 1 ? d.teile.map(t => `${zeitraum(t)}: ${t.kurz}`).join("\n") : d.kurz
}`
}))
]
})Skills
htl.html`<div class="cv-skills">${
d3.groups(skills, d => d.kategorie).map(([kategorie, eintraege]) => htl.html`
<div class="cv-skill-gruppe">
<h3>${kategorie}</h3>
<ul>${eintraege.map(s => htl.html`<li>${s.name}${s.seit ? htl.html` <span class="cv-seit">seit ${s.seit}</span>` : ""}</li>`)}</ul>
</div>`)
}</div>`Werdegang
htl.html`<div class="cv-liste">${
[...auswahl].sort((a, b) => b.start - a.start).map(d => htl.html`
<div class="cv-eintrag" style="--cv-farbe: ${farben[gruppenNamen.indexOf(d.gruppe)]}">
<div class="cv-zeit">${zeitraum(d)}</div>
<div>
<div class="cv-titel">${d.titel} <span class="cv-org">· ${d.org}</span></div>
<div>${d.kurz}</div>
${[d.detail1, d.detail2].filter(Boolean).length
? htl.html`<ul>${[d.detail1, d.detail2].filter(Boolean).map(t => htl.html`<li>${t}</li>`)}</ul>`
: ""}
</div>
</div>`)
}</div>`