Skip to content

Instantly share code, notes, and snippets.

@monfera
Last active September 16, 2016 17:53
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save monfera/21be9bb116a8e5b2423b706155fdb718 to your computer and use it in GitHub Desktop.
Save monfera/21be9bb116a8e5b2423b706155fdb718 to your computer and use it in GitHub Desktop.
Generative elevation map with SVG filters
license: mit
border: yes
height: 500

Click for a new map and palette. Move the mouse sideways to change where the light source is.

[There is also a pure SVG map with elevation contours.]

This SVG filter generates elevation, flat waters and icy mesas. Also see my previous filter and twitter/@monfera for visualizations including glitchy shots from making this block. A couple of D3 color scales are added but otherwise there's no dependency, it's kept minimal.

This block has:

  • generative topographic map creation with SVG
  • use of a D3 or custom palette in an SVG filter, testing with pretty topo palettes
  • bump mapping and lighting

Sometimes we lean on D3 for things that the underlying standards already provide, or miss opportunities. I'm impressed by Nadieh Bremer's work with filters which shows how much can be done beyond the basics.

Palette copyrights in the source. Palette authors:

  1. tv-a: Jim Mossman
  2. wiki-schwarzwald-cont: W-j-s and Jide
  3. viridis, magma: Stéfan van der Walt and Nathaniel Smith, via D3 by Mike Bostock (not meant for topography)

This is what I wanted:

  • find out how a custom palette (e.g. from d3-scale or d3-scale-chromatic can be applied via SVG filters, which have no direct notion of palettes or color scales
  • explore some promising topographic and topographic-bathymetric color schemes for future D3 / WebGL tasks; I'm on the lookout for other open source topo or topo-bathy runs too
  • have a contour or shaded map backdrop for datavis or UI prototyping without the need to load an actual map
  • D3 works with Web standards like SVG, so most D3 learning is standards learning
  • SVG filters are a simple dataflow language, and as I'm working on declarative and data flow oriented WebGL visualization tools, I wanted to refresh my memory on how this graphics DAG works
  • try to make something pretty
  • relive the SVG filter creation fun I had when working on a geotemporal dashboard a few years ago

I found that:

  • this technique can be equally applied to rendering real DEMs rather than just noise
  • my D3 and other work benefits from exploring corners of SVG that are lesser used with D3 or in general
  • SVG is very powerful, but it's not quite true to its name - it is scalable spatially, but is NOT scalable temporally (slow), all signs point to browser makers not investing too much in its performance (basic rendering such as CSS/SVG transform isn't even hardare accelerated as of Aug 2016, unlike the HTML DOM)
  • custom palettes could be passed on fairly simply with SVG, a limitation being that the input domain is sliced to even intervals, I haven't found a way of passing on the percentage thresholds
  • it's easy to generate the basics such as the noise and the contours, but building the full data flow and tweaking the visuals took long hours

Built with blockbuilder.org

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SVG generative map</title>
<script src="https://d3js.org/d3.v4.min.js"></script>
</head>
<body>
<svg width=960 height=500 version="1.1" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="bump">
<!--generate noise-->
<feTurbulence id="noise" seed="603883" type="fractalNoise"
baseFrequency=".001" numOctaves="12" result="turbulence" />
<!--scale the noise and keep the alpha channel only-->
<feComponentTransfer in="turbulence" result="bumpMap">
<feFuncA type="table" tableValues="-1.9 2.3"/>
<feFuncR type="gamma" amplitude="0" />
<feFuncG type="gamma" amplitude="0" />
<feFuncB type="gamma" amplitude="0" />
</feComponentTransfer>
<!--make a bump map out of the noise
Safari chokes on multiline attrs so... split lines and use Chrome-->
<feConvolveMatrix in="bumpMap" result="fineContours" order="3"
kernelMatrix="10 10 10 10 -80 10 10 10 10" />
<!--convert the alpha channel to grayscale RGB channels for palette -->
<feColorMatrix in="bumpMap" result="grayscaleBumpMap" mode="matrix"
values="0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 1" />
<!--pick up colors from the palette and map them into the channels-->
<feComponentTransfer in="grayscaleBumpMap" result="coloredBumpMap">
<feFuncR id="geoR" type="discrete" />
<feFuncG id="geoG" type="discrete" />
<feFuncB id="geoB" type="discrete" />
</feComponentTransfer>
<!--Elevation run color alterations 2.-->
<!--desaturate the original palette a bit-->
<feColorMatrix id="saturate" type="saturate" values="0.8"
in="coloredBumpMap" result="saturationAdjusted"/>
<!--adjust the gamma for darker tones
... lighter tones would be better if there are overlays-->
<feComponentTransfer in="saturationAdjusted" result="colorAdjusted">
<feFuncR id="gammaR" type="gamma" amplitude="1" exponent="2.5"/>
<feFuncG id="gammaG" type="gamma" amplitude="1" exponent="2.5"/>
<feFuncB id="gammaB" type="gamma" amplitude="1" exponent="1.8"/>
</feComponentTransfer>
<!--join the elevation coloring, contour lines and original SVG input-->
<feBlend in="colorAdjusted" in2="fineContours" result="topoMap"/>
<feComposite in="SourceGraphic" in2="topoMap" result="blendedContents"/>
<!--make light-->
<feSpecularLighting in="bumpMap" lighting-color="#fff" surfaceScale="100"
specularConstant="1" specularExponent="2" result="light">
<fePointLight id="specularPointLight" x="960" y="-500" z="500" />
</feSpecularLighting>
<!--output contents with light-->ec
<feComposite in="blendedContents" in2="light"
operator = "arithmetic" k1="0" k2="1" k3="0.3" k4="0"/>
</filter>
</defs>
<g style="filter: url('#bump'); transform: scale(0.5)"
width="1920" height="1000">
<rect id="r" width="1920" height="1000" style="fill:black;fill-opacity:0"/>
<text x="60" y="980" style="font: bold 42px 'Arial Black'">@monfera</text>
<text id="meta" x="1880" y="980" text-anchor="end"
style="font: bold 42px 'Arial Black'"></text>
</g>
<script>
// Elements
var noise = document.getElementById('noise')
var light = document.getElementById('specularPointLight')
var sat = document.getElementById('saturate')
var gamma = document.getElementById('gamma')
var container = document.getElementById('r')
var geom = document.getElementById('geom')
var meta = document.getElementById('meta')
// Elevation run colors
//wiki-schwarzwald-cont
//Copyright Jide and W-j-s (https://als.wikipedia.org/wiki/Benutzer:W-j-s)
// soliton.vm.bytemark.co.uk
// /pub/cpt-city/wkp/schwarzwald/wiki-schwarzwald-cont.png.index.html
// Creative commons attribution share-alike 3.0 unported
function fromByte(d) { return d / 255}
var wikiSchwarzwald = {
name: "wiki-schwarzwald-cont by Jide and W-j-s",
r: [
174,175,176,176,177,176,176,178,181,186,192,198,204,210,217,224,231,
238,245,250,248,238,226,213,198,184,170,154,140,125,110,94,77,62,49,
39,30,24,18,14,9,7,12, 24,40,52,64,76,87,99,110,120,128,137,147,156,
166,176,187,197,207,218,228,238,246,248,244,238,232,226,220,216,211,
206,200,192,186,180,174,169,163,157,151,146,141,135,130,125,122,119,
118,117,117,117,116,116,114,114,112,111,110,110,109,108,108,108,107,
106,106,107,110,113,116,118,121,125,128,131,135,138,140,144,147,150,
152,156,158,160,163,166,167,170,172,174,178,181,184,188,192,196,200,
204,208,212,216,218,221,225,229,233,235
].map(fromByte),
g: [
239,240,242,242,242,243,244,246,246,247,247,248,249,250,250,251,252,
252,252,252,249,244,240,235,228,222,216,211,205,199,194,188,182,176,
171,165,160,154,148,142,137,132,130,130,132,136,140,142,146,148,150,
154,156,160,162,164,166,170,173,176,177,179,180,182,182,176,166,155,
144,132,122,111,102,92,84,74,66,58,49,42,36,30,23,18,14,8,5,4,8,13,16,
18,20,21,22,24,26,29,31,33,35,36,38,40,40,42,44,44,46,48,52,57,62,66,
70,74,79,85,90,96,101,106,111,116,122,129,135,141,147,154,160,167,172,
174,178,181,184,188,192,196,200,204,206,210,214,216,219,223,227,231,233
].map(fromByte),
b: [
213,211,208,202,196,190,186,181,178,178,178,178,178,177,178,178,178,
179,179,178,172,162,151,140,128,118,108,98,89,82,74,66,57,50,44,42,43,
46,49,52,56,60,63,63, 61,60,59,59,56,54,52,50,48,46,43,41,39,36,34,30,
28,24,20,14,8,4,2,2,2,2,2,2,2,2, 2,2,2,2,2,2,2,2,2,1,1,0,0,0,2,2,2,4,
4,4,4,5,6,6,7,8,8,8,9,10,10,10,11,12,12,14,18,23,28,32,37,43,50,56,63,
69,76,84,90,96,104,112,120,130,139,147,156,164,171,174,178,181,184,
188,192,196,200,204,208,212,216,218,221,225,229,233,235
].map(fromByte),
water: {r: 174/255, g: 213/255, b: 239/255},
gamma: {r: 1, g: 1, b: 1},
saturation: 0.8,
cut: 26 // start with greens
}
//tv-a
//Copyright Jim Mossman http://www.esri.com/news/arcuser/0101/shademax.html
// soliton.vm.bytemark.co.uk/pub/cpt-city/jm/tv/tn/tv-a.png.index.html
// http://soliton.vm.bytemark.co.uk/pub/cpt-city/jm/copying.html
var tva = {
name: "tv-a by Jim Mossman",
r: [115,115,115,115,140,140,148,148,155,155,163,163,171,171,178,178,186,
186,194,194,201,201,209,209,217,217,224,224,232,232,240,240,247,247,
255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,
255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,
255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,
255,255,255,255,255,255,255
].map(fromByte),
g: [255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,
255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,
255,255,255,247,247,240,240,232,232,225,225,218,218,211,211,204,204,
197,197,190,190,183,183,176,176,169,169,162,162,155,155,160,160,172,
172,183,183,189,189,195,195,201,201,210,210,218,218,226,226,232,232,
237,237,243,243,247,247,251,251,
].map(fromByte),
b: [143,143,115,115,115,115,115,115,115,115,115,115,115,115,115,115,115,
115,115,115,115,115,115,115,115,115,115,115,115,115,115,115,115,115,
115,115,119,119,123,123,127,127,127,127,127,127,127,127,127,127,127,
127,127,127,127,127,127,127,127,127,127,127,127,127,141,141,155,155,
168,168,176,176,183,183,190,190,200,200,210,210,220,220,227,227,233,
233,240,240,245,245,250,250
].map(fromByte),
water: {r: 174/255, g: 213/255, b: 239/255},
gamma: {r: 4, g: 4, b: 4},
saturation: 0.8,
cut: 0
}
function runToPalette(name, d3Palette) {
var palette = {
name: name,
r: [],
g: [],
b: [],
water: {r: 90/255, g: 105/255, b: 120/255},
gamma: {r: 1, g: 1, b: 1},
saturation: 1,
cut: 0
}
var color, i
for(i = 0; i <= 1000; i ++) {
color = d3.color(d3Palette(i / 1000))
palette.r.push(color.r / 255)
palette.g.push(color.g / 255)
palette.b.push(color.b / 255)
}
return palette
}
var viridis = runToPalette("Viridis run from D3", d3.interpolateViridis)
var magma = runToPalette("Magma run from D3", d3.interpolateMagma)
// ice effect
var ice = {
name: "grayscale/ice",
r: [],
g: [],
b: [],
gamma: {r: 1.5, g: 1.3, b: 1},
saturation: 0.8,
cut: 0
}
var coal = {
name: "text invisible anyway",
r: [0, 0],
g: [0, 0],
b: [0, 0],
gamma: {r: 1, g: 1, b: 1},
saturation: 1,
cut: 0
}
var palettes = [tva, wikiSchwarzwald, ice, magma, viridis]
var paletteIndex = 0
function setPalette() {
var p = palettes[paletteIndex++ % palettes.length]
// add color for water bodies:
var geoR= (p.water ? [p.water.r] : []).concat(p.r.slice(p.cut)).join(' ')
var geoG= (p.water ? [p.water.g] : []).concat(p.g.slice(p.cut)).join(' ')
var geoB= (p.water ? [p.water.b] : []).concat(p.b.slice(p.cut)).join(' ')
// set the palette on the receiving SVG filter channels
document.getElementById('geoR').setAttribute('tableValues', geoR)
document.getElementById('geoG').setAttribute('tableValues', geoG)
document.getElementById('geoB').setAttribute('tableValues', geoB)
// set saturation
sat.setAttribute("values", p.saturation)
// set gamma
document.getElementById('gammaR').setAttribute('exponent', p.gamma.r)
document.getElementById('gammaG').setAttribute('exponent', p.gamma.g)
document.getElementById('gammaB').setAttribute('exponent', p.gamma.b)
// set palette name
meta.innerHTML = p.name
}
//Interactions
function moveLight(e) {
light.setAttribute('x', 2 * e.x)
//sat.setAttribute('values', .4 + .6 * Math.round(3 * (1 - e.y/500)) / 3)
}
function newMap() {
setPalette()
var newSeed = Math.round(1e6 * Math.random())
noise.setAttribute('seed', newSeed)
console.log("Seed: ", newSeed)
// some good seeds: 453109 394778 221947 601567
}
container.addEventListener("mousemove", moveLight)
container.addEventListener("click", newMap)
// Initial palette
setPalette()
</script>
</svg>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment