container_export.js

import { render } from "./render.js";

/**
 * @function exportSVG
 * @description The `exportSVG` function returns the svg document as a file.
 * @see {@link https://observablehq.com/@neocartocnrs/geoviz}
 *
 * @property {SVGSVGElement} svg - SVG container to display. This can be generated using the `create` function.
 * @property {object[]} [order = []] - array determining the order of layers. This option is only useful in Observable notebooks (because of its topological nature).
 * @property {string} [filename = "map.svg"] - name of the downloaded file
 * @example
 * geoviz.exportSVG(svg, {filename: "worldmap.svg"}) // where svg is the container
 * svg.exportSVG({filename: "worldmap.svg"}}) // where svg is the container
 */
export function exportSVG(svg, { order = [], filename = "map.svg" } = {}) {
  const NS = "http://www.w3.org/2000/svg";
  const XLINK = "http://www.w3.org/1999/xlink";
  const INK = "http://www.inkscape.org/namespaces/inkscape";
  const SODIPODI = "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd";
  const ADOBE = "http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/";

  const svgNode = render(svg, { order });
  const clone = svgNode.cloneNode(true);

  clone.removeAttribute("id");

  clone.setAttribute("xmlns", NS);
  clone.setAttribute("xmlns:xlink", XLINK);
  clone.setAttribute("xmlns:inkscape", INK);
  clone.setAttribute("xmlns:sodipodi", SODIPODI);
  clone.setAttribute("xmlns:layer", ADOBE);

  clone.querySelectorAll("g[data-layer]").forEach((g) => {
    const layerName = g.getAttribute("data-layer") || "layer";

    g.setAttribute("id", layerName);
    g.setAttributeNS(INK, "groupmode", "layer");
    g.setAttributeNS(INK, "label", layerName);
    g.setAttributeNS(SODIPODI, "role", "layer");
    g.setAttributeNS(ADOBE, "name", layerName);
    g.removeAttribute("data-layer");
  });

  const tooltipLayer = clone.querySelector("g#geoviztooltip");
  if (tooltipLayer) {
    tooltipLayer.remove();
  }

  const serializer = new XMLSerializer();
  const svgString = serializer.serializeToString(clone);

  const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
  const url = URL.createObjectURL(blob);

  const link = document.createElement("a");
  link.href = url;
  link.download = filename;
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
  URL.revokeObjectURL(url);
}

/**
 * @function exportPNG
 * @description The `exportPNG` function returns the map as a png file.
 * @see {@link https://observablehq.com/@neocartocnrs/geoviz}
 *
 * @property {SVGSVGElement} svg - SVG container to display. This can be generated using the `create` function.
 * @property {object[]} [order = []] - array determining the order of layers. This option is only useful in Observable notebooks (because of its topological nature).
 * @property {Number} [scale = 3] - a number to enlarge the generated map and increase its resolution
 * @property {string} [filename = "map.png"] - name of the downloaded file
 * @example
 * geoviz.exportPNG(svg, {filename: "worldmap.png"}) // where svg is the container
 * svg.exportPNG({filename: "worldmap.png"}}) // where svg is the container
 */
export async function exportPNG(svg, { filename = "map.png", scale = 3 } = {}) {
  const svgNode = render(svg);

  let width = parseFloat(svgNode.getAttribute("width"));
  let height = parseFloat(svgNode.getAttribute("height"));
  if (!width || !height) {
    const bbox = svgNode.getBBox?.() || { width: 800, height: 600 };
    width = bbox.width;
    height = bbox.height;
  }

  const clone = svgNode.cloneNode(true);
  const images = Array.from(clone.querySelectorAll("image"));

  const canvas = document.createElement("canvas");
  canvas.width = width * scale;
  canvas.height = height * scale;
  const ctx = canvas.getContext("2d");
  ctx.fillStyle = "white";
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  function loadImage(src) {
    return new Promise((resolve, reject) => {
      const img = new Image();
      img.crossOrigin = "anonymous";
      img.onload = () => {
        resolve(img);
      };
      img.onerror = (e) => {
        reject(e);
      };
      img.src = src;
    });
  }

  for (const imgElem of images) {
    const href =
      imgElem.getAttributeNS("http://www.w3.org/1999/xlink", "href") ||
      imgElem.getAttribute("href");

    if (!href) continue;

    const x = parseFloat(imgElem.getAttribute("x") || 0);
    const y = parseFloat(imgElem.getAttribute("y") || 0);
    const w = parseFloat(imgElem.getAttribute("width") || 0);
    const h = parseFloat(imgElem.getAttribute("height") || 0);

    try {
      const tileImg = await loadImage(href);
      ctx.drawImage(tileImg, x * scale, y * scale, w * scale, h * scale);
    } catch (err) {
      console.warn("Erreur chargement tuile pour export", href, err);
    }
  }

  canvas.toBlob((blob) => {
    if (!blob) return;
    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);
    URL.revokeObjectURL(url);
  }, "image/png");
}