Public
Edited
Jan 15, 2023
1 fork
5 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
cssGridLayout = {
const map = generateMap({
marginLeft: -1,
marginRight: -1,
marginTop: -1,
marginBottom: -1,
step: selectedGeo.step
});
const scale = generateScaleBar(map, { offsetX: 16, debug });
const northKey = drawNorthArrow(map);

return html`<div class="map-folio">
<div class="map-folio__header">
<h2>${selectedGeo.title}</h2>
</div>
<div class="map-folio__map">
${map}
</div>
<div class="map-folio__scale">${scale}</div>
<div class="map-folio__legend">
${northKey}
</div>
</div>`;
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
function drawNorthArrow(map) {
const { geo, projection } = map.props;

const angle = computeAngleToNorthAtCentroid(geo, projection);
return htl.svg`<svg
viewBox=${[-31, -31, 62, 62]} width="62" height="62">
<g
transform="rotate(${(angle)} 0 0)
translate(-19 -31)
">${northArrow()}</g><svg>`;
}
Insert cell
function computeAngleToNorthAtCentroid(geo, projection) {
const [lon, lat] = d3.geoCentroid(geo);
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 generateScaleBar(map, { offsetX = 0, debug } = {}) {
const { projection, width, height, marginLeft, marginRight, padding } =
map.props;
const scaleHeight = 40;
// Generate an SVG with same width as the plot.
// Thus, even if the image scales it will be in proportion with the map
const svg = DOM.svg(width, scaleHeight);

d3.select(svg).attr(
"style",
"max-width: 100%; height: auto; height: intrinsic;"
);
const scaleBar = d3GeoScaleBar
.geoScaleBar()
.projection(projection)
.left(
(marginLeft + offsetX) /
(width - (marginLeft + marginRight + 2 * padding))
)
.top(20 / (height - (marginLeft + marginRight + 2 * padding)))
.size([
width - (marginLeft + marginRight + 2 * padding),
height - (marginLeft + marginRight + 2 * padding)
]);

d3.select(svg).append("g").call(scaleBar);

if (debug) {
d3.select(svg)
.append("rect")
.attr("width", width)
.attr("height", scaleHeight)
.attr("stroke", "#f0f")
.attr("fill", "none");
}

return svg;
}
Insert cell
function generateMap({
marginLeft,
marginRight,
marginBottom,
marginTop,
step
} = {}) {
let p =
projection === "geoIdentity"
? d3.geoIdentity().reflectY(true)
: d3.geoTransverseMercator().rotate([Γ, Φ, Λ]);

const svg = drawMap(geo, {
debug,
projection: p,
marginLeft,
marginRight,
marginBottom,
marginTop
});
drawGraticules(svg, { debug, step });
return svg;
}
Insert cell
Insert cell
function drawMap(
geo,
{
width = 640,
height,
marginTop = 1,
marginLeft = 4,
marginBottom = 1,
marginRight = 1,
padding = 30,
projection = d3.geoIdentity().reflectY(true),

fill = "none",
stroke = "black",
strokeWidth = 0.75,
strokeLinejoin = "round",

backgroundFill = "#fff",
debug = false
} = {}
) {
// If height is not provided, compute from Geo and projection
if (height == null) {
const fauxProjection = d3.geoIdentity().reflectY(true);
const fauxPath = d3.geoPath(fauxProjection);
fauxProjection.fitWidth(
width - (marginLeft + marginRight + 2 * padding),
geo
);
height =
Math.ceil(fauxPath.bounds(geo)[1][1]) +
(marginTop + marginBottom + 2 * padding);
}

projection = projection.scale === undefined ? projection() : projection;
// https://github.com/d3/d3-geo/blob/main/README.md#projection_fitSize
projection.fitExtent(
[
[marginLeft + padding, marginTop + padding],
[width - (marginRight + padding), height - (marginBottom + padding)]
],
geo
);
projection.clipExtent([
[0, 0],
[width, height]
]);

const path = d3.geoPath(projection);

const svg = DOM.svg(width, height);

d3.select(svg)
.attr("style", "max-width: 100%; height: auto; height: intrinsic;")
.style("background", backgroundFill);

const canvas = d3.select(svg).append("g").attr("class", "features");

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

canvas
.append("path")
.datum(geo)
.attr("fill", "none")
.attr("stroke", stroke)
.attr("stroke-width", strokeWidth)
.attr("stroke-linejoin", strokeLinejoin)
.attr("d", path);

return Object.assign(svg, {
props: {
projection,
width,
height,
marginTop,
marginLeft,
marginBottom,
marginRight,
padding,
geo
}
});
}
Insert cell
function drawGraticules(
svgNode,
{
fontSize = 10,
fontFamily = defaultFontFamily,
tickFill = "black",
stroke = "#999",
strokeWidth = 0.5,
step = [1, 1],
clipId = DOM.uid("clip"),
debug = false
} = {}
) {
const {
projection,
marginTop,
marginRight,
marginLeft,
marginBottom,
height,
width,
padding
} = svgNode.props;

const canvas = d3.select(svgNode);

const graticuleGenerator = d3.geoGraticule().step(step);
const graticules = graticuleGenerator();
const path = d3.geoPath(projection);

const [longitudes, latitudes] = generateLabelLatLons({
graticuleGenerator,
...svgNode.props
});
console.log({ latitudes, longitudes });

const defs = d3.select(svgNode).select("defs").node()
? d3.select(svgNode).select("defs")
: d3.select(svgNode).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));

const g = d3
.select(svgNode)
.append("g")
.attr("class", "key-graticules")
.attr("font-family", fontFamily)
.attr("font-size", fontSize);

g.append("path")
.attr("class", "graticules")
.attr("stroke", stroke)
.attr("stroke-width", strokeWidth)
.attr("fill", "none")
.attr("clip-path", clipId)
.attr("d", path(graticules));

g.append("rect")
.attr("class", "graticule-outline")
.attr("fill", "none")
.attr("stroke", stroke)
.attr("stroke-width", strokeWidth)
.attr("x", marginLeft)
.attr("y", marginTop)
.attr("width", width - (marginLeft + marginRight))
.attr("height", height - (marginTop + marginBottom));

const ticks = g
.append("g")
.attr("class", "graticule-ticks")
.attr("clip-path", clipId)
.attr("fill", tickFill);

const [labelLon] = projection.invert([marginLeft, marginTop]);
ticks
.append("g")
.selectAll("text.lat-tick")
.data(latitudes)
.join("text")
.attr("class", "lat-tick")
.attr("x", marginLeft)
.attr("dx", fontSize / 2)
.attr("y", (d) => projection([labelLon, d])[1])
.attr("dy", fontSize * -0.5)
// .attr("text-anchor", "end")
// .attr("dominant-baseline", "middle")
.text((d) => formatLatitude(d));

const [_, labelLat] = projection.invert([marginLeft, height - marginBottom]);
ticks
.append("g")
.selectAll("text.lat-tick")
.data(longitudes)
.join("text")
.attr("class", "lat-tick")
.attr("y", height - marginBottom)
.attr("x", (d) => projection([d, labelLat])[0])
.attr("dx", fontSize / 2)
.attr("dy", fontSize * -0.5)
// .attr("text-anchor", "end")
// .attr("dominant-baseline", "middle")
.text((d) => formatLongitude(d));
}
Insert cell
function generateLabelLatLons({
graticuleGenerator,
height,
width,
marginTop,
marginRight,
marginBottom,
marginLeft,
projection
} = {}) {
const extent = {
topLeft: projection.invert([marginLeft, marginTop]),
topRight: projection.invert([width - marginRight, marginTop]),
bottomRight: projection.invert([
width - marginRight,
height - marginBottom
]),
bottomLeft: projection.invert([marginLeft, height - marginRight])
};
console.log(extent)

const [lonStep, latStep] = graticuleGenerator.step();

const latMin = roundTillDecimalsLike(extent.topLeft[1], latStep);
const latMax = roundTillDecimalsLike(extent.bottomLeft[1], latStep);
console.log({ latMin, latMax },extent.bottomLeft[1]);

const latSign = latMin < latMax ? 1 : -1;
const latitudes = d3.range(
latMin,
latMax + latStep * latSign,
latStep * latSign
);

const lonMin = roundTillDecimalsLike(extent.bottomLeft[0], lonStep);
const lonMax = roundTillDecimalsLike(extent.bottomRight[0], lonStep);
const lonSign = lonMin < lonMax ? 1 : -1;
let longitudes = d3.range(
lonMin,
lonMax + lonStep * lonSign,
lonStep * lonSign
);

return [longitudes, latitudes];
}
Insert cell
function drawGraticulesOld(
svgNode,
{
fontSize = 10,
fontFamily = defaultFontFamily,
tickFill = "black",
stroke = "#ccc",
strokeWidth = 0.5,
outlineStroke = "black",
outlineStrokeWidth = 0.75,
step = [1, 1]
} = {}
) {
const {
projection,
width,
height,
marginLeft,
marginRight,
marginTop,
marginBottom
} = svgNode.props;
const extent = {
topLeft: projection.invert([marginLeft, marginTop]),
topRight: projection.invert([width - marginRight, marginTop]),
bottomRight: projection.invert([
width - marginRight,
height - marginBottom
]),
bottomLeft: projection.invert([marginLeft, height - marginRight])
};

const graticuleGenerator = d3.geoGraticule().step(step);
graticuleGenerator.extent([
[extent.topLeft[0], extent.topLeft[1]],
[extent.bottomRight[0], extent.bottomRight[1]]
]);

const graticules = graticuleGenerator();
const path = d3.geoPath(projection);

const g = d3
.select(svgNode)
.append("g")
.attr("class", "key-graticules")
.attr("font-family", fontFamily)
.attr("font-size", fontSize);

g.append("path")
.attr("class", "graticules")
.attr("fill", "none")
.attr("stroke", stroke)
.attr("stroke-width", strokeWidth)
.attr("d", path(graticules));

g.append("path")
.attr("class", "graticule-outline")
.attr("stroke", outlineStroke)
.attr("storke-width", outlineStrokeWidth)
.attr("fill", "none")
.attr("d", path(graticuleGenerator.outline()));

const graticuleLines = graticuleGenerator.lines();
const longitudes = graticuleLines.filter(
(l) => l.coordinates[0][0] === l.coordinates[1][0]
);
const latitudes = graticuleLines.filter(
(l) => l.coordinates[0][1] === l.coordinates[1][1]
);

const ticks = g.append("g").attr("fill", tickFill);
ticks
.append("g")
.selectAll("text.lat-tick")
.data(latitudes.map((f) => f.coordinates[0]))
.join("text")
.attr("class", "lat-tick")
.attr("x", (d) => projection(d)[0])
.attr("y", (d) => projection(d)[1])
.attr("dx", -3)
.attr("text-anchor", "end")
.attr("dominant-baseline", "middle")
.text((d) => formatLatitude(d[1]));

ticks
.append("g")
.selectAll("text.lon-tick")
.data(longitudes.map((f) => f.coordinates[0]))
.join("text")
.attr("class", "lat-tick")
.attr("x", (d) => projection(d)[0])
.attr("y", (d) => projection(d)[1])
.attr("dy", fontSize + 3)
.attr("text-anchor", "middle")
.text((d) => formatLongitude(d[0]));

return svgNode;
}
Insert cell
formatLongitude = (x) => `${formatLatLon(x)}°${x < 0 ? "W" : "E"}`
Insert cell
formatLatitude = y => `${formatLatLon(y)}°${y < 0 ? "S" : "N"}`
Insert cell
formatLatLon = d3.format(".0f")
Insert cell
defaultFontFamily = `-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"`
Insert cell
loadStyles({ /* options */})
Insert cell
slope = (x1, y1, x2, y2) => (y2 - y1) / (x2 - x1)
Insert cell
function radiansToDegrees(radians) {
const degrees = radians % (2 * Math.PI);
return (degrees * 180) / Math.PI;
}
Insert cell
function roundTillDecimalsLike(num, ref) {
const decimals = countDecimals(ref);
return round(num, decimals);
}
Insert cell
function round(num, decimals = 2) {
const formatter = d3.format(`.${decimals}f`);

const sign = Math.sign(num);

const multiplier = Math.pow(10, decimals);
const rounded = Math.floor(Math.abs(num) * multiplier) / multiplier;

return Number(formatter(rounded)) * sign;
}
Insert cell
// https://stackoverflow.com/a/27082406
function countDecimals(value) {
let text = value.toString();
// verify if number 0.000005 is represented as "5e-6"
if (text.indexOf("e-") > -1) {
let [base, trail] = text.split("e-");
let deg = parseInt(trail, 10);
return deg;
}
// count decimals for number in representation like "0.123456"
if (Math.floor(value) !== value) {
return value.toString().split(".")[1].length || 0;
}
return 0;
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
data = [
["Nepal", { geo: nepalGeo }],
["India", { geo: indiaGeo, yaw: -80, step: [10, 10] }],
["Kerala", { geo: keralaGeo, yaw: -80 }],
["Colombia", { geo: colombiaGeo, yaw: 15 }]
].map(([title, r]) => [title, { ...r, title }])
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more