---
title: "Daniel Saunders"
format:
html:
toc: false
echo: false
keep-md: true
---
<style>
/* 1. THE CANVAS: Creates a square container that stays centered on the page */
.scaffold-wrapper {
position: relative;
width: 100%;
max-width: 1000px;
margin: 0 auto;
aspect-ratio: 1 / 1;
}
/* 2. BACKGROUND LAYER: Pushes the animation behind everything else */
#animation-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
/* 3. FOREGROUND LAYER: Creates a 3x3 invisible grid over the animation */
.content-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
z-index: 2;
pointer-events: none; /* Let clicks pass through the empty parts of the grid */
}
/* MOBILE RESPONSIVENESS: Switch to a vertical stack on narrow screens */
@media (max-width: 768px) {
.scaffold-wrapper {
aspect-ratio: auto;
height: auto;
display: flex;
flex-direction: column;
}
#animation-background {
display: none; /* Turn off animation on mobile */
}
.content-overlay {
position: relative;
display: flex;
flex-direction: column;
height: auto;
width: 100%;
padding: 1rem; /* Tighter padding for mobile */
gap: 1.5rem;
}
.grid-item {
padding: 0;
text-align: center !important;
align-items: center !important;
width: 100%;
}
.headshot-img {
width: 120px;
height: 120px;
}
}
/* 4. GRID ITEMS: Common styling for the text blocks */
.grid-item {
pointer-events: auto;
display: flex;
flex-direction: column;
justify-content: center;
padding: 2rem; /* Increased padding to push text away from node clusters */
font-size: 1rem;
line-height: 1.5;
}
/* Ensure the inner text flows naturally and doesn't get treated as flex items */
.grid-item > div {
width: 100%;
}
/* 5. POSITIONING: Maps each piece of info to a specific cell in the 3x3 grid */
.headshot-item {
grid-column: 2;
grid-row: 1;
align-items: center;
text-align: center;
}
.work-item {
grid-column: 1;
grid-row: 2;
align-items: flex-start;
text-align: left;
}
.bio-item {
grid-column: 3;
grid-row: 2;
align-items: flex-end;
text-align: left;
}
.blog-item {
grid-column: 2;
grid-row: 3;
align-items: left;
text-align: left;
}
/* 6. AESTHETICS: Styling for the photo, headers, and links */
.headshot-img {
width: 140px;
height: 140px;
border-radius: 50%;
object-fit: cover;
border: 4px solid #E95420;
margin-bottom: 0.5rem;
}
.grid-item h3 {
margin-top: 0;
color: #E95420;
font-size: 1.2rem;
}
.grid-item a {
color: #E95420;
text-decoration: none;
font-weight: bold;
}
.grid-item a:hover {
text-decoration: underline;
}
</style>
```{ojs}
//| echo: false
csv = FileAttachment("assets/transition_matrix.csv").csv()
render_scaffold = {
const N = csv.columns.length;
const matrix = [];
for (let i = 0; i < csv.length; i++) {
const row = [];
for (let j = 0; j < N; j++) {
row.push(+csv[i][`column_${j}`]);
}
matrix.push(row);
}
const probs = matrix.map(row => {
const s = d3.sum(row);
return s > 0 ? row.map(v => v / s) : row;
});
const adjFrom = probs.map(row =>
row.map((v, j) => v > 0 ? j : -1).filter(j => j >= 0)
);
const pos = {};
const starts = [[0, 8], [6, 8], [3, 5], [0, 2], [6, 2]];
starts.forEach(([c, r], i) => {
const offset = i * 9;
for (let mc = 0; mc < 3; mc++) {
for (let mr = 0; mr < 3; mr++) {
pos[offset + mc + mr * 3] = { x: c + mc, y: r - mr };
}
}
});
const svg = d3.create("svg")
.attr("viewBox", "0 0 900 900")
.style("width", "100%")
.style("height", "100%")
.style("display", "block");
// Coordinate mapping - Smaller PAD means clusters fill more space
const PAD = 30;
const xSc = d3.scaleLinear().domain([0, 8]).range([PAD, 900 - PAD]);
const ySc = d3.scaleLinear().domain([0, 8]).range([900 - PAD, PAD]);
const color = "#E95420";
const allEdges = [];
for (let i = 0; i < N; i++) {
for (let j = 0; j < N; j++) {
if (probs[i][j] > 0) allEdges.push({ source: i, target: j });
}
}
// 1. Edge layer - baseline visibility
const lines = svg.append("g")
.selectAll("line").data(allEdges).join("line")
.attr("x1", d => xSc(pos[d.source].x))
.attr("y1", d => ySc(pos[d.source].y))
.attr("x2", d => xSc(pos[d.target].x))
.attr("y2", d => ySc(pos[d.target].y))
.attr("stroke", "#e0e0e0")
.attr("stroke-opacity", 0.15)
.attr("stroke-width", 1)
.attr("stroke-linecap", "round");
// 2. Backing layer - Opaque circles to hide lines behind nodes
const backing = svg.append("g")
.selectAll("circle").data(d3.range(N)).join("circle")
.attr("cx", d => xSc(pos[d].x))
.attr("cy", d => ySc(pos[d].y))
.attr("r", 8)
.attr("fill", "white") // Match page background
.attr("fill-opacity", 1.0)
.attr("stroke", "none");
// 3. Node layer - The visible colored nodes
const circles = svg.append("g")
.selectAll("circle").data(d3.range(N)).join("circle")
.attr("cx", d => xSc(pos[d].x))
.attr("cy", d => ySc(pos[d].y))
.attr("r", 8)
.attr("fill", "#e0e0e0")
.attr("fill-opacity", 0.3)
.attr("stroke", "none");
// Helpers
function sample(p) {
const r = Math.random();
let cum = 0;
for (let i = 0; i < p.length; i++) { cum += p[i]; if (r <= cum) return i; }
return p.length - 1;
}
// Animation state
let previous = -1;
let current = 22;
let alive = true;
invalidation.then(() => { alive = false; });
function update(transition = false) {
const neighbors = new Set(adjFrom[current]);
const t = transition ? circles.transition().duration(400).ease(d3.easeCubicOut) : circles;
const l = transition ? lines.transition().duration(400).ease(d3.easeCubicOut) : lines;
const b = transition ? backing.transition().duration(400).ease(d3.easeCubicOut) : backing;
l.attr("stroke-opacity", (_, i) => {
const e = allEdges[i];
// Spotlight edges leaving current node
return e.source === current ? 0.6 : 0.15;
})
.attr("stroke-width", (_, i) => {
const e = allEdges[i];
return e.source === current ? 3 : 1;
})
.attr("stroke", (_, i) => {
const e = allEdges[i];
return e.source === current ? color : "#e0e0e0";
});
t.attr("fill", d => d === current ? color :
neighbors.has(d) ? d3.interpolateRgb("#e0e0e0", color)(0.5) :
"#e0e0e0")
.attr("fill-opacity", d => d === current ? 1.0 :
neighbors.has(d) ? 0.6 : 0.3)
.attr("r", d => d === current ? 20 : neighbors.has(d) ? 14 : 8);
// Update backing radii to match the nodes exactly
b.attr("r", d => d === current ? 20 : neighbors.has(d) ? 14 : 8);
}
update();
(async () => {
while (alive) {
await Promises.delay(1200);
// filter out the previous node, renormalize the rest
const raw = probs[current].map((p, j) => j === previous ? 0 : p);
const total = d3.sum(raw);
const conditional = raw.map(p => p / total);
const next = sample(conditional);
previous = current;
current = next;
update(true);
}
})();
return svg.node();
}
d3.select("#animation-background").append(() => render_scaffold);
```
```{=html}
<div class="scaffold-wrapper">
<div id="animation-background"></div>
<div class="content-overlay">
<div class="grid-item headshot-item">
<img src="headshot.jpg" class="headshot-img">
</div>
<div class="grid-item work-item">
<div>
Data scientist at <a href="https://www.pymc-labs.io/">PyMC Labs</a>. I build Bayesian models in the consumer goods industry, supporting decision making around marketing, pricing, and promotions.
</div>
</div>
<div class="grid-item bio-item">
<div>
PhD from UBC. Complex adaptive systems and cultural evolution.
<a href="https://scholar.google.ca/citations?user=GD912IAAAAAJ">Publications</a>.
</div>
</div>
<div class="grid-item blog-item">
<div>
I use this blog to write through topics I find tricky or important.
Some favourites: <a href="posts/a-bayesian-decision-theory-workflow/">building optimizers on top of PyMC</a>,
and <a href="posts/geometric-intuition-mmm/">geometric intuition for media mix models</a>.
</div>
</div>
</div>
</div>
```