// 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>`// 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"];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 loadedviewof 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: " }
)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');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>`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;
}