import { scaleSqrt } from "d3-scale";
import { max, descending } from "d3-array";
import { geoPath, geoIdentity } from "d3-geo";
const d3 = Object.assign(
{},
{ scaleSqrt, max, descending, geoPath, geoIdentity }
);
import { picto } from "../helpers/picto";
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 { viewof } from "../helpers/viewof";
import {
camelcasetodash,
unique,
getsize,
check,
implantation,
propertiesentries,
detectinput,
order,
} from "../helpers/utils";
/**
* @function symbol
* @description The `symbol` function allows to create a layer with symbols from a geoJSON. 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/symbols}
*
* @property {object} data - GeoJSON FeatureCollection
* @property {string} [id] - id of the layer
* @property {number[]} [pos = [0,0]] - position of the sîkes to display a single spike
* @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
* @property {string|function} [strokeWidth = 0.2] - stroke-width
* @property {string} [coords = "geo"] - use "svg" if the coordinates are already in the plan of the svg document (default: "geo")
* @property {number|string} [r = 12] - a radius to set the size one the symbol (encompassing circle)
* @property {number} [scale] -the scale parameter allows you to change the scale of the symbol.
* @property {string} [symbol = "star"] - the name of the symbol to display (use viz.tool.symbols() to get the list of available symbols). Iy you use a fied, then a different symbol is assigned to each modality.
* @property {string} [missing = "missing"] - the name of the symbol for missig values. Use `null` to remove these symbols.
* @property {string} [coords = "geo"] - use "svg" if the coordinates are already in the plan of the svg document (default: "geo")
* @property {number} [rotate = 0] - to rotate symbols
* @property {number} [skewX = 0] - skewX
* @property {number} [skewX = 0] - skewY
* @property {boolean} [background = false] - to add a circle below the symbol
* @property {*} [background_*] - *parameters of the background. You can use paramers like `background_fill`, `background_stroke`...*
* @property {string|function} [fill] - fill color. To create choropleth maps or typologies, use the `tool.choro` and `tool.typo` functions
* @property {string|function} [stroke] - 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 = false] - use true and viewof in Observable for this layer to act as Input
* @property {object} [tipstyle] - tooltip style
* @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 {*} [*] - *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.symbol(svg, { pos: [10,20], r: 15 }) // a single circle
* geoviz.symbol(svg, { data: cities, r: "type" }) // where svg is the container
* svg.symbol({ data: cities, r: "type" }) // where svg is the container
* svg.plot({ type: "symbol", data: poi, r: "type" }) // where svg is the container
* geoviz.symbol({ data: poi, r: "type" }) // no container
*/
export function symbol(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 = {
mark: "symbol",
id: unique(),
data: undefined,
r: 12,
symbol: "star",
missing: "missing",
rotate: 0,
skewX: 0,
skewY: 0,
background: false,
k: 50,
pos: [0, 0],
sort: undefined,
dodge: false,
dodgegap: 0,
iteration: 200,
descending: true,
fixmax: null,
fill: random(),
stroke: "white",
strokeWidth: 0.2,
tip: undefined,
tipstyle: undefined,
};
let opts = { ...options, ...(newcontainer ? arg1 : arg2) };
// New container
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;
// init layer
let layer = svg.selectAll(`#${opts.id}`).empty()
? svg.append("g").attr("id", opts.id)
: svg.select(`#${opts.id}`);
layer.selectAll("*").remove();
if (!opts.data) {
opts.coords = opts.coords !== undefined ? opts.coords : "svg";
}
if (opts.data) {
svg.data = true;
opts.coords = opts.coords !== undefined ? opts.coords : "geo";
opts.data =
implantation(opts.data) == 3
? centroid(opts.data, {
latlong:
svg.initproj == "none" || opts.coords == "svg" ? false : true,
})
: opts.data;
}
// zoomable layer
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;
}
}
// Specific attributes
let entries = Object.entries(opts).map((d) => d[0]);
const notspecificattr = entries.filter(
(d) =>
![
"mark",
"id",
"coords",
"data",
"r",
"k",
"sort",
"dodge",
"dodgegap",
"iteration",
"descending",
"fixmax",
"tip",
"tipstyle",
"pos",
].includes(d)
);
// Background attributes
let backgroundopts = {};
Object.keys(opts)
.filter((str) => str.slice(0, 11) == "background_" || [].includes(str))
.forEach((d) =>
Object.assign(backgroundopts, {
[d.slice(0, 11) == "background_" ? d.slice(11) : d]: opts[d],
})
);
// Symbol
const pictoname = picto.map((d) => d.name);
const symb = new Map(picto.map((d) => [d.name, d.d]));
const factor = opts.background ? 12 : 10;
// Projection
let projection = opts.coords == "svg" ? d3.geoIdentity() : svg.projection;
let path = d3.geoPath(projection);
// Simple symbol
if (!opts.data) {
let mysymbol = layer.append("g");
let pos = path.centroid({ type: "Point", coordinates: opts.pos });
if (opts.background) {
mysymbol
.append("circle")
.attr("r", opts.r)
.attr("fill", "white")
.attr("stroke", "none")
.attr("transform", `translate(${pos})`)
.attr("visibility", isNaN(pos[0]) ? "hidden" : "visible");
let m = mysymbol
.append("circle")
.attr("r", opts.r)
.attr("fill", opts.background_fill || opts.fill)
.attr("fill-opacity", opts.background_fillOpacity || 0.5)
.attr("stroke", opts.background_stroke || opts.fill)
.attr("vector-effect", "non-scaling-stroke")
.attr("transform", `translate(${pos})`)
.attr("visibility", isNaN(pos[0]) ? "hidden" : "visible");
Object.entries(backgroundopts)
.map((d) => d[0])
.forEach((d) => {
m.attr(camelcasetodash(d), backgroundopts[d]);
});
}
let n = mysymbol
.attr("stroke-width", opts.strokeWidth)
.attr("vector-effect", "non-scaling-stroke")
.append("path")
.attr("d", symb.get(opts.symbol))
.attr(
"transform",
`translate(${pos}) rotate(${opts.rotate}) scale(${
opts.scale || opts.r / factor
}) skewX(${opts.skewX}) skewY(${opts.skewY})`
)
.attr("vector-effect", "non-scaling-stroke")
.attr("visibility", isNaN(pos[0]) ? "hidden" : "visible");
notspecificattr.forEach((d) => {
n.attr(camelcasetodash(d), opts[d]);
});
} else {
// // Centroid
opts.data =
implantation(opts.data) == 3
? centroid(opts.data, {
latlong:
svg.initproj == "none" || opts.coords == "svg" ? false : true,
})
: opts.data;
// layer attributes
let fields = propertiesentries(opts.data);
const layerattr = notspecificattr.filter(
(d) => detectinput(opts[d], fields) == "value"
);
layerattr.forEach((d) => {
layer.attr(camelcasetodash(d), opts[d]);
});
// features attributes (iterate on)
const eltattr = notspecificattr.filter((d) => !layerattr.includes(d));
// eltattr.forEach((d) => {
// opts[d] = check(opts[d], fields);
// });
// Projection
let projection =
opts.coords == "svg"
? d3.geoIdentity().scale(svg.zoom.k).translate([svg.zoom.x, svg.zoom.y])
: svg.projection;
// Dodge
let data;
if (opts.dodge) {
data = JSON.parse(JSON.stringify(opts.data));
let fet = {
features: data.features
.filter((d) => !isNaN(d3.geoPath(projection).centroid(d.geometry)[0]))
.filter(
(d) => !isNaN(d3.geoPath(projection).centroid(d.geometry)[1])
),
};
data = dodge(fet, {
projection,
gap: opts.dodgegap,
r: opts.r,
k: opts.k,
fixmax: opts.fixmax,
iteration: opts.iteration,
});
projection = d3.geoIdentity();
} else {
data = opts.data;
}
// Fields
let columns = propertiesentries(opts.data);
// Radius
let radius = attr2radius(opts.r, {
columns,
geojson: opts.data,
fixmax: opts.fixmax,
k: opts.k,
});
// symbols
let fieldtosymbol = getsymb(
opts.order || opts.data.features.map((d) => d.properties[opts.symbol]),
{ symbols: opts.symbols, missing: opts.missing, picto }
);
function attr2symbol(attr, { columns, pictoname, symb } = {}) {
let detect = detectinput(attr, columns);
if (detect == "function") {
return attr;
}
if (detect == "field") {
return (d) => symb.get(fieldtosymbol(d.properties[attr]));
}
if (detect == "value" && pictoname.includes(attr)) {
return (d) => symb.get(attr);
}
if (detect == "value" && !pictoname.includes(attr)) {
return (d) => attr;
}
}
let mysymb = attr2symbol(opts.symbol, {
columns,
geojson: opts.data,
missing: opts.missing,
symbols: opts.symbols,
pictoname,
symb,
});
// Sort & filter
data = data.features
.filter((d) => d.geometry)
.filter((d) => d.geometry.coordinates != undefined);
if (detectinput(opts.r, columns) == "field") {
data = data.filter((d) => d.properties[opts.r] != undefined);
}
data = order(data, opts.sort || opts.r, {
fields: columns,
descending: opts.descending,
});
// Drawing
path = d3.geoPath(projection);
layer
.selectAll("path")
.data(data)
.join((d) => {
let mysymbol = d.append("g");
if (opts.background) {
mysymbol
.append("circle")
.attr("r", (d) => radius(d, opts.r))
.attr("fill", "white")
.attr("stroke", "none")
.attr("transform", (d) => `translate(${path.centroid(d.geometry)})`)
.attr("visibility", (d) =>
isNaN(path.centroid(d.geometry)[0]) ? "hidden" : "visible"
);
let m = mysymbol
.append("circle")
.attr("r", (d) => radius(d, opts.r))
.attr("fill", opts.background_fill || opts.fill)
.attr("fill-opacity", opts.background_fillOpacity || 0.5)
.attr("stroke", opts.background_stroke || opts.fill)
.attr("vector-effect", "non-scaling-stroke")
.attr("transform", (d) => `translate(${path.centroid(d.geometry)})`)
.attr("visibility", (d) =>
isNaN(path.centroid(d.geometry)[0]) ? "hidden" : "visible"
);
Object.entries(backgroundopts)
.map((d) => d[0])
.forEach((d) => {
m.attr(camelcasetodash(d), backgroundopts[d]);
});
}
let n = mysymbol
.append("path")
.attr("d", (d) => mysymb(d, opts.symbol))
.attr("vector-effect", "non-scaling-stroke")
.attr(
"transform",
(d) =>
`translate(${path.centroid(d.geometry)}) rotate(${
opts.rotate
}) scale(${opts.scale || radius(d, opts.r) / factor}) skewX(${
opts.skewX
}) skewY(${opts.skewY})`
)
.attr("visibility", (d) =>
isNaN(path.centroid(d.geometry)[0]) ? "hidden" : "visible"
);
eltattr.forEach((e) => {
n.attr(camelcasetodash(e), opts[e]);
});
});
// Tooltip & view
if (opts.tip) {
tooltip(
layer,
opts.data,
svg,
opts.tip,
opts.tipstyle,
fields,
opts.view
);
}
if (!opts.tip && opts.view) {
viewof(layer, svg);
}
}
// 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 attrubute to radius function
function attr2radius(attr, { columns, geojson, fixmax, k } = {}) {
switch (detectinput(attr, columns)) {
case "function":
return attr;
case "field":
let radius = computeradius(
geojson.features.map((d) => d.properties[attr]),
{
fixmax,
k,
}
);
return (d, rr) => radius.r(d.properties[rr]);
case "value":
return (d) => attr;
}
}
function getsymb(
data,
{ symbols = undefined, missing = "missing", picto } = {}
) {
let arr = data.filter((d) => d !== "" && d != null && d != undefined);
let types = Array.from(new Set(arr));
const arrsymb = symbols || picto.slice(0, types.length).map((d) => d.name);
const symbolmap = new Map(types.map((d, i) => [d, arrsymb[i]]));
return function (d) {
if (types.includes(d)) {
return symbolmap.get(d);
} else if (typeof missing === "string") {
return missing;
}
};
}