DataViz.manishdatt.com

Edible Plants Database

Distribution of edible plants by water and sunlight requirements.

By Manish Datt

TidyTuesday dataset of 2026-02-03

* Note: The "requirements" column has been excluded due to length constraints.

Summary Statistics

Distribution of Edible Plants by Water and Sunlight Requirements

Plotting code



<!-- Import Tailwind CSS, Tabulator, PapaParse, danfojs, and D3.js -->
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://unpkg.com/tabulator-tables@5.6.1/dist/css/tabulator.min.css" rel="stylesheet">
<script src="https://unpkg.com/tabulator-tables@5.6.1/dist/js/tabulator.min.js"></script>
<script src="https://unpkg.com/papaparse@5.4.1/papaparse.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/danfojs@1.1.2/lib/bundle.min.js"></script>
<script src="https://d3js.org/d3.v7.min.js"></script>

<div id="edible-plants-table" class="mt-6 w-full"></div>

<p class="text-sm text-gray-600 mt-2 italic">* Note: The "requirements" column has been excluded due to length constraints.</p>

<!-- Summary Statistics Section -->
<div class="mt-8">
  <h3 class="text-2xl font-semibold mb-4 text-center">Summary Statistics</h3>
  <div id="summary-stats" class="w-full overflow-x-auto"></div>
</div>

<!-- Visualization Section -->
<div class="mt-8 justify-center items-center">
  <h3 class="text-3xl font-semibold mb-4 text-center">Distribution of Edible Plants by Water and Sunlight Requirements</h3>
  <div id="nutrient-filters" class="flex flex-wrap gap-4 justify-center pl-4"></div>
  <div id="viz-plot" class="w-full flex justify-center"></div>
</div>

<script type="module">
  import * as Plot from "https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6/+esm";

  // Helper to normalize case (title case) and fix spacing around slashes
  function toTitleCase(str) {
    if (!str || str === 'Unknown') return 'Unknown';
    // Remove extra spaces after slashes, then normalize case
    const cleaned = str.replace(/\/\s+/g, '/');
    return cleaned.toLowerCase().split(' ').map(word =>
      word.charAt(0).toUpperCase() + word.slice(1)
    ).join(' ');
  }

  // Helper to get numeric value for sorting (Very High=4, High=3, Medium=2, Low=1, Very Low=0)
  function getIntensityValue(str) {
    const lower = str.toLowerCase();
    if (lower.includes('very high')) return 4;
    if (lower.includes('high')) return 3;
    if (lower.includes('medium')) return 2;
    if (lower.includes('very low')) return 0;
    if (lower.includes('low')) return 1;
    return -1;
  }

  // Fetch and parse CSV data from local file
  Papa.parse('edible_plants.csv', {
    download: true,
    header: true,
    dynamicTyping: true,
    complete: function(results) {
      // Filter out empty rows
      const filteredData = results.data.filter(row => row.taxonomic_name && row.taxonomic_name.trim() !== '');
      
      // Add normalized columns
      filteredData.forEach(row => {
        row.water_normalized = toTitleCase(row.water);
        row.sunlight_normalized = toTitleCase(row.sunlight);
        row.nutrients_normalized = toTitleCase(row.nutrients);
      });
      
      // Define columns based on the CSV structure (19 of 20 columns, excluding 'requirements')
      const columns = [
        { title: "Taxonomic Name", field: "taxonomic_name", headerFilter: "input", width: 180, formatter: function(cell) { return "<i>" + cell.getValue() + "</i>"; } },
        { title: "Common Name", field: "common_name", headerFilter: "input", width: 150 },
        { title: "Cultivation", field: "cultivation", headerFilter: "input", width: 100 },
        { title: "Sunlight", field: "sunlight", headerFilter: "input", width: 120 },
        { title: "Water", field: "water", headerFilter: "input", width: 90 },
        { title: "pH Lower", field: "preferred_ph_lower", headerFilter: "number", hozAlign: "center", width: 80 },
        { title: "pH Upper", field: "preferred_ph_upper", headerFilter: "number", hozAlign: "center", width: 80 },
        { title: "Nutrients", field: "nutrients", headerFilter: "input", width: 90 },
        { title: "Soil", field: "soil", headerFilter: "input", width: 130 },
        { title: "Season", field: "season", headerFilter: "input", width: 100 },
        { title: "Temp Class", field: "temperature_class", headerFilter: "input", width: 100 },
        { title: "Temp Germ.", field: "temperature_germination", headerFilter: "input", width: 100 },
        { title: "Temp Growing", field: "temperature_growing", headerFilter: "input", width: 110 },
        { title: "Days Germ.", field: "days_germination", headerFilter: "number", hozAlign: "center", width: 90 },
        { title: "Days Harvest", field: "days_harvest", headerFilter: "number", hozAlign: "center", width: 100 },
        { title: "Nutritional Info", field: "nutritional_info", headerFilter: "input", width: 200 },
        { title: "Energy", field: "energy", headerFilter: "number", hozAlign: "center", width: 80 },
        { title: "Sensitivities", field: "sensitivities", headerFilter: "input", width: 150 },
        { title: "Description", field: "description", headerFilter: "input", width: 200 }
      ];

      // Initialize Tabulator
      new Tabulator("#edible-plants-table", {
        data: filteredData,
        columns: columns,
        layout: "fitColumns",
        pagination: true,
        paginationSize: 10,
        paginationSizeSelector: [10, 25, 50, 100],
        movableColumns: true,
        resizableColumns: true,
        initialSort: [
          { column: "common_name", dir: "asc" }
        ]
      });

      // Create summary statistics using danfojs
      createSummaryStats(filteredData);
      
      // Extract unique nutrient values and create checkboxes (using normalized values)
      const uniqueNutrients = [...new Set(filteredData.map(d => d.nutrients_normalized).filter(n => n && n.trim() !== ''))];
      // Sort from high to low, with "High Potassium Fertiliser Every 2 Weeks" last
      uniqueNutrients.sort((a, b) => {
        // Special case: "High Potassium Fertiliser Every 2 Weeks" always goes last
        if (a.includes('Potassium')) return 1;
        if (b.includes('Potassium')) return -1;
        // Otherwise sort by intensity value (high to low)
        return getIntensityValue(b) - getIntensityValue(a);
      });
      createNutrientCheckboxes(uniqueNutrients, filteredData);
      
      // Create visualization with Observable Plot
      createVisualization(filteredData);
    },
    error: function(error) {
      console.error('Error loading CSV:', error);
      document.getElementById('edible-plants-table').innerHTML = '<p class="text-red-500 p-4">Error loading data. Please try again later.</p>';
    }
  });

  function createSummaryStats(data) {
    // All columns except first 2 (taxonomic_name, common_name)
    const allCols = Object.keys(data[0]).slice(2).filter(col => !col.includes('_normalized'));
    
    // Separate numeric and categorical columns
    const numericCols = [];
    const categoricalCols = [];
    
    allCols.forEach(col => {
      // Check if column has numeric values
      const hasNumeric = data.some(d => {
        const val = d[col];
        return val && !isNaN(parseFloat(val)) && isFinite(val);
      });
      
      if (hasNumeric) {
        numericCols.push(col);
      } else {
        categoricalCols.push(col);
      }
    });
    
    // Calculate statistics for numeric columns
    const stats = {
      count: {},
      mean: {},
      std: {},
      min: {},
      '25%': {},
      '50%': {},
      '75%': {},
      max: {}
    };

    numericCols.forEach(col => {
      const values = data.map(d => parseFloat(d[col])).filter(v => !isNaN(v)).sort((a, b) => a - b);
      const n = values.length;
      
      if (n > 0) {
        const sum = values.reduce((a, b) => a + b, 0);
        const mean = sum / n;
        const variance = values.reduce((acc, val) => acc + Math.pow(val - mean, 2), 0) / n;
        const std = Math.sqrt(variance);
        
        stats.count[col] = n;
        stats.mean[col] = mean.toFixed(2);
        stats.std[col] = std.toFixed(2);
        stats.min[col] = values[0].toFixed(2);
        stats['25%'][col] = values[Math.floor(n * 0.25)]?.toFixed(2) || 'N/A';
        stats['50%'][col] = values[Math.floor(n * 0.5)]?.toFixed(2) || 'N/A';
        stats['75%'][col] = values[Math.floor(n * 0.75)]?.toFixed(2) || 'N/A';
        stats.max[col] = values[n - 1].toFixed(2);
      }
    });

    // Create numeric summary table HTML
    let tableHTML = '<h4 class="text-lg font-semibold mb-3">Numeric Columns Summary</h4>';
    tableHTML += '<table class="min-w-full border-collapse border border-gray-300 text-sm">';
    tableHTML += '<thead><tr class="bg-gray-100">';
    tableHTML += '<th class="border border-gray-300 px-4 py-2 text-left">Statistic</th>';
    numericCols.forEach(col => {
      tableHTML += `<th class="border border-gray-300 px-4 py-2 text-left">${col.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}</th>`;
    });
    tableHTML += '</tr></thead><tbody>';

    Object.keys(stats).forEach(stat => {
      tableHTML += '<tr class="hover:bg-gray-50">';
      tableHTML += `<td class="border border-gray-300 px-4 py-2 font-semibold">${stat}</td>`;
      numericCols.forEach(col => {
        tableHTML += `<td class="border border-gray-300 px-4 py-2">${stats[stat][col] || 'N/A'}</td>`;
      });
      tableHTML += '</tr>';
    });

    tableHTML += '</tbody></table>';

    // Categorical summary with normalized values for water and sunlight
    let catHTML = '<h4 class="text-lg font-semibold mt-6 mb-3">Categorical/Text Columns Summary</h4>';
    catHTML += '<table class="min-w-full border-collapse border border-gray-300 text-sm">';
    catHTML += '<thead><tr class="bg-gray-100">';
    catHTML += '<th class="border border-gray-300 px-4 py-2 text-left">Column</th>';
    catHTML += '<th class="border border-gray-300 px-4 py-2 text-left">Unique Values</th>';
    catHTML += '<th class="border border-gray-300 px-4 py-2 text-left">Most Common</th>';
    catHTML += '<th class="border border-gray-300 px-4 py-2 text-left">Count</th>';
    catHTML += '</tr></thead><tbody>';

    categoricalCols.forEach(col => {
      const counts = {};
      data.forEach(d => {
        // Use normalized values for water and sunlight
        let val;
        if (col === 'water') {
          val = d.water_normalized || 'Unknown';
        } else if (col === 'sunlight') {
          val = d.sunlight_normalized || 'Unknown';
        } else {
          val = d[col] && d[col].trim() !== '' ? d[col].trim() : 'Unknown';
        }
        counts[val] = (counts[val] || 0) + 1;
      });
      
      const uniqueCount = Object.keys(counts).length;
      const mostCommon = Object.entries(counts).sort((a, b) => b[1] - a[1])[0];
      
      catHTML += '<tr class="hover:bg-gray-50">';
      catHTML += `<td class="border border-gray-300 px-4 py-2 font-semibold">${col.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}</td>`;
      catHTML += `<td class="border border-gray-300 px-4 py-2">${uniqueCount}</td>`;
      catHTML += `<td class="border border-gray-300 px-4 py-2">${mostCommon ? (mostCommon[0].length > 30 ? mostCommon[0].substring(0, 30) + '...' : mostCommon[0]) : 'N/A'}</td>`;
      catHTML += `<td class="border border-gray-300 px-4 py-2">${mostCommon ? mostCommon[1] : 'N/A'}</td>`;
      catHTML += '</tr>';
    });

    catHTML += '</tbody></table>';

    document.getElementById('summary-stats').innerHTML = 
      `<div class="p-4 mb-6">${tableHTML}</div>
       <div class="p-4">${catHTML}</div>`;
  }

  let currentPlot = null;
  let fullData = [];
  let globalWaterDomain = [];
  let globalSunlightDomain = [];
  let globalMinCount = 0;
  let globalMaxCount = 0;
  let globalPhMin = 0;
  let globalPhMax = 0;

  function createNutrientCheckboxes(nutrients, data) {
    fullData = data;

    // Calculate global domains from full data (these won't change when filtering)
    globalWaterDomain = [...new Set(fullData.map(d => d.water_normalized || 'Unknown'))]
      .sort((a, b) => getIntensityValue(b) - getIntensityValue(a));
    globalSunlightDomain = [...new Set(fullData.map(d => d.sunlight_normalized || 'Unknown'))]
      .sort((a, b) => getIntensityValue(b) - getIntensityValue(a));

    // Calculate global min/max count from full data
    const fullGrouped = {};
    fullData.forEach(d => {
      const sun = d.sunlight_normalized || 'Unknown';
      const water = d.water_normalized || 'Unknown';
      const key = `${sun}|${water}`;
      if (!fullGrouped[key]) {
        fullGrouped[key] = { count: 0 };
      }
      fullGrouped[key].count++;
    });
    const counts = Object.values(fullGrouped).map(g => g.count);
    globalMinCount = Math.min(...counts);
    globalMaxCount = Math.max(...counts);

    // Calculate global pH min/max
    const phLowerValues = fullData.map(d => parseFloat(d.preferred_ph_lower)).filter(v => !isNaN(v));
    const phUpperValues = fullData.map(d => parseFloat(d.preferred_ph_upper)).filter(v => !isNaN(v));
    globalPhMin = Math.min(...phLowerValues);
    globalPhMax = Math.max(...phUpperValues);

    const container = document.getElementById('nutrient-filters');
    container.innerHTML = '<span class="font-semibold">Nutrients:</span>';

    // Add Select All checkbox
    const selectAllLabel = document.createElement('label');
    selectAllLabel.className = 'inline-flex items-center cursor-pointer mr-4';

    const selectAllCheckbox = document.createElement('input');
    selectAllCheckbox.type = 'checkbox';
    selectAllCheckbox.id = 'select-all-nutrients';
    selectAllCheckbox.checked = true;
    selectAllCheckbox.className = 'form-checkbox h-4 w-4 text-gray-600 rounded border-gray-300 focus:ring-gray-500 accent-gray-500';
    selectAllCheckbox.addEventListener('change', function() {
      const checkboxes = document.querySelectorAll('#nutrient-filters input[type="checkbox"]:not(#select-all-nutrients)');
      checkboxes.forEach(cb => {
        cb.checked = selectAllCheckbox.checked;
      });
      updateVisualization();
    });

    const selectAllSpan = document.createElement('span');
    selectAllSpan.className = 'ml-1 mr-3 text-sm font-bold text-gray-800';
    selectAllSpan.textContent = 'Select All';

    selectAllLabel.appendChild(selectAllCheckbox);
    selectAllLabel.appendChild(selectAllSpan);
    container.appendChild(selectAllLabel);

    nutrients.forEach(nutrient => {
      const label = document.createElement('label');
      label.className = 'inline-flex items-center cursor-pointer';
      
      const checkbox = document.createElement('input');
      checkbox.type = 'checkbox';
      checkbox.value = nutrient;
      checkbox.checked = true;
      checkbox.className = 'form-checkbox h-4 w-4 text-gray-600 rounded border-gray-300 focus:ring-gray-500 accent-gray-500';
      checkbox.addEventListener('change', updateVisualization);
      
      const span = document.createElement('span');
      span.className = 'ml-1 mr-3 text-sm text-gray-700';
      span.textContent = nutrient;
      
      label.appendChild(checkbox);
      label.appendChild(span);
      container.appendChild(label);
    });

    // Add pH range slider below checkboxes
    const phSliderContainer = document.createElement('div');
    phSliderContainer.className = 'w-full mt-3';
    phSliderContainer.innerHTML = `
      <div class="flex items-center justify-center gap-2 px-4">
        <span class="font-semibold text-sm">pH Range:</span>
        <span id="ph-range-display" class="text-sm font-medium text-gray-700 min-w-[80px] text-left">${globalPhMin.toFixed(1)} - ${globalPhMax.toFixed(1)}</span>
        <div id="ph-slider-container" class="relative w-full max-w-[336px] h-8 select-none cursor-pointer">
          <div class="absolute top-1/2 left-0 right-0 h-1 bg-gray-200 rounded -translate-y-1/2"></div>
          <div id="ph-slider-track" class="absolute top-1/2 h-1 bg-gray-500 rounded -translate-y-1/2 pointer-events-none" style="left: 0%; right: 0%;"></div>
          <div id="ph-lower-thumb" class="absolute top-1/2 w-4 h-4 bg-white border-2 border-gray-500 rounded shadow cursor-ew-resize -translate-y-1/2 -translate-x-1/2 z-20" style="left: 0%;"></div>
          <div id="ph-upper-thumb" class="absolute top-1/2 w-4 h-4 bg-white border-2 border-gray-500 rounded shadow cursor-ew-resize -translate-y-1/2 -translate-x-1/2 z-20" style="left: 100%;"></div>
        </div>
      </div>
    `;
    container.appendChild(phSliderContainer);

    // Initialize custom dual-handle slider - wait for DOM to be ready
    requestAnimationFrame(() => {
      const phSliderContainerEl = document.getElementById('ph-slider-container');
      const phLowerThumb = document.getElementById('ph-lower-thumb');
      const phUpperThumb = document.getElementById('ph-upper-thumb');
      const phSliderTrack = document.getElementById('ph-slider-track');
      const phRangeDisplay = document.getElementById('ph-range-display');

      if (!phSliderContainerEl || !phLowerThumb || !phUpperThumb || !phSliderTrack || !phRangeDisplay) {
        console.error('Slider elements not found');
        return;
      }

      let phLowerValue = globalPhMin;
      let phUpperValue = globalPhMax;
      let isDraggingLower = false;
      let isDraggingUpper = false;

      const updateSliderUI = function() {
        if (!phLowerThumb || !phUpperThumb || !phSliderTrack || !phRangeDisplay) return;
        
        const range = globalPhMax - globalPhMin;
        
        const lowerPercent = ((phLowerValue - globalPhMin) / range) * 100;
        const upperPercent = ((phUpperValue - globalPhMin) / range) * 100;
        
        phLowerThumb.style.left = `${lowerPercent}%`;
        phUpperThumb.style.left = `${upperPercent}%`;
        phSliderTrack.style.left = `${lowerPercent}%`;
        phSliderTrack.style.right = `${100 - upperPercent}%`;
        
        phRangeDisplay.textContent = `${phLowerValue.toFixed(1)} - ${phUpperValue.toFixed(1)}`;
      };

      const getValueFromPosition = function(clientX) {
        const rect = phSliderContainerEl.getBoundingClientRect();
        const x = clientX - rect.left;
        const percent = Math.max(0, Math.min(1, x / rect.width));
        return globalPhMin + percent * (globalPhMax - globalPhMin);
      };

      const handleDrag = function(clientX) {
        if (isDraggingLower) {
          let newValue = getValueFromPosition(clientX);
          // Round to nearest 0.1
          newValue = Math.round(newValue * 10) / 10;
          // Ensure lower doesn't exceed upper
          if (newValue > phUpperValue) {
            newValue = phUpperValue;
          }
          phLowerValue = newValue;
          updateSliderUI();
          updateVisualization();
        } else if (isDraggingUpper) {
          let newValue = getValueFromPosition(clientX);
          // Round to nearest 0.1
          newValue = Math.round(newValue * 10) / 10;
          // Ensure upper doesn't go below lower
          if (newValue < phLowerValue) {
            newValue = phLowerValue;
          }
          phUpperValue = newValue;
          updateSliderUI();
          updateVisualization();
        }
      };

    // Mouse events for lower thumb
    phLowerThumb.addEventListener('mousedown', function(e) {
      isDraggingLower = true;
      e.preventDefault();
    });

    // Mouse events for upper thumb
    phUpperThumb.addEventListener('mousedown', function(e) {
      isDraggingUpper = true;
      e.preventDefault();
    });

    // Mouse move and up events on document
    document.addEventListener('mousemove', function(e) {
      if (isDraggingLower || isDraggingUpper) {
        handleDrag(e.clientX);
      }
    });

    document.addEventListener('mouseup', function() {
      isDraggingLower = false;
      isDraggingUpper = false;
    });

    // Touch events for lower thumb
    phLowerThumb.addEventListener('touchstart', function(e) {
      isDraggingLower = true;
      e.preventDefault();
    });

    // Touch events for upper thumb
    phUpperThumb.addEventListener('touchstart', function(e) {
      isDraggingUpper = true;
      e.preventDefault();
    });

    // Touch move and end events on document
    document.addEventListener('touchmove', function(e) {
      if (isDraggingLower || isDraggingUpper) {
        handleDrag(e.touches[0].clientX);
      }
    });

    document.addEventListener('touchend', function() {
      isDraggingLower = false;
      isDraggingUpper = false;
    });

    // Click on track to move nearest thumb
    phSliderContainerEl.addEventListener('click', function(e) {
      // Check if clicking on thumbs - if so, let the thumb handlers work
      if (e.target === phLowerThumb || e.target === phUpperThumb) {
        return;
      }
      
      // Get click position relative to container
      const rect = phSliderContainerEl.getBoundingClientRect();
      const x = e.clientX - rect.left;
      
      // Clamp to container bounds
      const clampedX = Math.max(0, Math.min(rect.width, x));
      const newValue = globalPhMin + (clampedX / rect.width) * (globalPhMax - globalPhMin);
      const roundedValue = Math.round(newValue * 10) / 10;
      
      // Determine which thumb is closer
      const lowerDiff = Math.abs(roundedValue - phLowerValue);
      const upperDiff = Math.abs(roundedValue - phUpperValue);
      
      if (lowerDiff < upperDiff) {
        if (roundedValue <= phUpperValue) {
          phLowerValue = roundedValue;
        }
      } else {
        if (roundedValue >= phLowerValue) {
          phUpperValue = roundedValue;
        }
      }
      
      updateSliderUI();
      updateVisualization();
    });
    });
  }

  function updateVisualization() {
    const checkboxes = document.querySelectorAll('#nutrient-filters input[type="checkbox"]:not(#select-all-nutrients)');
    const selectedNutrients = Array.from(checkboxes)
      .filter(cb => cb.checked)
      .map(cb => cb.value);

    const phLowerSliderContainer = document.getElementById('ph-slider-container');
    let phLower = globalPhMin;
    let phUpper = globalPhMax;
    
    if (phLowerSliderContainer) {
      // Get values from the slider state
      const phLowerThumb = document.getElementById('ph-lower-thumb');
      const phUpperThumb = document.getElementById('ph-upper-thumb');
      
      if (phLowerThumb && phUpperThumb && phLowerThumb.style.left && phUpperThumb.style.left) {
        const range = globalPhMax - globalPhMin;
        const lowerPercent = parseFloat(phLowerThumb.style.left) / 100;
        const upperPercent = parseFloat(phUpperThumb.style.left) / 100;
        
        phLower = globalPhMin + lowerPercent * range;
        phUpper = globalPhMin + upperPercent * range;
        
        // Round to nearest 0.1
        phLower = Math.round(phLower * 10) / 10;
        phUpper = Math.round(phUpper * 10) / 10;
      }
    }

    const filteredData = fullData.filter(d => {
      const nutrientMatch = selectedNutrients.includes(d.nutrients_normalized);
      const phLowerVal = parseFloat(d.preferred_ph_lower);
      const phUpperVal = parseFloat(d.preferred_ph_upper);
      
      // Check if the plant's pH range is within the selected pH range
      const phMatch = !isNaN(phLowerVal) && !isNaN(phUpperVal) && 
                      phLowerVal >= phLower && phUpperVal <= phUpper;
      
      return nutrientMatch && phMatch;
    });
    createVisualization(filteredData);
  }

  function createVisualization(data) {
    // Clear previous plot
    const plotContainer = document.getElementById('viz-plot');
    plotContainer.innerHTML = '';

    // Group by normalized sunlight and water
    const grouped = {};
    data.forEach(d => {
      const sun = d.sunlight_normalized || 'Unknown';
      const water = d.water_normalized || 'Unknown';
      const key = `${sun}|${water}`;

      if (!grouped[key]) {
        grouped[key] = {
          sunlight: sun,
          water: water,
          count: 0
        };
      }
      grouped[key].count++;
    });

    const plotData = Object.values(grouped);

    // Use global domains so axes don't change when filtering
    // Use global min/max count so color scale doesn't change

    // Find leftmost point for each sunlight category for y-axis labels
    const yLabels = [];
    globalSunlightDomain.forEach(sun => {
      const pointsInCategory = plotData.filter(d => d.sunlight === sun);
      if (pointsInCategory.length > 0) {
        // Find leftmost point (minimum x value)
        const leftmost = pointsInCategory.reduce((min, d) => {
          const minVal = globalWaterDomain.indexOf(min.water);
          const dVal = globalWaterDomain.indexOf(d.water);
          return dVal < minVal ? d : min;
        });
        yLabels.push({
          sunlight: sun,
          water: leftmost.water,
          count: leftmost.count
        });
      }
    });

    // Build marks array - only add data marks if there's data
    const marks = [];

    // Add custom y-axis labels positioned at leftmost point of each category (only when there's data)
    if (plotData.length > 0) {
      marks.push(
        Plot.text(yLabels, {
          x: "water",
          y: "sunlight",
          text: d => d.sunlight + " ⟶",
          dx: -30,
          textAnchor: "end",
          fontSize: 14,
          fill: "black"
        })
      );
    }

    if (plotData.length > 0) {
      marks.push(
        Plot.dot(plotData, {
          x: "water",
          y: "sunlight",
          r: "count",
          fill: "count",
          stroke: "white",
          strokeWidth: 1
        }),
        Plot.text(plotData, {
          x: "water",
          y: "sunlight",
          text: "count",
          fill: d => d.count > (globalMinCount + globalMaxCount) / 2 ? "white" : "black",
          fontSize: 14,
          fontWeight: "bold"
        })
      );
    }

    // Create Observable Plot with fixed domains and larger fonts
    // Axes and colorbar are always shown, even when no data points
    const plot = Plot.plot({
      width: 800,
      height: 500,
      marginLeft: 21,
      marginBottom: 80,
      style: {
        fontSize: "14px"
      },
      x: {
        label: "",
        labelAnchor: "center",
        fontSize: 14,
        tickSize: 0,
        domain: globalWaterDomain,
        tickFormat: d => d === "Medium" ? "Medium Water" : d,
        grid: true
      },
      y: {
        label: "",
        labelAnchor: "center",
        fontSize: 14,
        tickSize: 0,
        axis: null,
        domain: globalSunlightDomain
      },
      r: {
        range: [8, 45]
      },
      color: {
        interpolate: t => d3.interpolateRgbBasis(["#c7e9b4", "#7fcdbb", "#41b6c4", "#1d91c0", "#225ea8", "#0c2c84"])(t),
        label: "Count",
        domain: [globalMinCount, globalMaxCount]
      },
      marks: marks
    });

    currentPlot = plot;

    // Create a wrapper for plot and colorbar
    const wrapper = document.createElement('div');
    wrapper.style.position = 'relative';
    wrapper.style.display = 'inline-block';
    wrapper.appendChild(plot);

    // Create custom SVG colorbar at bottom right
    const colorbarWidth = 150;
    const colorbarHeight = 50;
    const colorbarMargin = { top: 30, right: 0, bottom: 0, left: 0 };

    // Calculate total plants plotted
    const totalPlants = plotData.reduce((sum, d) => sum + d.count, 0);

    const colorbarSvg = d3.select(wrapper)
      .append('svg')
      .attr('width', colorbarWidth + colorbarMargin.left + colorbarMargin.right)
      .attr('height', colorbarHeight + colorbarMargin.top + colorbarMargin.bottom)
      .style('position', 'absolute')
      .style('bottom', '100px')
      .style('right', '97px')
      .style('background', 'transparent');

    // Add total plants count above colorbar
    colorbarSvg.append('text')
      .attr('x', colorbarMargin.left + colorbarWidth / 2)
      .attr('y', colorbarMargin.top - 15)
      .attr('text-anchor', 'middle')
      .style('font-size', '16px')
      .style('font-weight', 'bold')
      .text(`Total Plants: ${totalPlants}`);

    // Create gradient
    const defs = colorbarSvg.append('defs');
    const gradient = defs.append('linearGradient')
      .attr('id', 'colorbar-gradient')
      .attr('x1', '0%')
      .attr('x2', '100%')
      .attr('y1', '0%')
      .attr('y2', '0%');

    const colors = ["#c7e9b4", "#7fcdbb", "#41b6c4", "#1d91c0", "#225ea8", "#0c2c84"];
    colors.forEach((color, i) => {
      gradient.append('stop')
        .attr('offset', `${(i / (colors.length - 1)) * 100}%`)
        .attr('stop-color', color);
    });

    // Draw gradient rectangle
    colorbarSvg.append('rect')
      .attr('x', colorbarMargin.left)
      .attr('y', colorbarMargin.top)
      .attr('width', colorbarWidth)
      .attr('height', 10)
      .style('fill', 'url(#colorbar-gradient)');

    // Add axis
    const colorScale = d3.scaleLinear()
      .domain([globalMinCount, globalMaxCount])
      .range([0, colorbarWidth]);

    const axis = d3.axisBottom(colorScale)
      .ticks(5)
      .tickSize(5);

    const axisGroup = colorbarSvg.append('g')
      .attr('transform', `translate(${colorbarMargin.left}, ${colorbarMargin.top + 10})`)
      .call(axis.tickSize(3).tickPadding(2));

    axisGroup.selectAll('text')
      .style('font-size', '11px');

    // Remove the axis line (domain path)
    axisGroup.select('.domain').remove();

    // Add label
    colorbarSvg.append('text')
      .attr('x', colorbarMargin.left + colorbarWidth / 2)
      .attr('y', colorbarHeight + colorbarMargin.top - 15)
      .attr('text-anchor', 'middle')
      .style('font-size', '10px')
      .style('font-weight', 'bold')
      .text('Count');

    plotContainer.appendChild(wrapper);
  }
</script>