---
title: "The Cartography of Silence"
format:
html:
theme: darkly
page-layout: custom
css: styles.css
execute:
echo: false
bibliography: references.bib
---
::: {.column-screen style="position: relative; height: 300vh; background-color: #121212;"}
::: {style="position: sticky; top: 0; height: 100vh; width: 100vw; overflow: hidden; display: flex; flex-direction: column; justify-content: center; align-items: center;"}
<!-- Title overlay that fades as we scroll. ALL HTML IS FLUSH LEFT TO PREVENT CODE BLOCKS! -->
<div style="position: absolute; top: 5%; left: 5%; z-index: 10; font-family: 'Roboto', sans-serif; pointer-events: none;">
<h1 style="color: #33FFA2; margin-bottom: 0;">The Cartography of Silence</h1>
<div style="display: flex; gap: 20px; margin-top: 5px; margin-bottom: 15px; font-size: 0.8rem; color: #737373; text-transform: uppercase; letter-spacing: 1px;">
<div><b>Author:</b> nambo yang</div>
<div><b>Published:</b> April 3, 2026</div>
</div>
<p style="color: #737373; font-size: 1.2rem;">Scroll to traverse time. Current Year: <span id="year-display" style="color: #FF33FF; font-weight: bold;">300 AD</span></p>
<p style="color: #737373; font-size: 0.9rem; pointer-events: auto;">
<i>Data Source:</i> [@monasticmatrix]
</p>
</div>
<!-- This div holds our OJS D3 Visualization -->
<div id="vis-container" style="width: 100%; height: 100%;"></div>
```{ojs}
// 1. LOAD AND CLEAN THE DATA
sitesRaw = FileAttachment("data/monastic_sites.csv").csv({typed: true})
edgesRaw = FileAttachment("data/community_network.csv").csv({typed: true})
// Clean edges: parse "1150-2025" into a numeric duration for path opacity
edgesCleaned = edgesRaw.map(d => {
let duration = 0;
if (d.shared_period && typeof d.shared_period === 'string') {
let parts = d.shared_period.split('-');
if(parts.length === 2) {
duration = parseInt(parts[1]) - parseInt(parts[0]);
}
}
return { ...d, duration_num: duration };
})
// 2. SCROLLYTELLING LOGIC (Map scroll position to Year: 300 AD to 2025 AD)
mutable currentYear = 300;
updateYear = {
const handler = () => {
const scrollY = window.scrollY;
const maxScroll = document.body.scrollHeight - window.innerHeight;
const scrollProgress = Math.max(0, Math.min(1, scrollY / maxScroll)); // clamp 0-1
// Map progress to years (300 to 2025)
mutable currentYear = Math.floor(300 + (scrollProgress * (2025 - 300)));
const display = document.getElementById("year-display");
if (display) display.innerText = mutable currentYear + " AD";
};
window.addEventListener("scroll", handler);
handler(); // Init
return () => window.removeEventListener("scroll", handler);
}
// 3. REACTIVE DATA FILTERING
// Monastery must be founded before current year, AND not yet dissolved
activeSites = sitesRaw.filter(d => {
const founded = d.founded || 9999;
const dissolved = d.dissolution || 2025; // Assume still active if null
return founded <= currentYear && dissolved >= currentYear;
})
activeSiteIds = new Set(activeSites.map(d => d.name))
// Edges only exist if BOTH sister abbeys exist in this year
activeEdges = edgesCleaned.filter(d => activeSiteIds.has(d.source) && activeSiteIds.has(d.target))
// 4. THE ORGANIC D3 VISUALIZATION
chart = {
// Clear previous renders (important for OJS reactivity)
const container = document.getElementById("vis-container");
if (container) container.innerHTML = "";
const w = document.getElementById("vis-container").clientWidth || 1000;
const h = window.innerHeight;
const svg = d3.create("svg")
.attr("width", w)
.attr("height", h)
.style("background", "#121212");
// Create a glow filter for the nodes
const defs = svg.append("defs");
const filter = defs.append("filter").attr("id", "glow");
filter.append("feGaussianBlur").attr("stdDeviation", "2.5").attr("result", "coloredBlur");
const feMerge = filter.append("feMerge");
feMerge.append("feMergeNode").attr("in", "coloredBlur");
feMerge.append("feMergeNode").attr("in", "SourceGraphic");
// Geographic Projection (With safety filter for missing lat/lon)
const validSites = sitesRaw.filter(d => d && d.lon != null && d.lat != null);
const projection = d3.geoMercator()
.fitSize([w * 0.85, h * 0.85], {
type: "FeatureCollection",
features: validSites.map(d => ({
type: "Feature",
geometry: {type: "Point", coordinates: [d.lon, d.lat]}
}))
});
// Danki Brand Colors mapped to your real data's specific Orthodox orders
const colorMap = {
"Georgian Orthodox": "#33FFA2", // Fluorescent Green
"Bulgarian Orthodox": "#FF33FF", // Fluorescent Violet
"Armenian Orthodox": "#FF33FF", // Fluorescent Violet
"Coptic Orthodox": "#737373", // Grey
"Greek Orthodox": "#737373" // Grey
};
const defaultColor = "#737373";
// DRAW EDGES (Organic Bezier Curves)
svg.append("g")
.selectAll("path")
.data(activeEdges)
.join("path")
.attr("fill", "none")
.attr("stroke", "#737373")
.attr("stroke-width", 1.2)
// Opacity based on how long they shared a connection
.attr("stroke-opacity", d => Math.min(0.5, Math.max(0.1, d.duration_num / 1000)))
.attr("d", d => {
const s = activeSites.find(n => n.name === d.source);
const t = activeSites.find(n => n.name === d.target);
if (!s || !t || !s.lon || !t.lon) return null;
const p1 = projection([s.lon, s.lat]);
const p2 = projection([t.lon, t.lat]);
const dx = p2[0] - p1[0], dy = p2[1] - p1[1];
// The Q (Quadratic Bezier) creates the sweeping arc
return `M${p1[0]},${p1[1]} Q${p1[0] + dy/3},${p1[1] - dx/3} ${p2[0]},${p2[1]}`;
});
// DRAW NODES (Glowing Constellation Stars)
svg.append("g")
.selectAll("circle")
.data(activeSites.filter(d => d.lon && d.lat))
.join("circle")
.attr("cx", d => projection([d.lon, d.lat])[0])
.attr("cy", d => projection([d.lon, d.lat])[1])
.attr("r", d => Math.max(2, Math.sqrt(d.lifespan || 100) * 0.3)) // Size by lifespan
.attr("fill", d => colorMap[d.order] || defaultColor)
.attr("opacity", 0.9)
.style("filter", "url(#glow)")
.append("title") // Native hover tooltip
.text(d => `${d.name}\nFounded: ${d.founded}\nDissolved: ${d.dissolution || 'Active'}\nOrder: ${d.order}\nLifespan: ${d.lifespan} years`);
// ==========================================
// DRAW THE LEGEND (Bottom Center)
// ==========================================
// Calculate how wide the legend will be to center it perfectly
const legendSpacing = 160;
const legendEntries = Object.entries(colorMap);
const totalLegendWidth = legendEntries.length * legendSpacing;
const startX = (w / 2) - (totalLegendWidth / 2) + (legendSpacing / 2);
const legend = svg.append("g")
.attr("transform", `translate(${startX}, ${h - 50})`); // 50px from the bottom
legendEntries.forEach(([order, color], i) => {
const legendItem = legend.append("g")
.attr("transform", `translate(${i * legendSpacing}, 0)`);
// Legend Glow Circle
legendItem.append("circle")
.attr("r", 6)
.attr("fill", color)
.style("filter", "url(#glow)");
// Legend Text (Switched to Roboto)
legendItem.append("text")
.text(order)
.attr("x", 15) // Space between circle and text
.attr("y", 4) // Vertically align with circle
.attr("fill", "#737373") // Your Danki Grey
.style("font-family", "'Roboto', sans-serif")
.style("font-size", "14px");
});
// Append SVG to the DOM
container.appendChild(svg.node());
}
```
:::
:::
::: {.column-screen style="background-color: #121212; padding: 40px 5%; color: #737373; font-family: 'Georgia', serif; border-top: 1px solid #333;"}
### References
<!-- Quarto will magically inject the University of St Andrews reference inside this div -->
::: {#refs}
:::
:::