// Combine them into a two-column layout
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 from_year}</div>
<div>${viewof detailed_category}</div>
<div>${viewof to_year}</div>
</div>`// 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 regionall_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"];lcdb_change = FileAttachment("Urban_landcover_change.csv").csv({ typed: true })
metadata = FileAttachment("concordance_pal_descriptions.csv").csv({ typed: true})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 from_year = Inputs.select(from_years, {value: '2018', label: "From year: "})
viewof to_year = Inputs.select(years.slice(years.indexOf(from_year) + 1, 6), {value: '2023', label: "To year: "})which_category = detailed_category === "All"
? medium_category === "All"
? medium_class
: medium_category
: detailed_category
// select columnames
class_from = detailed_category === "All"
? "from_lcdb_medium_class"
: "from_lcdb_detailed_class"
class_to = detailed_category === "All"
? "to_lcdb_medium_class"
: "to_lcdb_detailed_class"
from_array = aq.from(lcdb_change.filter(function(data) {
const tmp = (which_category.includes(data[class_from])) &&
from_year.includes(data.year_from) && to_year.includes(data.year_to) &&
!which_category.includes(data[class_to]) // exclude landcover which doesn't change category
return(tmp)
}))
.groupby('region_name')
.rollup({loss_ha: aq.op.sum('area_ha')})
.orderby(aq.desc("region_name"))
.select('region_name', 'loss_ha')
// permanent ice and snow only has losses - requires empty to_array
blank_array = from_array
.derive({ area_ha: d => 0 })
.select('region_name', 'area_ha')
// check if there are values in the to_ class column
tmp_array = aq.from(lcdb_change.filter(function(data) {
const tmp = (which_category.includes(data[class_to])) &&
from_year.includes(data.year_from) && to_year.includes(data.year_to)&&
!which_category.includes(data[class_from]) // exclude landcover which doesn't change category
return(tmp)
}))
// permanent ice and snow only has losses - requires empty to_array (nothing goes to ice and snow)
// replace any arrays of size zero with a blank array
tmp2_array = tmp_array.size > 0 ? tmp_array : blank_array
//tmp2_array
to_array = tmp2_array
// summarise data by region
.groupby('region_name')
.rollup({gain_ha: aq.op.sum('area_ha')})
.orderby(aq.desc("region_name"))
.select('region_name', 'gain_ha')
region_order = regions.slice(1) // remove NZ from list
plot_data = to_array
.join_full(from_array, "region_name")
.derive({ loss_ha: d => d.loss_ha || 0}) // replace missing values with 0 so net change calculates correctly
.derive({ gain_ha: d => d.gain_ha || 0})
.derive({ net_ha: d => d.gain_ha - d.loss_ha })
.derive({ loss_ha: d => - aq.op.round(d.loss_ha)}) // round final values for graphing
.derive({ gain_ha: d => aq.op.round(d.gain_ha)})
.derive({ net_ha: d => aq.op.round(d.net_ha)})
.derive({ order: aq.escape(row => region_order.indexOf(row["region_name"])) })
.orderby("order") // sort by the numeric helper
//.select(d => d, { drop: ["order"] }) // remove helper column if you want
.objects() // <-- convert final table to array of objects
.map(d => ({
...d,
tooltip: `<strong>${d.region_name}</strong><br>Gain: ${d.gain_ha}<br>Loss: ${d.loss_ha}<br>Net: ${d.net_ha}`
}));tooltip = {
const el = document.createElement("div");
el.style.cssText = `
position: absolute;
pointer-events: none;
background: white;
border: 1px solid #ccc;
padding: 6px 10px;
border-radius: 4px;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
font-family: sans-serif;
font-size: 12px;
display: none;
`;
document.body.appendChild(el);
return el; // 👉 return it so other cells can use "tooltip"
}plot_title = detailed_category === "All"
? medium_category === "All"
? `Change in ${indicator.toLowerCase()} land cover area, by region, ${from_year}\u2013${to_year}`
: `Change in ${medium_category.toLowerCase()} land cover area, by region, ${from_year}\u2013${to_year}`
: `Change in ${detailed_category.toLowerCase()} land cover area, by region, ${from_year}\u2013${to_year}`;
html`<p style="text-align:center; font-weight:bold; font-size:14px; margin:10px 0;">${plot_title}</p>`;(() => {
const xMin = Math.min(...plot_data.map(d => d.loss_ha));
const xMax = Math.max(...plot_data.map(d => d.gain_ha));
const p = Plot.plot({
width: 800,
marginRight: 10,
marginLeft: 150, // space to the left of the chart
marginBottom: 30, // 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: { domain: [xMin, xMax], grid: true, label: null, labelAnchor: "Center" },
y: { type: "band", domain: plot_data.map(d => d.region_name), padding: 0.2, label: null },
marks: [
Plot.barX(plot_data, { y: "region_name", x: "gain_ha", fill: "#4B0082", title: "Gain", ariaLabel: d => d.tooltip }),
Plot.barX(plot_data, { y: "region_name", x: d => d.loss_ha, fill: "#B22222", title: "Loss", ariaLabel: d => d.tooltip }),
Plot.dot(plot_data, { y: "region_name", x: d => d.net_ha, fill: "black", r: 4, title: "Net change", ariaLabel: d => d.tooltip }),
Plot.ruleX([0]),
Plot.frame({anchor: "left"}),
Plot.frame({anchor: "bottom"})
],
color: { legend: true }
});
// Attach tooltip events
p.querySelectorAll("rect, circle").forEach(el => {
el.addEventListener("mousemove", event => {
tooltip.style.display = "block";
tooltip.innerHTML = el.getAttribute("aria-label");
tooltip.style.left = (event.pageX + 10) + "px";
tooltip.style.top = (event.pageY + 10) + "px";
});
el.addEventListener("mouseout", () => {
tooltip.style.display = "none";
});
});
// Add a manual centered x-axis label (no arrow)
const xlabel = html`<p style="text-align:center; font-size:14px; margin:6px 0 0 0;">Hectares</p>`;
// Add a custom legend below the plot
const legend = html`
<div style="
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
margin-top: 10px;
font-family: system-ui;
font-size: 14px;">
<div style="display: flex; align-items: center; gap: 6px;">
<div style="width: 15px; height: 15px; background-color: #4B0082;"></div> Gain
</div>
<div style="display: flex; align-items: center; gap: 6px;">
<div style="width: 15px; height: 15px; background-color: #B22222;"></div> Loss
</div>
<div style="display: flex; align-items: center; gap: 6px;">
<div style="width: 10px; height: 10px; border-radius: 50%; background-color: black;"></div> Net change
</div>
</div>
`;
// Combine chart and legend
const container = html`<div style="text-align: center;"></div>`;
container.append(p, xlabel, legend);
return container;
})();html`<p style="text-align:right; font-size:10px;">Source: Stats NZ using data from Bioeconomy Science Institute</p>`filename = plot_title
.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";
// Clean data (remove tooltip)
plot_data_clean = plot_data.map(({ tooltip, ...rest }) => rest);
// Download button
downloadCSV = {
const button = html`<button>Download current selection as a csv</button>`;
button.onclick = () => {
// Convert to CSV
const csv = toCSV(plot_data_clean);
// Create blob
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
// Create temporary <a> link
const a = document.createElement("a");
a.href = url;
a.download = filename; // ✅ use dynamic filename
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Cleanup
setTimeout(() => URL.revokeObjectURL(url), 10000);
};
return button;
}