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;
}
}