interactive webgl maps emphasizing direct access to the rendering pipeline
mixmap provides basic plumbing for webgl cartography and then gets out of your way. You have direct control over loading data, mapping geometry to a cartographic projection in your vertex shader, and coloring your maps in the fragment shader.
mixmap supplies a bounding box layer model. As the bounding box changes, mixmap will call your code to request bounding box manifests at the appropriate zoom level and mixmap will call your code again to handle adding and removing geometry.
- direct access to fragment and vertex shaders
- directly set attributes and uniforms
- use whatever cartographic projections you want
- use whatever coordinate systems you want
- avoids webgl context overload by sharing a single scissored webgl context
- designed to work with modern frameworks or raw html
- specifically tested to work with vdom/domdiff libraries (react, choo)
You can have dozens of inline maps on a single page!
Internally mixmap uses regl and regl-component for setting up webgl and managing the webgl context. If the webgl context is lost because the root DOM nodes are detached from the DOM, mixmap will set everything back to where it was when the nodes are inserted back onto the page.
A large part of the mixmap api passes through to the regl API, so it might be best to learn regl first.
Early release. Many interfaces subject to change (subject to semver of course) and many parts are not as optimized as they ought to be.
This demo uses 3 levels of tiles from 10m-ne2 and renders the population data from cities1000 over top. There are 138398 cities with over 1000 people, which for webgl is no big deal.
You can view this demo on:
- https://ipfs.io/ipfs/QmTTbHTnijEYYBk4UX6SJVbsL1AjWcN52DwS3D3rH172hv
- https://substack.neocities.org/mixmap/demos/ne2srw-cities.html
- dat://81e8ab9b6944e5263ff517be5e9c002446a8a881eff74c1df9ad3fbd6d875da2 (open in beaker browser)
To download the public domain tiles for yourself there are many options, using ipfs, dat, or http mirrors:
ipfs get -o ne2srw /ipfs/QmV5PLazMsBk8bRhRAyDhNuJt9N19cjayUSDvw8DKxSmFz
dat clone dat://db9c54fd4775da34109c9afd366cac5d3dff26c6a3902fc9c9c454193b543cbb ne2srw
- https://ipfs.io/ipfs/QmV5PLazMsBk8bRhRAyDhNuJt9N19cjayUSDvw8DKxSmFz
- https://ne2srw-tiles-substack.hashbase.io/
To get the cities1000 data:
ipfs get -o cities1000.json /ipfs/QmaWnPUwrd4DjG2zsGMrjgttqGHcTJrEBCPvM47PLSLZ9A
dat clone dat://ee15f68b43230825cf9a7c8e9f61a77df5bdceeccdb9ea67e4aac23dfd8e5b80 cities1000 && mv cities1000/cities1000.json .
- https://ipfs.io/ipfs/QmaWnPUwrd4DjG2zsGMrjgttqGHcTJrEBCPvM47PLSLZ9A
- https://cities1000-lon-lat-elev-substack.hashbase.io/
To run the demo, you can use budo:
$ budo map.js
var mixmap = require('mixmap')
var regl = require('regl')
var glsl = require('glslify')
var resl = require('resl')
var mix = mixmap(regl, { extensions: ['oes_element_index_uint'] })
var map = mix.create()
var drawTile = map.createDraw({
frag: glsl`
precision highp float;
#pragma glslify: hsl2rgb = require('glsl-hsl2rgb')
uniform float id;
uniform sampler2D texture;
varying vec2 vtcoord;
void main () {
float h = mod(id/8.0,1.0);
float s = mod(id/4.0,1.0)*0.5 0.25;
float l = mod(id/16.0,1.0)*0.5 0.25;
vec3 c = hsl2rgb(h,s,l);
vec4 tc = texture2D(texture,vtcoord);
gl_FragColor = vec4(c*(1.0-tc.a) tc.rgb*tc.a,0.5 tc.a*0.5);
}
`,
vert: `
precision highp float;
attribute vec2 position;
uniform vec4 viewbox;
uniform vec2 offset;
uniform float zindex;
attribute vec2 tcoord;
varying vec2 vtcoord;
void main () {
vec2 p = position offset;
vtcoord = tcoord;
gl_Position = vec4(
(p.x - viewbox.x) / (viewbox.z - viewbox.x) * 2.0 - 1.0,
(p.y - viewbox.y) / (viewbox.w - viewbox.y) * 2.0 - 1.0,
1.0/(1.0 zindex), 1);
}
`,
uniforms: {
id: map.prop('id'),
zindex: map.prop('zindex'),
texture: map.prop('texture')
},
attributes: {
position: map.prop('points'),
tcoord: [0,1,0,0,1,1,1,0] // sw,se,nw,ne
},
elements: [0,1,2,1,2,3],
blend: {
enable: true,
func: { src: 'src alpha', dst: 'one minus src alpha' }
}
})
var manifest = require('./ne2srw/tiles.json')
var tiles = [ {}, {}, {} ]
manifest.forEach(function (file,id) {
var level = Number(file.split('/')[0])
var bbox = file.split('/')[1].replace(/\.jpg$/,'').split('x').map(Number)
tiles[level][id '!' file] = bbox
})
map.addLayer({
viewbox: function (bbox, zoom, cb) {
zoom = Math.round(zoom)
if (zoom < 2) cb(null, tiles[0])
else if (zoom < 4) cb(null, tiles[1])
else cb(null, tiles[2])
},
add: function (key, bbox) {
var file = key.split('!')[1]
var level = Number(file.split('/')[0])
var prop = {
id: Number(key.split('!')[0]),
key: key,
zindex: 2 level,
texture: map.regl.texture(),
points: [
bbox[0], bbox[1], // sw
bbox[0], bbox[3], // se
bbox[2], bbox[1], // nw
bbox[2], bbox[3] // ne
]
}
drawTile.props.push(prop)
map.draw()
resl({
manifest: { tile: { type: 'image', src: 'ne2srw/' file } },
onDone: function (assets) {
prop.texture = map.regl.texture(assets.tile)
map.draw()
}
})
},
remove: function (key, bbox) {
drawTile.props = drawTile.props.filter(function (p) {
return p.key !== key
})
}
})
var drawCities = map.createDraw({
frag: glsl`
precision highp float;
#pragma glslify: hsl2rgb = require('glsl-hsl2rgb')
varying float population;
void main () {
if (length(gl_PointCoord.xy-0.5) > 0.5) discard;
vec3 c = hsl2rgb(
0.0, pow(population/1000.0,0.065)-1.0, 0.5
);
gl_FragColor = vec4(c,1);
}
`,
vert: `
precision highp float;
attribute vec3 position;
uniform vec4 viewbox;
uniform vec2 offset;
uniform float zoom;
varying float population;
void main () {
vec2 p = position.xy offset;
population = max(1000.0,position.z);
float z = 1.0-pow(population/1000.0,0.065);
gl_PointSize = pow(population,0.4)*pow(zoom,1.2)*0.01;
gl_Position = vec4(
(p.x - viewbox.x) / (viewbox.z - viewbox.x) * 2.0 - 1.0,
(p.y - viewbox.y) / (viewbox.w - viewbox.y) * 2.0 - 1.0,
z, 1);
}
`,
primitive: 'points',
attributes: {
position: map.prop('position')
},
count: map.prop('count')
})
resl({
manifest: {
cities: { type: 'text', src: 'cities1000.json', parser: JSON.parse }
},
onDone: function (assets) {
drawCities.props.push({
position: assets.cities,
count: assets.cities.length
})
}
})
window.addEventListener('keydown', function (ev) {
if (ev.code === 'Equal') {
map.setZoom(Math.min(6,Math.round(map.getZoom() 1)))
} else if (ev.code === 'Minus') {
map.setZoom(map.getZoom()-1)
}
})
document.body.appendChild(mix.render())
document.body.appendChild(map.render({ width: 600, height: 400 }))
In this demo, there are two draw calls: one for tile data and one for city dots. The tiles are set up as a layer, but the city dots are always visible and on top so they don't need to be managed as a layer.
The draw objects each have a .props
array where properties bound into the regl
setup by map.prop('name')
can be defined.
With the background tile layer, as the viewbox changes, the viewbox handler
function returns different sets of candidate bounding boxes to perform
intersections with the current viewbox. The boxes that match are passed to the
add
function and old ones are culled in remove
. The map data in add
is
appended to the drawTile.props
array.
var mixmap = require('mixmap')
Create a new mixmap instance given a regl interface and some opts
that
will be passed to the regl constructor.
There should only be one mix
instance per-page because this interface creates
a full-page canvas that the logical sub-canvases for each map is scissored out
of.
Return a root html element with a full-screen canvas inside.
This root element can re-establish itself as needed, so it is safe to use with
virtual dom and dom-diffing libraries: you can call .render()
whenever your
state changes.
Create a new map instance with a scissored rendering context.
opts.viewbox
- set the viewbox. default:[-180,-90, 180, 90]
opts.backgroundColor
- default:[1,1,1,1]
(white)opts.pickfb
- options to create the picking framebuffer
To do picking with floating point rgba data, do:
var map = mix.create({
pickfb: { colorFormat: 'rgba', colorType: 'float32' }
})
and if you use floating point framebuffer data you'll need to enable the
oes_texture_float
extension:
var mix = mixmap(regl, {
extensions: [ 'oes_element_index_uint', 'oes_texture_float' ]
})
Return an html element to hold a map on the page given:
opts.width
opts.height
opts.mouse
- set tofalse
if you don't want the default map panning
You can call .render()
whenever your state changes to get a new element.
Resize an already-rendered map. If you are using a virtual DOM or a DOM diffing
abstraction you can call .render()
instead.
Emits a 'resize', width, height
event on map
.
Access the map's wrapped regl instance.
Alias for map.regl.prop(name)
.
Call this function if you need to explicitly trigger a re-draw of all the draw functions on the page.
Create a new draw
instance. opts
should be the same as to create a draw
function with regl(opts)
plus these additional options:
opts.pickFrag
- fragment shader to use for pickingopts.pickVert
- vertex shader to use for picking. defaults toopts.vert
The main difference between regl draw functions and mixmap draw objects is that
in regl, you pass props to your draw function when you are ready to render it,
but in mixmap, usually the rendering will be triggered by user actions. Because
of this you've got to store the props you want to use in your draw call in
the draw.props
array.
During picking, a context variable picking
is set to true.
Put properties into this array that you've bound into your draw setup with
map.prop()
or map.regl.prop()
.
Remove a draw function from the map.
Create a new layer to handle loading and unloading tile geometries.
opts.viewbox(bbox, zoom, cb)
- called every time the viewbox changes. Setcb(err, boxes)
for an array of objectboxes
to test for intersections with the current viewbox.opts.add(key, bbox)
- called when there is an intersection with the key or index of the matching boxopts.remove(key, bbox)
- called when a box previously inside the viewbox is no longer inside
The viewbox is stored as map.viewbox
in the form:
[west,south,east,north]
.
Set a new viewbox as [west,south,east,north]
.
Access the map dimensions in pixels as [width,height]
.
Get the zoom level as a floating point number. The values are the same 1-21 scheme that google maps uses.
Set the zoom level (from 1 to 21, including in-between values).
Move the map by a delta of screen pixels dx,dy
.
This is useful if you want to move the map based on mouse or touch events instead of setting the viewbox manually.
Read pixels from a framebuffer rendered with picking shaders (pickFrag
and
pickVert
).
opts.x
- dom element x coordinateopts.y
- dom element y coordinateopts.width
- width in pixels to read. default: 1opts.height
- height in pixels to read. default: 1
To rig up picking you can do something like this:
var element = map.render({ width: 400, height: 300 })
element.addEventListener('click', function (ev) {
map.pick({ x: ev.offsetX, y: ev.offsetY }, function (err, data) {
console.log(data)
})
})
npm install mixmap
public domain