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 stringconst 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")
import { concat } from'@uwdata/arquero'
largestAbs = (a, b) =>Math.max(Math.abs(a),Math.abs(b))
// rounding functionfunctionroundToDecimal(number, decimals) {// Multiply the number by 10^decimalslet factor =Math.pow(10, decimals);// Round to the nearest integer and then divide by the same factorreturnMath.round(number * factor) / factor;}
functionget_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 functiongetCombinedMinMax(data1, data2, column) {// Convert to plain arrays if they are Arquero tablesconst arr1 =typeof data1.objects==="function"? data1.objects() : data1;const arr2 =typeof data2.objects==="function"? data2.objects() : data2;// Combine and filter valid numeric valuesconst 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 viridisfunctionmap(plot_data, col_var, nz_outline, options = {}) {// defaults and merge with provided optionsconst { 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 layerconst nzOutlineLayer = L.geoJSON(nz_outline, {style: {fillColor:'#E8E8E8',//'#FFFFFF', // white fill //"#1D1C1C", // Dark fill // '#E8E8E8',// light greyfillOpacity:1,color:'#E8E8E8',//"#000", // Optional dark borderweight: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_divlet 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)) returnnull;// or keep original here if you preferif (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 polygonsconst 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 renderingfillOpacity: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;// Titleconst 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 SVGconst 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);// Labelsconst 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 =newResizeObserver(() => { m.invalidateSize(); m.fitBounds(bounds);// optional if you want to maintain framing }); resizeObserver.observe(div);// Fit bounds once layout is readysetTimeout(() => {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 ---returnObject.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 → CSVfunctiontoCSV(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");}
functionjoinGeoJSON(geojson, table, key ="seqnum") {// Build a lookup map from the table for fast joinslet lookup =newMap(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) returnnull;// skip if no matchreturn {...f,properties: {...f.properties,...match } }; }).filter(f => f !==null) // remove unmatched features };}
// App specific values for dropdown menusregions = ["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 2025Urban: ["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_classdetailed_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"];