indicator = "Urban"
import { concat } from '@uwdata/arquero'
largestAbs = (a, b) => Math.max(Math.abs(a), Math.abs(b))
// rounding function
function roundToDecimal(number, decimals) {
  // Multiply the number by 10^decimals
  let factor = Math.pow(10, decimals);
  
  // Round to the nearest integer and then divide by the same factor
  return Math.round(number * factor) / factor;
}
function quantile(arr, q) {
  const nums = arr.filter(d => Number.isFinite(d)).slice();
  if (!nums.length) return undefined;
  nums.sort((a, b) => a - b);
  const pos = (nums.length - 1) * q;
  const base = Math.floor(pos);
  const rest = pos - base;
  if (nums[base + 1] !== undefined) {
    return nums[base] + rest * (nums[base + 1] - nums[base]);
  } else {
    return nums[base];
  }
}
function get_data_spread(plot_data, col_var) {

  // --- Extract numeric values, filter out invalids ---
  const numericValues = plot_data.features
    .map(f => f.properties[col_var])
    .filter(v => v != null && !isNaN(v));
  
  const sorted = numericValues.slice().sort((a,b)=>a-b);
  const min = sorted.length ? d3.quantile(sorted, 0.025) : 0;
  const max = sorted.length ? d3.quantile(sorted, 0.975) : 100;   /// this works for percentages - may need to change if using ha??

  const mid = 0;  
  const max_div = largestAbs(min, max); 
  
  return([min, max, max_div])
}
// return min and max of map datasets that have shared columns 

function getCombinedMinMax(data1, data2, column) {
  // Convert to plain arrays if they are Arquero tables
  const arr1 = typeof data1.objects === "function" ? data1.objects() : data1;
  const arr2 = typeof data2.objects === "function" ? data2.objects() : data2;

  // Combine and filter valid numeric values
  const allValues = [...arr1, ...arr2]
    .map(d => d[column])
    .filter(v => v != null && !isNaN(v));

  if (allValues.length === 0) return { min: 0, max: 0 };

  return {
    min: Math.min(...allValues),
    max: Math.max(...allValues)
  };
}
d3 = require("d3@7")  // need this for viridis

function map(plot_data, col_var, nz_outline, options = {}) {
  // defaults and merge with provided options
  const {
    min_max = null,
    legend_title = 'Landcover area (ha)',
    type = 'state',
    mapTitle = "",
    wetland = "FALSE",
    id = `map-${Math.random().toString(36).slice(2)}`  // unique map id
  } = options;

  // --- create the div and Leaflet map ---
  //const div = html`<div id="${id}" style="height:500px; width:100%; position:relative; background-color:#666363;"></div>`;
  const div = html`<div id="${id}" style="height:500px; width:100%; position:relative; background-color: #FFFFFF;"></div>`;
  const m = L.map(div, {
    zoomControl: true,
    attributionControl: false
  });

  // Add NZ outline as a dark background layer
  const nzOutlineLayer = L.geoJSON(nz_outline, {
    style: {
      fillColor: '#E8E8E8', //'#FFFFFF', // white fill //"#1D1C1C", // Dark fill // '#E8E8E8',// light grey
      fillOpacity: 1,
      color: '#E8E8E8', //"#000",         // Optional dark border
      weight: 0.5
    }
  }).addTo(m);



  // --- Title overlay ---
  if (mapTitle) {
    const titleDiv = document.createElement("div");
    titleDiv.innerText = mapTitle;
    titleDiv.style.position = "absolute";
    titleDiv.style.top = "8px";
    titleDiv.style.left = "50%";
    titleDiv.style.transform = "translateX(-50%)";
    titleDiv.style.background = "rgba(255,255,255,0.8)";
    titleDiv.style.padding = "2px 2px 2px 2px";
    titleDiv.style.font = "14px sans-serif";
    titleDiv.style.fontWeight = "bold";
    titleDiv.style.borderRadius = "4px";
    titleDiv.style.pointerEvents = "none";
    div.appendChild(titleDiv);
  }

  // --- unique gradient id for legend ---
  const gradientId = `legend-gradient-${id}`;


  // --- Extract numeric values, filter out invalids ---

  const numericValues = plot_data.features
    .map(f => f.properties[col_var])
    .filter(v => v != null && !isNaN(v));

  const sorted = numericValues.slice().sort((a,b)=>a-b);

  // Conditional logic based on input_max_div
  let min, max, max_div;

    min = sorted.length ? d3.quantile(sorted, 0.025) : 0;
    max = sorted.length ? d3.quantile(sorted, 0.975) : 100;
    max_div = largestAbs(min, max);

  const mid = 0;

  // adjust col_var to 95 quantiles for colour
  // Add an index into GeoJSON before table conversion
  plot_data.features.forEach((f, i) => f.properties.__id = i);

  const aq_plot_data = aq.from(plot_data.features)
      .derive({
        col_adjusted: aq.escape(d => {
          const v = Number(d.properties[col_var]);
          if (isNaN(v)) return null; // or keep original here if you prefer
          if (v > max) return max;
          if (v < min) return min;
          return v;
        })
      })
      
  // note this is wrapped so quarto knows it outputs a value    
  const adj_plot_data = ({
    ...plot_data,
    features: aq_plot_data.objects().map(row => {
      const i = row.properties.__id;
      return {
        ...plot_data.features[i],
        properties: {
          ...plot_data.features[i].properties,
          adj_col_var : row.col_adjusted
        }
      };
    })
  });
  

  // --- Polygon color scale ---

  // Set Bonnie's diverging colour scale (white centre in RColourBrewer Orange to Purple)
  const hexColors = [ "#7F0000", "#FF7F0E", "#F5F5F5", "#9B30FF","#3F0071"];
  // try vanimo high contrast
  //const hexColors = ["#E4B4D2", "#BC71A7", "#833C71", "#401D34", "#1F1816", "#2E351C", "#506734", "#7DA351", "#B3D189"];
  const customInterpolator = d3.piecewise(d3.interpolateRgb, hexColors);

  const baseScale = type === "state"
    ? d3.scaleSequential(d3.interpolateViridis).domain([min, max])
    : d3.scaleDiverging()
      .domain([-max_div, mid, max_div]) // Your data's range: low, neutral, high
      .interpolator(customInterpolator);

  const colorScale = v => (v == null || isNaN(v)) ? "#cccccc" : baseScale(v);

  // --- create polygons

  const polyLayer = L.geoJSON(adj_plot_data, {
    style: feature => {
      const val = feature.properties.adj_col_var;
      return {
        fillColor: val == null ? "#d3d3d3" : colorScale(val),
        color: val == null ? "#d3d3d3" : colorScale(val),  // colour outline to reduce white showing in rendering
        fillOpacity: 0.7,
        stroke: true,
        opacity: 0.7,
        weight: .5,
      };
    },
    onEachFeature: (feature, layer) => {
      layer.bindPopup(
        wetland === 'FALSE'
          ? type === 'state'
            ? `
              polygon ID: ${feature.properties.seqnum}<br>
              Landcover: ${feature.properties.landcover}<br>
              Area (ha): ${feature.properties.area_ha}<br>
              Percent area: ${feature.properties.percent_landcover}<br>
            `
            : `
              Polygon : ${feature.properties.seqnum}<br>
              Landcover: ${feature.properties.landcover}<br>
              Percent change: ${feature.properties.percent_change}<br>
              Change (ha): ${feature.properties.change_ha}<br>
            `
          : type === 'state'
            ? `
              polygon ID: ${feature.properties.seqnum}<br>
              Area (ha): ${feature.properties.wetland_area_ha}<br>
            `
            : `
              Polygon : ${feature.properties.seqnum}<br>
              Change (ha): ${feature.properties.change_ha}<br>
            `
      );
    }
  }).addTo(m);

  // --- Legend ---
  const legend = L.control({ position: "bottomright" });
  legend.onAdd = function () {
    const div = L.DomUtil.create("div", `info legend legend-${id}`);
    div.style.background = "none";
    div.style.padding = "0";
    div.style.margin = "0";
    div.style.marginBottom = "20px";
    div.style.marginRight = "10px";
    div.style.font = "11px/1.3 sans-serif";
    div.style.color = "#333";
    div.style.display = "flex";
    div.style.flexDirection = "column";
    div.style.alignItems = "flex-start";

    const barHeight = 180;

    // Title
    const title = document.createElement("div");
    title.innerHTML = legend_title;
    title.style.fontWeight = "bold";
    title.style.marginBottom = "4px";
    div.appendChild(title);

    // --- Gradient row ---
    const rowDiv = document.createElement("div");
    rowDiv.style.display = "flex";
    rowDiv.style.alignItems = "flex-start";

    // Gradient SVG
    const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
    svg.setAttribute("width", 20);
    svg.setAttribute("height", barHeight);

    const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
    const linearGrad = document.createElementNS("http://www.w3.org/2000/svg", "linearGradient");
    linearGrad.setAttribute("id", gradientId);
    linearGrad.setAttribute("x1", "0%");
    linearGrad.setAttribute("y1", "100%");
    linearGrad.setAttribute("x2", "0%");
    linearGrad.setAttribute("y2", "0%");

    const nStops = 50;
    for (let i = 0; i <= nStops; i++) {
      const t = i / nStops;
      let val;
      if (type === 'state') {
        val = min + t * (max - min);
      } else {
        const scale = d3.scaleLinear().domain([0, 0.5, 1]).range([-max_div, mid, max_div]);
        val = scale(t);
      }
      const stop = document.createElementNS("http://www.w3.org/2000/svg", "stop");
      stop.setAttribute("offset", `${t*100}%`);
      stop.setAttribute("stop-color", colorScale(val));
      linearGrad.appendChild(stop);
    }
    defs.appendChild(linearGrad);
    svg.appendChild(defs);

    const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
    rect.setAttribute("x", 0);
    rect.setAttribute("y", 0);
    rect.setAttribute("width", 20);
    rect.setAttribute("height", barHeight);
    rect.setAttribute("fill", `url(#${gradientId})`);
    rect.setAttribute("stroke", "black");
    rect.setAttribute("stroke-width", "0.3");
    svg.appendChild(rect);

    const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
    line.setAttribute("x1", 0);
    line.setAttribute("y1", barHeight);
    line.setAttribute("x2", 20);
    line.setAttribute("y2", barHeight);
    line.setAttribute("stroke", "black");
    line.setAttribute("stroke-width", "0.6");
    svg.appendChild(line);

    rowDiv.appendChild(svg);

    // Labels
    const minCLabel = `< ${Math.round(-max_div)}`;
    const maxCLabel = `> ${Math.round(max_div)}`;
    const midCLabel = "0";
    const maxSLabel = `> ${Math.round(max_div)}`;

    const labelsDiv = document.createElement("div");
    labelsDiv.style.display = "flex";
    labelsDiv.style.flexDirection = "column";
    labelsDiv.style.height = `${barHeight}px`;
    labelsDiv.style.justifyContent = "space-between";
    labelsDiv.style.marginLeft = "4px";
    labelsDiv.innerHTML = type === "state"
      ? `
        <span>${maxSLabel}</span>
        <span>${Math.round((max+min)/2)}</span>
        <span>${Math.round(min)}</span>
      `
      : `
        <span>${maxCLabel}</span>
        <span>${midCLabel}</span>
        <span>${minCLabel}</span>
      `;
    rowDiv.appendChild(labelsDiv);

    div.appendChild(rowDiv);

    return div;
  };
  legend.addTo(m);

  // --- map sizing hacks ---
  
  const bounds = L.latLngBounds([
    [-46.0, 166.0],
    [-34.0, 179.0]
  ]);

  const resizeObserver = new ResizeObserver(() => {
    m.invalidateSize();
    m.fitBounds(bounds); // optional if you want to maintain framing
  });
  resizeObserver.observe(div);

  // Fit bounds once layout is ready
  setTimeout(() => {
    try {
      //m.fitBounds(polyLayer.getBounds());
      m.invalidateSize();
    } catch (err) {
      console.warn("fitBounds failed:", err);
    }
  }, 300); // allow Quarto's grid layout to finalize

  // --- cleanup on invalidation ---
  invalidation.then(() => {
    resizeObserver.disconnect();
    m.remove();
  });

  // --- Return ---
  return Object.assign(div, {
    value: m,
    invalidateSize: () => m.invalidateSize(),
    fitBounds: b => m.fitBounds(b),
    remove: () => m.remove(),
    legend,
    ready: Promise.resolve()
  });
}
// can add a swatch to the map graph to add a value for NA into the legend label etc.

swatch = function (maindiv){   const naDiv = document.createElement("div");
    naDiv.style.display = "flex";
    naDiv.style.alignItems = "center";
    naDiv.style.marginTop = "6px";

    const naBox = document.createElement("div");
    naBox.style.width = "20px";
    naBox.style.height = "20px";
    naBox.style.background = "#cccccc";
    naBox.style.border = "0.3px solid black";

    const naLabel = document.createElement("span");
    naLabel.style.fontSize = "10px";
    naLabel.style.marginLeft = "4px";
    naLabel.innerText = "NA";           // adjust label as needed

    naDiv.appendChild(naBox);
    naDiv.appendChild(naLabel);

    maindiv.appendChild(naDiv);
}
// Utility function to convert array of objects → CSV
function toCSV(data) {
  if (!data.length) return "";
  
  const headers = Object.keys(data[0]);
  const rows = data.map(row => 
    headers.map(h => JSON.stringify(row[h] ?? "")).join(",")
  );
  
  return [headers.join(","), ...rows].join("\n");
}
function joinGeoJSON(geojson, table, key = "seqnum") {
  // Build a lookup map from the table for fast joins
  let lookup = new Map(table.map(d => [String(d[key]), d]));

  return {
    type: "FeatureCollection",
    features: geojson.features
      .map(f => {
        let match = lookup.get(String(f.properties[key]));
        if (!match) return null;  // skip if no match
        return {
          ...f,
          properties: {
            ...f.properties,
            ...match
          }
        };
      })
      .filter(f => f !== null) // remove unmatched features
  };
}
// Function to sync maps
function syncMaps(mapA, mapB) {
  let syncing = false; // prevent infinite loop

  mapA.on('move', () => {
    if (syncing) return;
    syncing = true;
    const center = mapA.getCenter();
    const zoom = mapA.getZoom();
    mapB.setView(center, zoom);
    syncing = false;
  });

  mapB.on('move', () => {
    if (syncing) return;
    syncing = true;
    const center = mapB.getCenter();
    const zoom = mapB.getZoom();
    mapA.setView(center, zoom);
    syncing = false;
  });
}
// loads two maps in one container with spinner 

// --- wrapper function for your maps ---
function mapsWithLoading(maps) {
  const wrapper = html`<div style="position: relative">
    <!-- loading overlay -->
    <div class="loading-overlay" 
         style="position: absolute; inset: 0; 
                display: flex; align-items: center; justify-content: center;
                background: rgba(255,255,255,0.8); z-index: 10; font-weight: bold;">
      Loading maps...
    </div>

    <!-- grid of maps -->
    <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 15px">
      <div>${maps.map1}</div>
      <div>${maps.map2}</div>
    </div>
  </div>`;

  const overlay = wrapper.querySelector(".loading-overlay");

  let loadedCount = 0;
  const totalMaps = 2; // change if you add more maps later

  // Listen for map load events
  [maps.map1, maps.map2].forEach(m => {
    m.on("load", () => {
      loadedCount++;
      if (loadedCount === totalMaps) {
        overlay.style.display = "none"; // hide overlay when all are ready
      }
    });
  });

  return wrapper;
}
from_years  = ['1996', '2001', '2008', '2012', '2018']
years = ['1996', '2001', '2008', '2012', '2018', '2023']
// App specific values for dropdown menus
regions = ["New Zealand", "Northland", "Auckland", "Waikato", "Bay of Plenty", "Gisborne", "Hawke's Bay", "Taranaki",  "Manawat\u016B-Whanganui", "Wellington", "Tasman", "Nelson", "Marlborough", "West Coast", "Canterbury", "Otago", "Southland",  "Islands outside region"]  // removed "Chatham Islands", - just using area outside region
all_medium_class = ["Indigenous total", "Exotic total", "Urban area total", "Artificial bare surfaces", "Cropping/horticulture", "Exotic forest", "Exotic grassland", "Exotic scrub/shrubland", "Indigenous forest", "Indigenous scrub", "Natural bare/lightly vegetated surfaces",  "Other herbaceous vegetation", "Tussock grassland", "Urban area", "Water bodies"]  // removed "Not land", and "NA"


medium_class = ({
  Indigenous: ["All", "Indigenous forest", "Indigenous scrub/shrubland", 
               "Natural bare/lightly-vegetated surfaces", 
               "Other herbaceous vegetation", "Tussock grassland"],
  Exotic: ["All", "Cropping/horticulture", "Exotic forest", "Exotic grassland", "Exotic scrub/shrubland"],  // medium_class changed from Cropland to Cropping/horticulture, and Exotic scrub to Exotic scrub/shrubland in 2025
  Urban: ["All", "Artificial bare surfaces", "Urban area"], 
  Wetland: ["Artificial bare surfaces", "Cropping/horticulture", "Exotic forest", "Exotic grassland", "Exotic scrub/shrubland", "Indigenous forest", "Indigenous scrub/shrubland", "Natural bare/lightly-vegetated surfaces", "Other herbaceous vegetation", "Tussock grassland", "Urban area", "Water bodies"]
})[indicator] ?? ["All"]; 


// Define the options for the detailed_class based on medium_class
detailed_class = ({
  Indigenous: {
    "All": ["All"], 
    "Indigenous forest": ["All", "Broadleaved indigenous hardwoods", "Indigenous forest", "Indigenous forest - harvested"],
    "Indigenous scrub/shrubland": ["All", "Fernland", "M\u0101nuka and/or k\u0101nuka", "Sub alpine shrubland", "Matagouri or Grey scrub", "Mangrove", "Peat shrubland (Chatham Islands)", "Dune shrubland (Chatham Islands)", "Indigenous scrub - harvested"],   
    "Natural bare/lightly-vegetated surfaces": ["All", "Sand or gravel", "Landslide", "Permanent snow and ice", "Alpine grass/herbfield", "Gravel or rock"], 
    "Other herbaceous vegetation": ["All", "Herbaceous freshwater vegetation", "Herbaceous saline vegetation", "Flaxland"], 
    "Tussock grassland": ["All"]  // there is only one detailed class of tussock == 'Tall tussock grassland'. Removing for reduced memory load
  },
  
  Exotic: {
     "All": ["All"], 
     "Cropping/horticulture": ["All", "Orchard, vineyard or other perennial crop", "Short-rotation cropland"],  // medium_class changed from Cropland to Cropping/horticulture in 2025
     "Exotic forest": ["All", "Deciduous hardwoods", "Exotic forest", "Forest - harvested"],
     "Exotic grassland": ["All", "Depleted grassland", "High producing exotic grassland", "Low producing grassland"], 
     "Exotic scrub/shrubland": ["All", "Gorse and/or broom", "Mixed exotic shrubland"]
  },
  
  Urban: {
    "All": ["All"], 
    "Artificial bare surfaces": ["All", "Surface mine or dump", "Transport infrastructure"],
    "Urban area": ["All", "Built-up area (settlement)", "Urban parkland/open space"]
  }, 
  
  Wetland: {
    "All": ["All"],
    "Artificial bare surfaces": ["All", "Surface mine or dump", "Transport infrastructure"],
    "Cropping/horticulture": ["All", "Orchard, vineyard or other perennial crop", "Short-rotation cropland"],
    "Exotic forest": ["All", "Deciduous hardwoods", "Exotic forest", "Forest - harvested"],
    "Exotic grassland": ["All", "High producing exotic grassland", "Low producing grassland"],
    "Exotic scrub/shrubland": ["All", "Gorse and/or broom", "Mixed exotic shrubland"],
    "Indigenous forest": ["All", "Broadleaved indigenous hardwoods", "Indigenous forest"],
    "Indigenous scrub/shrubland": ["All", "Fernland", "Mangrove", "M\u0101nuka and/or k\u0101nuka", "Matagouri or grey scrub", "Sub alpine shrubland"],
    "Natural bare/lightly-vegetated surfaces": ["All", "Gravel or rock", "Sand or gravel"],
    "Other herbaceous vegetation": ["All", "Flaxland", "Herbaceous freshwater vegetation", "Herbaceous saline vegetation"],
    "Tussock grassland": ["All", "Tall tussock grassland"],
    "Urban area": ["All", "Built-up area (settlement)", "Urban parkland/open space"],
    "Water bodies": ["All", "Estuarine open water", "Lake or pond", "River"]
}
  
})[indicator] ?? ["All"];
import {plot} from "@observablehq/plot";
import {tsvParse} from "@observablehq/d3";
import { aq, table, not } from '@uwdata/arquero';
import { download } from "@observablehq/stdlib";
lcdb_data =  FileAttachment("Urban_state_over_time.csv").csv({ typed: true })
metadata = FileAttachment("concordance_pal_descriptions.csv").csv({ typed: true})
// only show regions with data in the category chosen
region_filter = lcdb_data
  ? [
      ...new Set(
        lcdb_data.filter(row => {
          const type_match = row.class_type === (detailed_category === "All" ? "Medium class" : "Detailed class");
          const name_match = detailed_category === "All"
                             ? (medium_category === "All" ? medium_class.slice(1).includes(row.class_name) : row.class_name === medium_category)
                             : row.class_name === detailed_category;
          return type_match && name_match;
        }).map(row => row.region_name)
      ),
      "New Zealand"
    ]
  : [];   // ensures empty array returned if lcdb_change is not yet loaded
viewof medium_category = Inputs.select(medium_class, {value: "All", label: "Medium class: "})
//viewof detailed_category = Inputs.select(detailed_class[medium_category], {value: detailed_class[medium_category][0], label: "Detailed class: "})

viewof detailed_category = Inputs.select(
  detailed_class[medium_category] || ["All"],   // fallback if undefined
  {
    value: (detailed_class[medium_category] || ["All"])[0], 
    label: "Detailed class: "
  }
)

// regions are filtered to only included areas that have the chosen medium class
viewof geography = Inputs.select(
  medium_category !== "All"
    ? regions.filter(r => region_filter.includes(r))  // only show filtered regions
    : regions,                                         // otherwise show all regions  
  { value: "New Zealand", label: "Region: " }
)
// create a two column layout for viewof objects 

html`<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; row-gap: 5px; margin-bottom: 15px">
  <div>${viewof medium_category}</div>
  <div>${viewof geography}</div>
  <div>${viewof detailed_category}</div>
 
</div>`
import {op} from "@uwdata/arquero"

// aquero does not support closures (i.e. can't use externally defined variables). Therefore dynamically filter here first.
filtered_data = lcdb_data.filter(function(data) {
  
    let class_type = detailed_category === "All" ? "Medium class" : "Detailed class"
    
    // Determine which class names to match
    let name_list;
    if (detailed_category === "All" && medium_category === "All") {
      name_list = medium_class.slice(1);  // match all medium_classes except "All"
    } else if (detailed_category === "All") {
      name_list = [medium_category];
    } else {
      name_list = [detailed_category];
    }
    
    // Check class type
    const type_match = data.class_type === class_type;
    
    // Check class name
    const name_match = name_list.includes(data.class_name);
    
    // Check region
    const region_match = geography === "New Zealand" ? true : data.region_name === geography;
    
    return type_match && name_match && region_match;
    
})



// summarise data using aquero library - note that output is an observable table
line_data = aq.from(filtered_data)
           .groupby('year')
           .rollup({
              area_ha: aq.op.sum('area_ha')
           })
           .derive({ area_ha: d => aq.op.round(d.area_ha)})
           .orderby('year');
  • Graph
  • Table
  • Metadata
  • Download
minYear = Math.min(...years);
maxYear = Math.max(...years);

title_main = medium_category === "All"
  ? `Urban land cover area in ${geography}, ${minYear}\u2013${maxYear}` 
  : detailed_category === "All"
      ? `${medium_category} land cover area in ${geography}, ${minYear}\u2013${maxYear}`
      : `${detailed_category} land cover area in ${geography}, ${minYear}\u2013${maxYear}`;
html`<p style="text-align:center; font-weight:bold; font-size:14px; margin:10px 0;">${title_main}</p>`;
lineplot = Plot.plot({
  //layout options
  width: 800,
  height: 500,
  marginLeft: 80,   // space to the left of the chart
  marginRight: 20,
  marginBottom: 50, // space below the chart (space before caption rendered)
  insetBottom: 20,  // space between the x-axis and the marks
  insetLeft: 20,    // space between the y-axis and the marks
  insetRight: 20,
  // styling options
  style: {
    backgroundColor: "#7e9a9a",
    color: "black",
    fontFamily: "system-ui",
    fontSize: "14px",
    overflow: "visible"  // not clipped and rendered outside the box
  },
  x: {label: "Year", labelAnchor: "Center",  // position axis label
      tickFormat: d => d.toString(),      // format the string
      ticks: years,                        // control values shown
      },
  y: {label: "Area (hectares)\u200B",   // zero-width space
      labelAnchor: "Center",
      tickFormat: "s", 
      tickSpacing: 50},
  marks: [
    Plot.line(line_data, {
      x: "year", 
      y: "area_ha",
    }),
    Plot.dot(line_data, {
      x: "year", 
      y: "area_ha",
      tip: true,
      title: d => `${d.year}\n${d.area_ha.toLocaleString()} ha`
    }),
    Plot.frame({anchor: "left"}),
    Plot.frame({anchor: "bottom"})
  ]
});


// add caption below the plot
html`<p style="text-align:right; font-size:10px;">Source: Stats NZ using data from Bioeconomy Science Institute</p>`
//Inputs.table(await line_data)

Inputs.table(await line_data, {
  columns: [
    "year",  
    "area_ha"
  ],
  format: {
    year: d => String(d),
    area_ha: d => d
  },
  header: {
    year: "Year", 
    area_ha: "Area (ha)"
  }
});
display_meta = aq.from(metadata)
  .select("class_code", "medium_class", "detailed_class", "description")
  .rename({
    class_code: "Class code",
    medium_class: "Medium class",
    detailed_class: "Detailed class",
    description: "Description"
  })
  .objects()

Inputs.table(await display_meta)
filename = title_main
  .replace(/\u2013/g, "-")   // replace en dash with hyphen
  .replace(/,/g, "")         // remove commas
  .replace(/\s+/g, "_")      // replace spaces with underscores
  .replace(/[^\w\-]+/g, "")  // strip non-word chars except underscore & hyphen
  + ".csv";

downloadCSV = {
  const button = html`<button>Download current selection as a csv</button>`;

  button.onclick = () => {
    const csv = line_data.toCSV();
    const blob = new Blob([csv], { type: "text/csv" });
    const url = URL.createObjectURL(blob);

    const a = document.createElement("a");
    a.href = url;
    a.download = filename;

    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);

    setTimeout(() => URL.revokeObjectURL(url), 10000);
  };

  return button;
}