viewof graph_type = Inputs.radio(["State", "Change", "State and change"], {value: "State", label: "Compare: "});
html`<div style="display:${graph_type === "State" ? "grid" : "none"}; grid-template-columns: 1fr 1fr; gap: 20px; row-gap: 5px; margin-bottom: 15px">
<div>${viewof year}</div>
<div></div>
<div>${viewof state_medium_category1}</div>
<div>${viewof state_medium_category2}</div>
</div>
<div style="display: ${graph_type === "Change" ? "grid" : "none"}; grid-template-columns: 1fr 1fr; gap: 10px; row-gap: 5px; margin-bottom: 15px">
<div>${viewof from_year}</div>
<div></div>
<div>${viewof to_year}</div>
<div></div>
<div>${viewof change_medium_category1}</div>
<div>${viewof change_medium_category2}</div>
</div>
<div style="display: ${graph_type === "State and change" ? "grid" : "none"}; grid-template-columns: 1fr 1fr; gap: 10px; row-gap: 5px; margin-bottom: 15px">
<div></div>
<div>${viewof sc_from_year}</div>
<div>${viewof sc_medium_category}</div>
<div>${viewof sc_to_year}</div>
</div>`grid_csv = FileAttachment("lcdb6_dggs_12_grid.csv").csv()
nz_outline_csv = FileAttachment("nz_outline.csv").csv()
state = FileAttachment("lcdb6_dggs_12_state.csv").csv()
change = FileAttachment("lcdb6_dggs_12_change.csv").csv()csv_to_json = async (csv_file, data_name, colname) => {
const data = await csv_file;
return {
type: "FeatureCollection",
name: data_name,
crs: {
type: "name",
properties: {
name: "urn:ogc:def:crs:OGC:1.3:CRS84"
}
},
features: data.map(row => {
// Get the geo_points string from the CSV row.
const geoPointsString = row.coordinates;
// Optional: Fix invalid JSON (e.g., single quotes instead of double)
const correctedJsonString = geoPointsString.replace(/'/g, '"');
const parsedCoordinates = JSON.parse(correctedJsonString);
// Dynamically decide if value should be number or string
const readcol = colname === "name"
? row[colname]
: Number(row[colname]);
return {
type: "Feature",
properties: {
[colname]: readcol
},
geometry: {
type: "MultiPolygon",
coordinates: parsedCoordinates
}
};
})
};
}
nz_outline = csv_to_json(nz_outline_csv, "nz_outline", "name")
grid = csv_to_json(grid_csv, "lcdb6_dggs_12_grid", "seqnum")// 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/shrubland", "Natural bare/lightly-vegetated surfaces", "Other herbaceous vegetation", "Tussock grassland", "Urban area", "Water bodies"] // removed "Not land", and "NA"viewof state_medium_category1 = Inputs.select(all_medium_class, {value: "Indigenous total", label: "Landclass 1: ", display: "none"});
viewof state_medium_category2 = Inputs.select(all_medium_class, {value: "Exotic total", label: "Landclass 2: ", style: "display:none"});
viewof year = Inputs.select(years, {value: '2023', label: "Year: ", style: "display:none"})
viewof change_medium_category1 = Inputs.select(all_medium_class, {value: "Indigenous total", label: "Landclass 1: "});
viewof change_medium_category2 = Inputs.select(all_medium_class, {value: "Exotic total", label: "Landclass 2: "});
//viewof detailed_category = Inputs.select(detailed_class[change_medium_category], {value: detailed_class[change_medium_category][0], label: "Detailed class: "})
detailed_category = "All" // alternative for when no detailed category supplied
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: "});
// for state and change
viewof sc_medium_category = Inputs.select(all_medium_class, {value: "Indigenous total", label: "Landclass: ", display: "none"});
//viewof sc_change_medium_category = Inputs.select(all_medium_class, {value: "Indigenous forest", label: "Landclass: "})
viewof sc_from_year = Inputs.select(from_years, {value: '2018', label: "From year: "});
viewof sc_to_year = Inputs.select(years.slice(years.indexOf(from_year) + 1, 6), {value: '2023', label: "To year: "});function filterState(data, medium_category, year) {
let filtered = data.filter(d =>
d.year === year && (medium_category.includes(d.landcover))).map(d => ({
landcover: d.landcover,
year: d.year,
seqnum: d.seqnum,
area_ha: d.area_ha,
percent_landcover: d.percent_area // changed label to landcover here
//percent_change_overall: d.percent_change_overall
}));
return filtered
}
function filterChange(data, change_medium_category, detailed_category, from_year, to_year) {
let filtered = data.filter(d =>
d.year_from === from_year &&
d.year_to === to_year &&
(
detailed_category === "All"
? change_medium_category.includes(d.landcover)
: detailed_category.includes(d.landcover)
)
).map(d => ({
landcover: d.landcover,
from_year: d.year_from,
to_year: d.year_to,
seqnum: d.seqnum,
change_ha: d.change_ha,
percent_change: d.percent_change,
relative_change: d.relative_change
}));
return filtered;
}state1_filtered_data = filterState(state, state_medium_category1, year)
state2_filtered_data = filterState(state, state_medium_category2, year)
plot_state1 = joinGeoJSON(grid, state1_filtered_data, "seqnum")
plot_state2 = joinGeoJSON(grid, state2_filtered_data, "seqnum")filtered_change1 = filterChange(change, change_medium_category1, detailed_category, from_year, to_year)
filtered_change2 = filterChange(change, change_medium_category2, detailed_category, from_year, to_year)
// merge with polygon grid
plot_change1 = joinGeoJSON(grid, filtered_change1, "seqnum")
plot_change2 = joinGeoJSON(grid, filtered_change2, "seqnum")sc_filtered_state = filterState(state, sc_medium_category, sc_to_year)
sc_filtered_change = filterChange(change, sc_medium_category, detailed_category, sc_from_year, sc_to_year)
// merge with polygon grid
sc_plot_state = joinGeoJSON(grid, sc_filtered_state, "seqnum")
sc_plot_change = joinGeoJSON(grid, sc_filtered_change, "seqnum"){
// Build maps locally
const localMaps = (() => {
if (graph_type === "State") {
return {
map1: map(plot_state1, "area_ha", nz_outline, {
legend_title: "Land cover (ha)",
type: "state",
mapTitle: `${state_medium_category1} land cover area, ${year}`
}),
map2: map(plot_state2, "area_ha", nz_outline, {
legend_title: "Land cover (ha)",
type: "state",
mapTitle: `${state_medium_category2} land cover area, ${year}`
})
};
}
if (graph_type === "Change") {
return {
map1: map(plot_change1, "change_ha", nz_outline, {
legend_title: "Land cover<br>change (ha)",
type: "change",
mapTitle: `Change in ${change_medium_category1.toLowerCase()} land cover area, ${from_year}\u2013${to_year}`
}),
map2: map(plot_change2, "change_ha", nz_outline, {
legend_title: "Land cover<br>change (ha)",
type: "change",
mapTitle: `Change in ${change_medium_category2.toLowerCase()} land cover area, ${from_year}\u2013${to_year}`
})
};
}
if (graph_type === "State and change") {
return {
map1: map(sc_plot_state, "area_ha", nz_outline, {
legend_title: "Land cover (ha)",
type: "state",
mapTitle: `${sc_medium_category} land cover area, ${sc_to_year}`
}),
map2: map(sc_plot_change, "change_ha", nz_outline, {
legend_title: "Land cover<br>change (ha)",
type: "change",
mapTitle: `Change in ${sc_medium_category.toLowerCase()} land cover area, ${sc_from_year}\u2011${sc_to_year}`
})
};
}
})();
// Just sync and clean up
syncMaps(localMaps.map1.value, localMaps.map2.value);
//syncTooltips(localMaps.map1.value, localMaps.map2.value);
invalidation.then(() => {
localMaps.map1.remove();
localMaps.map2.remove();
});
return html`
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 15px;">
<div>${localMaps.map1}</div>
<div>${localMaps.map2}</div>
</div>`;
}html`<p style="text-align:right; font-size:10px;">Source: Stats NZ using data from Bioeconomy Science Institute</p>`