mark_circle-save.js

import { scaleSqrt } from "d3-scale";
import { max, descending, ascending } from "d3-array";
import { geoPath, geoIdentity } from "d3-geo";
import { select, selectAll } from "d3-selection";
import { transition } from "d3-transition";

const d3 = Object.assign(
  {},
  {
    scaleSqrt,
    max,
    descending,
    ascending,
    geoPath,
    geoIdentity,
    select,
    selectAll,
    transition,
  }
);
import { create } from "../container/create";
import { render } from "../container/render";
import { random } from "../tool/random";
import { radius as computeradius } from "../tool/radius";
import { dodge } from "../tool/dodge";
import { centroid } from "../tool/centroid";
import { tooltip } from "../helpers/tooltip";
import {
  camelcasetodash,
  unique,
  getsize,
  check,
  implantation,
  propertiesentries,
  detectinput,
  order,
} from "../helpers/utils";
/**
 * @function circle
 * @description The `circle` function allows to add circles on a map. The function adds a layer to the SVG container and returns the layer identifier. If the container is not defined, then the layer is displayed directly.
 * @see {@link https://observablehq.com/@neocartocnrs/circle-mark}
 *
 * @property {object} data - GeoJSON FeatureCollection
 * @property {string} [id] - id of the layer
 * @property {number[]} [pos = [0,0]] - position of the circle to display a single circle
 * @property {number|string} [r = 10] - a number or the name of a property containing numerical values
 * @property {number} [k = 50] - radius of the largest circle (or corresponding to the value defined by `fixmax`)
 * @property {number} [fixmax = null] - value matching the circle with radius `k`. Setting this value is useful for making maps comparable with each other
 * @property {boolean} [dodge = false] - to avoid circle overlap
 * @property {number} [iteration = 200] - number of iteration to dodge circles
 * @property {string|function} [sort] - the field to sort circles or a sort function
 * @property {boolean} [descending] - circle sorting order
 * @property {string} [coords = "geo"] - use "svg" if the coordinates are already in the plan of the svg document
 * @property {string|function} [fill] - fill color. To create choropleth maps or typologies, use the `tool.choro` and `tool.typo` functions
 * @property {string|function} [stroke = "white"] - stroke color. To create choropleth maps or typologies, use the `tool.choro` and `tool.typo` functions
 * @property {boolean|function} [tip = false] - a function to display the tip. Use true tu display all fields
 * @property {boolean} [view] - use true and viewof in Observable for this layer to act as Input
 * @property {object} [tipstyle] - tooltip style
 * @property {boolean} [transition] - to allow transiation effects on circle updates
 * @property {number} [duration = 500] - duration of the transition in milliseconds
 * @property {*} [*] - *other SVG attributes that can be applied (strokeDasharray, strokeWidth, opacity, strokeLinecap...)*
 * @property {*} [svg_*]  - *parameters of the svg container created if the layer is not called inside a container (e.g svg_width)*
 * @example
 * // There are several ways to use this function
 * geoviz.circle(svg, { pos: [10,20], r: 15 }) // a single circle
 * geoviz.circle(svg, { data: cities, r: "population" }) // where svg is the container
 * svg.circle({ data: cities, r: "population" }) // where svg is the container
 * svg.plot({ type: "circle", data: cities, r: "population" }) // where svg is the container
 * geoviz.circle({ data: cities, r: "population" }) // no container
 * geoviz.plot({ type = "circle", data: cities, r: "population" }) // no container
 */

export function circle(arg1, arg2) {
  // Determine if creating a new container
  const newContainer = !arg2 && !arg1?._groups ? true : false;
  arg1 = newContainer && !arg1 ? {} : arg1;
  arg2 = arg2 || {};

  // Default options
  const options = {
    mark: "circle",
    id: unique(),
    data: undefined,
    r: 10,
    k: 50,
    pos: [0, 0],
    sort: undefined,
    dodge: false,
    dodgegap: 0,
    iteration: 200,
    descending: true,
    fixmax: null,
    fill: random(),
    stroke: "white",
    tip: undefined,
    tipstyle: undefined,
    before: null,
    after: null,
    transition: false,
    duration: 500,
  };
  const opts = { ...options, ...(newContainer ? arg1 : arg2) };

  // Extract svg_* options
  const svgOpts = { domain: opts.data || opts.datum };
  Object.keys(opts)
    .filter((key) => key.startsWith("svg_"))
    .forEach((key) => {
      svgOpts[key.slice(4)] = opts[key];
      delete opts[key];
    });

  // Create or reference SVG container
  const svg = newContainer ? create(svgOpts) : arg1;

  // Initialize layer
  let layer = svg.select(`#${opts.id}`);
  if (layer.empty()) {
    layer = svg.append("g").attr("id", opts.id).attr("data-layer", "circle");
  }

  // Clear previous content if no transition
  if (!opts.transition) layer.selectAll("*").remove();

  // Determine coordinate type
  opts.coords = opts.data ? opts.coords || "geo" : opts.coords || "svg";

  // Compute centroids once if needed
  if (opts.data && implantation(opts.data) === 3) {
    opts.data = centroid(opts.data, {
      latlong: svg.initproj !== "none" && opts.coords !== "svg",
    });
  }

  // Zoomable layer handling
  if (svg.zoomable && !svg.parent) {
    const existingIndex = svg.zoomablelayers.findIndex((d) => d.id === opts.id);
    if (existingIndex < 0) {
      svg.zoomablelayers.push(opts);
    } else {
      svg.zoomablelayers[existingIndex] = opts;
    }
  }

  // Determine attributes that are not specific to circle drawing
  const entries = Object.keys(opts);
  const notSpecificAttr = entries.filter(
    (key) =>
      ![
        "mark",
        "id",
        "coords",
        "data",
        "r",
        "k",
        "sort",
        "dodge",
        "dodgegap",
        "iteration",
        "descending",
        "fixmax",
        "tip",
        "tipstyle",
        "pos",
        "before",
        "after",
        "transition",
        "duration",
      ].includes(key)
  );

  // Choose projection
  let projection = opts.coords === "svg" ? d3.geoIdentity() : svg.projection;
  let path = d3.geoPath(projection);

  // --- Simple single circle ---
  if (!opts.data) {
    notSpecificAttr.forEach((key) =>
      layer.attr(camelcasetodash(key), opts[key])
    );
    const pos = path.centroid({ type: "Point", coordinates: opts.pos });
    layer
      .append("circle")
      .attr("cx", pos[0])
      .attr("cy", pos[1])
      .attr("r", opts.r)
      .attr("visibility", isNaN(pos[0]) ? "hidden" : "visible");
  } else {
    // Prepare fields
    const fields = propertiesentries(opts.data);
    const layerAttr = notSpecificAttr.filter(
      (key) => detectinput(opts[key], fields) === "value"
    );
    layerAttr.forEach((key) => layer.attr(camelcasetodash(key), opts[key]));
    const eltAttr = notSpecificAttr.filter((key) => !layerAttr.includes(key));
    eltAttr.forEach((key) => (opts[key] = check(opts[key], fields)));

    // Use svg zoom if coordinates are SVG
    projection =
      opts.coords === "svg"
        ? d3.geoIdentity().scale(svg.zoom.k).translate([svg.zoom.x, svg.zoom.y])
        : svg.projection;
    path = d3.geoPath(projection);

    // Handle dodge
    let data;
    if (opts.dodge) {
      data = JSON.parse(JSON.stringify(opts.data));
      const filtered = {
        features: data.features.filter(
          (d) =>
            !isNaN(path.centroid(d.geometry)[0]) &&
            !isNaN(path.centroid(d.geometry)[1])
        ),
      };
      data = dodge(filtered, {
        projection,
        gap: opts.dodgegap,
        r: opts.r,
        k: opts.k,
        fixmax: opts.fixmax,
        iteration: opts.iteration,
      });
      projection = d3.geoIdentity(); // After dodge, coordinates are SVG
      path = d3.geoPath(projection);
    } else {
      data = opts.data;
    }

    // Compute radius function
    const columns = propertiesentries(opts.data);
    const radius = attr2radius(opts.r, {
      columns,
      geojson: opts.data,
      fixmax: opts.fixmax,
      k: opts.k,
    });

    // Filter & sort features
    data = data.features.filter(
      (d) => d.geometry && d.geometry.coordinates !== undefined
    );
    if (detectinput(opts.r, columns) === "field") {
      data = data.filter(
        (d) =>
          d.properties?.hasOwnProperty(opts.r) &&
          d.properties[opts.r] !== undefined
      );
    }
    data = order(data, opts.sort || opts.r, {
      fields: columns,
      descending: opts.descending,
    });

    // --- Draw circles ---
    if (opts.transition) {
      // Compute positions and IDs
      const points = data
        .map((d, i) => {
          const c = path.centroid(d.geometry);
          return {
            ...d,
            d,
            cx: Number.isFinite(c[0]) ? c[0] : null,
            cy: Number.isFinite(c[1]) ? c[1] : null,
            radius: radius(d, opts.r),
            id: d.id || d.properties?.id || i,
            __order__: i, // preserve sorted order
          };
        })
        .filter((d) => d.cx !== null && d.cy !== null);

      const keyFn = (d) => d.id;

      const circles = layer.selectAll("circle").data(points, keyFn);

      // EXIT
      circles
        .exit()
        .transition()
        .duration(opts.duration)
        .attr("r", 0)
        .attr("opacity", 0)
        .remove();

      // UPDATE
      circles
        .transition()
        .duration(opts.duration)
        .attr("cx", (d) => d.cx)
        .attr("cy", (d) => d.cy)
        .attr("r", (d) => d.radius)
        .attr("opacity", 1)
        .attr("visibility", "visible")
        .style("fill", (d) =>
          typeof opts.fill === "function" ? opts.fill(d.d) : opts.fill
        )
        .style("stroke", (d) =>
          typeof opts.stroke === "function" ? opts.stroke(d.d) : opts.stroke
        );

      // ENTER
      circles
        .enter()
        .append("circle")
        .attr("cx", (d) => d.cx)
        .attr("cy", (d) => d.cy)
        .attr("r", 0)
        .attr("opacity", 0)
        .attr("visibility", "visible")
        .style("fill", (d) =>
          typeof opts.fill === "function" ? opts.fill(d.d) : opts.fill
        )
        .style("stroke", (d) =>
          typeof opts.stroke === "function" ? opts.stroke(d.d) : opts.stroke
        )
        .transition()
        .duration(opts.duration)
        .attr("r", (d) => d.radius)
        .attr("opacity", 1);

      // Ensure proper DOM order
      layer
        .selectAll("circle")
        .sort((a, b) => d3.ascending(a.__order__, b.__order__));
    } else {
      // Non-transition path
      layer
        .selectAll("circle")
        .data(data)
        .join((d) => {
          const n = d
            .append("circle")
            .attr("cx", (d) => path.centroid(d.geometry)[0])
            .attr("cy", (d) => path.centroid(d.geometry)[1])
            .attr("r", (d) => radius(d, opts.r))
            .attr("visibility", (d) =>
              isNaN(path.centroid(d.geometry)[0]) ? "hidden" : "visible"
            );

          eltAttr.forEach((e) => n.attr(camelcasetodash(e), opts[e]));
          return n;
        });
    }

    // Tooltip & view handling
    if (opts.tip || opts.tipstyle || opts.view) {
      tooltip(
        layer,
        opts.data,
        svg,
        opts.tip,
        opts.tipstyle,
        fields,
        opts.view
      );
    }
  }

  // --- Output ---
  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 `#${opts.id}`;
  }
}

// Convert r attribute to a radius function
function attr2radius(attr, { columns, geojson, fixmax, k } = {}) {
  switch (detectinput(attr, columns)) {
    case "function":
      return attr;
    case "field":
      const radius = computeradius(
        geojson.features.map((d) => d.properties?.[attr]),
        { fixmax, k }
      );
      return (d, rr) => radius.r(Math.abs(d.properties?.[rr]));
    case "value":
      return (d) => attr;
  }
}