Medieval Monastic Mapping
  • Workbench
  • Community Explorer
  • Methodology
  • Findings
  • Discussion

The Cartography of Silence

  • Show All Code
  • Hide All Code

  • View Source

On this page

  • References

The Cartography of Silence

Author: nambo yang
Published: April 3, 2026

Scroll to traverse time. Current Year: 300 AD

Data Source: (University of St Andrews, 2024)

Code
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());
}

References

University of St Andrews. (2024). Monastic matrix: A scholarly resource for the study of women’s religious communities. https://arts.st-andrews.ac.uk/monasticmatrix/
Source Code
---
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}
:::

:::