Public
Edited
Oct 3, 2023
2 forks
110 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
// as a percent. 100 = no exaggeration
verticalExaggeration = 150
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
brighten = d3.scaleSqrt()
.domain([0,1])
.range([.2, .95])
Insert cell
Insert cell
Insert cell
// domain is luminance, range is color
// a default grayscale would be d3.scaleLinear().domain([0, 1]).range(['#000', '#fff'])
luminanceColor = d3.scaleLinear()
.domain([0,.7,1])
.range(['#333', '#999', '#fff'])
.interpolate(luminanceColorInterpolation)
.clamp(true)
Insert cell
Insert cell
// d3.interpolateRgb would be a "default"
luminanceColorInterpolation = d3.interpolateHcl
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
tints = {
const valueRange = d3.extent(elevationData);
// besides the "low" and "high" colors, I want one in between, in this case 75% of the way between low and high
const middleValue = .65*(Math.max(0, valueRange[0]) + valueRange[1]);
// for no coloring, try d3.scaleLinear([0, 1]).range(['#fff', '#fff']).clamp(true);
//c7d6ba
return d3.scaleSqrt()
.domain([Math.max(0, valueRange[0]), middleValue, .9*valueRange[1]])
.range(['#a7b898','#ffeee5', '#fff'])
.clamp(true)
.interpolate(tintInterpolation);
}
Insert cell
Insert cell
tintInterpolation = d3.interpolateHclLong
Insert cell
Insert cell
Insert cell
tintBlendMode = 'multiply'
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
Insert cell
Insert cell
elevationData = {
// get all terrain tiles in map view
const terrainPromises = terrainTileObjects.map(tile => {
return d3.image(terrainTileUrl(tile.coords), {crossOrigin: "anonymous"});
});
const images = await Promise.all(terrainPromises)
// canvas on which to assemble individual terrain tile images
demContext.clearRect(0, 0, width, height);
const hillshadeBounds = finalMapCanvas.getBoundingClientRect();
// draw each tile in the right place on the big canvas
images.forEach((image, i) => {
const bounds = terrainTileObjects[i].el.getBoundingClientRect();
demContext.drawImage(image, bounds.x - hillshadeBounds.x, bounds.y - hillshadeBounds.y);
});
// now get elevation values from all pixels on the assembled canvas
const demImageData = demContext.getImageData(0,0,width,height);
const demData = demImageData.data;
const values = [];
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
// x + y*width is the position for point (x, y) in a flat array of pixel data
// multiply by 4 for imageData position as each pixel is 4 (rgba) values
values[x + y*width] = getElevation(4 * (x + y*width), demData);
}
}
return values;
}
Insert cell
Insert cell
slopeData = elevationData.map((d,i) => {
if (!d) {
return undefined;
}
const x = i % width;
const y = Math.floor(i / width);
return getSlopeAndAspect(x, y);
});
Insert cell
function getSlopeAndAspect (x, y) {
// refers to a 3x3 grid centered on the pixel in question
// each "cell" may be a single pixel or itself a grid of pixels, depending on the cellSize variable
const cells = [[], [], []];
// just ignore pixels near edges
if (x < 2 * cellSize || x > width - 2 * cellSize || y < 2 * cellSize|| y > height - 2 * cellSize) return;
// get values for each grid cell. if cells themselves are grids, cell value is an average of its pixels
// this is where the "generalization" comes into play, as it specifies the size of cells
// bigger cell size = more generalized
for (let row = -1; row <= 1; row ++) {
for (let col = -1; col <= 1; col++) {
if (row == 0 && col == 0) continue;
const cx = x + col * cellSize;
const cy = y + row * cellSize;
let avg = 0;
for (let cellX = cx - halfCell; cellX <= cx + halfCell; cellX++) {
for (let cellY = cy - halfCell; cellY <= cy +halfCell ; cellY++) {
const val = elevationData[cellX + cellY * width] * (verticalExaggeration / 100);
if (val !== undefined) avg += val;
}
}
avg = avg / squaredCell;
cells[row + 1][col + 1] = avg;
}
}
/*
A B C

D E F

G H I
*/

const a = cells[0][0],
b = cells[0][1],
c = cells[0][2],
d = cells[1][0],
e = cells[1][1],
f = cells[1][2],
g = cells[2][0],
h = cells[2][1],
i = cells[2][2];
const dzdx = ((c + 2*f + i) - (a + 2*d + g)) / 8;
const dzdy = ((g + 2*h + i) - (a + 2*b + c)) / 8;
const aspect = halfpi - Math.atan2(dzdx, -dzdy); // rotated 90 degrees for proper north up
/*
simple slope is Math.atan(Math.sqrt( dzdx ** 2 + dzdy ** 2 ))
...but that turns out way too strong on this data.
the arbitrary z-factor (.02 by default) and extra square root help
*/
const slope = Math.atan(Math.sqrt(Math.sqrt( dzdx ** 2 + dzdy ** 2 ) * z));
// the drawing function just uses dzdx and dzdy directly, but we send aspect and slope anyway
return {aspect, dzdx, dzdy, slope};
}
Insert cell
Insert cell
hillshadeImageData = {
const {a1, a2, a3} = getConstants(azimuth, elevation);
const hillshadeData = new Uint8ClampedArray(width * height * 4);
elevationData.forEach((d,i) => {
const n = i * 4;
const slopeAndAspect = slopeData[i];
// in case data is missing somehow, draw white
if (!slopeAndAspect) {
hillshadeData[n] = 255;
hillshadeData[n + 1] = 255;
hillshadeData[n + 2] = 255;
hillshadeData[n + 3] = 255;
return;
}
const {slope, aspect, dzdx, dzdy} = slopeAndAspect;
const dzdx_z = dzdx * z;
const dzdy_z = dzdy * z;
let luminance = (a1 - a2 * dzdx_z - a3 * dzdy_z) / Math.sqrt(1 + dzdx_z ** 2 + dzdy_z ** 2);

// an alternative way to do it
// const a = halfpi - slope; // just a constant to simplify luminance a little bit
//let luminance = Math.cos(aspect - sunAzimuth) * Math.cos(a) * cosSunElev + Math.sin(a) * sinSunElev;
// make it a little brigther
luminance = brighten(luminance);
// get final hillshade color
const shade = d3.color(luminanceColor(luminance));
hillshadeData[n] = shade.r;
hillshadeData[n + 1] = shade.g;
hillshadeData[n + 2] = shade.b;
hillshadeData[n + 3] = 255;
});
const hillshadeImageData = new ImageData(hillshadeData, width, height);
hillshadeContext.putImageData(hillshadeImageData, 0, 0);
return hillshadeImageData;
}
Insert cell
Insert cell
tintImageData = {
const tintData = new Uint8ClampedArray(width * height * 4);
const white = d3.color('#fff');
elevationData.forEach((d,i) => {
const n = i * 4;
// get color based on elevation
const tint = d3.color(tints(d)) || white;
tintData[n] = tint.r;
tintData[n + 1] = tint.g;
tintData[n + 2] = tint.b;
tintData[n + 3] = tint.opacity * 255;

});
const tintImageData = new ImageData(tintData, width, height);
tintCanvas.getContext('2d').putImageData(tintImageData, 0, 0);
return tintImageData;
}
Insert cell
Insert cell
Insert cell
waterImageData =
{
const ctx = waterCanvas.getContext('2d');
function projectPoint(x, y) {
var point = map.latLngToContainerPoint(new L.LatLng(y, x));
this.stream.point(point.x, point.y);
}
const transform = d3.geoTransform({point: projectPoint});
const path = d3.geoPath().projection(transform).context(ctx);
// get tiles
const vectorTilePromises = waterTileObjects.map(tile => {
return d3.json(waterTileUrl(tile.coords));
});
const vectorTiles = await Promise.all(vectorTilePromises);
// filter and flatten to only polygon water features
const waterFeatures = vectorTiles.map(d => d.water)
.map(d => d.features)
.reduce((accumulator, currentValue) => accumulator.concat(currentValue), [])
.filter(d => d.geometry.type === 'Polygon' || d.geometry.type === 'MultiPolygon');
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = waterColor;
ctx.beginPath();
waterFeatures.forEach(path);
ctx.fill();
return ctx.getImageData(0, 0, width, height);
}

Insert cell
Insert cell
shadowData = {
// I have been pleased with shadows derived from a 45º high sun, even if the hillshade uses a different sun elevation, but for more "accurate" shadows you could use angle = sunElev
const angle = Math.PI / 4;
// find minimum elevation of the map view, as a basis for peak elevations and shadow length
const minElev = Math.max(0, d3.min(elevationData));
// will be a value for each pixel, which in turn will be drawn to a canvas below
const data = [];
// very rough conversion from shadow distance (meters) to pixel distance on screen
// smaller number means fewer, longer shadows
const mPerPx = Math.pow(2, 15 - map.getZoom());
// gets the shadow (0 - 1, 1 being full darkness) for a single pixel, given some data about the elevations and shadows that precede it in the path of light
const getShadowForPixel = ({x, y, currentShadow, shadowDistance, distanceTraveled, previousElev, peak, decay}) => {
const n = y * width + x,
elev = elevationData[n];
// peak is the current high point of the path, prior to this point
if (elev >= peak && elev > previousElev) {
// in here, if this elevation is higher than the current high point, it becomes the new high point
// come up with a length for the shadow of this peak with a lil trig
const newShadowDistance = ((elev - minElev) / Math.tan(angle)) / mPerPx;
return {
// this is a new set of data upon which subsequent pixels in the path will get their shadow data
// resets any previous shadow info, as a lower peak is no longer relevant.
newPeak: elev,
newShadowDistance,
newDistanceTraveled: 0, // incremented with each pixel, mostly so we know when to end the shadow
newCurrentShadow: 1, // start with full shadow. we don't draw it for the peak but the next pixel needs this
newPreviousElev: elev, // for each pixel to know the elevation of the previous pixel
newDecay: Math.SQRT2/newShadowDistance, // shadow will fade out over its full length
shadowValue: 0, // the actual shadow value for this pixel. 0 (no shadow) for the peak
}
} else if (distanceTraveled < shadowDistance && decay <= 1) {
// in this case, we're still working through the shadow of a prior peak
// fade the shadow from its previous value a bit, and increment the distance tally
let newCurrentShadow = currentShadow - decay,
newDistanceTraveled = distanceTraveled + Math.SQRT2 // sqrt2 = distance of 1 pixel, diagonally
return {
newPeak: peak, // no change in peak
newShadowDistance: shadowDistance, // no change
newDistanceTraveled, // increased abobe
newCurrentShadow, // set above
newPreviousElev: elev,
newDecay: decay, // no change
shadowValue: Math.sqrt(1 - (elev/peak)) * newCurrentShadow,
/*
the actual shadow value (above) is further refined based on how close current elevation
is to peak elevation. the closer it is in elevation, the weaker the shadow. */
}
}
//or maybe there's just no shadow because we're far enough from a peak and haven't hit another one yet
return {
newPeak: 0,
newShadowDistance: 0,
newDistanceTraveled: 0,
newCurrentShadow: 0,
newPreviousElev: elev,
newDecay: .05,
shadowValue: 0,
}
}
// gets the shadows along diagonal lines, in the direction of light
// starts from pixel at (col, row) and then traverses a diagonal line, getting shadow data for each pixel
// keeps track of the running values like peak, currentShadow, etc. to pass to the pixel function
const getShadowsForDiagonal = (col, row) => {
let x = col;
let y = row;
let peak = 0;
let shadowDistance = 0;
let distanceTraveled = 0;
let currentShadow = 0;
let previousElev = 0;
let decay = .05;
while (x < width - 1 && y < height - 1) {
const {
newPeak,
newShadowDistance,
newDistanceTraveled,
newCurrentShadow,
newPreviousElev,
shadowValue,
newDecay,
} = getShadowForPixel({
x: x,
y: y,
currentShadow,
shadowDistance,
distanceTraveled,
previousElev,
peak,
decay,
});
currentShadow = newCurrentShadow;
shadowDistance = newShadowDistance;
distanceTraveled = newDistanceTraveled;
previousElev = newPreviousElev;
peak = newPeak;
decay = newDecay;
data[y * width + x] = shadowValue;
// this assumes a northwestern light source
x = x + 1;
y = y + 1;
}
}
// get shadows from "sun rays" starting at each pixel along the top
for (let col = 0; col < width - 1; col += 1) {
const row = 0;
getShadowsForDiagonal(col, row);
}
// and starting at each pixel down the left side
for (let row = 1; row < height - 1; row += 1) {
const col = 0;
getShadowsForDiagonal(col, row);
}
return data;
}
Insert cell
Insert cell
shadowCanvas = {
// will put shadow imageData on this canvas
const canvas = DOM.canvas(width, height);
const context = canvas.getContext('2d');
// will then draw the first canvas onto this one, with a blur
const finalCanvas = DOM.canvas(width, height);
const finalContext = finalCanvas.getContext('2d');
finalContext.filter = 'blur(5px)'; // try commenting this line to see the resultant garbage
const data = new Uint8ClampedArray(width * height * 4);
// convert the shadow data into grayscale values
shadowData.forEach((d, i) => {
data[i * 4] = 255 * (1 - d);
data[i * 4 + 1] = 255 * (1 - d);
data[i * 4 + 2] = 255 * (1 - d);
data[i * 4 + 3] = 255;
});
const imgData = new ImageData(data, width, height);
context.putImageData(imgData, 0, 0);
finalContext.drawImage(canvas,0,0)
return finalCanvas;
}
Insert cell
Insert cell
finalMap = {
// drawing the layers to temporary canvases
const hillshade = DOM.canvas(width, height);
const tint = DOM.canvas(width, height);
const water = DOM.canvas(width, height);
hillshade.getContext('2d').putImageData(hillshadeImageData, 0, 0);
tint.getContext('2d').putImageData(tintImageData, 0, 0);
water.getContext('2d').putImageData(waterImageData, 0, 0);
tint.getContext('2d').drawImage(water, 0, 0);
finalMapContext.clearRect(0, 0, width, height);
// assemble canvases into final image
finalMapContext.drawImage(hillshade, 0, 0);
finalMapContext.save();
finalMapContext.globalCompositeOperation = tintBlendMode;
finalMapContext.drawImage(tint, 0, 0);
// shadows image (explained below) goes on top
if (+shadowsOn === 1) {
// finalMapContext.globalAlpha = 1;
finalMapContext.globalCompositeOperation = 'multiply';
finalMapContext.drawImage(shadowCanvas, 0, 0);
}
finalMapContext.restore();
// returning imageData, which makes it easier to repeat the map several times in this notebook
return finalMapContext.getImageData(0, 0, width, height);
}
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
Insert cell
// decodes terrain tile RGB to elevation values
getElevation = (index, demData) => {
if (!demData) return 0;
if (index < 0 || demData[index] === undefined) return undefined;
return ((demData[index] * 256 + demData[index+1] + demData[index+2] / 256) - 32768);
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
terrainTileUrl = (coords) => `https://elevation-tiles-prod.s3.amazonaws.com/terrarium/${coords.z}/${coords.x}/${coords.y}.png`
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