mark_sketch.js

import { geoPath, geoNaturalEarth1, geoIdentity } from "d3-geo";
import { path } from "d3-path";
import { line, curveBasisClosed } from "d3-shape";
const d3 = Object.assign(
  {},
  { geoPath, geoIdentity, geoNaturalEarth1, path, line, curveBasisClosed },
);
import { create } from "../container/create";
import { render } from "../container/render";
import { camelcasetodash, unique } from "../helpers/utils";
import { simplify as simpl, aggregate } from "geotoolbox";

/**
 * @function sketch
 * @description The `sketch` function generates a hand-drawn (sketchy) SVG representation of GeoJSON geometries.
 * It applies SVG filters (`feTurbulence` and `feDisplacementMap`) to create a pencil-like effect.
 * 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.
 * @property {object} data - GeoJSON FeatureCollection. Use `data` or `datum` indifferently to provide the input geometry.
 * @property {string} [id] - id of the layer (auto-generated if not provided)
 * @property {string} [fill="none"] - fill color
 * @property {string} [stroke="#000"] - stroke color
 * @property {number} [strokeWidth=1] - stroke width
 * @property {number|number[]|false} [simplify] - geometry simplification (see `tool.simplify`)
 * @property {number} [baseFrequency=0.03] - base frequency of the turbulence filter (controls noise density)
 * @property {number} [feDisplacementMap=5] - displacement intensity of the sketch effect
 * @property {string} [fillStyle="dashed"] - fill style (reserved for future rough-like fills)
 * @property {number} [roughness=5] - roughness level (not fully implemented, reserved for future use)
 * @property {number} [hachureGap=3] - gap between hachure lines (reserved)
 * @property {number} [bowing=30] - line bowing effect (reserved)
 * @property {number} [fillWeight=0.12] - fill stroke weight (reserved)
 * @property {*} [*] - *other SVG attributes that can be applied (strokeDasharray, opacity, strokeLinecap, etc.)*
 * @property {*} [svg_*] - *parameters of the SVG container created if the layer is not called inside a container (e.g. svg_width, svg_height)*
 *
 * @example
 * // Basic usage
 * geoviz.sketch(svg, { data: world })
 *
 * // With styling
 * geoviz.sketch(svg, {
 *   data: world,
 *   stroke: "#333",
 *   strokeWidth: 1.5,
 *   baseFrequency: 0.02,
 *   feDisplacementMap: 8
 * })
 *
 * // Without container
 * geoviz.sketch({ data: world })
 *
 * // Using plot API
 * svg.plot({ type: "sketch", data: world })
 */

export function sketch(arg1, arg2) {
  // Test if new container
  let newcontainer =
    (arguments.length <= 1 || arguments[1] == undefined) &&
    !arguments[0]?._groups
      ? true
      : false;
  arg1 = newcontainer && arg1 == undefined ? {} : arg1;
  arg2 = arg2 == undefined ? {} : arg2;

  // Arguments
  const options = {
    simplify: undefined,
    mark: "sketch",
    id: unique(),
    fill: "none",
    stroke: "#000",
    strokeWidth: 1,
    baseFrequency: 0.03,
    feDisplacementMap: 5,
    fillStyle: "dashed",
    roughness: 5,
    hachureGap: 3,
    bowing: 30,
    fillWeight: 0.12,
  };
  let opts = { ...options, ...(newcontainer ? arg1 : arg2) };

  // New container
  //let svgopts = { projection: d3.geoNaturalEarth1() };
  let svgopts = { domain: opts.data || opts.datum };
  Object.keys(opts)
    .filter((str) => str.slice(0, 4) == "svg_")
    .forEach((d) => {
      Object.assign(svgopts, {
        [d.slice(0, 4) == "svg_" ? d.slice(4) : d]: opts[d],
      });
      delete opts[d];
    });
  let svg = newcontainer ? create(svgopts) : arg1;

  // Defs

  let defs = svg.select("#defs");
  const pencil1 = defs.append("filter").attr("id", "pencil1_" + opts.id);
  pencil1.append("feTurbulence").attr("baseFrequency", opts.baseFrequency);
  pencil1
    .append("feDisplacementMap")
    .attr("in", "SourceGraphic")
    .attr("scale", opts.feDisplacementMap);
  const pencil2 = defs.append("filter").attr("id", "pencil2_" + opts.id);
  pencil2.append("feTurbulence").attr("baseFrequency", opts.baseFrequency * 2);
  pencil2
    .append("feDisplacementMap")
    .attr("in", "SourceGraphic")
    .attr("scale", opts.feDisplacementMap + 2);

  // Projection
  let projection = svg.projection;

  // init layer
  let layer = svg.selectAll(`#${opts.id}`).empty()
    ? svg.append("g").attr("id", opts.id).attr("data-layer", "outline")
    : svg.select(`#${opts.id}`);
  layer.selectAll("*").remove();

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

  // Manage options
  let entries = Object.entries(opts).map((d) => d[0]);
  const layerattr = entries.filter((d) => !["mark", "id"].includes(d));

  // layer attributes
  layerattr.forEach((d) => {
    layer.attr(camelcasetodash(d), opts[d]);
  });

  // Sketch
  let data = opts.data || opts.datum;
  let land = aggregate(data);
  land = simpl(land, { k: opts.simplify, arcs: 500 });

  // Draw outline

  layer
    .append("path")
    .datum(land)
    .attr("filter", `url(#pencil1_${opts.id})`)
    .attr("d", geoCurvePath(d3.curveBasisClosed, projection));
  // .attr("fill", "none");

  layer
    .append("path")
    .datum(land)
    .attr("filter", `url(#pencil2_${opts.id})`)
    .attr("d", geoCurvePath(d3.curveBasisClosed, projection))
    .attr("fill", "none");

  // Output
  if (newcontainer) {
    return render(svg);
  } else {
    return `#${opts.id}`;
  }
}

// HELPERS

function curveContext(curve) {
  return {
    moveTo(x, y) {
      curve.lineStart();
      curve.point(x, y);
    },
    lineTo(x, y) {
      curve.point(x, y);
    },
    closePath() {
      curve.lineEnd();
    },
  };
}

function geoCurvePath(curve, projection, context) {
  return (object) => {
    const pathContext = context === undefined ? d3.path() : context;
    d3.geoPath(projection, curveContext(curve(pathContext)))(object);
    return context === undefined ? pathContext + "" : undefined;
  };
}