Public
Edited
Jun 2, 2023
2 forks
Importers
21 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
// Rendered in the resize component above
sampleMap = draw(
{
projection: d3.geoTransverseMercator().rotate([Γ, 0, 0]),
background: getSvgPatternUrl("water"), //"hsl(217, 88%, 68%)",
// scaleBar: false,
// northingArrow: false,
extent: baliGeo,
patterns: [getSvgPattern("water")],
layers: [
{
geojson: baliGeo,
fill: "hsl(42, 46%, 93%)",
stroke: "hsl(39, 50%, 90%)"
},
{
active: activeFeatures.includes("Forest"),
geojson: baliForestGeo,
fill: "hsl(136, 40%, 65%)",
stroke: "none"
},
{
active: activeFeatures.includes("Water bodies"),
geojson: baliWaterBodiesGeo,
fill: "hsl(217, 88%, 79%)",
stroke: "none"
},
{
active: activeFeatures.includes("Rivers"),
geojson: baliRiversGeo,
stroke: "hsl(217, 88%, 68%)"
},
{
type: "graticule",
stroke: "white",
strokeOpacity: 0.5,
step: [0.5, 0.5]
},
{
active: activeFeatures.includes("Water bodies"),
type: "labels",
callout: true,
geojson: largestWaterBodiesGeo,
label: (f) => f.properties?.tags?.name,
dLat: (f, i) => ((i % 2 === 0 ? 1 : -1) * 1) / 10,
textAnchor: "start"
},
{
type: "graticule-labels",
step: [0.5, 0.5],
tickPositions,
place: graticuleTickInside ? "inside" : undefined
},
{ type: "outline" }
// {
// type: "custom",
// render: function (svg, map) {
// svg
// .append("g")
// .append("rect")
// .attr("x", map.width / 2)
// .attr("y", map.height / 2)
// .attr("height", 100)
// .attr("width", 200)
// .style("fill", "red");
// }
// }
]
},
{
header: `Water Bodies in Bali, Indonesia`,
legend: [
{ label: "Rivers", stroke: "hsl(217, 88%, 68%)" },
{ label: "Forest", fill: "hsl(136, 40%, 65%)" },
{ label: "Lakes", fill: "hsl(217, 88%, 79%)" },
{
label: "Ocean",
fill: getSvgPatternUrl("water") //"hsl(217, 88%, 68%)"
}
],
content: md`### About Bali

Bali is a province of Indonesia and the westernmost of the Lesser Sunda Islands. The province is Indonesia's main tourist destination.<br>[More on Wikipedia](https://en.wikipedia.org/wiki/Bali)`,
footer: md`**Attribution:** Data are from [www.openstreetmap.org](https://www.openstreetmap.org), made available under ODbL.`
},
{ debug }
)
Insert cell
function draw(
mapOptions,
asideOptions,

// Folio Options
{
borderWidth = 1,
border = "whitesmoke",
padding = "1rem",
fontFamily = defaultFontFamily,
debug
} = {}
) {
const mapEl = drawMap({ ...mapOptions, debug });
const asideEls = generateAsideElements({ ...asideOptions, debug }).filter(
Boolean
);
const hasSidebar = Boolean(asideEls.length);

const folioBorderWidth =
typeof borderWidth === "number" ? `${borderWidth}px` : borderWidth;

initializeStyles();

return html`<div class=${ns} style=${{
fontFamily,
display: "flex",
flexWrap: "wrap",
flexDirection: "row-reverse",
justifyContent: "start",

borderWidth: folioBorderWidth,
borderColor: border,
borderStyle: "solid"
}}>
${
hasSidebar
? html`<div
class="flow flow-space-l ${ns}__aside"
style=${{
flex: "1 1 200px",
display: "flex",
flexDirection: "column",
fontSize: ".85rem",
padding,
borderWidth: 0,
borderColor: folioBorderWidth ? border : undefined,
borderStyle: "solid",
borderLeftWidth: folioBorderWidth,
marginLeft: folioBorderWidth ? `-${folioBorderWidth}` : undefined
}}>
${asideEls}
</div>`
: undefined
}
<div style=${{
flex: "4 1 600px",
padding,
borderWidth: 0,
borderColor: folioBorderWidth ? border : undefined,
borderStyle: "solid",
borderTopWidth: folioBorderWidth,
marginTop: folioBorderWidth ? `-${folioBorderWidth}` : undefined
}}>
${mapEl}
</div>
</div>`;
}
Insert cell
function generateAsideElements({
header,
footer,
legendTitle,
legend,
content,
headerTag = "h2",
footerColor = "#595959"
} = {}) {
const headerEl = header
? html`<header style=${{ fontSize: "1em" }}>${html({
raw: [`<${headerTag}>${header}`]
})}`
: undefined;
const footerEl = footer
? html`<div style=${{
fontSize: ".85em",
color: footerColor,
paddingTop: "1rem",
marginTop: "auto"
}}>${footer}</div>`
: undefined;
const legendEl = legend
? generateLegendElements(legend, legendTitle)
: undefined;

const contentEl = content
? html`<div>
<div class="flow-child flow-space-s" style=${{
maxWidth: "640px"
}}>${content}</div>
</div>`
: undefined;

return [headerEl, contentEl, legendEl, footerEl];
}
Insert cell
function generateLegendElements(legend, legendTitle = "Legend") {
legend = legend.filter(Boolean).filter((d) => d.active !== false);
const items = legend.map(
(l) =>
html`<li style=${{
display: "flex",
alignItems: "center",
gap: ".5rem",
flex: "1 1 200px"
}}><div style=${{
flex: 0,
position: "relative",
top: "-0.08141665em" // 1px / 12px
}}>${generateSwatch(l)}</div> ${l.label}`
);

return html`<div>
<div class="flow flow-space-s">
<h3>${legendTitle}</h3>
<ul style=${{
display: "flex",
flexWrap: "wrap",
gap: ".5rem",
listStyle: "none",
paddingLeft: 0,
maxWidth: "100%",
fontSize: ".85em"
}}>
${items}
</ul>
</div>
</div>`;
}
Insert cell
function generateSwatch({
symbol,
fill,
fillOpacity = 1,
stroke,
strokeWidth,
strokeDasharray = "none",
strokeOpacity = 1,
strokeLinecap = "round",
strokeLinejoin = "round",
swatchWidth = 32,
swatchHeight = 16
} = {}) {
const node = DOM.svg(swatchWidth, swatchHeight);
const svg = d3.select(node).attr("style", "display: block");

strokeWidth = strokeWidth ?? (stroke != null ? 1 : 0);
if (symbol) {
const symbolSize = 16;
const maxSymbolSize = swatchHeight - 2;
const symbolObj = symbols[symbol] ?? symbols["circle"];
const mark = d3.symbol(symbolObj).size(symbolSize);
const path = svg
.append("path")
.attr("d", mark())
.attr("fill", fill)
.attr("transform", `translate(${[swatchWidth / 2, swatchHeight / 2]})`)
.attr("fill-opacity", fillOpacity)
.attr("stroke", stroke)
.attr("stroke-width", strokeWidth)
.attr("stroke-opacity", strokeOpacity)
.attr("stroke-linecap", strokeLinecap)
.attr("stroke-linejoin", strokeLinejoin)
.attr("stroke-dasharray", strokeDasharray);

setTimeout(() => {
// See https://observablehq.com/@d3/fitted-symbols
const bbox = path.node().getBBox();
const error = Math.min(
maxSymbolSize / bbox.width,
maxSymbolSize / bbox.height
);
mark.size(error * error * symbolSize);
path.attr("d", mark());
});
} else if (fill) {
svg
.append("rect")
.attr("x", strokeWidth / 2)
.attr("y", strokeWidth / 2)
.attr("width", swatchWidth - strokeWidth)
.attr("height", swatchHeight - strokeWidth)
.attr("fill", fill)
.attr("fill-opacity", fillOpacity)
.attr("stroke", stroke)
.attr("stroke-width", strokeWidth)
.attr("stroke-opacity", strokeOpacity)
.attr("stroke-linecap", strokeLinecap)
.attr("stroke-linejoin", strokeLinejoin)
.attr("stroke-dasharray", strokeDasharray);
} else {
svg
.append("line")
.attr("x1", strokeWidth / 2)
.attr("y1", swatchHeight / 2)
.attr("x2", swatchWidth - strokeWidth / 2)
.attr("y2", swatchHeight / 2)
.attr("fill", "none")
.attr("stroke", stroke)
.attr("stroke-width", strokeWidth)
.attr("stroke-opacity", strokeOpacity)
.attr("stroke-linecap", strokeLinecap)
.attr("stroke-linejoin", strokeLinejoin)
.attr("stroke-dasharray", strokeDasharray);
}
return node;
}
Insert cell
function drawMap({
// Map
projection = d3.geoEquirectangular(),
extent,
width = 800,
layers = [],
padding = 10,
marginLeft = 1,
marginRight = 1,
marginTop = 1,
marginBottom = 1,
height,
background = "none",
scaleBar = true,
units = "km", // Supporst "km" | "miles"
northingArrow = true,
patterns,
debug
} = {}) {
// Change to `max-width` if the map need not be expanded more than the width
const svgBaseStyles =
"display: block; width: 100%; height: auto; height: intrinsic;";

layers = layers.filter(Boolean).filter((d) => d.active !== false);

// Adjust margins based graticule-labels layer positions
const graticuleTicksLayer = layers.find(
(l) => l.type === "graticule-labels" && l.place != "inside"
);
if (graticuleTicksLayer) {
const graticulePositions =
graticuleTicksLayer.tickPositions || drawGraticuleLabels.positions;

if (graticulePositions.includes("top")) {
marginTop = Math.max(marginTop, drawGraticuleLabels.margins.y);
}
if (graticulePositions.includes("bottom")) {
marginBottom = Math.max(marginBottom, drawGraticuleLabels.margins.y);
}
if (graticulePositions.includes("left")) {
marginLeft = Math.max(marginLeft, drawGraticuleLabels.margins.x);
}
if (graticulePositions.includes("right")) {
marginRight = Math.max(marginRight, drawGraticuleLabels.margins.x);
}
}

const extentGeo =
extent != null
? getExtentAsFeature(extent)
: featureCollectionFromLayers(layers);

height =
height ??
getHeight({
projection,
padding,
marginTop,
marginRight,
marginBottom,
marginLeft,
width,
extentGeo
});

projection.fitExtent(
[
[padding + marginLeft, padding + marginTop],
[
width - 2 * padding - marginLeft - marginRight,
height - 2 * padding - marginTop - marginBottom
]
],
extentGeo
);
projection.clipExtent([
[marginLeft, marginTop],
[width - marginRight, height - marginBottom]
]);

const hasMarkings = scaleBar || northingArrow;
const northArrowEl = northingArrow
? drawNorthArrow({ projection, width, height })
: undefined;
const scaleBarEl = scaleBar
? drawScaleBar({
projection,
width,
height,
padding,
marginTop,
marginRight,
marginBottom,
marginLeft,
units,
svgBaseStyles,
debug
})
: undefined;

const node = DOM.svg(width, height);
const svg = d3.select(node);
svg.attr("style", svgBaseStyles);

// Add patterns
if (patterns?.length) {
const defs = svg.select("defs").node()
? svg.select("defs")
: svg.insert("defs", ":first-child");
patterns.forEach((p) => defs.node().appendChild(p));
}

const canvas = svg.append("g");
//.attr("transform", `translate(${marginLeft},${marginTop})`);

if (background != null || background !== "none") {
canvas
.append("rect")
.attr("x", marginLeft)
.attr("y", marginTop)
.attr("width", width - marginRight - marginLeft)
.attr("height", height - marginTop - marginBottom)
.attr("fill", background);
}

layers.forEach((l) => {
const selection = canvas.append("g");

switch (l.type) {
case "custom":
if (typeof l.render === "function") {
const { type, render, ...restOfLayer } = l;
l.render(
selection,
{
projection,
width,
height,
svg
},
restOfLayer
);
}
break;
case "symbols":
drawSymbols({
selection,
projection,
width,
height,
layer: l,
debug
});
break;
case "labels":
drawLabels({
selection,
projection,
width,
height,
layer: l,
debug
});
break;
case "graticule":
drawGraticules({
svg, // Needs add defs and cilp
selection,
projection,
width,
height,
marginTop,
marginRight,
marginBottom,
marginLeft,
layer: l,
debug
});
break;
case "graticule-labels":
drawGraticuleLabels({
selection: svg, // Don't need translated <g>
projection,
width,
height,
marginTop,
marginRight,
marginBottom,
marginLeft,
layer: l,
debug
});
break;
case "outline":
drawOutline({
selection,
projection,
width,
height,
marginTop,
marginRight,
marginBottom,
marginLeft,
layer: l,
debug
});
break;
default:
if (l.geojson) {
drawSimpleLayer({
selection,
projection,
width,
height,
layer: l,
debug
});
}
break;
}
});

if (debug) {
canvas
.append("rect")
.attr("stroke", "#f0f")
.attr("fill", "none")
.attr("x", padding + marginLeft)
.attr("y", padding + marginTop)
.attr("width", width - 2 * padding - marginLeft - marginRight)
.attr("height", height - 2 * padding - marginTop - marginBottom);
}

return hasMarkings
? html`<div>
<div>${node}</div>
<div style=${{
display: "grid",
gridTemplateColumns: "1fr 3rem",
alignItems: "center",
paddingTop: "0.5rem"
}}>
<div style=${{ gridRow: 1, gridColumn: "1 / -1" }}>
${scaleBarEl}
</div>
<div style=${{
gridRow: 1,
gridColumn: 2,
justifyContent: "end"
}}>${northArrowEl}</div>
</div>
</div>`
: node;
}
Insert cell
Insert cell
function drawSimpleLayer({ selection, projection, layer }) {
const {
geojson,
fill = "none",
fillOpacity = "1",
stroke = "black",
strokeWidth = 0.75,
strokeOpacity = 1,
strokeLinecap = "round",
strokeLinejoin = "round",
strokeDasharray = "none",
style
} = layer;

if (geojson == null) return;

const features = Array.isArray(geojson.features)
? [...geojson.features]
: [geojson];

selection
.selectAll("path")
.data(features)
.join("path")
.attr("d", d3.geoPath(projection))
.attr("fill", fill)
.attr("fill-opacity", fillOpacity)
.attr("stroke", stroke)
.attr("stroke-width", strokeWidth)
.attr("stroke-opacity", strokeOpacity)
.attr("stroke-linecap", strokeLinecap)
.attr("stroke-linejoin", strokeLinejoin)
.attr("stroke-dasharray", strokeDasharray)
.attr("style", style);
}
Insert cell
function drawOutline({
selection,
projection,
width,
height,
marginTop,
marginRight,
marginBottom,
marginLeft,
layer
}) {
const {
stroke = "black",
strokeWidth = 1,
strokeOpacity = 1,
strokeLinecap = "round",
strokeLinejoin = "round",
strokeDasharray = "none",
style,
clipId
} = layer;

selection
.append("rect")
.attr("x", marginLeft)
.attr("y", marginTop)
.attr("width", width - marginLeft - marginRight)
.attr("height", height - marginTop - marginBottom)
.attr("fill", "none")
.attr("stroke", stroke)
.attr("stroke-width", strokeWidth)
.attr("stroke-opacity", strokeOpacity)
.attr("stroke-linecap", strokeLinecap)
.attr("stroke-linejoin", strokeLinejoin)
.attr("stroke-dasharray", strokeDasharray)
.attr("style", style);
}
Insert cell
function drawGraticules({
svg,
selection,
projection,
width,
height,
marginTop,
marginRight,
marginBottom,
marginLeft,
layer
}) {
const {
geojson,
stroke = "#ccc",
strokeWidth = 1,
strokeOpacity = 1,
strokeLinecap = "round",
strokeLinejoin = "round",
strokeDasharray = 2,
style,
step = [10, 10],
clipId = DOM.uid("clip"),
precision
} = layer;

const graticuleGenerator = d3.geoGraticule().step(step);
if (precision) {
graticuleGenerator.precision(precision);
}
const graticules = graticuleGenerator();
const path = d3.geoPath(projection);

const defs = svg.select("defs").node()
? svg.select("defs")
: svg.insert("defs", ":first-child");

defs
.append("clipPath")
.attr("id", clipId.id)
.append("rect")
.attr("x", marginLeft)
.attr("y", marginTop)
.attr("width", width - (marginLeft + marginRight))
.attr("height", height - (marginTop + marginBottom));

selection
.append("path")
.attr("class", "graticules")
.attr("fill", "none")
.style("stroke", stroke)
.style("stroke-width", strokeWidth)
.style("stroke-opacity", strokeOpacity)
.style("stroke-linecap", strokeLinecap)
.style("stroke-linejoin", strokeLinejoin)
.style("stroke-dasharray", strokeDasharray)
.attr("style", style)
.attr("clip-path", clipId.id)
.attr("d", path(graticules));
}
Insert cell
drawGraticuleLabels = {
const defaultPositions = ["top", "left", "bottom", "right"];

function drawGraticuleTicks({
selection,
projection,
width,
height,
marginTop,
marginRight,
marginBottom,
marginLeft,
layer,
debug
}) {
const {
tickPositions = defaultPositions,
place = "outside", // or, "inside"
fontFamily = "inherit",
fontSize = "12px",
fontWeight = "500",
fontFill = "black",
fontStroke = "white",
fontStrokeWidth = 3,
stroke = "black",
strokeWidth = 1,
strokeOpacity = 1,
strokeLinecap = "round",
strokeLinejoin = "round",
tickSize = 4,
tickPadding = 4,
step = [10, 10],
closenessPrecision = 0.001,
precision,
formatLonTick,
formatLatTick
} = layer;

const formatLatLon = d3.format(".2~f");
const formatLongitude =
typeof formatLonTick === "function"
? formatLonTick
: (x) => `${formatLatLon(Math.abs(x))}°${x < 0 ? "W" : "E"}`;
const formatLatitude =
typeof formatLatTick === "function"
? formatLatTick
: (y) => `${formatLatLon(Math.abs(y))}°${y < 0 ? "S" : "N"}`;

const boundsGeo = getBoundsAsFeature({
projection,
width,
height,
marginTop,
marginRight,
marginBottom,
marginLeft,
precision
});
const pos = graticuleLabelPositions(boundsGeo, { step });
const P = pos.map((p) => {
const [x, y] = projection(p);

p.x = x;
p.y = y;
return p;
});
const g = selection.append("g").attr("class", "graticule-labels");
let topTicks = [],
rightTicks = [],
bottomTicks = [],
leftTicks = [];

if (tickPositions.includes("top")) {
const topMost = d3.min(P, (p) => p.y);
let topTicks = pos.filter(
(p) => Math.abs(p.y - topMost) <= closenessPrecision
);
topTicks = topTicks.filter((t) => t.type === "longitude");
const topTickG = g
.append("g")
.attr("class", "bottom-graticules-ticks")
.selectAll(".tick")
.data(topTicks)
.join("g")
.attr("class", "tick")
.attr("transform", (d) => `translate(${d.x},${d.y})`);
topTickG
.append("line")
.attr("class", "tick-line")
.attr("y2", (place === "outside" ? -1 : 1) * tickSize);
topTickG
.append("text")
.attr("dy", (place === "outside" ? -1 : 1) * (tickSize + tickPadding))
.attr("text-anchor", "middle")
.attr("dominant-baseline", place === "outside" ? "auto" : "hanging");
}

if (tickPositions.includes("bottom")) {
const bottomMost = d3.max(P, (p) => p.y);
let bottomTicks = pos.filter(
(p) => Math.abs(p.y - bottomMost) <= closenessPrecision
);
bottomTicks = bottomTicks.filter((t) => t.type === "longitude");
const bottomTickG = g
.append("g")
.attr("class", "bottom-graticules-ticks")
.selectAll(".tick")
.data(bottomTicks)
.join("g")
.attr("class", "tick")
.attr("transform", (d) => `translate(${d.x},${d.y})`);
bottomTickG
.append("line")
.attr("class", "tick-line")
.attr("y2", (place === "outside" ? 1 : -1) * tickSize);
bottomTickG
.append("text")
.attr("dy", (place === "outside" ? 1 : -1) * (tickSize + tickPadding))
.attr("text-anchor", "middle")
.attr("dominant-baseline", place === "outside" ? "hanging" : "auto");
}
if (tickPositions.includes("right")) {
const rightMost = d3.max(P, (p) => p.x);
rightTicks = pos.filter(
(p) => Math.abs(p.x - rightMost) <= closenessPrecision
);
rightTicks = rightTicks.filter((t) => t.type === "latitude");

const rightTickG = g
.append("g")
.attr("class", "left-graticules-ticks")
.selectAll(".tick")
.data(rightTicks)
.join("g")
.attr("class", "tick")
.attr("transform", (d) => `translate(${d.x},${d.y})`);
rightTickG
.append("line")
.attr("class", "tick-line")
.attr("x2", (place === "outside" ? 1 : -1) * tickSize);
rightTickG
.append("text")
.attr("dy", (place === "outside" ? -1 : 1) * (tickSize + tickPadding))
.attr("dominant-baseline", place === "outside" ? "auto" : "hanging")
.attr("text-anchor", "middle")
.attr("transform", "rotate(90)");
}

if (tickPositions.includes("left")) {
const leftMost = d3.min(P, (p) => p.x);
leftTicks = pos.filter(
(p) => Math.abs(p.x - leftMost) <= closenessPrecision
);
leftTicks = leftTicks.filter((t) => t.type === "latitude");
const leftTickG = g
.append("g")
.attr("class", "left-graticules-ticks")
.selectAll(".tick")
.data(leftTicks)
.join("g")
.attr("class", "tick")
.attr("transform", (d) => `translate(${d.x},${d.y})`);
leftTickG
.append("line")
.attr("class", "tick-line")
.attr("x2", (place === "outside" ? -1 : 1) * tickSize);
leftTickG
.append("text")
.attr("dy", (place === "outside" ? -1 : 1) * (tickSize + tickPadding))
.attr("dominant-baseline", place === "outside" ? "auto" : "hanging")
.attr("text-anchor", "middle")
.attr("transform", "rotate(-90)");
}

// Add tick styles
g.selectAll(".tick-line")
.attr("stroke", stroke)
.attr("stroke-width", strokeWidth)
.attr("stroke-opacity", strokeOpacity)
.attr("stroke-linecap", strokeLinecap)
.attr("stroke-linejoin", strokeLinejoin);

// Add label text
g.selectAll("text")
.attr("font-family", fontFamily)
.attr("font-size", fontSize)
.attr("font-weight", fontWeight)
.attr("fill", fontFill)
.attr("stroke", fontStroke)
.attr("stroke-width", fontStrokeWidth)
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round")
.attr("paint-order", "stroke")
.text((d) =>
d.type === "longitude" ? formatLongitude(d[0]) : formatLatitude(d[1])
);

if (debug) {
g.append("path")
.attr("class", "debug-dgt-bounds")
.attr("d", d3.geoPath(projection)(boundsGeo))
.attr("fill", "#0ff")
.attr("fill-opacity", 0.5)
.attr("stroke", "none");

g.selectAll(".debug-dgt-corner")
.data([
[marginLeft, marginTop],
[width - marginRight, marginTop],
[width - marginRight, height - marginBottom],
[marginLeft, height - marginBottom]
])
.join("circle")
.attr("class", "debug-dgt-corner")
.attr("fill", "#0ff")
.attr("cx", (d) => d[0])
.attr("cy", (d) => d[1])
.attr("r", 5);

g.append("g")
.attr("class", "graticules-intersection-points")
.selectAll(".graticules-intersection-point")
.data(pos)
.join("circle")
.attr("class", "graticules-intersection-point")
.attr("r", 4)
.attr("fill", "none")
.attr("stroke-width", 2)
.each(function (d) {
const [cx, cy] = projection(d);
d3.select(this)
.attr("cx", cx)
.attr("cy", cy)
.attr("stroke", d.type === "longitude" ? "red" : "orange");
});
}
}

drawGraticuleTicks.margins = {
x: 20,
y: 20
};
drawGraticuleTicks.positions = defaultPositions;

return drawGraticuleTicks;
}
Insert cell
Insert cell
function drawLabels({ selection, projection, layer, debug }) {
let {
geojson,
label,
callout = false,
fontFamily = "inherit",
fontSize = 12,
fontWeight = "500",
lineHeight,
fontFill = "black",
fontStroke = "white",
fontStrokeWidth = 3,
stroke = "black",
strokeWidth = 1,
strokeOpacity = 1,
strokeLinecap = "round",
strokeLinejoin = "round",
dominantBaseline,
textAnchor,
dLat = 0,
dLon = 0,
dCLat = 0,
dCLon = 0,
calloutStroke = "black",
calloutStrokeWidth = 1.25,
callOutStrokeDasharray = "none",
callOutStrokeOpacity = 1,
callOutStrokeLinecap = "round",
callOutStrokeLinejoin = "round",
calloutPadding = 3
} = layer;
if (geojson == null) return;

const labelGenerator =
typeof label === "function" ? label : () => label || "";
const F = Array.isArray(geojson.features) ? [...geojson.features] : [geojson];

const dLatFn = typeof dLat === "function" ? dLat : () => dLat;
const dLonFn = typeof dLon === "function" ? dLon : () => dLon;

const dCLatFn = typeof dCLat === "function" ? dCLat : () => dCLat;
const dCLonFn = typeof dCLon === "function" ? dCLon : () => dCLon;

const line = d3.line();

let LC = F.map(computeLabelPole);
let CC = [...LC];
LC = LC.map(([lon, lat], i) => [
lon + dLonFn(F[i], i),
lat + dLatFn(F[i], i)
]);
CC = CC.map(([lon, lat], i) => [
lon + dCLonFn(F[i], i),
lat + dCLatFn(F[i], i)
]);

selection.attr("class", "labels");

const labels = selection
.selectAll(".label-group")
.data(F)
.join("g")
.join("text")
.attr("class", "label-group");

labels
.append("text")
.attr("font-family", fontFamily)
.attr("font-size", fontSize)
.attr("font-weight", fontWeight)
.attr("fill", fontFill)
.attr("stroke", fontStroke)
.attr("stroke-width", fontStrokeWidth)
.attr("stroke-linejoin", strokeLinejoin)
.attr("stroke-linecap", strokeLinecap)
.attr("dominant-baseline", dominantBaseline)
.attr("text-anchor", textAnchor)
.attr("paint-order", "stroke");

// Insert label text and break in lines
labels.each(function (d, i) {
const s = d3.select(this).select("text");
const [x, y] = projection(LC[i]);

s.attr("x", x)
.attr("y", y)
.call(multilineText, {
fontSize,
lineHeight,
textAnchor,
dominantBaseline,
textFn: (d, i) => labelGenerator(d, i)
});

if (debug) {
selection
.append("circle")
.attr("cx", x)
.attr("cy", y)
.attr("r", 2)
.attr("fill", "#f0f");
}
});

// Add arrows
if (callout) {
labels.each(function (d, i) {
const label = d3.select(this);
const labelText = label.select("text");
const [x, y] = projection(LC[i]);
const [cx, cy] = projection(CC[i]);

// Draw the arrows on next cycle to get bounding box of the label
setTimeout(() => {
const padding = calloutPadding + calloutStrokeWidth / 2;
const rect = labelText.node().getBBox();
let x1 = cx;
let y1 = cy;
let x2 = rect.x;
let y2 = rect.y + rect.height + padding;
let x3 = rect.x + rect.width;
let y3 = y2;

// Connect on right edge
if (rect.x + rect.width / 2 < cx) {
x3 = rect.x;
x2 = rect.x + rect.width;
}

if (rect.y > cy) {
y2 = rect.y - padding;
y3 = y2;
}

label
.append("path")
.attr(
"d",
line([
[x1, y1],
[x2, y2],
[x3, y3]
])
)
.attr("fill", "none")
.attr("stroke", calloutStroke)
.attr("stroke-width", calloutStrokeWidth)
.attr("stroke-dasharray", callOutStrokeDasharray)
.attr("stroke-opacity", callOutStrokeOpacity)
.attr("stroke-linecap", callOutStrokeLinecap)
.attr("stroke-join", callOutStrokeLinejoin);

if (debug) {
label
.append("rect")
.attr("x", rect.x)
.attr("y", rect.y)
.attr("width", rect.width)
.attr("height", rect.height)
.attr("fill", "none")
.attr("stroke", "#f0f");
}
});

if (debug) {
label
.append("circle")
.attr("cx", cx)
.attr("cy", cy)
.attr("r", 2)
.attr("fill", "none")
.attr("stroke", "#f0f");
}
});
}
}
Insert cell
Insert cell
symbols = "circle cross diamond square star triangle wye"
.split(/ /)
.reduce((obj, n, i) => ({ [n]: d3.symbolsFill[i], ...obj }), {})

Insert cell
function drawSymbols({ selection, projection, layer }) {
const {
geojson,
size = 64,
symbol = "circle",
fill = "black",
fillOpacity = "1",
stroke = "none",
strokeWidth = 0.75,
strokeOpacity = 1,
strokeLinecap = "round",
strokeLinejoin = "round",
strokeDasharray = "none",
style
} = layer;

if (geojson == null) return;

let features = Array.isArray(geojson.features)
? [...geojson.features]
: [geojson];

const L = features.map((f) => {
if (f?.geometry?.type === "Point") return f.geometry.coordinates;
return d3.geoCentroid(f);
});

const symbolObj = symbols[symbol] ?? symbols["cirlce"];
const mark = d3.symbol(symbolObj).size(size);

selection
.append("g")
.selectAll("path")
.data(L)
.join("path")
.attr("d", mark())
.attr("transform", (pos) => `translate(${projection(pos)})`)
.attr("fill", fill)
.attr("fill-opacity", fillOpacity)
.attr("stroke", stroke)
.attr("stroke-width", strokeWidth)
.attr("stroke-opacity", strokeOpacity)
.attr("stroke-linecap", strokeLinecap)
.attr("stroke-linejoin", strokeLinejoin)
.attr("stroke-dasharray", strokeDasharray)
.attr("style", style);
}
Insert cell
Insert cell
function drawScaleBar({
projection,
width,
height,
padding,
marginTop,
marginRight,
marginBottom,
marginLeft,
units,
svgBaseStyles,
scaleHeight = 48,
debug
}) {
const node = DOM.svg(width, scaleHeight);
const svg = d3.select(node).attr("style", svgBaseStyles);

const x = 3;
const y = 24;

if (debug) {
svg
.append("rect")
.attr("width", width)
.attr("height", scaleHeight)
.attr("fill", "#ff0");
}

const scaleBar = d3GeoScaleBar
.geoScaleBar()
.projection(projection)
.size([width, height])
.label(units == "miles" ? "Miles" : "Kilometres")
.orient(d3GeoScaleBar.geoScaleTop)
.tickSize(6)
.left(0)
.top(0)
.units(
units === "miles"
? d3GeoScaleBar.geoScaleMiles
: d3GeoScaleBar.geoScaleKilometers
);
const bar = svg
.append("g")
.attr("transform", `translate(${x},${y})`)
.append("g");

bar.call(scaleBar);
bar.attr("font-family", "inherit");

return node;
}
Insert cell
function drawNorthArrow({ projection, width, height } = {}) {
const angle = computeAngleToNorthAtCentroid(projection, width, height);
return svg`<svg
viewBox=${[-31, -31, 62, 62]} width="62" height="62" style=${{
display: "block",
maxWidth: "100%",
height: "auto"
}}>
<g
transform="rotate(${angle} 0 0)
translate(-19 -31)
">${northArrow()}</g><svg>`;
}
Insert cell
Insert cell
function computeLabelPole(feat, precision) {
if (feat.geometry.type === "Polygon") {
return polylabel(feat.geometry.coordinates, precision);
}

return d3.geoCentroid(feat);
}
Insert cell
function multilineText(
el,
{
fontSize = 10,
lineHeight = 1.05,
textAnchor = "middle",
dominantBaseline = "middle",
textFn
} = {}
) {
el.each(function (d) {
const text = textFn(d);
if (text == null) return;

const lines = text.split("\n");
const textContentHeight = (lines.length - 1) * lineHeight * fontSize;

const el = d3.select(this);

const anchor = {
x: +el.attr("x"),
y: +el.attr("y")
};

const dy =
dominantBaseline === "middle"
? -textContentHeight / 2
: dominantBaseline === "hanging"
? -textContentHeight
: 0;

el
// .attr("font-family", fontFamily)
.attr("font-size", fontSize)
.attr("dominant-baseline", dominantBaseline)
.attr("text-anchor", textAnchor)
.selectAll("tspan")
.data(lines)
.join("tspan")
.text((d) => d)
.attr("x", anchor.x)
.attr("y", (d, i) => anchor.y + i * lineHeight * fontSize + dy);
});
}
Insert cell
function computeAngleToNorthAtCentroid(projection, width, height) {
const [lon, lat] = projection.invert([width / 2, height / 2]);

const c = projection([lon, lat]);
const cNext = projection([lon + 1, lat]);

const rad = Math.atan2(cNext[0] - c[0], cNext[1] - c[1]);

return radiansToDegrees(Math.PI / 2 - rad);
}
Insert cell
function radiansToDegrees(radians) {
const degrees = radians % (2 * Math.PI);
return (degrees * 180) / Math.PI;
}
Insert cell
getHeight({
projection: d3.geoMercator(),
extentGeo: featureCollectionFromLayers([
{ geojson: baliForestGeo },
{ geojson: baliRiversGeo }
]),
width: 1000,
padding: 1
})
Insert cell
function getHeight({
projection,
extentGeo,
width,
padding = 0,
marginTop = 0,
marginRight = 0,
marginLeft = 0,
marginBottom = 0
}) {
const [[x0, y0], [x1, y1]] = d3
.geoPath(
projection.fitWidth(
width - padding * 2 - marginLeft - marginRight,
extentGeo
)
)
.bounds(extentGeo);

let trans = projection.translate();
projection.translate([
trans[0] + padding + marginLeft,
trans[1] + padding + marginTop
]);

return Math.ceil(y1 - y0) + padding * 2 + marginTop + marginBottom;
}
Insert cell
featureCollectionFromLayers([
{ geojson: baliForestGeo },
{ geojson: baliRiversGeo }
])
Insert cell
// Based on https://github.com/neocarto/bertin/blob/main/src/helpers/height.js
function featureCollectionFromLayers(layers) {
let L = layers
.map((l) => l.geojson)
.filter(Boolean)
.map((f) =>
Array.isArray(f.features) ? f.features : [f] // Assume it is a single feature
);

return {
type: "FeatureCollection",
features: L.flat()
};
}
Insert cell
getExtentAsFeature(baliForestGeo)
Insert cell
getExtentAsFeature([
[114.4409786, -8.7116904],
[115.672946, -8.0982983]
])
Insert cell
function getExtentAsFeature(extent) {
let feat;
if (
Array.isArray(extent) &&
Array.isArray(extent[0]) &&
Array.isArray(extent[1])
) {
feat = bboxPolygon(extent.flat());
} else {
feat = bboxPolygon(bbox(extent));
}

return rewind(feat, true);
}
Insert cell
function getBoundsAsFeature({
projection,
width,
height,
marginTop = 0,
marginRight = 0,
marginLeft = 0,
marginBottom = 0,
precision = 2.5
} = {}) {
const p1 = [marginLeft, marginTop];
const p2 = [width - marginRight, marginTop];
const p3 = [width - marginRight, height - marginBottom];
const p4 = [marginLeft, height - marginBottom];

const vertices = [p1, p2, p3, p4, p1];

const viewportSize = Math.min(
width - marginRight - marginLeft,
height - marginTop - marginTop
);
const parts = Math.floor(viewportSize / precision);

let lines = pairUp(vertices);
lines = lines
.flatMap(([p1, p2]) => partitionLine(...p1, ...p2, parts))
.map((l) => l[0]);
lines = [...lines, p1];

return {
type: "Polygon",
coordinates: [lines.map((p) => projection.invert(p))]
};
}
Insert cell
function pairUp(arr) {
return arr.reduce((res, cur, i, arr) => {
if (i === 0) return res;
return [...res, [arr[i - 1], cur]];
}, []);
}
Insert cell
function graticuleLabelPositions(boundsGeo, { step = [1, 1] }) {
let i = [];
let poly = boundsGeo.coordinates[0].slice();
let cur = poly[0];
// poly.push(cur);
// let p0 = cur;
poly.forEach((p) => {
// Above 80° longitude, we can reduce the lines
// I think, that's default on d3.graticules
let latStep = Math.abs(p[1]) > 80 ? 90 : step[1];
let lonStep = step[0];
if (Math.floor(p[1] / latStep) != Math.floor(cur[1] / latStep)) {
p.type = "latitude";
i.push(p);
} else if (Math.floor(p[0] / lonStep) != Math.floor(cur[0] / lonStep)) {
p.type = "longitude";
i.push(p);
}
cur = p;
});

return i;
}
Insert cell
function isNumber(value) {
return typeof value === "number" && isFinite(value);
}
Insert cell
Insert cell
Insert cell
defaultFontFamily = `-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"`
Insert cell
Insert cell
initializeStyles = {
let initialized;
const inputsNs = Inputs.text().classList[0];

return () => {
if (initialized) return;

initialized = true;

const styles = html`<style>
.${ns} .flow > * + * {
margin-top: var(--flow-space, 1rem);
}

.${ns} .flow-child > * > * + * {
margin-top: var(--flow-space, 1rem);
}

.${ns} .flow-space-xs {
--flow-space: .25rem;
}

.${ns} .flow-space-s {
--flow-space: .5rem;
}

.${ns} .flow-space-m {
--flow-space: 1rem;
}

.${ns} .flow-space-l {
--flow-space: 1.5rem;
}

.${ns} .flow-space-xl {
--flow-space: 1.5rem;
}


.${ns}__aside * {
margin-top: 0;
margin-bottom: 0;
}
</style>`;

document.querySelector("head").append(styles);

invalidation.then(
() => styles.parentNode && styles.parentNode.removeChild(styles)
);
};
}
Insert cell
ns = {
const base = Inputs.text().classList[0];
return base.replace("oi-", "map-folio-");
}
Insert cell
Insert cell
patterns = new Map([
[
"water",
svg`<pattern patternTransform="scale(0.5)" patternUnits="userSpaceOnUse" width="24" height="24">
<rect width="24" height="24" fill="hsl(217, 88%, 68%)" />
<path fill="none" stroke-width=".75" stroke="hsl(217, 88%, 79%)" d="M12.5 9a.5.5 0 0 0-1 0h1ZM0 20.5c3.134 0 6.25-1.303 8.582-3.376C10.915 15.05 12.5 12.172 12.5 9h-1c0 2.828-1.415 5.45-3.582 7.376C5.75 18.303 2.866 19.5 0 19.5v1ZM11.5 9c0 3.172 1.585 6.05 3.918 8.124C17.75 19.197 20.866 20.5 24 20.5v-1c-2.866 0-5.75-1.197-7.918-3.124C13.915 14.45 12.5 11.828 12.5 9h-1Z" />
</pattern>`
]
])
Insert cell
function getSvgPatternId(key, namespace = ns ?? "") {
return `${namespace}-pattern-${key}`;
}
Insert cell
getSvgPatternId('water')
Insert cell
function getSvgPatternUrl(key) {
const id = getSvgPatternId(key);
return `url(#${id})`
}
Insert cell
function getSvgPattern(key, patternsMap = patterns) {
const p = patternsMap.get(key);
if (p) {
const pClone = p.cloneNode(true);
const id = getSvgPatternId(key);
pClone.setAttribute("id", id);
return pClone;
}
}
Insert cell
Insert cell
baliForestGeo = FileAttachment("bali-forest.geo.json").json()
Insert cell
baliRiversGeo = FileAttachment("bali-rivers.geo.json").json()
Insert cell
largestWaterBodiesGeo = {
const top3WaterBodies = baliWaterBodiesGeo.features.sort(
(a, b) => d3.geoArea(b) - d3.geoArea(a)
);

return {
type: "FeatureCollection",
features: top3WaterBodies.slice(0, 3)
};
}
Insert cell
baliWaterBodiesGeo = FileAttachment("bali-water-bodies.geo.json").json()
Insert cell
baliGeo = FileAttachment("bali@1.geo.json").json()
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell

One platform to build and deploy the best data apps

Experiment and prototype by building visualizations in live JavaScript notebooks. Collaborate with your team and decide which concepts to build out.
Use Observable Framework to build data apps locally. Use data loaders to build in any language or library, including Python, SQL, and R.
Seamlessly deploy to Observable. Test before you ship, use automatic deploy-on-commit, and ensure your projects are always up-to-date.
Learn more