mark_contour.js

import { scaleSqrt, scaleSequential, scaleLinear } from "d3-scale";
import { max, sum, extent } from "d3-array";
import { geoPath, geoIdentity } from "d3-geo";
import { contourDensity } from "d3-contour";
import { getSequentialColors } from "dicopal";

const d3 = Object.assign(
  {},
  {
    scaleSqrt,
    scaleSequential,
    scaleLinear,
    max,
    geoPath,
    geoIdentity,
    contourDensity,
    sum,
    extent,
  },
);

import { create } from "../container/create.js";
import { shadow } from "../effect/shadow.js";
import { render } from "../container/render.js";
import { flattendots } from "../tool/flattendots.js";
import { centroid } from "../tool/centroid.js";
import { tooltip } from "../helpers/tooltip.js";
import { gradient_vertical } from "../legend/gradient-vertical.js";

import {
  camelcasetodash,
  unique,
  getsize,
  check,
  implantation,
  propertiesentries,
  detectinput,
} from "../helpers/utils.js";

/**
 * @function contour
 * @description
 * The `contour` function allows you to generate and display **isobands** (density contours) from a set of points.
 *
 * The function uses D3 to compute density contours (`d3.contourDensity`) and can color polygons either with a single color (`opts.fill`)
 * or with a sequential palette generated by `dicopal` when `opts.colors` is defined.
 *
 * A shadow effect can be applied via `opts.shadow`, and tooltips can be enabled with `opts.tip` or `opts.tipstyle`.
 * @see {@link https://observablehq.com/@neocartocnrs/contour}
 *
 * @property {object|GeoJSON[]} [data] - a GeoJSON FeatureCollection (points)
 * @property {string} [id] - ID of the layer
 * @property {string|number} [var] - name of the variable used to weight points
 * @property {number} [nb=100000] - number of sampled points for density calculation
 * @property {number} [bandwidth] - bandwidth used for density computation
 * @property {boolean} [fixbandwidth=false] - if true, scales bandwidth by zoom factor
 * @property {number} [thresholds] - number of contour levels
 * @property {number} [cellSize] - size of the grid cell for density computation
 * @property {string} [stroke="white"] - stroke color of polygons
 * @property {number} [strokeWidth=0.7] - stroke width
 * @property {boolean} [shadow=true] - add a shadow filter on polygons
 * @property {string|function} [fill=random()] - single fill color (used if `colors` is not defined)
 * @property {string} [colors] - name of a dicopal sequential palette for coloring polygons (see https://observablehq.com/@neocartocnrs/dicopal-library)
 * @property {number} [opacity] - global opacity
 * @property {number} [fillOpacity=0.6] - fill opacity for polygons
 * @property {boolean|function} [tip=false] - function to display tooltips; true displays all properties
 * @property {object} [tipstyle] - custom tooltip styles
 * @property {string} [coords="geo"] - use "svg" if the coordinates are already in the SVG plane
 * @property {boolean} [view] - for Observable notebooks: make this layer act as an input
 * @property {*} [*] - other SVG attributes that can be applied (strokeDasharray, strokeWidth, opacity, strokeLinecap, etc.)
 * @property {*} [svg_*] - parameters of the SVG container if the layer is created without an existing container (e.g., svg_width)
 *
 * @example
 * // Several ways to use this function:
 * geoviz.contour({ data: cities, colors: "Viridis"}) // no container
 * const svg = geoviz.create({ width: 800, height: 600 });
 * geoviz.contour(svg, { data: cities, fill: "orange" }) // add to existing SVG
 * svg.contour({ data: cities, var: "population", colors: "Viridis" }) // using SVG instance
 * svg.plot({ type: "contour", data: cities, colors: "Viridis" }) // alternative syntax
 */
export function contour(arg1, arg2) {
  const newcontainer =
    (arguments.length <= 1 || arguments[1] == undefined) &&
    !arguments[0]?._groups;

  arg1 = newcontainer && arg1 == undefined ? {} : arg1;
  arg2 = arg2 == undefined ? {} : arg2;

  const options = {
    mark: "contour",
    id: unique(),
    data: undefined,
    var: undefined,
    nb: 100000,
    bandwidth: undefined,
    fixbandwidth: false,
    thresholds: undefined,
    cellSize: undefined,
    shadow: false,
    fill: "none",
    stroke: "#333333",
    strokeWidth: 0.7,
  };

  const opts = { ...options, ...(newcontainer ? arg1 : arg2) };

  let svgopts = { domain: opts.data || opts.datum };

  Object.keys(opts)
    .filter((str) => str.slice(0, 4) === "svg_")
    .forEach((d) => {
      Object.assign(svgopts, { [d.slice(4)]: opts[d] });
      delete opts[d];
    });

  const svg = newcontainer ? create(svgopts) : arg1;

  const layer = svg.selectAll(`#${opts.id}`).empty()
    ? svg.append("g").attr("id", opts.id).attr("data-layer", "contour")
    : svg.select(`#${opts.id}`);

  layer.selectAll("*").remove();

  if (!opts.data) opts.coords = opts.coords ?? "svg";

  if (opts.data) {
    opts.coords = opts.coords ?? "geo";
    opts.data =
      implantation(opts.data) === 3
        ? centroid(opts.data, {
            latlong:
              svg.initproj === "none" || opts.coords === "svg" ? false : true,
          })
        : opts.data;
  }

  if (svg.zoomable && !svg.parent) {
    if (!svg.zoomablelayers.map((d) => d.id).includes(opts.id)) {
      svg.zoomablelayers.push(opts);
    } else {
      const i = svg.zoomablelayers.indexOf(
        svg.zoomablelayers.find((d) => d.id === opts.id),
      );
      svg.zoomablelayers[i] = opts;
    }
  }

  const entries = Object.entries(opts).map((d) => d[0]);
  const notspecificattr = entries.filter(
    (d) =>
      ![
        "mark",
        "id",
        "coords",
        "data",
        "bandwidth",
        "fixbandwidth",
        "thresholds",
        "cellSize",
        "tip",
        "tipstyle",
        "var",
        "nb",
        "colors",
      ].includes(d),
  );

  if (!opts.data) return;

  const projection =
    opts.coords === "svg"
      ? d3.geoIdentity().scale(svg.zoom.k).translate([svg.zoom.x, svg.zoom.y])
      : svg.projection;

  const path = d3.geoPath(d3.geoIdentity());

  opts.data =
    implantation(opts.data) === 3
      ? centroid(opts.data, {
          latlong:
            svg.initproj === "none" || opts.coords === "svg" ? false : true,
        })
      : opts.data;

  const fields = propertiesentries(opts.data);

  const layerattr = notspecificattr.filter(
    (d) => detectinput(opts[d], fields) === "value",
  );
  layerattr.forEach((d) => {
    layer.attr(camelcasetodash(d), opts[d]);
  });

  const eltattr = notspecificattr.filter((d) => !layerattr.includes(d));
  eltattr.forEach((d) => {
    opts[d] = check(opts[d], fields);
  });

  let dots = flattendots({
    data: opts.data,
    var: opts.var,
    nb: opts.nb,
    projection,
  });
  if (!dots.length) return;

  const n = dots.length;
  const bandwidth = !opts.fixbandwidth
    ? (opts.bandwidth ?? Math.min(svg.width, svg.height) / 50)
    : (opts.bandwidth ?? Math.min(svg.width, svg.height) / 50) *
      (svg.zoom.k ?? 1);

  const thresholds =
    opts.thresholds ?? Math.max(5, Math.min(12, Math.round(Math.sqrt(n) / 2)));
  const cellSize = opts.cellSize ?? Math.max(2, Math.round(bandwidth / 3));

  const contour = d3
    .contourDensity()
    .x((d) => d[0])
    .y((d) => d[1])
    .size([svg.width, svg.height])
    .bandwidth(bandwidth)
    .thresholds(thresholds)
    .cellSize(cellSize);

  const bands = {
    type: "FeatureCollection",
    features: contour(dots).map((d) => ({
      type: "Feature",
      geometry: {
        type: d.type,
        coordinates: d.coordinates,
      },
      properties: {
        value: d.value,
      },
    })),
  };

  // ---------------------------
  // Draw
  // ---------------------------

  const getcol = opts.colors
    ? getSequentialColors(opts.colors, bands.features.length)
    : undefined;

  const shadowFilter = shadow(svg, {
    dx: 2,
    dy: 2,
    stdDeviation: 1.5,
    fill: "black",
    fillOpacity: 0.4,
  });

  layer
    .selectAll("path")
    .data(bands.features)
    .join("path")
    .attr("d", path)
    //.attr("fill", opts.fill)
    .attr("fill", opts.colors ? (d, i) => getcol[i] : opts.fill)
    .attr("stroke", opts.stroke)
    .attr("filter", opts.shadow ? shadowFilter : "none")
    .each(function (d) {
      eltattr.forEach((e) => {
        this.setAttribute(camelcasetodash(e), opts[e](d));
      });
    });

  let ids = `#${opts.id}`;

  if (opts.tip || opts.tipstyle || opts.view) {
    tooltip(layer, bands, svg, opts.tip, opts.tipstyle, ["value"], opts.view);
  }

  // LEGEND

  if (opts.legend && opts.colors) {
    let legopts = {};
    Object.keys(opts)
      .filter((str) => str.slice(0, 4) == "leg_" || ["id"].includes(str))
      .forEach((d) =>
        Object.assign(legopts, {
          [d.slice(0, 4) == "leg_" ? d.slice(4) : d]: opts[d],
        }),
      );
    legopts.id = "leg_" + legopts.id;

    legopts = {
      ...legopts,
      title: opts.leg_title || opts.var,
      pos: opts.leg_pos,
      breaks: bands.features.map((d) => d.properties.value),
      colors: getcol,
    };

    console.log(legopts);

    gradient_vertical(svg, legopts);

    ids = [`#${opts.id}`, `#${legopts.id}`];
  }

  if (newcontainer) {
    const size = getsize(layer);
    svg
      .attr("width", size.width)
      .attr("height", size.height)
      .attr("viewBox", [size.x, size.y, size.width, size.height]);
    return render(svg);
  } else {
    return ids;
  }
}