makeCarto = (Plot) => {
Plot.carto = ({
projection,
rotate,
clipSphere,
x,
y,
reflectY,
reflectX,
...options
}) => {
const svg = d3.select(
Plot.plot({
...options,
x: { ...x, axis: null, type: "identity" },
y: { ...y, axis: null, type: "identity" }
})
);
if (typeof projection !== "function") {
svg
.select("g.carto")
.each(function ({
width,
height,
marginTop,
marginRight,
marginBottom,
marginLeft,
inset = 0,
insetTop = inset,
insetRight = inset,
insetBottom = inset,
insetLeft = inset
}) {
const g = d3.select(this).selectChildren("g");
const features = g.datum().G;
const geo = features.find((d) => d.type === "Sphere")
? { type: "Sphere" }
: {
type: "FeatureCollection",
features: features.flatMap((d) => {
switch (d.type) {
case "FeatureCollection":
return d.features;
case "Feature":
return d;
case "Geometry":
case "MultiPolygon":
case "Polygon":
case "MultiLineString":
case "LineString":
case "Point":
return { type: "Feature", geometry: d };
}
})
};
// 🌶 mercator being conformal and north-up, it works at all scales
// todo: auto-detect between identity (for alread-projected data),
// mercator, and equalEarth (when the extent is the Sphere)
if (projection === undefined) {
projection = geo.type === "Sphere" ? "equalEarth" : "mercator";
}
projection = d3[
"geo" + projection[0].toUpperCase() + projection.slice(1)
]();
if (reflectX) projection.reflectX(true);
if (reflectY) projection.reflectY(true); // useful for metric projections
projection.fitExtent(
[
[marginLeft + insetLeft, marginTop + insetTop],
[
width - marginRight - insetRight,
height - marginBottom - insetBottom
]
],
geo
);
});
}
if (rotate && projection.rotate) projection.rotate(rotate);
const path = d3.geoPath(projection);
if (clipSphere) {
svg
.selectChildren("g")
//.selectChildren("g")
.each(function (facetKey) {
const facet = d3.select(this);
const g = facet.selectAll("g.carto>g");
if (g.size()) {
const uid = DOM.uid("carto");
facet
.append("clipPath")
.attr("id", uid.id)
.datum({ type: "Sphere" })
.append("path")
.attr("d", path);
for (const m of g) {
if (m.__data__?.G?.[0]?.type !== "Sphere")
// 😱
d3.select(m).attr("clip-path", `${uid}`);
}
}
});
}
function update() {
svg.selectAll("g.carto > g").each(function ({ r, R, G }) {
d3.select(this)
.selectAll("path")
.attr("d", (i) => {
path.pointRadius(R ? R[i] : r || 0);
return path(G[i]);
});
d3.select(this)
.selectAll("text")
.attr("transform", (i) => {
const p = path.pointRadius(0)({
type: "Point",
coordinates: G[i].coordinates
});
return `translate(${
p ? p.replace(/m.*$/, "").slice(1) : [-1000, -1000]
})`;
});
});
}
update();
return Object.assign(svg.node(), { projection, update });
};
class Features extends Plot.Mark {
constructor(
data,
{
z,
r,
dx,
dy,
title,
text, // for labels
fill,
fillOpacity,
stroke,
strokeOpacity,
strokeWidth,
// TODO: add textAnchor, fontSize, etc for labels
textAnchor,
fontSize,
geometry = (d) => d,
...options
} = {}
) {
mutable debug = { stroke, strokeWidth };
const [vr, cr] = maybeNumber(r, 3);
const [vfill, cfill] = maybeColor(fill, "none");
const [vstroke, cstroke] = maybeColor(
stroke,
cfill === "none" ? "currentColor" : "none"
);
const [vstrokewidth, cstrokewidth] = maybeNumber(strokeWidth, 1);
const [vfillopacity, cfillopacity] = maybeNumber(fillOpacity, 1);
const [vstrokeopacity, cstrokeopacity] = maybeNumber(strokeOpacity, 1);
super(
data,
[
{ name: "geometry", value: geometry, optional: false },
{ name: "z", value: z, optional: true },
{ name: "r", value: vr, scale: "r", optional: true },
{ name: "title", value: title, optional: true },
{ name: "text", value: text, optional: true },
{ name: "fill", value: vfill, scale: "color", optional: true },
{ name: "stroke", value: vstroke, scale: "color", optional: true },
{
name: "strokeWidth",
value: vstrokewidth,
// scale: "strokewidth", // 🌶 channel works, but not the scale
optional: true
},
{
name: "fillOpacity",
value: vfillopacity,
// scale: "fillopacity",
optional: true
},
{
name: "strokeOpacity",
value: vstrokeopacity,
// scale: "fillopacity",
optional: true
}
],
options
);
this.r = cr;
this.dx = dx;
this.dy = dy;
this.fontSize = fontSize != null ? +fontSize : undefined;
this.textAnchor = textAnchor; // todo: maybeKeyword…
Style(this, {
fill: cfill,
fillOpacity: cfillopacity,
strokeOpacity: cstrokeopacity,
stroke: cstroke,
strokeWidth:
cstroke === "none"
? undefined
: cstrokewidth === undefined
? 1.5
: cstrokewidth,
...options
});
}
render(
I,
scales,
{
r: R,
geometry: G,
z: Z,
title: L,
text: T,
fill: F,
fillOpacity: FO,
stroke: S,
strokeOpacity: SO,
strokeWidth: SW
},
dimensions
) {
let { data, r, dx, dy, halo, fontSize, textAnchor } = this;
let index = filter(I, G);
if (R) index = index.filter((i) => positive(R[i]));
const elem = G[index[0]]?.type === "Text" ? "text" : "path";
return create("svg:g")
.datum(dimensions)
.classed("carto", true)
.call(applyIndirectStyles, this)
.attr("paint-order", elem === "text" ? "stroke" : null)
.attr(
"font-size",
elem === "text" && fontSize != null ? fontSize : null
)
.attr(
"text-anchor",
elem === "text" && textAnchor != null ? textAnchor : null
)
.call((g) =>
g
.append("g")
.attr(
"transform",
dx || dy ? `translate(${dx || 0},${dy || 0})` : null
)
.datum({ r, R, G }) // attach the feature data to the root
.selectAll()
.data(index)
.join(elem)
.text(elem === "text" && T ? (i) => T[i] : null)
.call(applyDirectStyles, this)
.attr("fill", F && ((i) => F[i]))
.attr("fill-opacity", FO && ((i) => clamp(FO[i], 0, 1)))
.attr("stroke", S && ((i) => S[i]))
.attr("stroke-opacity", SO && ((i) => clamp(SO[i], 0, 1)))
.attr("stroke-width", SW && ((i) => Math.max(0, SW[i])))
.call(title(L))
)
.node();
}
}
Plot.feature = (data, options) => new Features([data], options);
Plot.features = (data, options) => new Features(getFeatures(data), options);
Plot.points = (data, { lonLat = (d) => d, ...options }) =>
new Features((data = getFeatures(data)), {
...options,
geometry: Plot.valueof(data, lonLat).map((coordinates) => ({
type: "Point",
coordinates
}))
});
Plot.centroid = (data, { lonLat = d3.geoCentroid, ...options } = {}) =>
new Features((data = getFeatures(data)), {
...options,
geometry: Plot.valueof(data, lonLat).map((coordinates) => ({
type: "Point",
coordinates
}))
});
Plot.label = (
data,
{
lonLat = d3.geoCentroid, // TODO: centroid of largest polygon, or better yet: pole of inaccessibility
fill = "black",
halo,
stroke = halo ? "white" : undefined,
strokeWidth = halo ? 4 : undefined,
...options
} = {}
) =>
new Features((data = getFeatures(data)), {
geometry: Plot.valueof(data, lonLat).map((coordinates) => ({
type: "Text",
coordinates
})),
fill,
stroke,
strokeWidth,
...options
});
return Plot;
}