Perils
  • About
  • Methodology
  • Contact

Perils Atlas

Ecological Jurisprudence Atlas

Explore 673 geocoded initiatives across ecological jurisprudence, rights of nature, personhood, indigenous models, animal rights, and eco-governance.

records = FileAttachment("data/02_cleaned/ejm_records.csv").csv({ typed: true })
jurisprudenceTypes = FileAttachment("data/02_cleaned/ejm_jurisprudence_types.csv").csv({ typed: true })
actorTypes = FileAttachment("data/02_cleaned/ejm_actor_types.csv").csv({ typed: true })
summary = FileAttachment("data/02_cleaned/ejm_summary.json").json()

regions = ["All Regions", ...new Set(records.map(d => d.region).filter(Boolean))]
legalProvisions = ["All Legal Provisions", ...new Set(records.map(d => d.legal_provision).filter(Boolean))]
statusOptions = ["All Statuses", ...new Set(records.map(d => d.status_current).filter(Boolean))]
jurisprudenceOptions = ["All Jurisprudence Types", ...new Set(jurisprudenceTypes.map(d => d.jurisprudence_type).filter(Boolean))]
actorTypeOptions = ["All Actor Types", ...new Set(actorTypes.map(d => d.actor_type).filter(Boolean))]

yearExtent = d3.extent(records, d => d.year_initiated)

themeOptions = [
  { label: "Rights of Nature", column: "ron" },
  { label: "Personhood", column: "personhood" },
  { label: "Animal Rights", column: "animal_rights" },
  { label: "Local Eco Knowledge", column: "lek" },
  { label: "Indigenous Framing", column: "indigenous_framing" },
  { label: "Eco-Governance", column: "eco_gov" }
]

legalProvisionColors = new Map([
  ["Legislation", "#b45309"],
  ["Court Case", "#0f766e"],
  ["Declaration", "#7c3aed"],
  ["Civil Society Declaration", "#dc2626"],
  ["Constitution", "#1d4ed8"],
  ["Indigenous Declaration", "#059669"],
  ["Indigenous Law", "#0891b2"],
  ["Policy", "#334155"],
  ["Civil Society Tribunal", "#be185d"],
  ["Position Statement", "#9333ea"],
  ["Private Sector Governance", "#4f46e5"]
])
  • Map View
  • Analytics
  • Data Table

Filters

viewof searchQuery = Inputs.text({
  label: "Search",
  placeholder: "Title, country, location, actor"
})

viewof selectedRegion = Inputs.select(regions, {
  label: "Region",
  value: "All Regions"
})

viewof selectedLegalProvision = Inputs.select(legalProvisions, {
  label: "Legal Provision",
  value: "All Legal Provisions"
})

viewof selectedStatus = Inputs.select(statusOptions, {
  label: "Current Status",
  value: "All Statuses"
})

viewof selectedJurisprudenceType = Inputs.select(jurisprudenceOptions, {
  label: "Jurisprudence Type",
  value: "All Jurisprudence Types"
})

viewof selectedActorType = Inputs.select(actorTypeOptions, {
  label: "Initiating Actor Type",
  value: "All Actor Types"
})

viewof selectedThemes = Inputs.checkbox(themeOptions.map(d => d.label), {
  label: "Theme Flags"
})

viewof minYear = Inputs.range(yearExtent, {
  label: "From Year",
  value: yearExtent[0],
  step: 1
})

viewof maxYear = Inputs.range(yearExtent, {
  label: "To Year",
  value: yearExtent[1],
  step: 1
})
normalizedSearch = (searchQuery ?? "").trim().toLowerCase()
lowerYear = Math.min(minYear, maxYear)
upperYear = Math.max(minYear, maxYear)
selectedThemeColumns = themeOptions
  .filter(d => selectedThemes.includes(d.label))
  .map(d => d.column)

filteredData = records.filter(d => {
  const textBlob = [
    d.title,
    d.country_name,
    d.location,
    d.initiating_actor_name,
    d.ecological_actor_name,
    d.jurisprudence_types
  ].join(" ").toLowerCase()

  const regionMatch = selectedRegion === "All Regions" || d.region === selectedRegion
  const legalProvisionMatch = selectedLegalProvision === "All Legal Provisions" || d.legal_provision === selectedLegalProvision
  const statusMatch = selectedStatus === "All Statuses" || d.status_current === selectedStatus
  const yearMatch = (!d.year_initiated || (d.year_initiated >= lowerYear && d.year_initiated <= upperYear))
  const searchMatch = !normalizedSearch || textBlob.includes(normalizedSearch)

  const jurisprudenceList = d.jurisprudence_types ? d.jurisprudence_types.split(" | ") : []
  const actorTypeList = d.initiating_actor_types ? d.initiating_actor_types.split(" | ") : []

  const jurisprudenceMatch = selectedJurisprudenceType === "All Jurisprudence Types" || jurisprudenceList.includes(selectedJurisprudenceType)
  const actorTypeMatch = selectedActorType === "All Actor Types" || actorTypeList.includes(selectedActorType)
  const themeMatch = selectedThemeColumns.every(column => d[column] === 1)

  return regionMatch && legalProvisionMatch && statusMatch && yearMatch && searchMatch && jurisprudenceMatch && actorTypeMatch && themeMatch
})

filteredIds = new Set(filteredData.map(d => d.record_id))
filteredJurisprudence = jurisprudenceTypes.filter(d => filteredIds.has(d.record_id))
filteredActorTypes = actorTypes.filter(d => filteredIds.has(d.record_id))

countryCount = new Set(filteredData.map(d => d.country_name).filter(Boolean)).size
ronCount = d3.sum(filteredData, d => d.ron)
indigenousCount = d3.sum(filteredData, d => d.indigenous_framing || d.actor_indigenous)
minFilteredYear = d3.min(filteredData, d => d.year_initiated)
maxFilteredYear = d3.max(filteredData, d => d.year_initiated)
yearLabel = filteredData.length ? `${minFilteredYear}-${maxFilteredYear}` : "No data"

Initiatives in view

Countries represented

Initiation span

Tagged Rights of Nature

Indigenous-linked records

Dataset snapshot

  • total records in the current atlas
  • countries represented
  • \({summary.min_year}-\){summary.max_year} temporal coverage
  • Prepared for comparative legal and ecological analysis

Global initiative map

html`<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>`
leaflet = require("leaflet@1.9.4")
mapDisplay = {
  const container = html`<div class="map-container"><div class="map-inner"></div></div>`
  yield container

  const map = leaflet.map(container.querySelector(".map-inner"), {
    worldCopyJump: true,
    scrollWheelZoom: false
  }).setView([15, -10], 2)

  leaflet.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
    attribution: "&copy; OpenStreetMap contributors",
    maxZoom: 18
  }).addTo(map)

  const markers = []

  filteredData.forEach(d => {
    if (d.lat == null || d.lon == null || d.lat === "" || d.lon === "") return
    const fillColor = legalProvisionColors.get(d.legal_provision) ?? "#64748b"
    const marker = leaflet.circleMarker([d.lat, d.lon], {
      radius: 6,
      fillColor,
      color: "#ffffff",
      weight: 1.5,
      fillOpacity: 0.82
    })

    marker.bindPopup(`
      <div style="min-width: 240px;">
        <strong>${d.title}</strong><br>
        <span>${d.country_name}${d.location ? `, ${d.location}` : ""}</span><br>
        <span>${d.legal_provision} | ${d.status_current}</span><br>
        <span>Initiated: ${d.year_initiated || "Unspecified"}</span><br>
        <span>${d.jurisprudence_types || "Unspecified"}</span><br>
        <a href="${d.permalink}" target="_blank" rel="noopener noreferrer">Open source record</a>
      </div>
    `)

    marker.addTo(map)
    markers.push(marker)
  })

  if (markers.length) {
    const group = leaflet.featureGroup(markers)
    map.fitBounds(group.getBounds().pad(0.15))
  }

  setTimeout(() => map.invalidateSize(), 100)
  invalidation.then(() => map.remove())
}

Legal provision legend

legendItems = Array.from(legalProvisionColors.entries())
  .filter(([name]) => filteredData.some(d => d.legal_provision === name))
  .slice(0, 8)

html`<div class="legend">${legendItems.map(([name, color]) => html`<span class="legend-item"><span class="legend-dot" style="background:${color}"></span>${name}</span>`)}</div>`

Map markers are colored by legal provision type. Use the filters to narrow by region, year, jurisprudence type, actor type, or theme flags.

Initiatives by region

Plot.plot({
  marginLeft: 120,
  marginBottom: 40,
  height: 300,
  x: { label: "Initiatives" },
  y: { label: null },
  marks: [
    Plot.barX(filteredData, Plot.groupY({ x: "count" }, { y: "region", fill: "region", tip: true, sort: { y: "x", reverse: true } })),
    Plot.ruleX([0])
  ]
})

Top legal provision types

topLegalProvisions = d3.rollups(filteredData, values => values.length, d => d.legal_provision)
  .sort((a, b) => d3.descending(a[1], b[1]))
  .slice(0, 8)
  .map(([legal_provision, count]) => ({ legal_provision, count }))

Plot.plot({
  marginLeft: 70,
  marginBottom: 80,
  height: 300,
  x: { label: null, tickRotate: -25 },
  y: { label: "Records" },
  color: { legend: false },
  marks: [
    Plot.barY(topLegalProvisions, { x: "legal_provision", y: "count", fill: "legal_provision", tip: true }),
    Plot.ruleY([0])
  ]
})

Initiatives by year initiated

yearlyCounts = d3.rollups(filteredData.filter(d => d.year_initiated), values => values.length, d => d.year_initiated)
  .sort((a, b) => d3.ascending(a[0], b[0]))
  .map(([year_initiated, count]) => ({ year_initiated, count }))

Plot.plot({
  marginLeft: 60,
  marginBottom: 50,
  height: 300,
  x: { label: "Year initiated" },
  y: { label: "Records" },
  marks: [
    Plot.areaY(yearlyCounts, { x: "year_initiated", y: "count", fill: "#93c5fd" }),
    Plot.lineY(yearlyCounts, { x: "year_initiated", y: "count", stroke: "#1d4ed8" }),
    Plot.dot(yearlyCounts, { x: "year_initiated", y: "count", fill: "#1d4ed8", r: 2.5, tip: true }),
    Plot.ruleY([0])
  ]
})

Top jurisprudence types

topJurisprudenceTypes = d3.rollups(filteredJurisprudence, values => values.length, d => d.jurisprudence_type)
  .sort((a, b) => d3.descending(a[1], b[1]))
  .slice(0, 10)
  .map(([jurisprudence_type, count]) => ({ jurisprudence_type, count }))

Plot.plot({
  marginLeft: 140,
  marginBottom: 40,
  height: 320,
  x: { label: "Records" },
  y: { label: null },
  marks: [
    Plot.barX(topJurisprudenceTypes, { x: "count", y: "jurisprudence_type", fill: "#7c3aed", tip: true, sort: { y: "x", reverse: true } }),
    Plot.ruleX([0])
  ]
})

Top initiating actor types

topActorTypes = d3.rollups(filteredActorTypes, values => values.length, d => d.actor_type)
  .sort((a, b) => d3.descending(a[1], b[1]))
  .slice(0, 12)
  .map(([actor_type, count]) => ({ actor_type, count }))

Plot.plot({
  marginLeft: 170,
  marginBottom: 40,
  height: 360,
  x: { label: "Records" },
  y: { label: null },
  marks: [
    Plot.barX(topActorTypes, { x: "count", y: "actor_type", fill: "#0f766e", tip: true, sort: { y: "x", reverse: true } }),
    Plot.ruleX([0])
  ]
})

The record table is structured to support comparative browsing across geography, legal form, status, and jurisprudential framing.

Inputs.table(filteredData, {
  columns: [
    "title",
    "country_name",
    "region",
    "year_initiated",
    "legal_provision",
    "status_current",
    "jurisprudence_types",
    "permalink"
  ],
  header: {
    title: "Title",
    country_name: "Country",
    region: "Region",
    year_initiated: "Year",
    legal_provision: "Legal Provision",
    status_current: "Status",
    jurisprudence_types: "Jurisprudence Types",
    permalink: "Source"
  },
  format: {
    permalink: value => html`<a href="${value}" target="_blank" rel="noopener noreferrer">Open</a>`
  },
  width: {
    title: 360,
    country_name: 120,
    region: 120,
    year_initiated: 80,
    legal_provision: 180,
    status_current: 110,
    jurisprudence_types: 240,
    permalink: 80
  }
})

Data source: EJM ecological jurisprudence initiative records, prepared for interactive analysis and mapping.

 

© 2026 Perils. All rights reserved.