DHW Logo
  • About
  • Code
    • Data extraction
    • Data visualisation

On this page

  • d3.heatmap.js
  • Notes
    • - Global Variables and Defaults
    • - Heatmap and Label Setup
    • - CSS Styling with JS
    • - Dynamic row height
    • - TomSelect Setup

Data processing - D3

The D3.js heatmap script renders a dynamic heatmap with the following features:

  • interactive selection of reefs using TomSelect textbox / select
  • dynamic row height adjustment using slider to expand/contract heatmap height
  • fixed column headers (years) during dynamic scrolling
  • tooltips with reef ID, latitude, DHW
  • internal css style

d3.heatmap.js

full script:

// D3 Heatmap with sticky header and fixed column labels

// Ensure all code runs after DOM is ready
document.addEventListener("DOMContentLoaded", function() {

  let currentDataset = 'https://raw.githubusercontent.com/marine-ecologist/dhw3/refs/heads/main/data/CRW_DHWmax.csv';
  let currentSelectedReefs = [];
  let currentRowHeight = 1.5;
  let userSelectedRowHeight = currentRowHeight;

  const fixedHeatmapWidth = 1550;
  const heatmapContainer = document.getElementById('heatmap-container');
  const svg = d3.select("#heatmap-svg");
  const colLabelsSvg = d3.select("#col-labels-svg");

  let reefSelect, rowHeightSlider;
  let currentMeta, currentMatrix, yearColumns;

  const style = document.createElement('style');
  style.textContent = `
    body {
      background-color: #333;
      color: white;
      font-family: Helvetica;
      margin: 0;
      padding: 0;
    }
    #header {
      position: sticky;
      top: 0;
      background: #333;
      z-index: 101;
      display: flex;
      justify-content: space-between;
      width: 100%;
      box-sizing: border-box;
      padding: 5px 40px 0px 40px;
    }
    .tooltip {
      position: absolute;
      text-align: left;
      padding: 5px;
      background: white;
      border: 1px solid #ccc;
      pointer-events: none;
      font-size: 12px;
      color: black;
      z-index: 1000;
      width: 120px;
    }
    #heatmap-container {
      margin: 0;
      display: flex;
      flex-direction: column;
      align-items: flex-start;
      width: 100%;
      position: relative;
    }
    #col-labels-wrapper {
      position: sticky;
      top: 40px;
      background: #333;
      z-index: 100;
      width: fit-content;
      margin-left: 0px;
    }
    #col-labels-svg {
      display: block;
    }
    #heatmap-scroll {
      width: 100%;
      overflow-x: auto;
      overflow-y: hidden;
      padding-top: 0px; /* Reset padding to avoid vertical misalignment */
      box-sizing: border-box;
    }
    #heatmap-wrapper {
      display: flex;
      flex-direction: column;
      width: fit-content;
    }
    svg {
      display: block;
    }
    .cell {
      stroke: none;
    }
  `;
  document.head.appendChild(style);

  function loadData(dataset) {
    d3.csv(dataset).then(data => {
      currentMeta = data.map(row => ({ id: row.ID, reef: row.Reef, lat: +row.lat }));
      yearColumns = data.columns.filter(c => !['ID', 'Reef', 'lat'].includes(c));
      currentMatrix = data.map(row => yearColumns.map(year => +row[year]));
      setupTomSelect(currentMeta);
      drawHeatmap(currentMeta, currentMatrix);
    });
  }

  function getRowHeight() {
    return currentSelectedReefs.length > 0 ? 20 : userSelectedRowHeight;
  }

  function drawHeatmap(meta, matrix) {
    svg.selectAll("*").remove();
    colLabelsSvg.selectAll("*").remove();
    d3.selectAll('.tooltip').remove();

    const tooltip = d3.select("body").append("div").attr("class", "tooltip").style("opacity", 0);
    const rowHeight = getRowHeight();
    const cellWidth = fixedHeatmapWidth / yearColumns.length * 0.8;

    svg.attr("width", fixedHeatmapWidth + 66).attr("height", matrix.length * rowHeight + 40);
    const container = svg.append("g").attr("transform", `translate(66,40)`);
    colLabelsSvg.attr("width", fixedHeatmapWidth + 66).attr("height", 40);

    colLabelsSvg.selectAll(".colLabel")
      .data(yearColumns)
      .join("text")
      .attr("class", "colLabel")
      .attr("x", (d, i) => 66 + i * cellWidth + cellWidth / 2)
      .attr("y", 35) // nudge up by 5 pixels
      .attr("text-anchor", "start")
      .attr("alignment-baseline", "center")
      .attr("fill", "white")
      .attr("font-size", "12px")
      .attr("transform", (d, i) => `rotate(-45, ${66 + i * cellWidth + cellWidth / 2}, 20)`) // adjust rotation center
      .text(d => `–${d}`); // add dash before year

    const seen = new Set();
    svg.selectAll(".rowLabel")
      .data(meta)
      .join("text")
      .attr("class", "rowLabel")
      .attr("x", 55)
      .attr("y", (d, i) => 40 + i * rowHeight + rowHeight * 0.5)
      .attr("text-anchor", "end")
      .attr("alignment-baseline", "middle")
      .attr("fill", "white")
      .attr("font-size", "15px")
      .text(d => {
        if (currentSelectedReefs.length > 0) return `${d.reef} [${d.id}]`;
        const roundedLat = d.lat.toFixed(1);
        if (!seen.has(roundedLat)) {
          seen.add(roundedLat);
          const degrees = Math.floor(Math.abs(d.lat));
          const minutes = Math.round((Math.abs(d.lat) - degrees) * 60);
          return `${degrees}°${minutes.toString().padStart(2, '0')}'`;
        }
        return "";
      });

    matrix.forEach((rowData, rowIndex) => {
      const row = container.append("g").attr("transform", `translate(0, ${rowIndex * rowHeight})`);
      row.selectAll("rect")
        .data(rowData.map((value, colIndex) => ({ value, rowIndex, colIndex })))
        .enter()
        .append("rect")
        .attr("class", "cell")
        .attr("x", d => d.colIndex * cellWidth)
        .attr("width", cellWidth)
        .attr("height", rowHeight)
        .attr("fill", d => isNaN(d.value) ? "#000" : d3.scaleLinear()
          .domain([0, 3, 6, 9, 12, 15, 18, 21])
          .range(["#006f99", "#00A6E5", "#FFD700", "#FF8C00", "#B20000", "#550000", "#3D0000", "#3D0000"])(d.value))
        .on("mouseover", function (event, d) {
          const m = meta[d.rowIndex];
          const year = yearColumns[d.colIndex];
          tooltip.transition().duration(200).style("opacity", 0.9);
          tooltip.html(`<b>ID:</b> ${m.id}<br><b>Reef:</b> ${m.reef}<br><b>Latitude:</b> ${m.lat}<br><b>Year:</b> ${year}<br><b>DHW:</b> ${d.value}`)
            .style("left", (event.pageX + 10) + "px")
            .style("top", (event.pageY - 15) + "px");
        })
        .on("mouseout", () => tooltip.transition().duration(500).style("opacity", 0));
    });
  }

  function setupTomSelect(meta) {
    if (reefSelect && reefSelect.tomselect) reefSelect.tomselect.destroy();
    const sortedOptions = meta.slice().sort((a, b) => a.reef.localeCompare(b.reef));

    new TomSelect(reefSelect, {
      plugins: ['remove_button'],
      placeholder: 'Search by reef name or GBRMPA ID',
      closeAfterSelect: false,
      options: sortedOptions.map(d => ({ value: d.id, text: `${d.reef} [${d.id}]` })),
      maxOptions: null,
      items: currentSelectedReefs,
      onInitialize() {
        try {
          this.control_input.style.paddingLeft = '10px';
        } catch (e) {
          console.warn("Could not set padding on TomSelect input:", e);
        }
      },
      onItemAdd() {
        currentSelectedReefs = this.items;
        currentRowHeight = 20;
        rowHeightSlider.value = currentRowHeight;
        this.setTextboxValue('');
        applyFilter();
      },
      onItemRemove() {
        currentSelectedReefs = this.items;
        currentRowHeight = this.items.length > 0 ? 20 : userSelectedRowHeight;
        rowHeightSlider.value = currentRowHeight;
        applyFilter();
      },
      onChange(selectedIds) {
        currentSelectedReefs = selectedIds;
        currentRowHeight = selectedIds.length > 0 ? 20 : userSelectedRowHeight;
        rowHeightSlider.value = currentRowHeight;
        applyFilter();
      }
    });
  }

  function applyFilter() {
    if (currentSelectedReefs.length === 0) {
      drawHeatmap(currentMeta, currentMatrix);
    } else {
      const filteredMeta = currentMeta.filter(d => currentSelectedReefs.includes(d.id));
      const filteredMatrix = filteredMeta.map(d => {
        const idx = currentMeta.findIndex(m => m.id === d.id);
        return idx !== -1 ? currentMatrix[idx] : [];
      });
      drawHeatmap(filteredMeta, filteredMatrix);
    }
  }

  function buildHeader() {
    let header = document.getElementById('header');
    if (!header) {
      header = document.createElement('div');
      header.id = 'header';
      heatmapContainer.insertBefore(header, heatmapContainer.firstChild);
    } else {
      header.innerHTML = '';
    }

    const leftContainer = document.createElement('div');
    leftContainer.style.display = 'flex';
    leftContainer.style.alignItems = 'center';
    leftContainer.style.gap = '20px';

    const datasetWrapper = document.createElement('div');
    datasetWrapper.style.display = 'flex';
    datasetWrapper.style.alignItems = 'center';
    datasetWrapper.style.gap = '6px';

    const datasetLabel = document.createElement('i');
    datasetLabel.className = 'fa-solid fa-database';
    datasetLabel.title = 'Select dataset';
    datasetLabel.style.color = 'white';
    datasetLabel.style.fontSize = '16px';
    datasetLabel.style.cursor = 'pointer';

    const datasetSelect = document.createElement('select');
    datasetSelect.style.width = '180px';
    datasetSelect.style.background = '#333';
    datasetSelect.style.color = 'white';
    datasetSelect.style.border = '1px solid #004a5c';
    datasetSelect.style.borderRadius = '4px';

    const datasets = [
      { name: 'CoralTemp v3.1', value: 'https://raw.githubusercontent.com/marine-ecologist/dhw3/refs/heads/main/data/CRW_DHWmax.csv' },
      { name: 'OISST v2.1', value: 'https://raw.githubusercontent.com/marine-ecologist/dhw3/refs/heads/main/data/OISST_DHWmax.csv' },
      { name: 'ERA v5', value: 'https://raw.githubusercontent.com/marine-ecologist/dhw3/refs/heads/main/data/ERA5_DHWmax.csv' }
    ];

    datasets.forEach(d => {
      const option = document.createElement('option');
      option.value = d.value;
      option.textContent = d.name;
      datasetSelect.appendChild(option);
    });

    datasetSelect.value = currentDataset;
    datasetSelect.addEventListener('change', () => {
      currentDataset = datasetSelect.value;
      loadData(currentDataset);
    });

    datasetWrapper.appendChild(datasetLabel);
    datasetWrapper.appendChild(datasetSelect);
    leftContainer.appendChild(datasetWrapper);

    const slider = document.createElement('input');
    slider.type = 'range';
    slider.min = 0.25;
    slider.max = 10;
    slider.step = 0.1;
    slider.value = currentRowHeight;
    slider.style.cursor = 'pointer';
    slider.addEventListener('input', function () {
      userSelectedRowHeight = +this.value;
      if (currentSelectedReefs.length === 0) {
        currentRowHeight = userSelectedRowHeight;
        drawHeatmap(currentMeta, currentMatrix);
      }
    });
    rowHeightSlider = slider;
    leftContainer.appendChild(slider);

    const rightContainer = document.createElement('div');
    rightContainer.style.display = 'flex';
    rightContainer.style.alignItems = 'center';
    rightContainer.style.gap = '10px';

    const selectElement = document.createElement('select');
    selectElement.id = 'reefSelect';
    selectElement.multiple = true;
    selectElement.style.width = '400px';

    const clearButton = document.createElement('i');
    clearButton.className = 'fa-solid fa-circle-xmark';
    clearButton.title = 'Clear selection';
    clearButton.style.fontSize = '20px';
    clearButton.style.color = '#FFF';
    clearButton.style.cursor = 'pointer';

    clearButton.addEventListener('click', () => {
      if (selectElement.tomselect) {
        selectElement.tomselect.clear();
        currentSelectedReefs = [];
        currentRowHeight = userSelectedRowHeight;
        rowHeightSlider.value = userSelectedRowHeight;
        drawHeatmap(currentMeta, currentMatrix);
      }
    });

    rightContainer.appendChild(selectElement);
    rightContainer.appendChild(clearButton);

    header.appendChild(leftContainer);
    header.appendChild(rightContainer);

    reefSelect = selectElement;
  }

  buildHeader();
  loadData(currentDataset);
});

Notes

- Global Variables and Defaults

Initial dataset and UI state. currentRowHeight is dynamically updated depending on reef selection

let currentDataset = 'https://raw.githubusercontent.com/marine-ecologist/dhw3/refs/heads/main/data/CRW_DHWmax.csv';
let currentSelectedReefs = [];
let currentRowHeight = 1.5;
let userSelectedRowHeight = currentRowHeight;

- Heatmap and Label Setup

Define SVG containers and constants to maintain fixed layout.

const fixedHeatmapWidth = 1550;
const heatmapContainer = document.getElementById('heatmap-container');
const svg = d3.select("#heatmap-svg");
const colLabelsSvg = d3.select("#col-labels-svg");

- CSS Styling with JS

Injects necessary styles for layout, color, tooltips, sticky headers, and responsive design for standalone js

const style = document.createElement('style');
style.textContent = `...`;
document.head.appendChild(style);

- Dynamic row height

Returns 20px row height when reefs are selected; otherwise uses user-controlled slider.

function getRowHeight() {
  return currentSelectedReefs.length > 0 ? 20 : userSelectedRowHeight;
}

- TomSelect Setup

Instantiates TomSelect dropdown to allow multi-select filtering of reefs.

Handles: - Adding/removing reefs - Updating row height - Clearing selections

function setupTomSelect(meta) {
  ...
}