Repository: kekscom/osmbuildings Branch: master Commit: 47dd8c1f4d8c Files: 44 Total size: 342.6 KB Directory structure: gitextract_r4y828n7/ ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── PERFORMANCE.md ├── README.md ├── build.js ├── dist/ │ ├── OSMBuildings-Leaflet.debug.js │ ├── OSMBuildings-Leaflet.js │ ├── OSMBuildings-OpenLayers.debug.js │ ├── OSMBuildings-OpenLayers.js │ ├── OSMBuildings.css │ ├── index-Leaflet-3db.html │ ├── index-Leaflet.html │ └── index-OpenLayers.html ├── docs/ │ └── server.md ├── package.json ├── src/ │ ├── Data.js │ ├── Debug.js │ ├── GeoJSON.js │ ├── OSMBuildings.css │ ├── Request.js │ ├── adapter.js │ ├── engines/ │ │ ├── Leaflet.js │ │ ├── OpenLayers.js │ │ ├── index-Leaflet.html │ │ └── index-OpenLayers.html │ ├── functions.js │ ├── geometry/ │ │ ├── Cylinder.js │ │ ├── Extrusion.js │ │ ├── Pyramid.js │ │ └── __Dome.js │ ├── geometry.js │ ├── layers/ │ │ ├── Buildings.js │ │ ├── Picking.js │ │ ├── Shadows.js │ │ ├── Simplified.js │ │ └── index.js │ ├── lib/ │ │ └── getSunPosition.js │ ├── shortcuts.js │ └── variables.js └── tests/ ├── openlayers-5.3.0/ │ ├── OSMBuildings-OL5.js │ ├── README.md │ └── index.js └── shadows.html ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ .idea/ _misc/ node_modules/ ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## 3.0.0 @ 2020-03-20 WIP - refactoring for ES6 - npm package creation ## 0.2.2b @ 2014-11-25 **Features** - detection for circular objects **Fixes** - several fixes for radius handling - several click-hit issues fixed ## 0.2.1b @ 2014-09-24 **Features** - pyramidal buildungs and roofs - buildings parts are a logical unit now (i.e. OSM relations) - explicitly injecting OSMBuildings class into global namespace [#66](https://github.com/kekscom/osmbuildings/issues/66) ## 0.2.0b @ 2014-09-04 *From this version on, OSM Buildings is entering beta phase \o/* **Features** - Buildings are clickable now, use .click(function(featureId) {...}) - Massive improvements in GeoJSON reading, bigger set of properties and GeometryCollections are supported - Ambient shadows for buildings added - Introduced a data service for filtering and caching OSM data, results in massive speedup for loading - Geometry: cones enabled, also used as an interim replacement for domes - Tested device accelerated perspective aka Amazon's 'Dynamic Perspective', but disabled again in favor for performance - Successfully tested with LeafletJS 0.8 and OpenLayers 2.13.1 - Code size reduced from 10.23 to 9.44k (all gzipped) **Fixes** - Height scale fixed - Relation properties precedence fixed - Simple buildings layer refactored - Flipped perspective on some latitudes fixed - Tried requestAnimationFrame, but needed to drop again for IE and iOS ## 0.1.9a @ 2013-10-17 - multipolygon support added - backend removed, now using web services with GeoJSON or OSM Overpass XAPI - vector data is subdivided into tiles - data tiles are cached - fix for chained method calls - fix for flat buildings from rendering tall buildings too - min zoom level decreased to 15 - fix for setStyle() removing shadows - material color mapping added - HSLA color support added - support for W3C named colors added - CORS-XHR support for MSIE added - cylindric object rendering added - API is now documented in GitHub README - map engine adapters simplified - minHeight and height units for GeoJSON enabled - very simple fix for building occlusion - successful tests with LeafletJS 0.6.4 ## 0.1.8a @ 2013-03-10 - on layer removal from map, OSM Buildings is not destroyed anymore - introduced multiple rendering layers - improved simplification algorithm, inspired by Vladimir Agafonkin (https://mourner.github.com/simplify-js) - initial version of objects draw order (farthest first, lower first) - directional wall shading added - building shadows added - shadow date / time dependency added, inspired by Vladimir Agafonkin (https://github.com/mourner/suncalc) - `min_height` support added (requires backend change) - color / style table handling improved - rendering tests added - successful tests with LeafletJS 0.5.1 - recommendation: reduce building `$heightScale` in backend server config down to 1.2 ## 0.1.7a @ 2012-10-10 - adding OpenLayers support, credits to Jérémy Judéaux (https://github.com/Volune) - aligning Layer naming convention to engines - fixing some rare cases where layer got removed ## 0.1.6a @ 2012-09-04 - GeoJSON: min zoom removed - GeoJSON: height property re-enabled - GeoJSON: multi polygons enabled - Examples are rebuilt entirely - Roof colors are re-enbled - JSHint is now part of the build process ## 0.1.5a - support for GeoJSON improved - deep integration with Leaflet in order to avoid jittery movement - enabled individual building colors - polygon winding fixed ## 0.1.0a - GeoJSON support added - method chaining added - adding converter PostGIS > MySQL - data for Frankfurt added - made either MySQL or PostGIS (Mapnik) fully configurable - lat/lon order of your coordinates is configurable - polygon direction is forced to be clockwise - simpler initialization process ================================================ FILE: LICENSE.md ================================================ Copyright (c) 2020, OSM Buildings, Jan Marsch All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: PERFORMANCE.md ================================================ # Performance What's done & why in terms of performance. ## IDEAS - combine canvases - render items of same style at once - delete and redraw animated objects - split off animated shadows - combine simple buildings in low zoom - use web workers for data processing - adapt features dynamically - pre-calc face direction - consider using a render queue - style lookup index ## Projection cache Looks like this has been a bad idea - removed. Probably develop the concept into rendering pipelines. (http://jsperf.com/projcache) ## Readable keys (for compact data structures) Seemed to be a good idea making these a bit more readable. Loss expected but turned out to be a slight gain. http://jsperf.com/readable-keys/2 TODO: Not true anymore. Refactor to objects/properties. DONE ## Combined 2d faces iPhone4, iOS5: seperate faces are 56% slower MBA 2010, Chrome: combined faces are 68% slower Staying with combined so far, probably add system detection. (http://jsperf.com/canvas-polygon-combiner) ## Typed arrays Using slice() is not worth it, but it turned out dropping some typed arrays is a good idea. Especially when these are created on each rendering pass. (http://jsperf.com/slice-access) ## Canvas anti alias While looking slick, it eats performance. Not so on iPhone4, iOS5: it doesn't matter MBA 2010, Chrome: anti alias is 24% slower Node: for *any* test, stroking lines eats ~60% performance (http://jsperf.com/canvas-anti-alias) ## Math round Using ~~ for a while, it turns out, bit shift << 0 is even faster. iPad4, iOS6: 12% faster MBA 2010, Chrome: 25% faster (http://jsperf.com/math-round-vs-hack/3) ## Public methods vs. closure(d) functions Huge gain for methods on desktop browsers vs. Safari (mobile). Will stay a bit until mobile catches up. Finally, Safari 6 mobile beats it too. http://jsperf.com/osmb-method-vs-function ## Considerations for further performance improvement degrade instantly, increase slowly (take average score of a few passes) ### FADE IN - NO_STROKES - NO_SHADING - NO_SHADOWS_SCALE - NO_SCALE ### MOVE ### STATIC - NO_STROKES - NO_SHADING - NO_FLAT - NO_SHADOWS http://jsperf.com/osmb-hidden-canvas4 ================================================ FILE: README.md ================================================ OSM Buildings is a JavaScript library for showing building geometry on interactive maps. Example: https://osmbuildings.org/ **The standalone WebGL version odf OSM Buildings is located here: https://github.com/OSMBuildings/OSMBuildings** There is also documentation of OSM Buildings Server side: https://github.com/kekscom/osmbuildings/blob/master/docs/server.md **Example** https://osmbuildings.org/ It's safe use the [master branch](https://github.com/kekscom/osmbuildings/tree/master/dist/) for production. For further information visit https://osmbuildings.org, follow [@osmbuildings](https://twitter.com/osmbuildings/) on Twitter or report issues [here on Github](https://github.com/kekscom/osmbuildings/issues/). ## Documentation ### Integration with Leaflet Link Leaflet and OSM Buildings files in your HTML head section. ~~~ html ~~~ Initialize the map engine and add a map tile layer.
Position is set to Berlin at zoom level 17, I'm using Mapbox tiles here. ~~~ javascript var map = new L.Map('map').setView([52.52020, 13.37570], 17); new L.TileLayer('https://{s}.tiles.mapbox.com/v3//{z}/{x}/{y}.png', { attribution: 'Map tiles © Mapbox', maxZoom: 17 }).addTo(map); ~~~ Add the buildings layer. ~~~ javascript new OSMBuildings(map).load(); ~~~ As a popular alternative, you could pass a GeoJSON FeatureCollection object.
Geometry types Polygon, Multipolygon and GeometryCollection are supported.
Make sure the building coordinates are projected in EPSG:4326.
Height units m, ft, yd, mi are accepted, no given unit defaults to meters. ~~~ javascript var geoJSON = { "type": "FeatureCollection", "features": [{ "type": "Feature", "id": 134, "geometry": { "type": "Polygon", "coordinates": [[ [13.37356, 52.52064], [13.37350, 52.51971], [13.37664, 52.51973], [13.37594, 52.52062], [13.37356, 52.52064] ]] }, "properties": { "wallColor": "rgb(255,0,0)", "roofColor": "rgb(255,128,0)", "height": 500, "minHeight": 0 } }] }; new OSMBuildings(map).set(geoJSON); ~~~ ### Integration with OpenLayers * NEW: for Integration with OpenLayers 5 see /tests/openlayers-5.3.0 * Link OpenLayers and OSM Buildings files in your HTML head section. ~~~ html ~~~ Initialize the map engine and add a map tile layer.
Position is set to Berlin at zoom level 17. ~~~ javascript var map = new OpenLayers.Map('map'); map.addControl(new OpenLayers.Control.LayerSwitcher()); var osm = new OpenLayers.Layer.OSM(); map.addLayer(osm); map.setCenter( new OpenLayers.LonLat(13.37570, 52.52020) .transform( new OpenLayers.Projection('EPSG:4326'), map.getProjectionObject() ), 17 ); ~~~ Add the buildings layer. ~~~ javascript new OSMBuildings(map).load(); ~~~ ## API ### Constructors
Constructor Description
new OSMBuildings(map) Initializes the buildings layer for a given map engine.
Currently Leaflet and OpenLayers are supported.
Constants
Option Type Description
ATTRIBUTION String Holds OSM Buildings copyright information.
VERSION String Holds current version information.
Methods
Method Description
style({Object}) Set default styles. See below for details.
date(new Date(2017, 15, 1, 10, 30))) Set date/time for shadow projection.
each({Function}) A callback wrapper to override each feature's properties on read. Return false in order to skip a particular feature.
Callback receives a feature object as argument.
click({Function}) A callback wrapper to handle click events on features.
Callback receives an object { featureId{number,string}, lat{float}, lon{float} } as argument.
set({GeoJSON FeatureCollection}) Just add GeoJSON data to your map.
load({Provider}) Without parameter, it loads OpenStreetMap data tiles via an OSM Buildings proxy. This proxy enables transparent data filtering and caching. Interface of such provider is to be published.
Styles
Option Type Description
color/wallColor String Defines the objects default primary color. I.e. #ffcc00, rgb(255,200,200), rgba(255,200,200,0.9)
roofColor String Defines the objects default roof color.
shadows Boolean Enables or disables shadow rendering, default: enabled
## Data ### OSM Tags used
GeoJSON property OSM Tags
height height, building:height, levels, building:levels
minHeight min_height, building:min_height, min_level, building:min_level
color/wallColor building:color, building:colour
material building:material, building:facade:material, building:cladding
roofColor roof:color, roof:colour, building:roof:color, building:roof:colour
roofMaterial roof:material, building:roof:material
shape building:shape[=cylinder,sphere]
roofShape roof:shape[=dome]
roofHeight roof:height
================================================ FILE: build.js ================================================ const fs = require('fs'); const Terser = require('terser'); //***************************************************************************** const package = require('./package.json'); const src = `${__dirname}/src`; const dist = `${__dirname}/dist`; const code = [ "src/shortcuts.js", "node_modules/qolor/dist/Qolor.debug.js", "src/lib/getSunPosition.js", "src/GeoJSON.js", "src/variables.js", "src/geometry.js", "src/functions.js", "src/Request.js", "src/Data.js", "src/geometry/Extrusion.js", "src/geometry/Cylinder.js", "src/geometry/Pyramid.js", "src/layers/index.js", "src/layers/Buildings.js", "src/layers/Simplified.js", "src/layers/Shadows.js", "src/layers/Picking.js", "src/Debug.js", "src/adapter.js" ]; //***************************************************************************** function joinFiles (files) { if (!files.push) { files = [files]; } return files.map(file => fs.readFileSync(file)).join('\n'); } function copy (srcFile, distFile) { fs.writeFileSync(distFile, fs.readFileSync(srcFile, 'utf8')); } //***************************************************************************** function buildEngine (name, customJS) { const commonJS = joinFiles(code); let js = commonJS + '\n' + customJS; js = js.replace(/\{\{VERSION\}\}/g, package.version); js = `const OSMBuildings = (function() {\n${js}\n return OSMBuildings;\n}());`; fs.writeFileSync(`${dist}/OSMBuildings-${name}.debug.js`, js); fs.writeFileSync(`${dist}/OSMBuildings-${name}.js`, Terser.minify(js).code); copy(`${src}/engines/index-${name}.html`, `${dist}/index-${name}.html`); } //***************************************************************************** if (!fs.existsSync(dist)) { fs.mkdirSync(dist); } buildEngine('Leaflet', fs.readFileSync(`${src}/engines/Leaflet.js`)); buildEngine('OpenLayers', fs.readFileSync(`${src}/engines/OpenLayers.js`)); copy(`${src}/OSMBuildings.css`, `${dist}/OSMBuildings.css`); ================================================ FILE: dist/OSMBuildings-Leaflet.debug.js ================================================ const OSMBuildings = (function() { const m = Math, exp = m.exp, log = m.log, sin = m.sin, cos = m.cos, tan = m.tan, atan = m.atan, atan2 = m.atan2, min = m.min, max = m.max, sqrt = m.sqrt, ceil = m.ceil, pow = m.pow; /** * @class */ class Qolor { /** * @constructor * @param r {Number} 0.0 .. 1.0 red value of a color * @param g {Number} 0.0 .. 1.0 green value of a color * @param b {Number} 0.0 .. 1.0 blue value of a color * @param a {Number} 0.0 .. 1.0 alpha value of a color, default 1 */ constructor (r, g, b, a = 1) { this.r = this._clamp(r, 1); this.g = this._clamp(g, 1); this.b = this._clamp(b, 1); this.a = this._clamp(a, 1); } /** * @param str {String} can be any color dfinition like: 'red', '#0099ff', 'rgb(64, 128, 255)', 'rgba(64, 128, 255, 0.5)' */ static parse (str) { if (typeof str === 'string') { str = str.toLowerCase(); str = Qolor.w3cColors[str] || str; let m; if ((m = str.match(/^#?(\w{2})(\w{2})(\w{2})$/))) { return new Qolor(parseInt(m[1], 16)/255, parseInt(m[2], 16)/255, parseInt(m[3], 16)/255); } if ((m = str.match(/^#?(\w)(\w)(\w)$/))) { return new Qolor(parseInt(m[1]+m[1], 16)/255, parseInt(m[2]+m[2], 16)/255, parseInt(m[3]+m[3], 16)/255); } if ((m = str.match(/rgba?\((\d+)\D+(\d+)\D+(\d+)(\D+([\d.]+))?\)/))) { return new Qolor( parseFloat(m[1])/255, parseFloat(m[2])/255, parseFloat(m[3])/255, m[4] ? parseFloat(m[5]) : 1 ); } } return new Qolor(); } static fromHSL (h, s, l, a) { const qolor = new Qolor().fromHSL(h, s, l); qolor.a = a === undefined ? 1 : a; return qolor; } //*************************************************************************** _hue2rgb(p, q, t) { if (t<0) t += 1; if (t>1) t -= 1; if (t<1/6) return p + (q - p)*6*t; if (t<1/2) return q; if (t<2/3) return p + (q - p)*(2/3 - t)*6; return p; } _clamp(v, max) { if (v === undefined) { return; } return Math.min(max, Math.max(0, v || 0)); } //*************************************************************************** isValid () { return this.r !== undefined && this.g !== undefined && this.b !== undefined; } toHSL () { if (!this.isValid()) { return; } const max = Math.max(this.r, this.g, this.b); const min = Math.min(this.r, this.g, this.b); const range = max - min; const l = (max + min)/2; // achromatic if (!range) { return { h: 0, s: 0, l: l }; } const s = l > 0.5 ? range/(2 - max - min) : range/(max + min); let h; switch (max) { case this.r: h = (this.g - this.b)/range + (this.g 0 ? WINDING_CLOCKWISE : WINDING_COUNTER_CLOCKWISE; } // enforce a polygon winding direcetion. Needed for proper backface culling. function makeWinding (points, direction) { let winding = getWinding(points); if (winding === direction) { return points; } let revPoints = []; for (let i = points.length-2; i >= 0; i -= 2) { revPoints.push(points[i], points[i+1]); } return revPoints; } function alignProperties(prop) { const item = {}; prop = prop || {}; item.height = prop.height || (prop.levels ? prop.levels *METERS_PER_LEVEL : DEFAULT_HEIGHT); item.minHeight = prop.minHeight || (prop.minLevel ? prop.minLevel*METERS_PER_LEVEL : 0); const wallColor = prop.material ? getMaterialColor(prop.material) : (prop.wallColor || prop.color); if (wallColor) { item.wallColor = wallColor; } const roofColor = prop.roofMaterial ? getMaterialColor(prop.roofMaterial) : prop.roofColor; if (roofColor) { item.roofColor = roofColor; } switch (prop.shape) { case 'cylinder': case 'cone': case 'dome': case 'sphere': item.shape = prop.shape; item.isRotational = true; break; case 'pyramid': item.shape = prop.shape; break; } switch (prop.roofShape) { case 'cone': case 'dome': item.roofShape = prop.roofShape; item.isRotational = true; break; case 'pyramid': item.roofShape = prop.roofShape; break; } if (item.roofShape && prop.roofHeight) { item.roofHeight = prop.roofHeight; item.height = max(0, item.height-item.roofHeight); } else { item.roofHeight = 0; } return item; } function getGeometries (geometry) { let polygon, geometries = [], sub; switch (geometry.type) { case 'GeometryCollection': geometries = []; for (let i = 0, il = geometry.geometries.length; i < il; i++) { if ((sub = getGeometries(geometry.geometries[i]))) { geometries.push.apply(geometries, sub); } } return geometries; case 'MultiPolygon': geometries = []; for (let i = 0, il = geometry.coordinates.length; i < il; i++) { if ((sub = getGeometries({ type: 'Polygon', coordinates: geometry.coordinates[i] }))) { geometries.push.apply(geometries, sub); } } return geometries; case 'Polygon': polygon = geometry.coordinates; break; default: return []; } let p, lat = 1, lon = 0, outer = [], inner = []; p = polygon[0]; for (let i = 0, il = p.length; i < il; i++) { outer.push(p[i][lat], p[i][lon]); } outer = makeWinding(outer, WINDING_CLOCKWISE); for (let i = 0, il = polygon.length-1; i < il; i++) { p = polygon[i+1]; inner[i] = []; for (let j = 0, jl = p.length; j < jl; j++) { inner[i].push(p[j][lat], p[j][lon]); } inner[i] = makeWinding(inner[i], WINDING_COUNTER_CLOCKWISE); } return [{ outer: outer, inner: inner.length ? inner : null }]; } function clone (obj) { let res = {}; for (const p in obj) { if (obj.hasOwnProperty(p)) { res[p] = obj[p]; } } return res; } class GeoJSON { static read (geojson) { if (!geojson || geojson.type !== 'FeatureCollection') { return []; } const collection = geojson.features; const res = []; for (let i = 0, il = collection.length; i < il; i++) { const feature = collection[i]; if (feature.type !== 'Feature' || onEach(feature) === false) { continue; } const baseItem = alignProperties(feature.properties); const geometries = getGeometries(feature.geometry); for (let j = 0, jl = geometries.length; j < jl; j++) { const item = clone(baseItem); item.footprint = geometries[j].outer; if (item.isRotational) { item.radius = getLonDelta(item.footprint); } if (geometries[j].inner) { item.holes = geometries[j].inner; } if (feature.id || feature.properties.id) { item.id = feature.id || feature.properties.id; } if (feature.properties.relationId) { item.relationId = feature.properties.relationId; } res.push(item); // TODO: clone base properties! } } return res; } } let VERSION = '0.3.2', ATTRIBUTION = '© OSM Buildings', DATA_SRC = 'https://{s}.data.osmbuildings.org/0.2/{k}/tile/{z}/{x}/{y}.json', PI = Math.PI, HALF_PI = PI/2, QUARTER_PI = PI/4, MAP_TILE_SIZE = 256, // map tile size in pixels ZOOM, MAP_SIZE, MIN_ZOOM = 15, LAT = 'latitude', LON = 'longitude', WIDTH = 0, HEIGHT = 0, CENTER_X = 0, CENTER_Y = 0, ORIGIN_X = 0, ORIGIN_Y = 0, WALL_COLOR = Qolor.parse('rgba(200, 190, 180)'), ALT_COLOR = WALL_COLOR.lightness(0.8), ROOF_COLOR = WALL_COLOR.lightness(1.2), WALL_COLOR_STR = ''+ WALL_COLOR, ALT_COLOR_STR = ''+ ALT_COLOR, ROOF_COLOR_STR = ''+ ROOF_COLOR, PIXEL_PER_DEG = 0, MAX_HEIGHT, // taller buildings will be cut to this DEFAULT_HEIGHT = 5, CAM_X, CAM_Y, CAM_Z = 450, IS_ZOOMING; function onEach () {} function onClick () {} function getDistance (p1, p2) { const dx = p1.x-p2.x, dy = p1.y-p2.y; return dx*dx + dy*dy; } function isRotational (polygon) { const length = polygon.length; if (length < 16) { return false; } let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; for (let i = 0; i < length-1; i+=2) { minX = Math.min(minX, polygon[i]); maxX = Math.max(maxX, polygon[i]); minY = Math.min(minY, polygon[i+1]); maxY = Math.max(maxY, polygon[i+1]); } const width = maxX-minX, height = (maxY-minY), ratio = width/height; if (ratio < 0.85 || ratio > 1.15) { return false; } const center = { x:minX+width/2, y:minY+height/2 }, radius = (width+height)/4, sqRadius = radius*radius; for (let i = 0; i < length-1; i+=2) { const dist = getDistance({ x:polygon[i], y:polygon[i+1] }, center); if (dist/sqRadius < 0.8 || dist/sqRadius > 1.2) { return false; } } return true; } function getSquareSegmentDistance (px, py, p1x, p1y, p2x, p2y) { let dx = p2x-p1x, dy = p2y-p1y, t; if (dx !== 0 || dy !== 0) { t = ((px-p1x) * dx + (py-p1y) * dy) / (dx*dx + dy*dy); if (t > 1) { p1x = p2x; p1y = p2y; } else if (t > 0) { p1x += dx*t; p1y += dy*t; } } dx = px-p1x; dy = py-p1y; return dx*dx + dy*dy; } function simplifyPolygon (buffer) { let sqTolerance = 2, len = buffer.length/2, markers = new Uint8Array(len), first = 0, last = len-1, maxSqDist, sqDist, index, firstStack = [], lastStack = [], newBuffer = []; markers[first] = markers[last] = 1; while (last) { maxSqDist = 0; for (let i = first+1; i < last; i++) { sqDist = getSquareSegmentDistance( buffer[i *2], buffer[i *2 + 1], buffer[first*2], buffer[first*2 + 1], buffer[last *2], buffer[last *2 + 1] ); if (sqDist > maxSqDist) { index = i; maxSqDist = sqDist; } } if (maxSqDist > sqTolerance) { markers[index] = 1; firstStack.push(first); lastStack.push(index); firstStack.push(index); lastStack.push(last); } first = firstStack.pop(); last = lastStack.pop(); } for (let i = 0; i < len; i++) { if (markers[i]) { newBuffer.push(buffer[i*2], buffer[i*2 + 1]); } } return newBuffer; } function getCenter (footprint) { let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; for (let i = 0, il = footprint.length-3; i < il; i += 2) { minX = min(minX, footprint[i]); maxX = max(maxX, footprint[i]); minY = min(minY, footprint[i+1]); maxY = max(maxY, footprint[i+1]); } return { x:minX+(maxX-minX)/2 <<0, y:minY+(maxY-minY)/2 <<0 }; } let EARTH_RADIUS = 6378137; function getLonDelta (footprint) { let minLon = 180, maxLon = -180; for (let i = 0, il = footprint.length; i < il; i += 2) { minLon = min(minLon, footprint[i+1]); maxLon = max(maxLon, footprint[i+1]); } return (maxLon-minLon)/2; } function rad (deg) { return deg * PI / 180; } function deg (rad) { return rad / PI * 180; } function pixelToGeo (x, y) { const res = {}; x /= MAP_SIZE; y /= MAP_SIZE; res[LAT] = y <= 0 ? 90 : y >= 1 ? -90 : deg(2 * atan(exp(PI * (1 - 2*y))) - HALF_PI); res[LON] = (x === 1 ? 1 : (x%1 + 1) % 1) * 360 - 180; return res; } function geoToPixel (lat, lon) { const latitude = min(1, max(0, 0.5 - (log(tan(QUARTER_PI + HALF_PI * lat / 180)) / PI) / 2)), longitude = lon/360 + 0.5; return { x: longitude*MAP_SIZE <<0, y: latitude *MAP_SIZE <<0 }; } function fromRange (sVal, sMin, sMax, dMin, dMax) { sVal = min(max(sVal, sMin), sMax); const rel = (sVal-sMin) / (sMax-sMin), range = dMax-dMin; return min(max(dMin + rel*range, dMin), dMax); } function isVisible (polygon) { const maxX = WIDTH+ORIGIN_X, maxY = HEIGHT+ORIGIN_Y; // TODO: checking footprint is sufficient for visibility - NOT VALID FOR SHADOWS! for (let i = 0, il = polygon.length-3; i < il; i+=2) { if (polygon[i] > ORIGIN_X && polygon[i] < maxX && polygon[i+1] > ORIGIN_Y && polygon[i+1] < maxY) { return true; } } return false; } let cacheData = {}; let cacheIndex = []; let cacheSize = 0; let maxCacheSize = 1024*1024 * 5; // 5MB function xhr (url, callback) { if (cacheData[url]) { if (callback) { callback(cacheData[url]); } return; } const req = new XMLHttpRequest(); req.onreadystatechange = function () { if (req.readyState !== 4) { return; } if (!req.status || req.status < 200 || req.status > 299) { return; } if (callback && req.responseText) { const responseText = req.responseText; cacheData[url] = responseText; cacheIndex.push({ url: url, size: responseText.length }); cacheSize += responseText.length; callback(responseText); while (cacheSize > maxCacheSize) { let item = cacheIndex.shift(); cacheSize -= item.size; delete cacheData[item.url]; } } }; req.open('GET', url); req.send(null); return req; } class Request { static loadJSON (url, callback) { return xhr(url, responseText => { let json; try { json = JSON.parse(responseText); } catch(ex) {} callback(json); }); } } class Data { static getPixelFootprint (buffer) { let footprint = new Int32Array(buffer.length), px; for (let i = 0, il = buffer.length-1; i < il; i+=2) { px = geoToPixel(buffer[i], buffer[i+1]); footprint[i] = px.x; footprint[i+1] = px.y; } footprint = simplifyPolygon(footprint); if (footprint.length < 8) { // 3 points & end==start (*2) return; } return footprint; } static resetItems () { this.items = []; this.cache = {}; Picking.reset(); } static addRenderItems (data, allAreNew) { let item, scaledItem, id; let geojson = GeoJSON.read(data); for (let i = 0, il = geojson.length; i < il; i++) { item = geojson[i]; id = item.id || [item.footprint[0], item.footprint[1], item.height, item.minHeight].join(','); if (!this.cache[id]) { if ((scaledItem = this.scaleItem(item))) { scaledItem.scale = allAreNew ? 0 : 1; this.items.push(scaledItem); this.cache[id] = 1; } } } fadeIn(); } static scalePolygon (buffer, factor) { return buffer.map(coord => coord*factor); } static scale (factor) { Data.items = Data.items.map(item => { // item.height = Math.min(item.height*factor, MAX_HEIGHT); // TODO: should be filtered by renderer item.height *= factor; item.minHeight *= factor; item.footprint = Data.scalePolygon(item.footprint, factor); item.center.x *= factor; item.center.y *= factor; if (item.radius) { item.radius *= factor; } if (item.holes) { for (let i = 0, il = item.holes.length; i < il; i++) { item.holes[i] = Data.scalePolygon(item.holes[i], factor); } } item.roofHeight *= factor; return item; }); } static scaleItem (item) { let res = {}, // TODO: calculate this on zoom change only zoomScale = 6 / pow(2, ZOOM-MIN_ZOOM); // TODO: consider using HEIGHT / (devicePixelRatio || 1) if (item.id) { res.id = item.id; } res.height = min(item.height/zoomScale, MAX_HEIGHT); res.minHeight = isNaN(item.minHeight) ? 0 : item.minHeight / zoomScale; if (res.minHeight > MAX_HEIGHT) { return; } res.footprint = this.getPixelFootprint(item.footprint); if (!res.footprint) { return; } res.center = getCenter(res.footprint); if (item.radius) { res.radius = item.radius*PIXEL_PER_DEG; } if (item.shape) { res.shape = item.shape; } if (item.roofShape) { res.roofShape = item.roofShape; } if ((res.roofShape === 'cone' || res.roofShape === 'dome') && !res.shape && isRotational(res.footprint)) { res.shape = 'cylinder'; } if (item.holes) { res.holes = []; let innerFootprint; for (let i = 0, il = item.holes.length; i < il; i++) { // TODO: simplify if ((innerFootprint = this.getPixelFootprint(item.holes[i]))) { res.holes.push(innerFootprint); } } } let color; if (item.wallColor) { if ((color = Qolor.parse(item.wallColor))) { res.altColor = ''+ color.lightness(0.8); res.wallColor = ''+ color; } } if (item.roofColor) { if ((color = Qolor.parse(item.roofColor))) { res.roofColor = ''+ color; } } if (item.relationId) { res.relationId = item.relationId; } res.hitColor = Picking.idToColor(item.relationId || item.id); res.roofHeight = isNaN(item.roofHeight) ? 0 : item.roofHeight/zoomScale; if (res.height+res.roofHeight <= res.minHeight) { return; } return res; } static set (data) { this.resetItems(); this._staticData = data; this.addRenderItems(this._staticData, true); } static load (src, key) { this.src = src || DATA_SRC.replace('{k}', (key || 'anonymous')); this.update(); } static update () { this.resetItems(); if (ZOOM < MIN_ZOOM) { return; } if (this._staticData) { this.addRenderItems(this._staticData); } if (this.src) { let tileZoom = 16, tileSize = 256, zoomedTileSize = ZOOM > tileZoom ? tileSize << (ZOOM - tileZoom) : tileSize >> (tileZoom - ZOOM), minX = ORIGIN_X / zoomedTileSize << 0, minY = ORIGIN_Y / zoomedTileSize << 0, maxX = ceil((ORIGIN_X + WIDTH) / zoomedTileSize), maxY = ceil((ORIGIN_Y + HEIGHT) / zoomedTileSize), x, y; let scope = this; function callback (json) { scope.addRenderItems(json); } for (y = minY; y <= maxY; y++) { for (x = minX; x <= maxX; x++) { this.loadTile(x, y, tileZoom, callback); } } } } static loadTile (x, y, zoom, callback) { let s = 'abcd'[(x+y) % 4]; let url = this.src.replace('{s}', s).replace('{x}', x).replace('{y}', y).replace('{z}', zoom); return Request.loadJSON(url, callback); } } Data.cache = {}; // maintain a list of cached items in order to avoid duplicates on tile borders Data.items = []; class Extrusion { static draw (context, polygon, innerPolygons, height, minHeight, color, altColor, roofColor) { let roof = this._extrude(context, polygon, height, minHeight, color, altColor), innerRoofs = []; if (innerPolygons) { for (let i = 0, il = innerPolygons.length; i < il; i++) { innerRoofs[i] = this._extrude(context, innerPolygons[i], height, minHeight, color, altColor); } } context.fillStyle = roofColor; context.beginPath(); this._ring(context, roof); if (innerPolygons) { for (let i = 0, il = innerRoofs.length; i < il; i++) { this._ring(context, innerRoofs[i]); } } context.closePath(); context.fill(); } static _extrude (context, polygon, height, minHeight, color, altColor) { let scale = CAM_Z / (CAM_Z-height), minScale = CAM_Z / (CAM_Z-minHeight), a = { x:0, y:0 }, b = { x:0, y:0 }, _a, _b, roof = []; for (let i = 0, il = polygon.length-3; i < il; i += 2) { a.x = polygon[i ]-ORIGIN_X; a.y = polygon[i+1]-ORIGIN_Y; b.x = polygon[i+2]-ORIGIN_X; b.y = polygon[i+3]-ORIGIN_Y; _a = Buildings.project(a, scale); _b = Buildings.project(b, scale); if (minHeight) { a = Buildings.project(a, minScale); b = Buildings.project(b, minScale); } // backface culling check if ((b.x-a.x) * (_a.y-a.y) > (_a.x-a.x) * (b.y-a.y)) { // depending on direction, set wall shading if ((a.x < b.x && a.y < b.y) || (a.x > b.x && a.y > b.y)) { context.fillStyle = altColor; } else { context.fillStyle = color; } context.beginPath(); this._ring(context, [ b.x, b.y, a.x, a.y, _a.x, _a.y, _b.x, _b.y ]); context.closePath(); context.fill(); } roof[i] = _a.x; roof[i+1] = _a.y; } return roof; } static _ring (context, polygon) { context.moveTo(polygon[0], polygon[1]); for (let i = 2, il = polygon.length-1; i < il; i += 2) { context.lineTo(polygon[i], polygon[i+1]); } } static simplified (context, polygon, innerPolygons) { context.beginPath(); this._ringAbs(context, polygon); if (innerPolygons) { for (let i = 0, il = innerPolygons.length; i < il; i++) { this._ringAbs(context, innerPolygons[i]); } } context.closePath(); context.fill(); } static _ringAbs (context, polygon) { context.moveTo(polygon[0]-ORIGIN_X, polygon[1]-ORIGIN_Y); for (let i = 2, il = polygon.length-1; i < il; i += 2) { context.lineTo(polygon[i]-ORIGIN_X, polygon[i+1]-ORIGIN_Y); } } static shadow (context, polygon, innerPolygons, height, minHeight) { let mode = null, a = { x:0, y:0 }, b = { x:0, y:0 }, _a, _b; for (let i = 0, il = polygon.length-3; i < il; i += 2) { a.x = polygon[i ]-ORIGIN_X; a.y = polygon[i+1]-ORIGIN_Y; b.x = polygon[i+2]-ORIGIN_X; b.y = polygon[i+3]-ORIGIN_Y; _a = Shadows.project(a, height); _b = Shadows.project(b, height); if (minHeight) { a = Shadows.project(a, minHeight); b = Shadows.project(b, minHeight); } // mode 0: floor edges, mode 1: roof edges if ((b.x-a.x) * (_a.y-a.y) > (_a.x-a.x) * (b.y-a.y)) { if (mode === 1) { context.lineTo(a.x, a.y); } mode = 0; if (!i) { context.moveTo(a.x, a.y); } context.lineTo(b.x, b.y); } else { if (mode === 0) { context.lineTo(_a.x, _a.y); } mode = 1; if (!i) { context.moveTo(_a.x, _a.y); } context.lineTo(_b.x, _b.y); } } if (innerPolygons) { for (let i = 0, il = innerPolygons.length; i < il; i++) { this._ringAbs(context, innerPolygons[i]); } } } static hitArea (context, polygon, innerPolygons, height, minHeight, color) { let mode = null, a = { x:0, y:0 }, b = { x:0, y:0 }, scale = CAM_Z / (CAM_Z-height), minScale = CAM_Z / (CAM_Z-minHeight), _a, _b; context.fillStyle = color; context.beginPath(); for (let i = 0, il = polygon.length-3; i < il; i += 2) { a.x = polygon[i ]-ORIGIN_X; a.y = polygon[i+1]-ORIGIN_Y; b.x = polygon[i+2]-ORIGIN_X; b.y = polygon[i+3]-ORIGIN_Y; _a = Buildings.project(a, scale); _b = Buildings.project(b, scale); if (minHeight) { a = Buildings.project(a, minScale); b = Buildings.project(b, minScale); } // mode 0: floor edges, mode 1: roof edges if ((b.x-a.x) * (_a.y-a.y) > (_a.x-a.x) * (b.y-a.y)) { if (mode === 1) { // mode is initially undefined context.lineTo(a.x, a.y); } mode = 0; if (!i) { context.moveTo(a.x, a.y); } context.lineTo(b.x, b.y); } else { if (mode === 0) { // mode is initially undefined context.lineTo(_a.x, _a.y); } mode = 1; if (!i) { context.moveTo(_a.x, _a.y); } context.lineTo(_b.x, _b.y); } } context.closePath(); context.fill(); } } class Cylinder { static draw (context, center, radius, topRadius, height, minHeight, color, altColor, roofColor) { let c = { x:center.x-ORIGIN_X, y:center.y-ORIGIN_Y }, scale = CAM_Z / (CAM_Z-height), minScale = CAM_Z / (CAM_Z-minHeight), apex = Buildings.project(c, scale), a1, a2; topRadius *= scale; if (minHeight) { c = Buildings.project(c, minScale); radius = radius*minScale; } // common tangents for ground and roof circle let tangents = this._tangents(c, radius, apex, topRadius); // no tangents? top circle is inside bottom circle if (!tangents) { a1 = 1.5*PI; a2 = 1.5*PI; } else { a1 = atan2(tangents[0].y1-c.y, tangents[0].x1-c.x); a2 = atan2(tangents[1].y1-c.y, tangents[1].x1-c.x); } context.fillStyle = color; context.beginPath(); context.arc(apex.x, apex.y, topRadius, HALF_PI, a1, true); context.arc(c.x, c.y, radius, a1, HALF_PI); context.closePath(); context.fill(); context.fillStyle = altColor; context.beginPath(); context.arc(apex.x, apex.y, topRadius, a2, HALF_PI, true); context.arc(c.x, c.y, radius, HALF_PI, a2); context.closePath(); context.fill(); context.fillStyle = roofColor; this._circle(context, apex, topRadius); } static simplified (context, center, radius) { this._circle(context, { x:center.x-ORIGIN_X, y:center.y-ORIGIN_Y }, radius); } static shadow (context, center, radius, topRadius, height, minHeight) { let c = { x:center.x-ORIGIN_X, y:center.y-ORIGIN_Y }, apex = Shadows.project(c, height), p1, p2; if (minHeight) { c = Shadows.project(c, minHeight); } // common tangents for ground and roof circle let tangents = this._tangents(c, radius, apex, topRadius); // TODO: no tangents? roof overlaps everything near cam position if (tangents) { p1 = atan2(tangents[0].y1-c.y, tangents[0].x1-c.x); p2 = atan2(tangents[1].y1-c.y, tangents[1].x1-c.x); context.moveTo(tangents[1].x2, tangents[1].y2); context.arc(apex.x, apex.y, topRadius, p2, p1); context.arc(c.x, c.y, radius, p1, p2); } else { context.moveTo(c.x+radius, c.y); context.arc(c.x, c.y, radius, 0, 2*PI); } } static hitArea (context, center, radius, topRadius, height, minHeight, color) { let c = { x:center.x-ORIGIN_X, y:center.y-ORIGIN_Y }, scale = CAM_Z / (CAM_Z-height), minScale = CAM_Z / (CAM_Z-minHeight), apex = Buildings.project(c, scale), p1, p2; topRadius *= scale; if (minHeight) { c = Buildings.project(c, minScale); radius = radius*minScale; } // common tangents for ground and roof circle let tangents = this._tangents(c, radius, apex, topRadius); context.fillStyle = color; context.beginPath(); // TODO: no tangents? roof overlaps everything near cam position if (tangents) { p1 = atan2(tangents[0].y1-c.y, tangents[0].x1-c.x); p2 = atan2(tangents[1].y1-c.y, tangents[1].x1-c.x); context.moveTo(tangents[1].x2, tangents[1].y2); context.arc(apex.x, apex.y, topRadius, p2, p1); context.arc(c.x, c.y, radius, p1, p2); } else { context.moveTo(c.x+radius, c.y); context.arc(c.x, c.y, radius, 0, 2*PI); } context.closePath(); context.fill(); } static _circle (context, center, radius) { context.beginPath(); context.arc(center.x, center.y, radius, 0, PI*2); context.fill(); } // http://en.wikibooks.org/wiki/Algorithm_Implementation/Geometry/Tangents_between_two_circles static _tangents (c1, r1, c2, r2) { let dx = c1.x-c2.x, dy = c1.y-c2.y, dr = r1-r2, sqdist = (dx*dx) + (dy*dy); if (sqdist <= dr*dr) { return; } let dist = sqrt(sqdist), vx = -dx/dist, vy = -dy/dist, c = dr/dist, res = [], h, nx, ny; // Let A, B be the centers, and C, D be points at which the tangent // touches first and second circle, and n be the normal vector to it. // // We have the system: // n * n = 1 (n is a unit vector) // C = A + r1 * n // D = B + r2 * n // n * CD = 0 (common orthogonality) // // n * CD = n * (AB + r2*n - r1*n) = AB*n - (r1 -/+ r2) = 0, <=> // AB * n = (r1 -/+ r2), <=> // v * n = (r1 -/+ r2) / d, where v = AB/|AB| = AB/d // This is a linear equation in unknown vector n. // Now we're just intersecting a line with a circle: v*n=c, n*n=1 h = sqrt(max(0, 1 - c*c)); for (let sign = 1; sign >= -1; sign -= 2) { nx = vx*c - sign*h*vy; ny = vy*c + sign*h*vx; res.push({ x1: c1.x + r1*nx <<0, y1: c1.y + r1*ny <<0, x2: c2.x + r2*nx <<0, y2: c2.y + r2*ny <<0 }); } return res; } } class Pyramid { static draw (context, polygon, center, height, minHeight, color, altColor) { let c = { x:center.x-ORIGIN_X, y:center.y-ORIGIN_Y }, scale = CAM_Z / (CAM_Z-height), minScale = CAM_Z / (CAM_Z-minHeight), apex = Buildings.project(c, scale), a = { x:0, y:0 }, b = { x:0, y:0 }; for (let i = 0, il = polygon.length-3; i < il; i += 2) { a.x = polygon[i ]-ORIGIN_X; a.y = polygon[i+1]-ORIGIN_Y; b.x = polygon[i+2]-ORIGIN_X; b.y = polygon[i+3]-ORIGIN_Y; if (minHeight) { a = Buildings.project(a, minScale); b = Buildings.project(b, minScale); } // backface culling check if ((b.x-a.x) * (apex.y-a.y) > (apex.x-a.x) * (b.y-a.y)) { // depending on direction, set shading if ((a.x < b.x && a.y < b.y) || (a.x > b.x && a.y > b.y)) { context.fillStyle = altColor; } else { context.fillStyle = color; } context.beginPath(); this._triangle(context, a, b, apex); context.closePath(); context.fill(); } } } static _triangle (context, a, b, c) { context.moveTo(a.x, a.y); context.lineTo(b.x, b.y); context.lineTo(c.x, c.y); } static _ring (context, polygon) { context.moveTo(polygon[0]-ORIGIN_X, polygon[1]-ORIGIN_Y); for (let i = 2, il = polygon.length-1; i < il; i += 2) { context.lineTo(polygon[i]-ORIGIN_X, polygon[i+1]-ORIGIN_Y); } } static shadow (context, polygon, center, height, minHeight) { let a = { x:0, y:0 }, b = { x:0, y:0 }, c = { x:center.x-ORIGIN_X, y:center.y-ORIGIN_Y }, apex = Shadows.project(c, height); for (let i = 0, il = polygon.length-3; i < il; i += 2) { a.x = polygon[i ]-ORIGIN_X; a.y = polygon[i+1]-ORIGIN_Y; b.x = polygon[i+2]-ORIGIN_X; b.y = polygon[i+3]-ORIGIN_Y; if (minHeight) { a = Shadows.project(a, minHeight); b = Shadows.project(b, minHeight); } // backface culling check if ((b.x-a.x) * (apex.y-a.y) > (apex.x-a.x) * (b.y-a.y)) { // depending on direction, set shading this._triangle(context, a, b, apex); } } } static hitArea (context, polygon, center, height, minHeight, color) { let c = { x:center.x-ORIGIN_X, y:center.y-ORIGIN_Y }, scale = CAM_Z / (CAM_Z-height), minScale = CAM_Z / (CAM_Z-minHeight), apex = Buildings.project(c, scale), a = { x:0, y:0 }, b = { x:0, y:0 }; context.fillStyle = color; context.beginPath(); for (let i = 0, il = polygon.length-3; i < il; i += 2) { a.x = polygon[i ]-ORIGIN_X; a.y = polygon[i+1]-ORIGIN_Y; b.x = polygon[i+2]-ORIGIN_X; b.y = polygon[i+3]-ORIGIN_Y; if (minHeight) { a = Buildings.project(a, minScale); b = Buildings.project(b, minScale); } // backface culling check if ((b.x-a.x) * (apex.y-a.y) > (apex.x-a.x) * (b.y-a.y)) { this._triangle(context, a, b, apex); } } context.closePath(); context.fill(); } } let animTimer; function fadeIn() { if (animTimer) { return; } animTimer = setInterval(t => { let dataItems = Data.items, isNeeded = false; for (let i = 0, il = dataItems.length; i < il; i++) { if (dataItems[i].scale < 1) { dataItems[i].scale += 0.5*0.2; // amount*easing if (dataItems[i].scale > 1) { dataItems[i].scale = 1; } isNeeded = true; } } Layers.render(); if (!isNeeded) { clearInterval(animTimer); animTimer = null; } }, 33); } class Layers { static init () { Layers.container.className = 'osmb-container'; // TODO: improve this Shadows.init(Layers.createContext(Layers.container)); Simplified.init(Layers.createContext(Layers.container)); Buildings.init(Layers.createContext(Layers.container)); Picking.init(Layers.createContext()); } static clear () { Shadows.clear(); Simplified.clear(); Buildings.clear(); Picking.clear(); } static setOpacity (opacity) { Shadows.setOpacity(opacity); Simplified.setOpacity(opacity); Buildings.setOpacity(opacity); Picking.setOpacity(opacity); } static render (quick) { // show on high zoom levels only if (ZOOM < MIN_ZOOM) { Layers.clear(); return; } // don't render during zoom if (IS_ZOOMING) { return; } requestAnimationFrame(f => { if (!quick) { Shadows.render(); Simplified.render(); //HitAreas.render(); // TODO: do this on demand } Buildings.render(); }); } static createContext (container) { let canvas = document.createElement('CANVAS'); canvas.className = 'osmb-layer'; let context = canvas.getContext('2d'); context.lineCap = 'round'; context.lineJoin = 'round'; context.lineWidth = 1; context.imageSmoothingEnabled = false; Layers.items.push(canvas); if (container) { container.appendChild(canvas); } return context; } static appendTo (parentNode) { parentNode.appendChild(Layers.container); } static remove () { Layers.container.parentNode.removeChild(Layers.container); } static setSize (width, height) { Layers.items.forEach(canvas => { canvas.width = width; canvas.height = height; }); } // usually called after move: container jumps by move delta, cam is reset static setPosition (x, y) { Layers.container.style.left = x +'px'; Layers.container.style.top = y +'px'; } } Layers.container = document.createElement('DIV'); Layers.items = []; class Buildings { static init (context) { this.context = context; } static clear () { this.context.clearRect(0, 0, WIDTH, HEIGHT); } static setOpacity (opacity) { this.context.canvas.style.opacity = opacity; } static project (p, m) { return { x: (p.x-CAM_X) * m + CAM_X <<0, y: (p.y-CAM_Y) * m + CAM_Y <<0 }; } static render () { this.clear(); let context = this.context, item, h, mh, sortCam = { x:CAM_X+ORIGIN_X, y:CAM_Y+ORIGIN_Y }, footprint, wallColor, altColor, roofColor, dataItems = Data.items; dataItems.sort((a, b) => { return (a.minHeight-b.minHeight) || getDistance(b.center, sortCam) - getDistance(a.center, sortCam) || (b.height-a.height); }); for (let i = 0, il = dataItems.length; i < il; i++) { item = dataItems[i]; if (Simplified.isSimple(item)) { continue; } footprint = item.footprint; if (!isVisible(footprint)) { continue; } // when fading in, use a dynamic height h = item.scale < 1 ? item.height*item.scale : item.height; mh = 0; if (item.minHeight) { mh = item.scale < 1 ? item.minHeight*item.scale : item.minHeight; } wallColor = item.wallColor || WALL_COLOR_STR; altColor = item.altColor || ALT_COLOR_STR; roofColor = item.roofColor || ROOF_COLOR_STR; context.strokeStyle = altColor; switch (item.shape) { case 'cylinder': Cylinder.draw(context, item.center, item.radius, item.radius, h, mh, wallColor, altColor, roofColor); break; case 'cone': Cylinder.draw(context, item.center, item.radius, 0, h, mh, wallColor, altColor); break; case 'dome': Cylinder.draw(context, item.center, item.radius, item.radius/2, h, mh, wallColor, altColor); break; case 'sphere': Cylinder.draw(context, item.center, item.radius, item.radius, h, mh, wallColor, altColor, roofColor); break; case 'pyramid': Pyramid.draw(context, footprint, item.center, h, mh, wallColor, altColor); break; default: Extrusion.draw(context, footprint, item.holes, h, mh, wallColor, altColor, roofColor); } switch (item.roofShape) { case 'cone': Cylinder.draw(context, item.center, item.radius, 0, h+item.roofHeight, h, roofColor, ''+ Qolor.parse(roofColor).lightness(0.9)); break; case 'dome': Cylinder.draw(context, item.center, item.radius, item.radius/2, h+item.roofHeight, h, roofColor, ''+ Qolor.parse(roofColor).lightness(0.9)); break; case 'pyramid': Pyramid.draw(context, footprint, item.center, h+item.roofHeight, h, roofColor, Qolor.parse(roofColor).lightness(0.9)); break; } } } } class Simplified { static init (context) { this.context = context; } static clear () { this.context.clearRect(0, 0, WIDTH, HEIGHT); } static setOpacity (opacity) { this.context.canvas.style.opacity = opacity; } static isSimple (item) { return (ZOOM <= Simplified.MAX_ZOOM && item.height+item.roofHeight < Simplified.MAX_HEIGHT); } static render () { this.clear(); let context = this.context; // show on high zoom levels only and avoid rendering during zoom if (ZOOM > Simplified.MAX_ZOOM) { return; } let item, footprint, dataItems = Data.items; for (let i = 0, il = dataItems.length; i < il; i++) { item = dataItems[i]; if (item.height >= Simplified.MAX_HEIGHT) { continue; } footprint = item.footprint; if (!isVisible(footprint)) { continue; } context.strokeStyle = item.altColor || ALT_COLOR_STR; context.fillStyle = item.roofColor || ROOF_COLOR_STR; switch (item.shape) { case 'cylinder': case 'cone': case 'dome': case 'sphere': Cylinder.simplified(context, item.center, item.radius); break; default: Extrusion.simplified(context, footprint, item.holes); } } } } Simplified.MAX_ZOOM = 16; // max zoom where buildings could render simplified Simplified.MAX_HEIGHT = 5; // max building height in order to be simple class Shadows { static init (context) { this.context = context; } static clear () { this.context.clearRect(0, 0, WIDTH, HEIGHT); } static setOpacity (opacity) { this.opacity = opacity; } static project (p, h) { return { x: p.x + this.direction.x*h, y: p.y + this.direction.y*h }; } static render () { this.clear(); let context = this.context, screenCenter, sun, length, alpha; // TODO: calculate this just on demand screenCenter = pixelToGeo(CENTER_X+ORIGIN_X, CENTER_Y+ORIGIN_Y); sun = getSunPosition(this.date, screenCenter.latitude, screenCenter.longitude); if (sun.altitude <= 0) { return; } length = 1 / tan(sun.altitude); alpha = length < 5 ? 0.75 : 1/length*5; this.direction.x = cos(sun.azimuth) * length; this.direction.y = sin(sun.azimuth) * length; let i, il, item, h, mh, footprint, dataItems = Data.items; context.canvas.style.opacity = alpha / (this.opacity * 2); context.shadowColor = this.blurColor; context.fillStyle = this.color; context.beginPath(); for (i = 0, il = dataItems.length; i < il; i++) { item = dataItems[i]; footprint = item.footprint; if (!isVisible(footprint)) { continue; } // when fading in, use a dynamic height h = item.scale < 1 ? item.height*item.scale : item.height; mh = 0; if (item.minHeight) { mh = item.scale < 1 ? item.minHeight*item.scale : item.minHeight; } switch (item.shape) { case 'cylinder': Cylinder.shadow(context, item.center, item.radius, item.radius, h, mh); break; case 'cone': Cylinder.shadow(context, item.center, item.radius, 0, h, mh); break; case 'dome': Cylinder.shadow(context, item.center, item.radius, item.radius/2, h, mh); break; case 'sphere': Cylinder.shadow(context, item.center, item.radius, item.radius, h, mh); break; case 'pyramid': Pyramid.shadow(context, footprint, item.center, h, mh); break; default: Extrusion.shadow(context, footprint, item.holes, h, mh); } switch (item.roofShape) { case 'cone': Cylinder.shadow(context, item.center, item.radius, 0, h+item.roofHeight, h); break; case 'dome': Cylinder.shadow(context, item.center, item.radius, item.radius/2, h+item.roofHeight, h); break; case 'pyramid': Pyramid.shadow(context, footprint, item.center, h+item.roofHeight, h); break; } } context.closePath(); context.fill(); } } Shadows.color = '#666666'; Shadows.blurColor = '#000000'; Shadows.date = new Date(); Shadows.direction = { x:0, y:0 }; Shadows.opacity = 1; class Picking { static init (context) { this.context = context; } static setOpacity (opacity) {} static clear () {} static reset () { this._idMapping = [null]; } static render () { if (this._timer) { return; } let self = this; this._timer = setTimeout(t => { self._timer = null; self._render(); }, 500); } static _render () { this.clear(); let context = this.context, item, h, mh, sortCam = { x:CAM_X+ORIGIN_X, y:CAM_Y+ORIGIN_Y }, footprint, color, dataItems = Data.items; dataItems.sort((a, b) => { return (a.minHeight-b.minHeight) || getDistance(b.center, sortCam) - getDistance(a.center, sortCam) || (b.height-a.height); }); for (let i = 0, il = dataItems.length; i < il; i++) { item = dataItems[i]; if (!(color = item.hitColor)) { continue; } footprint = item.footprint; if (!isVisible(footprint)) { continue; } h = item.height; mh = 0; if (item.minHeight) { mh = item.minHeight; } switch (item.shape) { case 'cylinder': Cylinder.hitArea(context, item.center, item.radius, item.radius, h, mh, color); break; case 'cone': Cylinder.hitArea(context, item.center, item.radius, 0, h, mh, color); break; case 'dome': Cylinder.hitArea(context, item.center, item.radius, item.radius/2, h, mh, color); break; case 'sphere': Cylinder.hitArea(context, item.center, item.radius, item.radius, h, mh, color); break; case 'pyramid': Pyramid.hitArea(context, footprint, item.center, h, mh, color); break; default: Extrusion.hitArea(context, footprint, item.holes, h, mh, color); } switch (item.roofShape) { case 'cone': Cylinder.hitArea(context, item.center, item.radius, 0, h+item.roofHeight, h, color); break; case 'dome': Cylinder.hitArea(context, item.center, item.radius, item.radius/2, h+item.roofHeight, h, color); break; case 'pyramid': Pyramid.hitArea(context, footprint, item.center, h+item.roofHeight, h, color); break; } } // otherwise fails on size 0 if (WIDTH && HEIGHT) { this._imageData = this.context.getImageData(0, 0, WIDTH, HEIGHT).data; } } static getIdFromXY (x, y) { let imageData = this._imageData; if (!imageData) { return; } let pos = 4*((y|0) * WIDTH + (x|0)); let index = imageData[pos] | (imageData[pos+1]<<8) | (imageData[pos+2]<<16); return this._idMapping[index]; } static idToColor (id) { let index = this._idMapping.indexOf(id); if (index === -1) { this._idMapping.push(id); index = this._idMapping.length-1; } let r = index & 0xff; let g = (index >>8) & 0xff; let b = (index >>16) & 0xff; return 'rgb('+ [r, g, b].join(',') +')'; } } Picking._idMapping = [null]; class Debug { static point (x, y, color, size) { const context = this.context; context.fillStyle = color || '#ffcc00'; context.beginPath(); context.arc(x, y, size || 3, 0, 2*PI); context.closePath(); context.fill(); } static line (ax, ay, bx, by, color) { const context = this.context; context.strokeStyle = color || '#ffcc00'; context.beginPath(); context.moveTo(ax, ay); context.lineTo(bx, by); context.closePath(); context.stroke(); } } function setOrigin (origin) { ORIGIN_X = origin.x; ORIGIN_Y = origin.y; } function moveCam (offset) { CAM_X = CENTER_X + offset.x; CAM_Y = HEIGHT + offset.y; Layers.render(true); } function setSize (size) { WIDTH = size.width; HEIGHT = size.height; CENTER_X = WIDTH /2 <<0; CENTER_Y = HEIGHT/2 <<0; CAM_X = CENTER_X; CAM_Y = HEIGHT; Layers.setSize(WIDTH, HEIGHT); MAX_HEIGHT = CAM_Z-50; } function setZoom (z) { ZOOM = z; MAP_SIZE = MAP_TILE_SIZE < fadeIn() => Layers.render() } function onZoomStart () { IS_ZOOMING = true; } function onZoomEnd (e) { IS_ZOOMING = false; const factor = Math.pow(2, e.zoom-ZOOM); setZoom(e.zoom); // Layers.render(); // TODO: requestAnimationFrame() causes flickering because layers are already cleared // show on high zoom levels only if (ZOOM <= MIN_ZOOM) { Layers.clear(); return; } Data.scale(factor); Shadows.render(); Simplified.render(); Buildings.render(); Data.update(); // => fadeIn() } class OSMBuildings extends L.Layer { constructor (map) { super(map); this.offset = {x: 0, y: 0}; Layers.init(); if (map) { map.addLayer(this); } } addTo (map) { map.addLayer(this); return this; } onAdd (map) { this.map = map; Layers.appendTo(map._panes.overlayPane); let off = this.getOffset(), po = map.getPixelOrigin(); setSize({width: map._size.x, height: map._size.y}); setOrigin({x: po.x - off.x, y: po.y - off.y}); setZoom(map._zoom); Layers.setPosition(-off.x, -off.y); map.on({ move: this.onMove, moveend: this.onMoveEnd, zoomstart: this.onZoomStart, zoomend: this.onZoomEnd, resize: this.onResize, viewreset: this.onViewReset, click: this.onClick }, this); if (map.options.zoomAnimation) { map.on('zoomanim', this.onZoom, this); } if (map.attributionControl) { map.attributionControl.addAttribution(ATTRIBUTION); } Data.update(); } onRemove () { let map = this.map; if (map.attributionControl) { map.attributionControl.removeAttribution(ATTRIBUTION); } map.off({ move: this.onMove, moveend: this.onMoveEnd, zoomstart: this.onZoomStart, zoomend: this.onZoomEnd, resize: this.onResize, viewreset: this.onViewReset, click: this.onClick }, this); if (map.options.zoomAnimation) { map.off('zoomanim', this.onZoom, this); } Layers.remove(); map = null; } onMove (e) { let off = this.getOffset(); moveCam({x: this.offset.x - off.x, y: this.offset.y - off.y}); } onMoveEnd (e) { if (this.noMoveEnd) { // moveend is also fired after zoom this.noMoveEnd = false; return; } let map = this.map, off = this.getOffset(), po = map.getPixelOrigin(); this.offset = off; Layers.setPosition(-off.x, -off.y); moveCam({x: 0, y: 0}); setSize({width: map._size.x, height: map._size.y}); // in case this is triggered by resize setOrigin({x: po.x - off.x, y: po.y - off.y}); onMoveEnd(e); } onZoomStart (e) { onZoomStart(e); } onZoom (e) { let center = this.map.latLngToContainerPoint(e.center); let scale = Math.pow(2, e.zoom - ZOOM); let dx = WIDTH / 2 - center.x; let dy = HEIGHT / 2 - center.y; let x = WIDTH / 2; let y = HEIGHT / 2; if (e.zoom > ZOOM) { x -= dx * scale; y -= dy * scale; } else { x += dx; y += dy; } Layers.container.classList.add('zoom-animation'); Layers.container.style.transformOrigin = x + 'px ' + y + 'px'; Layers.container.style.transform = 'translate3d(0, 0, 0) scale(' + scale + ')'; } onZoomEnd (e) { Layers.clear(); Layers.container.classList.remove('zoom-animation'); Layers.container.style.transform = 'translate3d(0, 0, 0) scale(1)'; let map = this.map, off = this.getOffset(), po = map.getPixelOrigin(); setOrigin({x: po.x - off.x, y: po.y - off.y}); onZoomEnd({zoom: map._zoom}); this.noMoveEnd = true; } onResize () { } onViewReset () { let off = this.getOffset(); this.offset = off; Layers.setPosition(-off.x, -off.y); moveCam({x: 0, y: 0}); } onClick (e) { let id = Picking.getIdFromXY(e.containerPoint.x, e.containerPoint.y); if (id) { onClick({feature: id, lat: e.latlng.lat, lon: e.latlng.lng}); } } getOffset () { return L.DomUtil.getPosition(this.map._mapPane); } //*** COMMON PUBLIC METHODS *** style (style) { style = style || {}; let color; if ((color = style.color || style.wallColor)) { WALL_COLOR = Qolor.parse(color); WALL_COLOR_STR = '' + WALL_COLOR; ALT_COLOR = WALL_COLOR.lightness(0.8); ALT_COLOR_STR = '' + ALT_COLOR; ROOF_COLOR = WALL_COLOR.lightness(1.2); ROOF_COLOR_STR = '' + ROOF_COLOR; } if (style.roofColor) { ROOF_COLOR = Qolor.parse(style.roofColor); ROOF_COLOR_STR = '' + ROOF_COLOR; } Layers.render(); return this; } date (date) { Shadows.date = date; Shadows.render(); return this; } load (url) { Data.load(url); return this; } set (data) { Data.set(data); return this; } each (handler) { onEach = function (payload) { return handler(payload); }; return this; } click (handler) { onClick = function (payload) { return handler(payload); }; return this; } } OSMBuildings.VERSION = VERSION; OSMBuildings.ATTRIBUTION = ATTRIBUTION; return OSMBuildings; }()); ================================================ FILE: dist/OSMBuildings-Leaflet.js ================================================ const OSMBuildings=function(){const e=Math,t=e.exp,i=e.log,r=e.sin,a=e.cos,o=e.tan,s=e.atan,n=e.atan2,l=e.min,c=e.max,h=e.sqrt,f=e.ceil,d=e.pow;class u{constructor(e,t,i,r=1){this.r=this._clamp(e,1),this.g=this._clamp(t,1),this.b=this._clamp(i,1),this.a=this._clamp(r,1)}static parse(e){if("string"==typeof e){let t;if(e=e.toLowerCase(),t=(e=u.w3cColors[e]||e).match(/^#?(\w{2})(\w{2})(\w{2})$/))return new u(parseInt(t[1],16)/255,parseInt(t[2],16)/255,parseInt(t[3],16)/255);if(t=e.match(/^#?(\w)(\w)(\w)$/))return new u(parseInt(t[1]+t[1],16)/255,parseInt(t[2]+t[2],16)/255,parseInt(t[3]+t[3],16)/255);if(t=e.match(/rgba?\((\d+)\D+(\d+)\D+(\d+)(\D+([\d.]+))?\)/))return new u(parseFloat(t[1])/255,parseFloat(t[2])/255,parseFloat(t[3])/255,t[4]?parseFloat(t[5]):1)}return new u}static fromHSL(e,t,i,r){const a=(new u).fromHSL(e,t,i);return a.a=void 0===r?1:r,a}_hue2rgb(e,t,i){return i<0&&(i+=1),i>1&&(i-=1),i<1/6?e+6*(t-e)*i:i<.5?t:i<2/3?e+(t-e)*(2/3-i)*6:e}_clamp(e,t){if(void 0!==e)return Math.min(t,Math.max(0,e||0))}isValid(){return void 0!==this.r&&void 0!==this.g&&void 0!==this.b}toHSL(){if(!this.isValid())return;const e=Math.max(this.r,this.g,this.b),t=Math.min(this.r,this.g,this.b),i=e-t,r=(e+t)/2;if(!i)return{h:0,s:0,l:r};const a=r>.5?i/(2-e-t):i/(e+t);let o;switch(e){case this.r:o=(this.g-this.b)/i+(this.g0?"CW":"CCW"}(e)===t)return e;let i=[];for(let t=e.length-2;t>=0;t-=2)i.push(e[t],e[t+1]);return i}function b(e){const t={};e=e||{},t.height=e.height||(e.levels?3*e.levels:G),t.minHeight=e.minHeight||(e.minLevel?3*e.minLevel:0);const i=e.material?m(e.material):e.wallColor||e.color;i&&(t.wallColor=i);const r=e.roofMaterial?m(e.roofMaterial):e.roofColor;switch(r&&(t.roofColor=r),e.shape){case"cylinder":case"cone":case"dome":case"sphere":t.shape=e.shape,t.isRotational=!0;break;case"pyramid":t.shape=e.shape}switch(e.roofShape){case"cone":case"dome":t.roofShape=e.roofShape,t.isRotational=!0;break;case"pyramid":t.roofShape=e.roofShape}return t.roofShape&&e.roofHeight?(t.roofHeight=e.roofHeight,t.height=c(0,t.height-t.roofHeight)):t.roofHeight=0,t}function w(e){let t,i,r=[];switch(e.type){case"GeometryCollection":r=[];for(let t=0,a=e.geometries.length;t1?(i=a,r=o):s>0&&(i+=n*s,r+=l*s)),n=e-i,l=t-r,n*n+l*l}function Y(e){let t=180,i=-180;for(let r=0,a=e.length;r=1?-90:function(e){return e/T*180}(2*s(t(T*(1-2*i)))-z),r.longitude=360*(1===e?1:(e%1+1)%1)-180,r}function Q(e,t){const r=l(1,c(0,.5-i(o(I+z*e/180))/T/2));return{x:(t/360+.5)*v<<0,y:r*v<<0}}function ee(e){const t=O+E,i=A+D;for(let r=0,a=e.length-3;rE&&e[r]D&&e[r+1]t&&(r=a,t=i);t>2&&(o[r]=1,l.push(s),c.push(r),l.push(r),c.push(n)),s=l.pop(),n=c.pop()}for(let t=0;t{let t=oe.items,i=!1;for(let e=0,r=t.length;e1&&(t[e].scale=1),i=!0);ce.render(),i||(clearInterval(te),te=null)},33)}()}static scalePolygon(e,t){return e.map(e=>e*t)}static scale(e){oe.items=oe.items.map(t=>{if(t.height*=e,t.minHeight*=e,t.footprint=oe.scalePolygon(t.footprint,e),t.center.x*=e,t.center.y*=e,t.radius&&(t.radius*=e),t.holes)for(let i=0,r=t.holes.length;iH)&&(i.footprint=this.getPixelFootprint(e.footprint),i.footprint)){if(i.center=function(e){let t=1/0,i=-1/0,r=1/0,a=-1/0;for(let o=0,s=e.length-3;o1.15)return!1;const c={x:i+s/2,y:a+n/2},h=(s+n)/4,f=h*h;for(let i=0;i1.2)return!1}return!0}(i.footprint)||(i.shape="cylinder"),e.holes){let t;i.holes=[];for(let r=0,a=e.holes.length;rr?a<<_-r:a>>r-_,s=E/o<<0,n=D/o<<0,l=f((E+O)/o),c=f((D+A)/o),h=this;function e(e){h.addRenderItems(e)}for(i=n;i<=c;i++)for(t=s;t<=l;t++)this.loadTile(t,i,r,e)}}static loadTile(e,t,i,r){let a="abcd"[(e+t)%4],o=this.src.replace("{s}",a).replace("{x}",e).replace("{y}",t).replace("{z}",i);return class{static loadJSON(e,t){return function(e,t){if(ie[e])return void(t&&t(ie[e]));const i=new XMLHttpRequest;return i.onreadystatechange=function(){if(4===i.readyState&&!(!i.status||i.status<200||i.status>299)&&t&&i.responseText){const r=i.responseText;for(ie[e]=r,re.push({url:e,size:r.length}),ae+=r.length,t(r);ae>5242880;){let e=re.shift();ae-=e.size,delete ie[e.url]}}},i.open("GET",e),i.send(null),i}(e,e=>{let i;try{i=JSON.parse(e)}catch(e){}t(i)})}}.loadJSON(o,r)}}oe.cache={},oe.items=[];class se{static draw(e,t,i,r,a,o,s,n){let l=this._extrude(e,t,r,a,o,s),c=[];if(i)for(let t=0,n=i.length;t(s.x-h.x)*(f.y-h.y)&&(h.xf.x&&h.y>f.y?e.fillStyle=o:e.fillStyle=a,e.beginPath(),this._ring(e,[f.x,f.y,h.x,h.y,s.x,s.y,n.x,n.y]),e.closePath(),e.fill()),d[i]=s.x,d[i+1]=s.y;return d}static _ring(e,t){e.moveTo(t[0],t[1]);for(let i=2,r=t.length-1;i(o.x-l.x)*(c.y-l.y)?(1===n&&e.lineTo(l.x,l.y),n=0,i||e.moveTo(l.x,l.y),e.lineTo(c.x,c.y)):(0===n&&e.lineTo(o.x,o.y),n=1,i||e.moveTo(o.x,o.y),e.lineTo(s.x,s.y));if(i)for(let t=0,r=i.length;t(s.x-c.x)*(h.y-c.y)?(1===l&&e.lineTo(c.x,c.y),l=0,i||e.moveTo(c.x,c.y),e.lineTo(h.x,h.y)):(0===l&&e.lineTo(s.x,s.y),l=1,i||e.moveTo(s.x,s.y),e.lineTo(n.x,n.y));e.closePath(),e.fill()}}class ne{static draw(e,t,i,r,a,o,s,l,c){let h,f,d={x:t.x-E,y:t.y-D},u=450/(450-a),g=450/(450-o),p=he.project(d,u);r*=u,o&&(d=he.project(d,g),i*=g);let y=this._tangents(d,i,p,r);y?(h=n(y[0].y1-d.y,y[0].x1-d.x),f=n(y[1].y1-d.y,y[1].x1-d.x)):(h=1.5*T,f=1.5*T),e.fillStyle=s,e.beginPath(),e.arc(p.x,p.y,r,z,h,!0),e.arc(d.x,d.y,i,h,z),e.closePath(),e.fill(),e.fillStyle=l,e.beginPath(),e.arc(p.x,p.y,r,f,z,!0),e.arc(d.x,d.y,i,z,f),e.closePath(),e.fill(),e.fillStyle=c,this._circle(e,p,r)}static simplified(e,t,i){this._circle(e,{x:t.x-E,y:t.y-D},i)}static shadow(e,t,i,r,a,o){let s,l,c={x:t.x-E,y:t.y-D},h=de.project(c,a);o&&(c=de.project(c,o));let f=this._tangents(c,i,h,r);f?(s=n(f[0].y1-c.y,f[0].x1-c.x),l=n(f[1].y1-c.y,f[1].x1-c.x),e.moveTo(f[1].x2,f[1].y2),e.arc(h.x,h.y,r,l,s),e.arc(c.x,c.y,i,s,l)):(e.moveTo(c.x+i,c.y),e.arc(c.x,c.y,i,0,2*T))}static hitArea(e,t,i,r,a,o,s){let l,c,h={x:t.x-E,y:t.y-D},f=450/(450-a),d=450/(450-o),u=he.project(h,f);r*=f,o&&(h=he.project(h,d),i*=d);let g=this._tangents(h,i,u,r);e.fillStyle=s,e.beginPath(),g?(l=n(g[0].y1-h.y,g[0].x1-h.x),c=n(g[1].y1-h.y,g[1].x1-h.x),e.moveTo(g[1].x2,g[1].y2),e.arc(u.x,u.y,r,c,l),e.arc(h.x,h.y,i,l,c)):(e.moveTo(h.x+i,h.y),e.arc(h.x,h.y,i,0,2*T)),e.closePath(),e.fill()}static _circle(e,t,i){e.beginPath(),e.arc(t.x,t.y,i,0,2*T),e.fill()}static _tangents(e,t,i,r){let a=e.x-i.x,o=e.y-i.y,s=t-r,n=a*a+o*o;if(n<=s*s)return;let l,f,d,u=h(n),g=-a/u,p=-o/u,y=s/u,m=[];l=h(c(0,1-y*y));for(let a=1;a>=-1;a-=2)f=g*y-a*l*p,d=p*y+a*l*g,m.push({x1:e.x+t*f<<0,y1:e.y+t*d<<0,x2:i.x+r*f<<0,y2:i.y+r*d<<0});return m}}class le{static draw(e,t,i,r,a,o,s){let n={x:i.x-E,y:i.y-D},l=450/(450-r),c=450/(450-a),h=he.project(n,l),f={x:0,y:0},d={x:0,y:0};for(let i=0,r=t.length-3;i(h.x-f.x)*(d.y-f.y)&&(f.xd.x&&f.y>d.y?e.fillStyle=s:e.fillStyle=o,e.beginPath(),this._triangle(e,f,d,h),e.closePath(),e.fill())}static _triangle(e,t,i,r){e.moveTo(t.x,t.y),e.lineTo(i.x,i.y),e.lineTo(r.x,r.y)}static _ring(e,t){e.moveTo(t[0]-E,t[1]-D);for(let i=2,r=t.length-1;i(l.x-o.x)*(s.y-o.y)&&this._triangle(e,o,s,l)}static hitArea(e,t,i,r,a,o){let s={x:i.x-E,y:i.y-D},n=450/(450-r),l=450/(450-a),c=he.project(s,n),h={x:0,y:0},f={x:0,y:0};e.fillStyle=o,e.beginPath();for(let i=0,r=t.length-3;i(c.x-h.x)*(f.y-h.y)&&this._triangle(e,h,f,c);e.closePath(),e.fill()}}class ce{static init(){ce.container.className="osmb-container",de.init(ce.createContext(ce.container)),fe.init(ce.createContext(ce.container)),he.init(ce.createContext(ce.container)),ue.init(ce.createContext())}static clear(){de.clear(),fe.clear(),he.clear(),ue.clear()}static setOpacity(e){de.setOpacity(e),fe.setOpacity(e),he.setOpacity(e),ue.setOpacity(e)}static render(e){_<15?ce.clear():M||requestAnimationFrame(t=>{e||(de.render(),fe.render()),he.render()})}static createContext(e){let t=document.createElement("CANVAS");t.className="osmb-layer";let i=t.getContext("2d");return i.lineCap="round",i.lineJoin="round",i.lineWidth=1,i.imageSmoothingEnabled=!1,ce.items.push(t),e&&e.appendChild(t),i}static appendTo(e){e.appendChild(ce.container)}static remove(){ce.container.parentNode.removeChild(ce.container)}static setSize(e,t){ce.items.forEach(i=>{i.width=e,i.height=t})}static setPosition(e,t){ce.container.style.left=e+"px",ce.container.style.top=t+"px"}}ce.container=document.createElement("DIV"),ce.items=[];class he{static init(e){this.context=e}static clear(){this.context.clearRect(0,0,O,A)}static setOpacity(e){this.context.canvas.style.opacity=e}static project(e,t){return{x:(e.x-C)*t+C<<0,y:(e.y-S)*t+S<<0}}static render(){this.clear();let e,t,i,r,a,o,s,n=this.context,l={x:C+E,y:S+D},c=oe.items;c.sort((e,t)=>e.minHeight-t.minHeight||B(t.center,l)-B(e.center,l)||t.height-e.height);for(let l=0,h=c.length;lfe.MAX_ZOOM)return;let t,i,r=oe.items;for(let a=0,o=r.length;a=fe.MAX_HEIGHT)&&(i=t.footprint,ee(i)))switch(e.strokeStyle=t.altColor||V,e.fillStyle=t.roofColor||X,t.shape){case"cylinder":case"cone":case"dome":case"sphere":ne.simplified(e,t.center,t.radius);break;default:se.simplified(e,i,t.holes)}}}fe.MAX_ZOOM=16,fe.MAX_HEIGHT=5;class de{static init(e){this.context=e}static clear(){this.context.clearRect(0,0,O,A)}static setOpacity(e){this.opacity=e}static project(e,t){return{x:e.x+this.direction.x*t,y:e.y+this.direction.y*t}}static render(){this.clear();let e,t,i,s,n=this.context;if(e=K(j+E,R+D),t=g(this.date,e.latitude,e.longitude),t.altitude<=0)return;i=1/o(t.altitude),s=i<5?.75:1/i*5,this.direction.x=a(t.azimuth)*i,this.direction.y=r(t.azimuth)*i;let l,c,h,f,d,u,p=oe.items;for(n.canvas.style.opacity=s/(2*this.opacity),n.shadowColor=this.blurColor,n.fillStyle=this.color,n.beginPath(),l=0,c=p.length;l{e._timer=null,e._render()},500)}static _render(){this.clear();let e,t,i,r,a,o=this.context,s={x:C+E,y:S+D},n=oe.items;n.sort((e,t)=>e.minHeight-t.minHeight||B(t.center,s)-B(e.center,s)||t.height-e.height);for(let s=0,l=n.length;s>8&255,t>>16&255].join(",")+")"}}ue._idMapping=[null];function ge(e){E=e.x,D=e.y}function pe(e){C=j+e.x,S=A+e.y,ce.render(!0)}function ye(e){O=e.width,A=e.height,j=O/2<<0,R=A/2<<0,C=j,S=A,ce.setSize(O,A),H=400}function me(e){_=e,v=256<<_;const t=K(E+j,D+R),i=Q(t.latitude,0),r=Q(t.latitude,1);$=r.x-i.x,ce.setOpacity(Math.pow(.95,_-15)),Z=""+F,V=""+N,X=""+q}class xe extends L.Layer{constructor(e){super(e),this.offset={x:0,y:0},ce.init(),e&&e.addLayer(this)}addTo(e){return e.addLayer(this),this}onAdd(e){this.map=e,ce.appendTo(e._panes.overlayPane);let t=this.getOffset(),i=e.getPixelOrigin();ye({width:e._size.x,height:e._size.y}),ge({x:i.x-t.x,y:i.y-t.y}),me(e._zoom),ce.setPosition(-t.x,-t.y),e.on({move:this.onMove,moveend:this.onMoveEnd,zoomstart:this.onZoomStart,zoomend:this.onZoomEnd,resize:this.onResize,viewreset:this.onViewReset,click:this.onClick},this),e.options.zoomAnimation&&e.on("zoomanim",this.onZoom,this),e.attributionControl&&e.attributionControl.addAttribution(P),oe.update()}onRemove(){let e=this.map;e.attributionControl&&e.attributionControl.removeAttribution(P),e.off({move:this.onMove,moveend:this.onMoveEnd,zoomstart:this.onZoomStart,zoomend:this.onZoomEnd,resize:this.onResize,viewreset:this.onViewReset,click:this.onClick},this),e.options.zoomAnimation&&e.off("zoomanim",this.onZoom,this),ce.remove(),e=null}onMove(e){let t=this.getOffset();pe({x:this.offset.x-t.x,y:this.offset.y-t.y})}onMoveEnd(e){if(this.noMoveEnd)return void(this.noMoveEnd=!1);let t=this.map,i=this.getOffset(),r=t.getPixelOrigin();this.offset=i,ce.setPosition(-i.x,-i.y),pe({x:0,y:0}),ye({width:t._size.x,height:t._size.y}),ge({x:r.x-i.x,y:r.y-i.y}),ce.render(),oe.update()}onZoomStart(e){M=!0}onZoom(e){let t=this.map.latLngToContainerPoint(e.center),i=Math.pow(2,e.zoom-_),r=O/2-t.x,a=A/2-t.y,o=O/2,s=A/2;e.zoom>_?(o-=r*i,s-=a*i):(o+=r,s+=a),ce.container.classList.add("zoom-animation"),ce.container.style.transformOrigin=o+"px "+s+"px",ce.container.style.transform="translate3d(0, 0, 0) scale("+i+")"}onZoomEnd(e){ce.clear(),ce.container.classList.remove("zoom-animation"),ce.container.style.transform="translate3d(0, 0, 0) scale(1)";let t=this.map,i=this.getOffset(),r=t.getPixelOrigin();ge({x:r.x-i.x,y:r.y-i.y}),function(e){M=!1;const t=Math.pow(2,e.zoom-_);me(e.zoom),_<=15?ce.clear():(oe.scale(t),de.render(),fe.render(),he.render(),oe.update())}({zoom:t._zoom}),this.noMoveEnd=!0}onResize(){}onViewReset(){let e=this.getOffset();this.offset=e,ce.setPosition(-e.x,-e.y),pe({x:0,y:0})}onClick(e){let t=ue.getIdFromXY(e.containerPoint.x,e.containerPoint.y);t&&J({feature:t,lat:e.latlng.lat,lon:e.latlng.lng})}getOffset(){return L.DomUtil.getPosition(this.map._mapPane)}style(e){let t;return(t=(e=e||{}).color||e.wallColor)&&(F=u.parse(t),Z=""+F,N=F.lightness(.8),V=""+N,q=F.lightness(1.2),X=""+q),e.roofColor&&(q=u.parse(e.roofColor),X=""+q),ce.render(),this}date(e){return de.date=e,de.render(),this}load(e){return oe.load(e),this}set(e){return oe.set(e),this}each(e){return W=function(t){return e(t)},this}click(e){return J=function(t){return e(t)},this}}return xe.VERSION="0.3.2",xe.ATTRIBUTION=P,xe}(); ================================================ FILE: dist/OSMBuildings-OpenLayers.debug.js ================================================ const OSMBuildings = (function() { const m = Math, exp = m.exp, log = m.log, sin = m.sin, cos = m.cos, tan = m.tan, atan = m.atan, atan2 = m.atan2, min = m.min, max = m.max, sqrt = m.sqrt, ceil = m.ceil, pow = m.pow; /** * @class */ class Qolor { /** * @constructor * @param r {Number} 0.0 .. 1.0 red value of a color * @param g {Number} 0.0 .. 1.0 green value of a color * @param b {Number} 0.0 .. 1.0 blue value of a color * @param a {Number} 0.0 .. 1.0 alpha value of a color, default 1 */ constructor (r, g, b, a = 1) { this.r = this._clamp(r, 1); this.g = this._clamp(g, 1); this.b = this._clamp(b, 1); this.a = this._clamp(a, 1); } /** * @param str {String} can be any color dfinition like: 'red', '#0099ff', 'rgb(64, 128, 255)', 'rgba(64, 128, 255, 0.5)' */ static parse (str) { if (typeof str === 'string') { str = str.toLowerCase(); str = Qolor.w3cColors[str] || str; let m; if ((m = str.match(/^#?(\w{2})(\w{2})(\w{2})$/))) { return new Qolor(parseInt(m[1], 16)/255, parseInt(m[2], 16)/255, parseInt(m[3], 16)/255); } if ((m = str.match(/^#?(\w)(\w)(\w)$/))) { return new Qolor(parseInt(m[1]+m[1], 16)/255, parseInt(m[2]+m[2], 16)/255, parseInt(m[3]+m[3], 16)/255); } if ((m = str.match(/rgba?\((\d+)\D+(\d+)\D+(\d+)(\D+([\d.]+))?\)/))) { return new Qolor( parseFloat(m[1])/255, parseFloat(m[2])/255, parseFloat(m[3])/255, m[4] ? parseFloat(m[5]) : 1 ); } } return new Qolor(); } static fromHSL (h, s, l, a) { const qolor = new Qolor().fromHSL(h, s, l); qolor.a = a === undefined ? 1 : a; return qolor; } //*************************************************************************** _hue2rgb(p, q, t) { if (t<0) t += 1; if (t>1) t -= 1; if (t<1/6) return p + (q - p)*6*t; if (t<1/2) return q; if (t<2/3) return p + (q - p)*(2/3 - t)*6; return p; } _clamp(v, max) { if (v === undefined) { return; } return Math.min(max, Math.max(0, v || 0)); } //*************************************************************************** isValid () { return this.r !== undefined && this.g !== undefined && this.b !== undefined; } toHSL () { if (!this.isValid()) { return; } const max = Math.max(this.r, this.g, this.b); const min = Math.min(this.r, this.g, this.b); const range = max - min; const l = (max + min)/2; // achromatic if (!range) { return { h: 0, s: 0, l: l }; } const s = l > 0.5 ? range/(2 - max - min) : range/(max + min); let h; switch (max) { case this.r: h = (this.g - this.b)/range + (this.g 0 ? WINDING_CLOCKWISE : WINDING_COUNTER_CLOCKWISE; } // enforce a polygon winding direcetion. Needed for proper backface culling. function makeWinding (points, direction) { let winding = getWinding(points); if (winding === direction) { return points; } let revPoints = []; for (let i = points.length-2; i >= 0; i -= 2) { revPoints.push(points[i], points[i+1]); } return revPoints; } function alignProperties(prop) { const item = {}; prop = prop || {}; item.height = prop.height || (prop.levels ? prop.levels *METERS_PER_LEVEL : DEFAULT_HEIGHT); item.minHeight = prop.minHeight || (prop.minLevel ? prop.minLevel*METERS_PER_LEVEL : 0); const wallColor = prop.material ? getMaterialColor(prop.material) : (prop.wallColor || prop.color); if (wallColor) { item.wallColor = wallColor; } const roofColor = prop.roofMaterial ? getMaterialColor(prop.roofMaterial) : prop.roofColor; if (roofColor) { item.roofColor = roofColor; } switch (prop.shape) { case 'cylinder': case 'cone': case 'dome': case 'sphere': item.shape = prop.shape; item.isRotational = true; break; case 'pyramid': item.shape = prop.shape; break; } switch (prop.roofShape) { case 'cone': case 'dome': item.roofShape = prop.roofShape; item.isRotational = true; break; case 'pyramid': item.roofShape = prop.roofShape; break; } if (item.roofShape && prop.roofHeight) { item.roofHeight = prop.roofHeight; item.height = max(0, item.height-item.roofHeight); } else { item.roofHeight = 0; } return item; } function getGeometries (geometry) { let polygon, geometries = [], sub; switch (geometry.type) { case 'GeometryCollection': geometries = []; for (let i = 0, il = geometry.geometries.length; i < il; i++) { if ((sub = getGeometries(geometry.geometries[i]))) { geometries.push.apply(geometries, sub); } } return geometries; case 'MultiPolygon': geometries = []; for (let i = 0, il = geometry.coordinates.length; i < il; i++) { if ((sub = getGeometries({ type: 'Polygon', coordinates: geometry.coordinates[i] }))) { geometries.push.apply(geometries, sub); } } return geometries; case 'Polygon': polygon = geometry.coordinates; break; default: return []; } let p, lat = 1, lon = 0, outer = [], inner = []; p = polygon[0]; for (let i = 0, il = p.length; i < il; i++) { outer.push(p[i][lat], p[i][lon]); } outer = makeWinding(outer, WINDING_CLOCKWISE); for (let i = 0, il = polygon.length-1; i < il; i++) { p = polygon[i+1]; inner[i] = []; for (let j = 0, jl = p.length; j < jl; j++) { inner[i].push(p[j][lat], p[j][lon]); } inner[i] = makeWinding(inner[i], WINDING_COUNTER_CLOCKWISE); } return [{ outer: outer, inner: inner.length ? inner : null }]; } function clone (obj) { let res = {}; for (const p in obj) { if (obj.hasOwnProperty(p)) { res[p] = obj[p]; } } return res; } class GeoJSON { static read (geojson) { if (!geojson || geojson.type !== 'FeatureCollection') { return []; } const collection = geojson.features; const res = []; for (let i = 0, il = collection.length; i < il; i++) { const feature = collection[i]; if (feature.type !== 'Feature' || onEach(feature) === false) { continue; } const baseItem = alignProperties(feature.properties); const geometries = getGeometries(feature.geometry); for (let j = 0, jl = geometries.length; j < jl; j++) { const item = clone(baseItem); item.footprint = geometries[j].outer; if (item.isRotational) { item.radius = getLonDelta(item.footprint); } if (geometries[j].inner) { item.holes = geometries[j].inner; } if (feature.id || feature.properties.id) { item.id = feature.id || feature.properties.id; } if (feature.properties.relationId) { item.relationId = feature.properties.relationId; } res.push(item); // TODO: clone base properties! } } return res; } } let VERSION = '0.3.2', ATTRIBUTION = '© OSM Buildings', DATA_SRC = 'https://{s}.data.osmbuildings.org/0.2/{k}/tile/{z}/{x}/{y}.json', PI = Math.PI, HALF_PI = PI/2, QUARTER_PI = PI/4, MAP_TILE_SIZE = 256, // map tile size in pixels ZOOM, MAP_SIZE, MIN_ZOOM = 15, LAT = 'latitude', LON = 'longitude', WIDTH = 0, HEIGHT = 0, CENTER_X = 0, CENTER_Y = 0, ORIGIN_X = 0, ORIGIN_Y = 0, WALL_COLOR = Qolor.parse('rgba(200, 190, 180)'), ALT_COLOR = WALL_COLOR.lightness(0.8), ROOF_COLOR = WALL_COLOR.lightness(1.2), WALL_COLOR_STR = ''+ WALL_COLOR, ALT_COLOR_STR = ''+ ALT_COLOR, ROOF_COLOR_STR = ''+ ROOF_COLOR, PIXEL_PER_DEG = 0, MAX_HEIGHT, // taller buildings will be cut to this DEFAULT_HEIGHT = 5, CAM_X, CAM_Y, CAM_Z = 450, IS_ZOOMING; function onEach () {} function onClick () {} function getDistance (p1, p2) { const dx = p1.x-p2.x, dy = p1.y-p2.y; return dx*dx + dy*dy; } function isRotational (polygon) { const length = polygon.length; if (length < 16) { return false; } let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; for (let i = 0; i < length-1; i+=2) { minX = Math.min(minX, polygon[i]); maxX = Math.max(maxX, polygon[i]); minY = Math.min(minY, polygon[i+1]); maxY = Math.max(maxY, polygon[i+1]); } const width = maxX-minX, height = (maxY-minY), ratio = width/height; if (ratio < 0.85 || ratio > 1.15) { return false; } const center = { x:minX+width/2, y:minY+height/2 }, radius = (width+height)/4, sqRadius = radius*radius; for (let i = 0; i < length-1; i+=2) { const dist = getDistance({ x:polygon[i], y:polygon[i+1] }, center); if (dist/sqRadius < 0.8 || dist/sqRadius > 1.2) { return false; } } return true; } function getSquareSegmentDistance (px, py, p1x, p1y, p2x, p2y) { let dx = p2x-p1x, dy = p2y-p1y, t; if (dx !== 0 || dy !== 0) { t = ((px-p1x) * dx + (py-p1y) * dy) / (dx*dx + dy*dy); if (t > 1) { p1x = p2x; p1y = p2y; } else if (t > 0) { p1x += dx*t; p1y += dy*t; } } dx = px-p1x; dy = py-p1y; return dx*dx + dy*dy; } function simplifyPolygon (buffer) { let sqTolerance = 2, len = buffer.length/2, markers = new Uint8Array(len), first = 0, last = len-1, maxSqDist, sqDist, index, firstStack = [], lastStack = [], newBuffer = []; markers[first] = markers[last] = 1; while (last) { maxSqDist = 0; for (let i = first+1; i < last; i++) { sqDist = getSquareSegmentDistance( buffer[i *2], buffer[i *2 + 1], buffer[first*2], buffer[first*2 + 1], buffer[last *2], buffer[last *2 + 1] ); if (sqDist > maxSqDist) { index = i; maxSqDist = sqDist; } } if (maxSqDist > sqTolerance) { markers[index] = 1; firstStack.push(first); lastStack.push(index); firstStack.push(index); lastStack.push(last); } first = firstStack.pop(); last = lastStack.pop(); } for (let i = 0; i < len; i++) { if (markers[i]) { newBuffer.push(buffer[i*2], buffer[i*2 + 1]); } } return newBuffer; } function getCenter (footprint) { let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; for (let i = 0, il = footprint.length-3; i < il; i += 2) { minX = min(minX, footprint[i]); maxX = max(maxX, footprint[i]); minY = min(minY, footprint[i+1]); maxY = max(maxY, footprint[i+1]); } return { x:minX+(maxX-minX)/2 <<0, y:minY+(maxY-minY)/2 <<0 }; } let EARTH_RADIUS = 6378137; function getLonDelta (footprint) { let minLon = 180, maxLon = -180; for (let i = 0, il = footprint.length; i < il; i += 2) { minLon = min(minLon, footprint[i+1]); maxLon = max(maxLon, footprint[i+1]); } return (maxLon-minLon)/2; } function rad (deg) { return deg * PI / 180; } function deg (rad) { return rad / PI * 180; } function pixelToGeo (x, y) { const res = {}; x /= MAP_SIZE; y /= MAP_SIZE; res[LAT] = y <= 0 ? 90 : y >= 1 ? -90 : deg(2 * atan(exp(PI * (1 - 2*y))) - HALF_PI); res[LON] = (x === 1 ? 1 : (x%1 + 1) % 1) * 360 - 180; return res; } function geoToPixel (lat, lon) { const latitude = min(1, max(0, 0.5 - (log(tan(QUARTER_PI + HALF_PI * lat / 180)) / PI) / 2)), longitude = lon/360 + 0.5; return { x: longitude*MAP_SIZE <<0, y: latitude *MAP_SIZE <<0 }; } function fromRange (sVal, sMin, sMax, dMin, dMax) { sVal = min(max(sVal, sMin), sMax); const rel = (sVal-sMin) / (sMax-sMin), range = dMax-dMin; return min(max(dMin + rel*range, dMin), dMax); } function isVisible (polygon) { const maxX = WIDTH+ORIGIN_X, maxY = HEIGHT+ORIGIN_Y; // TODO: checking footprint is sufficient for visibility - NOT VALID FOR SHADOWS! for (let i = 0, il = polygon.length-3; i < il; i+=2) { if (polygon[i] > ORIGIN_X && polygon[i] < maxX && polygon[i+1] > ORIGIN_Y && polygon[i+1] < maxY) { return true; } } return false; } let cacheData = {}; let cacheIndex = []; let cacheSize = 0; let maxCacheSize = 1024*1024 * 5; // 5MB function xhr (url, callback) { if (cacheData[url]) { if (callback) { callback(cacheData[url]); } return; } const req = new XMLHttpRequest(); req.onreadystatechange = function () { if (req.readyState !== 4) { return; } if (!req.status || req.status < 200 || req.status > 299) { return; } if (callback && req.responseText) { const responseText = req.responseText; cacheData[url] = responseText; cacheIndex.push({ url: url, size: responseText.length }); cacheSize += responseText.length; callback(responseText); while (cacheSize > maxCacheSize) { let item = cacheIndex.shift(); cacheSize -= item.size; delete cacheData[item.url]; } } }; req.open('GET', url); req.send(null); return req; } class Request { static loadJSON (url, callback) { return xhr(url, responseText => { let json; try { json = JSON.parse(responseText); } catch(ex) {} callback(json); }); } } class Data { static getPixelFootprint (buffer) { let footprint = new Int32Array(buffer.length), px; for (let i = 0, il = buffer.length-1; i < il; i+=2) { px = geoToPixel(buffer[i], buffer[i+1]); footprint[i] = px.x; footprint[i+1] = px.y; } footprint = simplifyPolygon(footprint); if (footprint.length < 8) { // 3 points & end==start (*2) return; } return footprint; } static resetItems () { this.items = []; this.cache = {}; Picking.reset(); } static addRenderItems (data, allAreNew) { let item, scaledItem, id; let geojson = GeoJSON.read(data); for (let i = 0, il = geojson.length; i < il; i++) { item = geojson[i]; id = item.id || [item.footprint[0], item.footprint[1], item.height, item.minHeight].join(','); if (!this.cache[id]) { if ((scaledItem = this.scaleItem(item))) { scaledItem.scale = allAreNew ? 0 : 1; this.items.push(scaledItem); this.cache[id] = 1; } } } fadeIn(); } static scalePolygon (buffer, factor) { return buffer.map(coord => coord*factor); } static scale (factor) { Data.items = Data.items.map(item => { // item.height = Math.min(item.height*factor, MAX_HEIGHT); // TODO: should be filtered by renderer item.height *= factor; item.minHeight *= factor; item.footprint = Data.scalePolygon(item.footprint, factor); item.center.x *= factor; item.center.y *= factor; if (item.radius) { item.radius *= factor; } if (item.holes) { for (let i = 0, il = item.holes.length; i < il; i++) { item.holes[i] = Data.scalePolygon(item.holes[i], factor); } } item.roofHeight *= factor; return item; }); } static scaleItem (item) { let res = {}, // TODO: calculate this on zoom change only zoomScale = 6 / pow(2, ZOOM-MIN_ZOOM); // TODO: consider using HEIGHT / (devicePixelRatio || 1) if (item.id) { res.id = item.id; } res.height = min(item.height/zoomScale, MAX_HEIGHT); res.minHeight = isNaN(item.minHeight) ? 0 : item.minHeight / zoomScale; if (res.minHeight > MAX_HEIGHT) { return; } res.footprint = this.getPixelFootprint(item.footprint); if (!res.footprint) { return; } res.center = getCenter(res.footprint); if (item.radius) { res.radius = item.radius*PIXEL_PER_DEG; } if (item.shape) { res.shape = item.shape; } if (item.roofShape) { res.roofShape = item.roofShape; } if ((res.roofShape === 'cone' || res.roofShape === 'dome') && !res.shape && isRotational(res.footprint)) { res.shape = 'cylinder'; } if (item.holes) { res.holes = []; let innerFootprint; for (let i = 0, il = item.holes.length; i < il; i++) { // TODO: simplify if ((innerFootprint = this.getPixelFootprint(item.holes[i]))) { res.holes.push(innerFootprint); } } } let color; if (item.wallColor) { if ((color = Qolor.parse(item.wallColor))) { res.altColor = ''+ color.lightness(0.8); res.wallColor = ''+ color; } } if (item.roofColor) { if ((color = Qolor.parse(item.roofColor))) { res.roofColor = ''+ color; } } if (item.relationId) { res.relationId = item.relationId; } res.hitColor = Picking.idToColor(item.relationId || item.id); res.roofHeight = isNaN(item.roofHeight) ? 0 : item.roofHeight/zoomScale; if (res.height+res.roofHeight <= res.minHeight) { return; } return res; } static set (data) { this.resetItems(); this._staticData = data; this.addRenderItems(this._staticData, true); } static load (src, key) { this.src = src || DATA_SRC.replace('{k}', (key || 'anonymous')); this.update(); } static update () { this.resetItems(); if (ZOOM < MIN_ZOOM) { return; } if (this._staticData) { this.addRenderItems(this._staticData); } if (this.src) { let tileZoom = 16, tileSize = 256, zoomedTileSize = ZOOM > tileZoom ? tileSize << (ZOOM - tileZoom) : tileSize >> (tileZoom - ZOOM), minX = ORIGIN_X / zoomedTileSize << 0, minY = ORIGIN_Y / zoomedTileSize << 0, maxX = ceil((ORIGIN_X + WIDTH) / zoomedTileSize), maxY = ceil((ORIGIN_Y + HEIGHT) / zoomedTileSize), x, y; let scope = this; function callback (json) { scope.addRenderItems(json); } for (y = minY; y <= maxY; y++) { for (x = minX; x <= maxX; x++) { this.loadTile(x, y, tileZoom, callback); } } } } static loadTile (x, y, zoom, callback) { let s = 'abcd'[(x+y) % 4]; let url = this.src.replace('{s}', s).replace('{x}', x).replace('{y}', y).replace('{z}', zoom); return Request.loadJSON(url, callback); } } Data.cache = {}; // maintain a list of cached items in order to avoid duplicates on tile borders Data.items = []; class Extrusion { static draw (context, polygon, innerPolygons, height, minHeight, color, altColor, roofColor) { let roof = this._extrude(context, polygon, height, minHeight, color, altColor), innerRoofs = []; if (innerPolygons) { for (let i = 0, il = innerPolygons.length; i < il; i++) { innerRoofs[i] = this._extrude(context, innerPolygons[i], height, minHeight, color, altColor); } } context.fillStyle = roofColor; context.beginPath(); this._ring(context, roof); if (innerPolygons) { for (let i = 0, il = innerRoofs.length; i < il; i++) { this._ring(context, innerRoofs[i]); } } context.closePath(); context.fill(); } static _extrude (context, polygon, height, minHeight, color, altColor) { let scale = CAM_Z / (CAM_Z-height), minScale = CAM_Z / (CAM_Z-minHeight), a = { x:0, y:0 }, b = { x:0, y:0 }, _a, _b, roof = []; for (let i = 0, il = polygon.length-3; i < il; i += 2) { a.x = polygon[i ]-ORIGIN_X; a.y = polygon[i+1]-ORIGIN_Y; b.x = polygon[i+2]-ORIGIN_X; b.y = polygon[i+3]-ORIGIN_Y; _a = Buildings.project(a, scale); _b = Buildings.project(b, scale); if (minHeight) { a = Buildings.project(a, minScale); b = Buildings.project(b, minScale); } // backface culling check if ((b.x-a.x) * (_a.y-a.y) > (_a.x-a.x) * (b.y-a.y)) { // depending on direction, set wall shading if ((a.x < b.x && a.y < b.y) || (a.x > b.x && a.y > b.y)) { context.fillStyle = altColor; } else { context.fillStyle = color; } context.beginPath(); this._ring(context, [ b.x, b.y, a.x, a.y, _a.x, _a.y, _b.x, _b.y ]); context.closePath(); context.fill(); } roof[i] = _a.x; roof[i+1] = _a.y; } return roof; } static _ring (context, polygon) { context.moveTo(polygon[0], polygon[1]); for (let i = 2, il = polygon.length-1; i < il; i += 2) { context.lineTo(polygon[i], polygon[i+1]); } } static simplified (context, polygon, innerPolygons) { context.beginPath(); this._ringAbs(context, polygon); if (innerPolygons) { for (let i = 0, il = innerPolygons.length; i < il; i++) { this._ringAbs(context, innerPolygons[i]); } } context.closePath(); context.fill(); } static _ringAbs (context, polygon) { context.moveTo(polygon[0]-ORIGIN_X, polygon[1]-ORIGIN_Y); for (let i = 2, il = polygon.length-1; i < il; i += 2) { context.lineTo(polygon[i]-ORIGIN_X, polygon[i+1]-ORIGIN_Y); } } static shadow (context, polygon, innerPolygons, height, minHeight) { let mode = null, a = { x:0, y:0 }, b = { x:0, y:0 }, _a, _b; for (let i = 0, il = polygon.length-3; i < il; i += 2) { a.x = polygon[i ]-ORIGIN_X; a.y = polygon[i+1]-ORIGIN_Y; b.x = polygon[i+2]-ORIGIN_X; b.y = polygon[i+3]-ORIGIN_Y; _a = Shadows.project(a, height); _b = Shadows.project(b, height); if (minHeight) { a = Shadows.project(a, minHeight); b = Shadows.project(b, minHeight); } // mode 0: floor edges, mode 1: roof edges if ((b.x-a.x) * (_a.y-a.y) > (_a.x-a.x) * (b.y-a.y)) { if (mode === 1) { context.lineTo(a.x, a.y); } mode = 0; if (!i) { context.moveTo(a.x, a.y); } context.lineTo(b.x, b.y); } else { if (mode === 0) { context.lineTo(_a.x, _a.y); } mode = 1; if (!i) { context.moveTo(_a.x, _a.y); } context.lineTo(_b.x, _b.y); } } if (innerPolygons) { for (let i = 0, il = innerPolygons.length; i < il; i++) { this._ringAbs(context, innerPolygons[i]); } } } static hitArea (context, polygon, innerPolygons, height, minHeight, color) { let mode = null, a = { x:0, y:0 }, b = { x:0, y:0 }, scale = CAM_Z / (CAM_Z-height), minScale = CAM_Z / (CAM_Z-minHeight), _a, _b; context.fillStyle = color; context.beginPath(); for (let i = 0, il = polygon.length-3; i < il; i += 2) { a.x = polygon[i ]-ORIGIN_X; a.y = polygon[i+1]-ORIGIN_Y; b.x = polygon[i+2]-ORIGIN_X; b.y = polygon[i+3]-ORIGIN_Y; _a = Buildings.project(a, scale); _b = Buildings.project(b, scale); if (minHeight) { a = Buildings.project(a, minScale); b = Buildings.project(b, minScale); } // mode 0: floor edges, mode 1: roof edges if ((b.x-a.x) * (_a.y-a.y) > (_a.x-a.x) * (b.y-a.y)) { if (mode === 1) { // mode is initially undefined context.lineTo(a.x, a.y); } mode = 0; if (!i) { context.moveTo(a.x, a.y); } context.lineTo(b.x, b.y); } else { if (mode === 0) { // mode is initially undefined context.lineTo(_a.x, _a.y); } mode = 1; if (!i) { context.moveTo(_a.x, _a.y); } context.lineTo(_b.x, _b.y); } } context.closePath(); context.fill(); } } class Cylinder { static draw (context, center, radius, topRadius, height, minHeight, color, altColor, roofColor) { let c = { x:center.x-ORIGIN_X, y:center.y-ORIGIN_Y }, scale = CAM_Z / (CAM_Z-height), minScale = CAM_Z / (CAM_Z-minHeight), apex = Buildings.project(c, scale), a1, a2; topRadius *= scale; if (minHeight) { c = Buildings.project(c, minScale); radius = radius*minScale; } // common tangents for ground and roof circle let tangents = this._tangents(c, radius, apex, topRadius); // no tangents? top circle is inside bottom circle if (!tangents) { a1 = 1.5*PI; a2 = 1.5*PI; } else { a1 = atan2(tangents[0].y1-c.y, tangents[0].x1-c.x); a2 = atan2(tangents[1].y1-c.y, tangents[1].x1-c.x); } context.fillStyle = color; context.beginPath(); context.arc(apex.x, apex.y, topRadius, HALF_PI, a1, true); context.arc(c.x, c.y, radius, a1, HALF_PI); context.closePath(); context.fill(); context.fillStyle = altColor; context.beginPath(); context.arc(apex.x, apex.y, topRadius, a2, HALF_PI, true); context.arc(c.x, c.y, radius, HALF_PI, a2); context.closePath(); context.fill(); context.fillStyle = roofColor; this._circle(context, apex, topRadius); } static simplified (context, center, radius) { this._circle(context, { x:center.x-ORIGIN_X, y:center.y-ORIGIN_Y }, radius); } static shadow (context, center, radius, topRadius, height, minHeight) { let c = { x:center.x-ORIGIN_X, y:center.y-ORIGIN_Y }, apex = Shadows.project(c, height), p1, p2; if (minHeight) { c = Shadows.project(c, minHeight); } // common tangents for ground and roof circle let tangents = this._tangents(c, radius, apex, topRadius); // TODO: no tangents? roof overlaps everything near cam position if (tangents) { p1 = atan2(tangents[0].y1-c.y, tangents[0].x1-c.x); p2 = atan2(tangents[1].y1-c.y, tangents[1].x1-c.x); context.moveTo(tangents[1].x2, tangents[1].y2); context.arc(apex.x, apex.y, topRadius, p2, p1); context.arc(c.x, c.y, radius, p1, p2); } else { context.moveTo(c.x+radius, c.y); context.arc(c.x, c.y, radius, 0, 2*PI); } } static hitArea (context, center, radius, topRadius, height, minHeight, color) { let c = { x:center.x-ORIGIN_X, y:center.y-ORIGIN_Y }, scale = CAM_Z / (CAM_Z-height), minScale = CAM_Z / (CAM_Z-minHeight), apex = Buildings.project(c, scale), p1, p2; topRadius *= scale; if (minHeight) { c = Buildings.project(c, minScale); radius = radius*minScale; } // common tangents for ground and roof circle let tangents = this._tangents(c, radius, apex, topRadius); context.fillStyle = color; context.beginPath(); // TODO: no tangents? roof overlaps everything near cam position if (tangents) { p1 = atan2(tangents[0].y1-c.y, tangents[0].x1-c.x); p2 = atan2(tangents[1].y1-c.y, tangents[1].x1-c.x); context.moveTo(tangents[1].x2, tangents[1].y2); context.arc(apex.x, apex.y, topRadius, p2, p1); context.arc(c.x, c.y, radius, p1, p2); } else { context.moveTo(c.x+radius, c.y); context.arc(c.x, c.y, radius, 0, 2*PI); } context.closePath(); context.fill(); } static _circle (context, center, radius) { context.beginPath(); context.arc(center.x, center.y, radius, 0, PI*2); context.fill(); } // http://en.wikibooks.org/wiki/Algorithm_Implementation/Geometry/Tangents_between_two_circles static _tangents (c1, r1, c2, r2) { let dx = c1.x-c2.x, dy = c1.y-c2.y, dr = r1-r2, sqdist = (dx*dx) + (dy*dy); if (sqdist <= dr*dr) { return; } let dist = sqrt(sqdist), vx = -dx/dist, vy = -dy/dist, c = dr/dist, res = [], h, nx, ny; // Let A, B be the centers, and C, D be points at which the tangent // touches first and second circle, and n be the normal vector to it. // // We have the system: // n * n = 1 (n is a unit vector) // C = A + r1 * n // D = B + r2 * n // n * CD = 0 (common orthogonality) // // n * CD = n * (AB + r2*n - r1*n) = AB*n - (r1 -/+ r2) = 0, <=> // AB * n = (r1 -/+ r2), <=> // v * n = (r1 -/+ r2) / d, where v = AB/|AB| = AB/d // This is a linear equation in unknown vector n. // Now we're just intersecting a line with a circle: v*n=c, n*n=1 h = sqrt(max(0, 1 - c*c)); for (let sign = 1; sign >= -1; sign -= 2) { nx = vx*c - sign*h*vy; ny = vy*c + sign*h*vx; res.push({ x1: c1.x + r1*nx <<0, y1: c1.y + r1*ny <<0, x2: c2.x + r2*nx <<0, y2: c2.y + r2*ny <<0 }); } return res; } } class Pyramid { static draw (context, polygon, center, height, minHeight, color, altColor) { let c = { x:center.x-ORIGIN_X, y:center.y-ORIGIN_Y }, scale = CAM_Z / (CAM_Z-height), minScale = CAM_Z / (CAM_Z-minHeight), apex = Buildings.project(c, scale), a = { x:0, y:0 }, b = { x:0, y:0 }; for (let i = 0, il = polygon.length-3; i < il; i += 2) { a.x = polygon[i ]-ORIGIN_X; a.y = polygon[i+1]-ORIGIN_Y; b.x = polygon[i+2]-ORIGIN_X; b.y = polygon[i+3]-ORIGIN_Y; if (minHeight) { a = Buildings.project(a, minScale); b = Buildings.project(b, minScale); } // backface culling check if ((b.x-a.x) * (apex.y-a.y) > (apex.x-a.x) * (b.y-a.y)) { // depending on direction, set shading if ((a.x < b.x && a.y < b.y) || (a.x > b.x && a.y > b.y)) { context.fillStyle = altColor; } else { context.fillStyle = color; } context.beginPath(); this._triangle(context, a, b, apex); context.closePath(); context.fill(); } } } static _triangle (context, a, b, c) { context.moveTo(a.x, a.y); context.lineTo(b.x, b.y); context.lineTo(c.x, c.y); } static _ring (context, polygon) { context.moveTo(polygon[0]-ORIGIN_X, polygon[1]-ORIGIN_Y); for (let i = 2, il = polygon.length-1; i < il; i += 2) { context.lineTo(polygon[i]-ORIGIN_X, polygon[i+1]-ORIGIN_Y); } } static shadow (context, polygon, center, height, minHeight) { let a = { x:0, y:0 }, b = { x:0, y:0 }, c = { x:center.x-ORIGIN_X, y:center.y-ORIGIN_Y }, apex = Shadows.project(c, height); for (let i = 0, il = polygon.length-3; i < il; i += 2) { a.x = polygon[i ]-ORIGIN_X; a.y = polygon[i+1]-ORIGIN_Y; b.x = polygon[i+2]-ORIGIN_X; b.y = polygon[i+3]-ORIGIN_Y; if (minHeight) { a = Shadows.project(a, minHeight); b = Shadows.project(b, minHeight); } // backface culling check if ((b.x-a.x) * (apex.y-a.y) > (apex.x-a.x) * (b.y-a.y)) { // depending on direction, set shading this._triangle(context, a, b, apex); } } } static hitArea (context, polygon, center, height, minHeight, color) { let c = { x:center.x-ORIGIN_X, y:center.y-ORIGIN_Y }, scale = CAM_Z / (CAM_Z-height), minScale = CAM_Z / (CAM_Z-minHeight), apex = Buildings.project(c, scale), a = { x:0, y:0 }, b = { x:0, y:0 }; context.fillStyle = color; context.beginPath(); for (let i = 0, il = polygon.length-3; i < il; i += 2) { a.x = polygon[i ]-ORIGIN_X; a.y = polygon[i+1]-ORIGIN_Y; b.x = polygon[i+2]-ORIGIN_X; b.y = polygon[i+3]-ORIGIN_Y; if (minHeight) { a = Buildings.project(a, minScale); b = Buildings.project(b, minScale); } // backface culling check if ((b.x-a.x) * (apex.y-a.y) > (apex.x-a.x) * (b.y-a.y)) { this._triangle(context, a, b, apex); } } context.closePath(); context.fill(); } } let animTimer; function fadeIn() { if (animTimer) { return; } animTimer = setInterval(t => { let dataItems = Data.items, isNeeded = false; for (let i = 0, il = dataItems.length; i < il; i++) { if (dataItems[i].scale < 1) { dataItems[i].scale += 0.5*0.2; // amount*easing if (dataItems[i].scale > 1) { dataItems[i].scale = 1; } isNeeded = true; } } Layers.render(); if (!isNeeded) { clearInterval(animTimer); animTimer = null; } }, 33); } class Layers { static init () { Layers.container.className = 'osmb-container'; // TODO: improve this Shadows.init(Layers.createContext(Layers.container)); Simplified.init(Layers.createContext(Layers.container)); Buildings.init(Layers.createContext(Layers.container)); Picking.init(Layers.createContext()); } static clear () { Shadows.clear(); Simplified.clear(); Buildings.clear(); Picking.clear(); } static setOpacity (opacity) { Shadows.setOpacity(opacity); Simplified.setOpacity(opacity); Buildings.setOpacity(opacity); Picking.setOpacity(opacity); } static render (quick) { // show on high zoom levels only if (ZOOM < MIN_ZOOM) { Layers.clear(); return; } // don't render during zoom if (IS_ZOOMING) { return; } requestAnimationFrame(f => { if (!quick) { Shadows.render(); Simplified.render(); //HitAreas.render(); // TODO: do this on demand } Buildings.render(); }); } static createContext (container) { let canvas = document.createElement('CANVAS'); canvas.className = 'osmb-layer'; let context = canvas.getContext('2d'); context.lineCap = 'round'; context.lineJoin = 'round'; context.lineWidth = 1; context.imageSmoothingEnabled = false; Layers.items.push(canvas); if (container) { container.appendChild(canvas); } return context; } static appendTo (parentNode) { parentNode.appendChild(Layers.container); } static remove () { Layers.container.parentNode.removeChild(Layers.container); } static setSize (width, height) { Layers.items.forEach(canvas => { canvas.width = width; canvas.height = height; }); } // usually called after move: container jumps by move delta, cam is reset static setPosition (x, y) { Layers.container.style.left = x +'px'; Layers.container.style.top = y +'px'; } } Layers.container = document.createElement('DIV'); Layers.items = []; class Buildings { static init (context) { this.context = context; } static clear () { this.context.clearRect(0, 0, WIDTH, HEIGHT); } static setOpacity (opacity) { this.context.canvas.style.opacity = opacity; } static project (p, m) { return { x: (p.x-CAM_X) * m + CAM_X <<0, y: (p.y-CAM_Y) * m + CAM_Y <<0 }; } static render () { this.clear(); let context = this.context, item, h, mh, sortCam = { x:CAM_X+ORIGIN_X, y:CAM_Y+ORIGIN_Y }, footprint, wallColor, altColor, roofColor, dataItems = Data.items; dataItems.sort((a, b) => { return (a.minHeight-b.minHeight) || getDistance(b.center, sortCam) - getDistance(a.center, sortCam) || (b.height-a.height); }); for (let i = 0, il = dataItems.length; i < il; i++) { item = dataItems[i]; if (Simplified.isSimple(item)) { continue; } footprint = item.footprint; if (!isVisible(footprint)) { continue; } // when fading in, use a dynamic height h = item.scale < 1 ? item.height*item.scale : item.height; mh = 0; if (item.minHeight) { mh = item.scale < 1 ? item.minHeight*item.scale : item.minHeight; } wallColor = item.wallColor || WALL_COLOR_STR; altColor = item.altColor || ALT_COLOR_STR; roofColor = item.roofColor || ROOF_COLOR_STR; context.strokeStyle = altColor; switch (item.shape) { case 'cylinder': Cylinder.draw(context, item.center, item.radius, item.radius, h, mh, wallColor, altColor, roofColor); break; case 'cone': Cylinder.draw(context, item.center, item.radius, 0, h, mh, wallColor, altColor); break; case 'dome': Cylinder.draw(context, item.center, item.radius, item.radius/2, h, mh, wallColor, altColor); break; case 'sphere': Cylinder.draw(context, item.center, item.radius, item.radius, h, mh, wallColor, altColor, roofColor); break; case 'pyramid': Pyramid.draw(context, footprint, item.center, h, mh, wallColor, altColor); break; default: Extrusion.draw(context, footprint, item.holes, h, mh, wallColor, altColor, roofColor); } switch (item.roofShape) { case 'cone': Cylinder.draw(context, item.center, item.radius, 0, h+item.roofHeight, h, roofColor, ''+ Qolor.parse(roofColor).lightness(0.9)); break; case 'dome': Cylinder.draw(context, item.center, item.radius, item.radius/2, h+item.roofHeight, h, roofColor, ''+ Qolor.parse(roofColor).lightness(0.9)); break; case 'pyramid': Pyramid.draw(context, footprint, item.center, h+item.roofHeight, h, roofColor, Qolor.parse(roofColor).lightness(0.9)); break; } } } } class Simplified { static init (context) { this.context = context; } static clear () { this.context.clearRect(0, 0, WIDTH, HEIGHT); } static setOpacity (opacity) { this.context.canvas.style.opacity = opacity; } static isSimple (item) { return (ZOOM <= Simplified.MAX_ZOOM && item.height+item.roofHeight < Simplified.MAX_HEIGHT); } static render () { this.clear(); let context = this.context; // show on high zoom levels only and avoid rendering during zoom if (ZOOM > Simplified.MAX_ZOOM) { return; } let item, footprint, dataItems = Data.items; for (let i = 0, il = dataItems.length; i < il; i++) { item = dataItems[i]; if (item.height >= Simplified.MAX_HEIGHT) { continue; } footprint = item.footprint; if (!isVisible(footprint)) { continue; } context.strokeStyle = item.altColor || ALT_COLOR_STR; context.fillStyle = item.roofColor || ROOF_COLOR_STR; switch (item.shape) { case 'cylinder': case 'cone': case 'dome': case 'sphere': Cylinder.simplified(context, item.center, item.radius); break; default: Extrusion.simplified(context, footprint, item.holes); } } } } Simplified.MAX_ZOOM = 16; // max zoom where buildings could render simplified Simplified.MAX_HEIGHT = 5; // max building height in order to be simple class Shadows { static init (context) { this.context = context; } static clear () { this.context.clearRect(0, 0, WIDTH, HEIGHT); } static setOpacity (opacity) { this.opacity = opacity; } static project (p, h) { return { x: p.x + this.direction.x*h, y: p.y + this.direction.y*h }; } static render () { this.clear(); let context = this.context, screenCenter, sun, length, alpha; // TODO: calculate this just on demand screenCenter = pixelToGeo(CENTER_X+ORIGIN_X, CENTER_Y+ORIGIN_Y); sun = getSunPosition(this.date, screenCenter.latitude, screenCenter.longitude); if (sun.altitude <= 0) { return; } length = 1 / tan(sun.altitude); alpha = length < 5 ? 0.75 : 1/length*5; this.direction.x = cos(sun.azimuth) * length; this.direction.y = sin(sun.azimuth) * length; let i, il, item, h, mh, footprint, dataItems = Data.items; context.canvas.style.opacity = alpha / (this.opacity * 2); context.shadowColor = this.blurColor; context.fillStyle = this.color; context.beginPath(); for (i = 0, il = dataItems.length; i < il; i++) { item = dataItems[i]; footprint = item.footprint; if (!isVisible(footprint)) { continue; } // when fading in, use a dynamic height h = item.scale < 1 ? item.height*item.scale : item.height; mh = 0; if (item.minHeight) { mh = item.scale < 1 ? item.minHeight*item.scale : item.minHeight; } switch (item.shape) { case 'cylinder': Cylinder.shadow(context, item.center, item.radius, item.radius, h, mh); break; case 'cone': Cylinder.shadow(context, item.center, item.radius, 0, h, mh); break; case 'dome': Cylinder.shadow(context, item.center, item.radius, item.radius/2, h, mh); break; case 'sphere': Cylinder.shadow(context, item.center, item.radius, item.radius, h, mh); break; case 'pyramid': Pyramid.shadow(context, footprint, item.center, h, mh); break; default: Extrusion.shadow(context, footprint, item.holes, h, mh); } switch (item.roofShape) { case 'cone': Cylinder.shadow(context, item.center, item.radius, 0, h+item.roofHeight, h); break; case 'dome': Cylinder.shadow(context, item.center, item.radius, item.radius/2, h+item.roofHeight, h); break; case 'pyramid': Pyramid.shadow(context, footprint, item.center, h+item.roofHeight, h); break; } } context.closePath(); context.fill(); } } Shadows.color = '#666666'; Shadows.blurColor = '#000000'; Shadows.date = new Date(); Shadows.direction = { x:0, y:0 }; Shadows.opacity = 1; class Picking { static init (context) { this.context = context; } static setOpacity (opacity) {} static clear () {} static reset () { this._idMapping = [null]; } static render () { if (this._timer) { return; } let self = this; this._timer = setTimeout(t => { self._timer = null; self._render(); }, 500); } static _render () { this.clear(); let context = this.context, item, h, mh, sortCam = { x:CAM_X+ORIGIN_X, y:CAM_Y+ORIGIN_Y }, footprint, color, dataItems = Data.items; dataItems.sort((a, b) => { return (a.minHeight-b.minHeight) || getDistance(b.center, sortCam) - getDistance(a.center, sortCam) || (b.height-a.height); }); for (let i = 0, il = dataItems.length; i < il; i++) { item = dataItems[i]; if (!(color = item.hitColor)) { continue; } footprint = item.footprint; if (!isVisible(footprint)) { continue; } h = item.height; mh = 0; if (item.minHeight) { mh = item.minHeight; } switch (item.shape) { case 'cylinder': Cylinder.hitArea(context, item.center, item.radius, item.radius, h, mh, color); break; case 'cone': Cylinder.hitArea(context, item.center, item.radius, 0, h, mh, color); break; case 'dome': Cylinder.hitArea(context, item.center, item.radius, item.radius/2, h, mh, color); break; case 'sphere': Cylinder.hitArea(context, item.center, item.radius, item.radius, h, mh, color); break; case 'pyramid': Pyramid.hitArea(context, footprint, item.center, h, mh, color); break; default: Extrusion.hitArea(context, footprint, item.holes, h, mh, color); } switch (item.roofShape) { case 'cone': Cylinder.hitArea(context, item.center, item.radius, 0, h+item.roofHeight, h, color); break; case 'dome': Cylinder.hitArea(context, item.center, item.radius, item.radius/2, h+item.roofHeight, h, color); break; case 'pyramid': Pyramid.hitArea(context, footprint, item.center, h+item.roofHeight, h, color); break; } } // otherwise fails on size 0 if (WIDTH && HEIGHT) { this._imageData = this.context.getImageData(0, 0, WIDTH, HEIGHT).data; } } static getIdFromXY (x, y) { let imageData = this._imageData; if (!imageData) { return; } let pos = 4*((y|0) * WIDTH + (x|0)); let index = imageData[pos] | (imageData[pos+1]<<8) | (imageData[pos+2]<<16); return this._idMapping[index]; } static idToColor (id) { let index = this._idMapping.indexOf(id); if (index === -1) { this._idMapping.push(id); index = this._idMapping.length-1; } let r = index & 0xff; let g = (index >>8) & 0xff; let b = (index >>16) & 0xff; return 'rgb('+ [r, g, b].join(',') +')'; } } Picking._idMapping = [null]; class Debug { static point (x, y, color, size) { const context = this.context; context.fillStyle = color || '#ffcc00'; context.beginPath(); context.arc(x, y, size || 3, 0, 2*PI); context.closePath(); context.fill(); } static line (ax, ay, bx, by, color) { const context = this.context; context.strokeStyle = color || '#ffcc00'; context.beginPath(); context.moveTo(ax, ay); context.lineTo(bx, by); context.closePath(); context.stroke(); } } function setOrigin (origin) { ORIGIN_X = origin.x; ORIGIN_Y = origin.y; } function moveCam (offset) { CAM_X = CENTER_X + offset.x; CAM_Y = HEIGHT + offset.y; Layers.render(true); } function setSize (size) { WIDTH = size.width; HEIGHT = size.height; CENTER_X = WIDTH /2 <<0; CENTER_Y = HEIGHT/2 <<0; CAM_X = CENTER_X; CAM_Y = HEIGHT; Layers.setSize(WIDTH, HEIGHT); MAX_HEIGHT = CAM_Z-50; } function setZoom (z) { ZOOM = z; MAP_SIZE = MAP_TILE_SIZE < fadeIn() => Layers.render() } function onZoomStart () { IS_ZOOMING = true; } function onZoomEnd (e) { IS_ZOOMING = false; const factor = Math.pow(2, e.zoom-ZOOM); setZoom(e.zoom); // Layers.render(); // TODO: requestAnimationFrame() causes flickering because layers are already cleared // show on high zoom levels only if (ZOOM <= MIN_ZOOM) { Layers.clear(); return; } Data.scale(factor); Shadows.render(); Simplified.render(); Buildings.render(); Data.update(); // => fadeIn() } // based on a pull request from Jérémy Judéaux (https://github.com/Volune) class OSMBuildings extends ol.layer.Layer { constructor (map) { super(OSMBuildings.name, {projection: 'EPSG:900913'}); this.offset = {x: 0, y: 0}; // cumulative cam offset during moveBy() Layers.init(); if (map) { map.addLayer(this); } } addTo (map) { this.setMap(map); return this; } setOrigin () { let map = this.map, origin = map.getLonLatFromPixel(new OpenLayers.Pixel(0, 0)), res = map.resolution, ext = this.maxExtent, x = (origin.lon - ext.left) / res << 0, y = (ext.top - origin.lat) / res << 0; setOrigin({x: x, y: y}); } setMap (map) { if (!this.map) { super.setMap.call(this, map); } Layers.appendTo(this.div); setSize({width: map.size.w, height: map.size.h}); setZoom(map.zoom); this.setOrigin(); let layerProjection = this.projection; map.events.register('click', map, e => { let id = Picking.getIdFromXY(e.xy.x, e.xy.y); if (id) { let geo = map.getLonLatFromPixel(e.xy).transform(layerProjection, this.projection); onClick({feature: id, lat: geo.lat, lon: geo.lon}); } }); Data.update(); } removeMap (map) { Layers.remove(); super.removeMap.call(this, map); this.map = null; } onMapResize () { let map = this.map; super.onMapResize.call(this); onResize({width: map.size.w, height: map.size.h}); } moveTo (bounds, zoomChanged, isDragging) { let map = this.map, res = super.moveTo.call(this, bounds, zoomChanged, isDragging); if (!isDragging) { let offsetLeft = parseInt(map.layerContainerDiv.style.left, 10), offsetTop = parseInt(map.layerContainerDiv.style.top, 10); this.div.style.left = -offsetLeft + 'px'; this.div.style.top = -offsetTop + 'px'; } this.setOrigin(); this.offset.x = 0; this.offset.y = 0; moveCam(this.offset); if (zoomChanged) { onZoomEnd({zoom: map.zoom}); } else { onMoveEnd(); } return res; } moveByPx (dx, dy) { this.offset.x += dx; this.offset.y += dy; let res = super.moveByPx.call(this, dx, dy); moveCam(this.offset); return res; } } OSMBuildings.name = 'OSM Buildings'; OSMBuildings.attribution = ATTRIBUTION; OSMBuildings.isBaseLayer = false; OSMBuildings.alwaysInRange = true; return OSMBuildings; }()); ================================================ FILE: dist/OSMBuildings-OpenLayers.js ================================================ const OSMBuildings=function(){const e=Math,t=e.exp,i=e.log,r=e.sin,a=e.cos,s=e.tan,o=e.atan,n=e.atan2,l=e.min,c=e.max,h=e.sqrt,f=e.ceil,d=e.pow;class u{constructor(e,t,i,r=1){this.r=this._clamp(e,1),this.g=this._clamp(t,1),this.b=this._clamp(i,1),this.a=this._clamp(r,1)}static parse(e){if("string"==typeof e){let t;if(e=e.toLowerCase(),t=(e=u.w3cColors[e]||e).match(/^#?(\w{2})(\w{2})(\w{2})$/))return new u(parseInt(t[1],16)/255,parseInt(t[2],16)/255,parseInt(t[3],16)/255);if(t=e.match(/^#?(\w)(\w)(\w)$/))return new u(parseInt(t[1]+t[1],16)/255,parseInt(t[2]+t[2],16)/255,parseInt(t[3]+t[3],16)/255);if(t=e.match(/rgba?\((\d+)\D+(\d+)\D+(\d+)(\D+([\d.]+))?\)/))return new u(parseFloat(t[1])/255,parseFloat(t[2])/255,parseFloat(t[3])/255,t[4]?parseFloat(t[5]):1)}return new u}static fromHSL(e,t,i,r){const a=(new u).fromHSL(e,t,i);return a.a=void 0===r?1:r,a}_hue2rgb(e,t,i){return i<0&&(i+=1),i>1&&(i-=1),i<1/6?e+6*(t-e)*i:i<.5?t:i<2/3?e+(t-e)*(2/3-i)*6:e}_clamp(e,t){if(void 0!==e)return Math.min(t,Math.max(0,e||0))}isValid(){return void 0!==this.r&&void 0!==this.g&&void 0!==this.b}toHSL(){if(!this.isValid())return;const e=Math.max(this.r,this.g,this.b),t=Math.min(this.r,this.g,this.b),i=e-t,r=(e+t)/2;if(!i)return{h:0,s:0,l:r};const a=r>.5?i/(2-e-t):i/(e+t);let s;switch(e){case this.r:s=(this.g-this.b)/i+(this.g0?"CW":"CCW"}(e)===t)return e;let i=[];for(let t=e.length-2;t>=0;t-=2)i.push(e[t],e[t+1]);return i}function b(e){const t={};e=e||{},t.height=e.height||(e.levels?3*e.levels:G),t.minHeight=e.minHeight||(e.minLevel?3*e.minLevel:0);const i=e.material?m(e.material):e.wallColor||e.color;i&&(t.wallColor=i);const r=e.roofMaterial?m(e.roofMaterial):e.roofColor;switch(r&&(t.roofColor=r),e.shape){case"cylinder":case"cone":case"dome":case"sphere":t.shape=e.shape,t.isRotational=!0;break;case"pyramid":t.shape=e.shape}switch(e.roofShape){case"cone":case"dome":t.roofShape=e.roofShape,t.isRotational=!0;break;case"pyramid":t.roofShape=e.roofShape}return t.roofShape&&e.roofHeight?(t.roofHeight=e.roofHeight,t.height=c(0,t.height-t.roofHeight)):t.roofHeight=0,t}function w(e){let t,i,r=[];switch(e.type){case"GeometryCollection":r=[];for(let t=0,a=e.geometries.length;t1?(i=a,r=s):o>0&&(i+=n*o,r+=l*o)),n=e-i,l=t-r,n*n+l*l}function W(e){let t=180,i=-180;for(let r=0,a=e.length;r=1?-90:function(e){return e/T*180}(2*o(t(T*(1-2*i)))-P),r.longitude=360*(1===e?1:(e%1+1)%1)-180,r}function Z(e,t){const r=l(1,c(0,.5-i(s(I+P*e/180))/T/2));return{x:(t/360+.5)*v<<0,y:r*v<<0}}function Y(e){const t=j+z,i=O+F;for(let r=0,a=e.length-3;rz&&e[r]F&&e[r+1]t&&(r=a,t=i);t>2&&(s[r]=1,l.push(o),c.push(r),l.push(r),c.push(n)),o=l.pop(),n=c.pop()}for(let t=0;t{let t=te.items,i=!1;for(let e=0,r=t.length;e1&&(t[e].scale=1),i=!0);se.render(),i||(clearInterval(U),U=null)},33)}()}static scalePolygon(e,t){return e.map(e=>e*t)}static scale(e){te.items=te.items.map(t=>{if(t.height*=e,t.minHeight*=e,t.footprint=te.scalePolygon(t.footprint,e),t.center.x*=e,t.center.y*=e,t.radius&&(t.radius*=e),t.holes)for(let i=0,r=t.holes.length;iH)&&(i.footprint=this.getPixelFootprint(e.footprint),i.footprint)){if(i.center=function(e){let t=1/0,i=-1/0,r=1/0,a=-1/0;for(let s=0,o=e.length-3;s1.15)return!1;const c={x:i+o/2,y:a+n/2},h=(o+n)/4,f=h*h;for(let i=0;i1.2)return!1}return!0}(i.footprint)||(i.shape="cylinder"),e.holes){let t;i.holes=[];for(let r=0,a=e.holes.length;rr?a<<_-r:a>>r-_,o=z/s<<0,n=F/s<<0,l=f((z+j)/s),c=f((F+O)/s),h=this;function e(e){h.addRenderItems(e)}for(i=n;i<=c;i++)for(t=o;t<=l;t++)this.loadTile(t,i,r,e)}}static loadTile(e,t,i,r){let a="abcd"[(e+t)%4],s=this.src.replace("{s}",a).replace("{x}",e).replace("{y}",t).replace("{z}",i);return class{static loadJSON(e,t){return function(e,t){if(K[e])return void(t&&t(K[e]));const i=new XMLHttpRequest;return i.onreadystatechange=function(){if(4===i.readyState&&!(!i.status||i.status<200||i.status>299)&&t&&i.responseText){const r=i.responseText;for(K[e]=r,Q.push({url:e,size:r.length}),ee+=r.length,t(r);ee>5242880;){let e=Q.shift();ee-=e.size,delete K[e.url]}}},i.open("GET",e),i.send(null),i}(e,e=>{let i;try{i=JSON.parse(e)}catch(e){}t(i)})}}.loadJSON(s,r)}}te.cache={},te.items=[];class ie{static draw(e,t,i,r,a,s,o,n){let l=this._extrude(e,t,r,a,s,o),c=[];if(i)for(let t=0,n=i.length;t(o.x-h.x)*(f.y-h.y)&&(h.xf.x&&h.y>f.y?e.fillStyle=s:e.fillStyle=a,e.beginPath(),this._ring(e,[f.x,f.y,h.x,h.y,o.x,o.y,n.x,n.y]),e.closePath(),e.fill()),d[i]=o.x,d[i+1]=o.y;return d}static _ring(e,t){e.moveTo(t[0],t[1]);for(let i=2,r=t.length-1;i(s.x-l.x)*(c.y-l.y)?(1===n&&e.lineTo(l.x,l.y),n=0,i||e.moveTo(l.x,l.y),e.lineTo(c.x,c.y)):(0===n&&e.lineTo(s.x,s.y),n=1,i||e.moveTo(s.x,s.y),e.lineTo(o.x,o.y));if(i)for(let t=0,r=i.length;t(o.x-c.x)*(h.y-c.y)?(1===l&&e.lineTo(c.x,c.y),l=0,i||e.moveTo(c.x,c.y),e.lineTo(h.x,h.y)):(0===l&&e.lineTo(o.x,o.y),l=1,i||e.moveTo(o.x,o.y),e.lineTo(n.x,n.y));e.closePath(),e.fill()}}class re{static draw(e,t,i,r,a,s,o,l,c){let h,f,d={x:t.x-z,y:t.y-F},u=450/(450-a),p=450/(450-s),g=oe.project(d,u);r*=u,s&&(d=oe.project(d,p),i*=p);let y=this._tangents(d,i,g,r);y?(h=n(y[0].y1-d.y,y[0].x1-d.x),f=n(y[1].y1-d.y,y[1].x1-d.x)):(h=1.5*T,f=1.5*T),e.fillStyle=o,e.beginPath(),e.arc(g.x,g.y,r,P,h,!0),e.arc(d.x,d.y,i,h,P),e.closePath(),e.fill(),e.fillStyle=l,e.beginPath(),e.arc(g.x,g.y,r,f,P,!0),e.arc(d.x,d.y,i,P,f),e.closePath(),e.fill(),e.fillStyle=c,this._circle(e,g,r)}static simplified(e,t,i){this._circle(e,{x:t.x-z,y:t.y-F},i)}static shadow(e,t,i,r,a,s){let o,l,c={x:t.x-z,y:t.y-F},h=le.project(c,a);s&&(c=le.project(c,s));let f=this._tangents(c,i,h,r);f?(o=n(f[0].y1-c.y,f[0].x1-c.x),l=n(f[1].y1-c.y,f[1].x1-c.x),e.moveTo(f[1].x2,f[1].y2),e.arc(h.x,h.y,r,l,o),e.arc(c.x,c.y,i,o,l)):(e.moveTo(c.x+i,c.y),e.arc(c.x,c.y,i,0,2*T))}static hitArea(e,t,i,r,a,s,o){let l,c,h={x:t.x-z,y:t.y-F},f=450/(450-a),d=450/(450-s),u=oe.project(h,f);r*=f,s&&(h=oe.project(h,d),i*=d);let p=this._tangents(h,i,u,r);e.fillStyle=o,e.beginPath(),p?(l=n(p[0].y1-h.y,p[0].x1-h.x),c=n(p[1].y1-h.y,p[1].x1-h.x),e.moveTo(p[1].x2,p[1].y2),e.arc(u.x,u.y,r,c,l),e.arc(h.x,h.y,i,l,c)):(e.moveTo(h.x+i,h.y),e.arc(h.x,h.y,i,0,2*T)),e.closePath(),e.fill()}static _circle(e,t,i){e.beginPath(),e.arc(t.x,t.y,i,0,2*T),e.fill()}static _tangents(e,t,i,r){let a=e.x-i.x,s=e.y-i.y,o=t-r,n=a*a+s*s;if(n<=o*o)return;let l,f,d,u=h(n),p=-a/u,g=-s/u,y=o/u,m=[];l=h(c(0,1-y*y));for(let a=1;a>=-1;a-=2)f=p*y-a*l*g,d=g*y+a*l*p,m.push({x1:e.x+t*f<<0,y1:e.y+t*d<<0,x2:i.x+r*f<<0,y2:i.y+r*d<<0});return m}}class ae{static draw(e,t,i,r,a,s,o){let n={x:i.x-z,y:i.y-F},l=450/(450-r),c=450/(450-a),h=oe.project(n,l),f={x:0,y:0},d={x:0,y:0};for(let i=0,r=t.length-3;i(h.x-f.x)*(d.y-f.y)&&(f.xd.x&&f.y>d.y?e.fillStyle=o:e.fillStyle=s,e.beginPath(),this._triangle(e,f,d,h),e.closePath(),e.fill())}static _triangle(e,t,i,r){e.moveTo(t.x,t.y),e.lineTo(i.x,i.y),e.lineTo(r.x,r.y)}static _ring(e,t){e.moveTo(t[0]-z,t[1]-F);for(let i=2,r=t.length-1;i(l.x-s.x)*(o.y-s.y)&&this._triangle(e,s,o,l)}static hitArea(e,t,i,r,a,s){let o={x:i.x-z,y:i.y-F},n=450/(450-r),l=450/(450-a),c=oe.project(o,n),h={x:0,y:0},f={x:0,y:0};e.fillStyle=s,e.beginPath();for(let i=0,r=t.length-3;i(c.x-h.x)*(f.y-h.y)&&this._triangle(e,h,f,c);e.closePath(),e.fill()}}class se{static init(){se.container.className="osmb-container",le.init(se.createContext(se.container)),ne.init(se.createContext(se.container)),oe.init(se.createContext(se.container)),ce.init(se.createContext())}static clear(){le.clear(),ne.clear(),oe.clear(),ce.clear()}static setOpacity(e){le.setOpacity(e),ne.setOpacity(e),oe.setOpacity(e),ce.setOpacity(e)}static render(e){_<15?se.clear():M||requestAnimationFrame(t=>{e||(le.render(),ne.render()),oe.render()})}static createContext(e){let t=document.createElement("CANVAS");t.className="osmb-layer";let i=t.getContext("2d");return i.lineCap="round",i.lineJoin="round",i.lineWidth=1,i.imageSmoothingEnabled=!1,se.items.push(t),e&&e.appendChild(t),i}static appendTo(e){e.appendChild(se.container)}static remove(){se.container.parentNode.removeChild(se.container)}static setSize(e,t){se.items.forEach(i=>{i.width=e,i.height=t})}static setPosition(e,t){se.container.style.left=e+"px",se.container.style.top=t+"px"}}se.container=document.createElement("DIV"),se.items=[];class oe{static init(e){this.context=e}static clear(){this.context.clearRect(0,0,j,O)}static setOpacity(e){this.context.canvas.style.opacity=e}static project(e,t){return{x:(e.x-S)*t+S<<0,y:(e.y-C)*t+C<<0}}static render(){this.clear();let e,t,i,r,a,s,o,n=this.context,l={x:S+z,y:C+F},c=te.items;c.sort((e,t)=>e.minHeight-t.minHeight||V(t.center,l)-V(e.center,l)||t.height-e.height);for(let l=0,h=c.length;lne.MAX_ZOOM)return;let t,i,r=te.items;for(let a=0,s=r.length;a=ne.MAX_HEIGHT)&&(i=t.footprint,Y(i)))switch(e.strokeStyle=t.altColor||E,e.fillStyle=t.roofColor||X,t.shape){case"cylinder":case"cone":case"dome":case"sphere":re.simplified(e,t.center,t.radius);break;default:ie.simplified(e,i,t.holes)}}}ne.MAX_ZOOM=16,ne.MAX_HEIGHT=5;class le{static init(e){this.context=e}static clear(){this.context.clearRect(0,0,j,O)}static setOpacity(e){this.opacity=e}static project(e,t){return{x:e.x+this.direction.x*t,y:e.y+this.direction.y*t}}static render(){this.clear();let e,t,i,o,n=this.context;if(e=J(A+z,L+F),t=p(this.date,e.latitude,e.longitude),t.altitude<=0)return;i=1/s(t.altitude),o=i<5?.75:1/i*5,this.direction.x=a(t.azimuth)*i,this.direction.y=r(t.azimuth)*i;let l,c,h,f,d,u,g=te.items;for(n.canvas.style.opacity=o/(2*this.opacity),n.shadowColor=this.blurColor,n.fillStyle=this.color,n.beginPath(),l=0,c=g.length;l{e._timer=null,e._render()},500)}static _render(){this.clear();let e,t,i,r,a,s=this.context,o={x:S+z,y:C+F},n=te.items;n.sort((e,t)=>e.minHeight-t.minHeight||V(t.center,o)-V(e.center,o)||t.height-e.height);for(let o=0,l=n.length;o>8&255,t>>16&255].join(",")+")"}}ce._idMapping=[null];function he(e){S=A+e.x,C=O+e.y,se.render(!0)}function fe(e){j=e.width,O=e.height,A=j/2<<0,L=O/2<<0,S=A,C=O,se.setSize(j,O),H=400}function de(e){_=e,v=256<<_;const t=J(z+A,F+L),i=Z(t.latitude,0),r=Z(t.latitude,1);B=r.x-i.x,se.setOpacity(Math.pow(.95,_-15)),N=""+D,E=""+R,X=""+q}class ue extends ol.layer.Layer{constructor(e){super(ue.name,{projection:"EPSG:900913"}),this.offset={x:0,y:0},se.init(),e&&e.addLayer(this)}addTo(e){return this.setMap(e),this}setOrigin(){let e=this.map,t=e.getLonLatFromPixel(new OpenLayers.Pixel(0,0)),i=e.resolution,r=this.maxExtent;!function(e){z=e.x,F=e.y}({x:(t.lon-r.left)/i<<0,y:(r.top-t.lat)/i<<0})}setMap(e){this.map||super.setMap.call(this,e),se.appendTo(this.div),fe({width:e.size.w,height:e.size.h}),de(e.zoom),this.setOrigin();let t=this.projection;e.events.register("click",e,i=>{let r=ce.getIdFromXY(i.xy.x,i.xy.y);if(r){let r=e.getLonLatFromPixel(i.xy).transform(t,this.projection);r.lat,r.lon}}),te.update()}removeMap(e){se.remove(),super.removeMap.call(this,e),this.map=null}onMapResize(){let e=this.map;super.onMapResize.call(this),fe({width:e.size.w,height:e.size.h}),se.render(),te.update()}moveTo(e,t,i){let r=this.map,a=super.moveTo.call(this,e,t,i);if(!i){let e=parseInt(r.layerContainerDiv.style.left,10),t=parseInt(r.layerContainerDiv.style.top,10);this.div.style.left=-e+"px",this.div.style.top=-t+"px"}return this.setOrigin(),this.offset.x=0,this.offset.y=0,he(this.offset),t?function(e){M=!1;const t=Math.pow(2,e.zoom-_);de(e.zoom),_<=15?se.clear():(te.scale(t),le.render(),ne.render(),oe.render(),te.update())}({zoom:r.zoom}):(se.render(),te.update()),a}moveByPx(e,t){this.offset.x+=e,this.offset.y+=t;let i=super.moveByPx.call(this,e,t);return he(this.offset),i}}return ue.name="OSM Buildings",ue.attribution='© OSM Buildings',ue.isBaseLayer=!1,ue.alwaysInRange=!0,ue}(); ================================================ FILE: dist/OSMBuildings.css ================================================ .osmb-container { transform: translate3d(0, 0, 0); pointerEvents: none; position: absolute; left: 0; top: 0; } .osmb-container.zoom-animation { transition-duration: 250ms; transition-property: transform; transform-origin: 50% 50%; } .osmb-layer { transform: translate3d(0, 0, 0); image-rendering: optimizeSpeed; position: absolute; left: 0; top: 0; } ================================================ FILE: dist/index-Leaflet-3db.html ================================================ OSM Buildings for Leaflet
================================================ FILE: dist/index-Leaflet.html ================================================ OSM Buildings Classic for Leaflet
================================================ FILE: dist/index-OpenLayers.html ================================================ OSM Buildings Classic for OpenLayers
================================================ FILE: docs/server.md ================================================ # OSM Buildings Server # Code for OSM Buildings data service ist not public. However, you may use our service for *free*. Before hitting us with heavy load, please get in touch at support@osmbuildings.org The built in default server URL schema is `https://{s}.data.osmbuildings.org/0.2/{k}/tile/{z}/{x}/{y}.json` Placeholder and adressing schema is described here: http://wiki.osgeo.org/wiki/Tile_Map_Service_Specification#Tile_Resources `{s}` in our case stands for a subdomain (a,b,c,d) to extend the number of browser requests per domain. ## Data Source ## We are pulling OpenStreetMap data from Overpass API (http://overpass-turbo.eu/). That way we are very flexible in terms of querying and data freshness. For retrieving building data, we convert TMS adressed tiles to geographic bounding boxes (projection EPSG 4326) and opted for a JSON formatted response. ## GeoJSON Properties ## OSM Buildings Server does a lot of alignments, optimizations and caching and finally returns GeoJSON. The result is fully compatible but has a few conventions, see below.
GeoJSON property OSM Tags
height height, building:height, levels, building:levels
minHeight min_height, building:min_height, min_level, building:min_level
color/wallColor building:color, building:colour
material building:material, building:facade:material, building:cladding
roofColor roof:color, roof:colour, building:roof:color, building:roof:colour
roofMaterial roof:material, building:roof:material
shape building:shape[=cylinder,sphere]
roofShape roof:shape[=dome]
roofHeight roof:height
## Sample Implementation ## Code by Michael Meier (michael.meier@fau.de) http://git.rrze.uni-erlangen.de/gitweb/?p=osmrrze.git;a=blob;f=scripts/osmbuildings-json-generator.pl ================================================ FILE: package.json ================================================ { "name": "osmbuildings-classic", "description": "OSM Buildings Classic", "version": "3.0.1", "homepage": "https://osmbuildings.org", "author": "@kekscom", "contributors": [ "Jan Marsch " ], "repository": { "type": "git", "url": "git@github.com:kekscom/osmbuildings.git" }, "scripts": {}, "dependencies": { "qolor": "^2.3.3" }, "devDependencies": { "terser": "^4.6.7" }, "optionalDependencies": {} } ================================================ FILE: src/Data.js ================================================ class Data { static getPixelFootprint (buffer) { let footprint = new Int32Array(buffer.length), px; for (let i = 0, il = buffer.length-1; i < il; i+=2) { px = geoToPixel(buffer[i], buffer[i+1]); footprint[i] = px.x; footprint[i+1] = px.y; } footprint = simplifyPolygon(footprint); if (footprint.length < 8) { // 3 points & end==start (*2) return; } return footprint; } static resetItems () { this.items = []; this.cache = {}; Picking.reset(); } static addRenderItems (data, allAreNew) { let item, scaledItem, id; let geojson = GeoJSON.read(data); for (let i = 0, il = geojson.length; i < il; i++) { item = geojson[i]; id = item.id || [item.footprint[0], item.footprint[1], item.height, item.minHeight].join(','); if (!this.cache[id]) { if ((scaledItem = this.scaleItem(item))) { scaledItem.scale = allAreNew ? 0 : 1; this.items.push(scaledItem); this.cache[id] = 1; } } } fadeIn(); } static scalePolygon (buffer, factor) { return buffer.map(coord => coord*factor); } static scale (factor) { Data.items = Data.items.map(item => { // item.height = Math.min(item.height*factor, MAX_HEIGHT); // TODO: should be filtered by renderer item.height *= factor; item.minHeight *= factor; item.footprint = Data.scalePolygon(item.footprint, factor); item.center.x *= factor; item.center.y *= factor; if (item.radius) { item.radius *= factor; } if (item.holes) { for (let i = 0, il = item.holes.length; i < il; i++) { item.holes[i] = Data.scalePolygon(item.holes[i], factor); } } item.roofHeight *= factor; return item; }); } static scaleItem (item) { let res = {}, // TODO: calculate this on zoom change only zoomScale = 6 / pow(2, ZOOM-MIN_ZOOM); // TODO: consider using HEIGHT / (devicePixelRatio || 1) if (item.id) { res.id = item.id; } res.height = min(item.height/zoomScale, MAX_HEIGHT); res.minHeight = isNaN(item.minHeight) ? 0 : item.minHeight / zoomScale; if (res.minHeight > MAX_HEIGHT) { return; } res.footprint = this.getPixelFootprint(item.footprint); if (!res.footprint) { return; } res.center = getCenter(res.footprint); if (item.radius) { res.radius = item.radius*PIXEL_PER_DEG; } if (item.shape) { res.shape = item.shape; } if (item.roofShape) { res.roofShape = item.roofShape; } if ((res.roofShape === 'cone' || res.roofShape === 'dome') && !res.shape && isRotational(res.footprint)) { res.shape = 'cylinder'; } if (item.holes) { res.holes = []; let innerFootprint; for (let i = 0, il = item.holes.length; i < il; i++) { // TODO: simplify if ((innerFootprint = this.getPixelFootprint(item.holes[i]))) { res.holes.push(innerFootprint); } } } let color; if (item.wallColor) { if ((color = Qolor.parse(item.wallColor))) { res.altColor = ''+ color.lightness(0.8); res.wallColor = ''+ color; } } if (item.roofColor) { if ((color = Qolor.parse(item.roofColor))) { res.roofColor = ''+ color; } } if (item.relationId) { res.relationId = item.relationId; } res.hitColor = Picking.idToColor(item.relationId || item.id); res.roofHeight = isNaN(item.roofHeight) ? 0 : item.roofHeight/zoomScale; if (res.height+res.roofHeight <= res.minHeight) { return; } return res; } static set (data) { this.resetItems(); this._staticData = data; this.addRenderItems(this._staticData, true); } static load (src, key) { this.src = src || DATA_SRC.replace('{k}', (key || 'anonymous')); this.update(); } static update () { this.resetItems(); if (ZOOM < MIN_ZOOM) { return; } if (this._staticData) { this.addRenderItems(this._staticData); } if (this.src) { let tileZoom = 16, tileSize = 256, zoomedTileSize = ZOOM > tileZoom ? tileSize << (ZOOM - tileZoom) : tileSize >> (tileZoom - ZOOM), minX = ORIGIN_X / zoomedTileSize << 0, minY = ORIGIN_Y / zoomedTileSize << 0, maxX = ceil((ORIGIN_X + WIDTH) / zoomedTileSize), maxY = ceil((ORIGIN_Y + HEIGHT) / zoomedTileSize), x, y; let scope = this; function callback (json) { scope.addRenderItems(json); } for (y = minY; y <= maxY; y++) { for (x = minX; x <= maxX; x++) { this.loadTile(x, y, tileZoom, callback); } } } } static loadTile (x, y, zoom, callback) { let s = 'abcd'[(x+y) % 4]; let url = this.src.replace('{s}', s).replace('{x}', x).replace('{y}', y).replace('{z}', zoom); return Request.loadJSON(url, callback); } } Data.cache = {}; // maintain a list of cached items in order to avoid duplicates on tile borders Data.items = []; ================================================ FILE: src/Debug.js ================================================ class Debug { static point (x, y, color, size) { const context = this.context; context.fillStyle = color || '#ffcc00'; context.beginPath(); context.arc(x, y, size || 3, 0, 2*PI); context.closePath(); context.fill(); } static line (ax, ay, bx, by, color) { const context = this.context; context.strokeStyle = color || '#ffcc00'; context.beginPath(); context.moveTo(ax, ay); context.lineTo(bx, by); context.closePath(); context.stroke(); } } ================================================ FILE: src/GeoJSON.js ================================================ const METERS_PER_LEVEL = 3; const materialColors = { brick:'#cc7755', bronze:'#ffeecc', canvas:'#fff8f0', concrete:'#999999', copper:'#a0e0d0', glass:'#e8f8f8', gold:'#ffcc00', plants:'#009933', metal:'#aaaaaa', panel:'#fff8f0', plaster:'#999999', roof_tiles:'#f08060', silver:'#cccccc', slate:'#666666', stone:'#996666', tar_paper:'#333333', wood:'#deb887' }; const baseMaterials = { asphalt:'tar_paper', bitumen:'tar_paper', block:'stone', bricks:'brick', glas:'glass', glassfront:'glass', grass:'plants', masonry:'stone', granite:'stone', panels:'panel', paving_stones:'stone', plastered:'plaster', rooftiles:'roof_tiles', roofingfelt:'tar_paper', sandstone:'stone', sheet:'canvas', sheets:'canvas', shingle:'tar_paper', shingles:'tar_paper', slates:'slate', steel:'metal', tar:'tar_paper', tent:'canvas', thatch:'plants', tile:'roof_tiles', tiles:'roof_tiles' }; // cardboard // eternit // limestone // straw function getMaterialColor (str) { str = str.toLowerCase(); if (str[0] === '#') { return str; } return materialColors[baseMaterials[str] || str] || null; } const WINDING_CLOCKWISE = 'CW'; const WINDING_COUNTER_CLOCKWISE = 'CCW'; // detect winding direction: clockwise or counter clockwise function getWinding (points) { let x1, y1, x2, y2, a = 0; for (let i = 0, il = points.length-3; i < il; i += 2) { x1 = points[i]; y1 = points[i+1]; x2 = points[i+2]; y2 = points[i+3]; a += x1*y2 - x2*y1; } return (a/2) > 0 ? WINDING_CLOCKWISE : WINDING_COUNTER_CLOCKWISE; } // enforce a polygon winding direcetion. Needed for proper backface culling. function makeWinding (points, direction) { let winding = getWinding(points); if (winding === direction) { return points; } let revPoints = []; for (let i = points.length-2; i >= 0; i -= 2) { revPoints.push(points[i], points[i+1]); } return revPoints; } function alignProperties(prop) { const item = {}; prop = prop || {}; item.height = prop.height || (prop.levels ? prop.levels *METERS_PER_LEVEL : DEFAULT_HEIGHT); item.minHeight = prop.minHeight || (prop.minLevel ? prop.minLevel*METERS_PER_LEVEL : 0); const wallColor = prop.material ? getMaterialColor(prop.material) : (prop.wallColor || prop.color); if (wallColor) { item.wallColor = wallColor; } const roofColor = prop.roofMaterial ? getMaterialColor(prop.roofMaterial) : prop.roofColor; if (roofColor) { item.roofColor = roofColor; } switch (prop.shape) { case 'cylinder': case 'cone': case 'dome': case 'sphere': item.shape = prop.shape; item.isRotational = true; break; case 'pyramid': item.shape = prop.shape; break; } switch (prop.roofShape) { case 'cone': case 'dome': item.roofShape = prop.roofShape; item.isRotational = true; break; case 'pyramid': item.roofShape = prop.roofShape; break; } if (item.roofShape && prop.roofHeight) { item.roofHeight = prop.roofHeight; item.height = max(0, item.height-item.roofHeight); } else { item.roofHeight = 0; } return item; } function getGeometries (geometry) { let polygon, geometries = [], sub; switch (geometry.type) { case 'GeometryCollection': geometries = []; for (let i = 0, il = geometry.geometries.length; i < il; i++) { if ((sub = getGeometries(geometry.geometries[i]))) { geometries.push.apply(geometries, sub); } } return geometries; case 'MultiPolygon': geometries = []; for (let i = 0, il = geometry.coordinates.length; i < il; i++) { if ((sub = getGeometries({ type: 'Polygon', coordinates: geometry.coordinates[i] }))) { geometries.push.apply(geometries, sub); } } return geometries; case 'Polygon': polygon = geometry.coordinates; break; default: return []; } let p, lat = 1, lon = 0, outer = [], inner = []; p = polygon[0]; for (let i = 0, il = p.length; i < il; i++) { outer.push(p[i][lat], p[i][lon]); } outer = makeWinding(outer, WINDING_CLOCKWISE); for (let i = 0, il = polygon.length-1; i < il; i++) { p = polygon[i+1]; inner[i] = []; for (let j = 0, jl = p.length; j < jl; j++) { inner[i].push(p[j][lat], p[j][lon]); } inner[i] = makeWinding(inner[i], WINDING_COUNTER_CLOCKWISE); } return [{ outer: outer, inner: inner.length ? inner : null }]; } function clone (obj) { let res = {}; for (const p in obj) { if (obj.hasOwnProperty(p)) { res[p] = obj[p]; } } return res; } class GeoJSON { static read (geojson) { if (!geojson || geojson.type !== 'FeatureCollection') { return []; } const collection = geojson.features; const res = []; for (let i = 0, il = collection.length; i < il; i++) { const feature = collection[i]; if (feature.type !== 'Feature' || onEach(feature) === false) { continue; } const baseItem = alignProperties(feature.properties); const geometries = getGeometries(feature.geometry); for (let j = 0, jl = geometries.length; j < jl; j++) { const item = clone(baseItem); item.footprint = geometries[j].outer; if (item.isRotational) { item.radius = getLonDelta(item.footprint); } if (geometries[j].inner) { item.holes = geometries[j].inner; } if (feature.id || feature.properties.id) { item.id = feature.id || feature.properties.id; } if (feature.properties.relationId) { item.relationId = feature.properties.relationId; } res.push(item); // TODO: clone base properties! } } return res; } } ================================================ FILE: src/OSMBuildings.css ================================================ .osmb-container { transform: translate3d(0, 0, 0); pointerEvents: none; position: absolute; left: 0; top: 0; } .osmb-container.zoom-animation { transition-duration: 250ms; transition-property: transform; transform-origin: 50% 50%; } .osmb-layer { transform: translate3d(0, 0, 0); image-rendering: optimizeSpeed; position: absolute; left: 0; top: 0; } ================================================ FILE: src/Request.js ================================================ let cacheData = {}; let cacheIndex = []; let cacheSize = 0; let maxCacheSize = 1024*1024 * 5; // 5MB function xhr (url, callback) { if (cacheData[url]) { if (callback) { callback(cacheData[url]); } return; } const req = new XMLHttpRequest(); req.onreadystatechange = function () { if (req.readyState !== 4) { return; } if (!req.status || req.status < 200 || req.status > 299) { return; } if (callback && req.responseText) { const responseText = req.responseText; cacheData[url] = responseText; cacheIndex.push({ url: url, size: responseText.length }); cacheSize += responseText.length; callback(responseText); while (cacheSize > maxCacheSize) { let item = cacheIndex.shift(); cacheSize -= item.size; delete cacheData[item.url]; } } }; req.open('GET', url); req.send(null); return req; } class Request { static loadJSON (url, callback) { return xhr(url, responseText => { let json; try { json = JSON.parse(responseText); } catch(ex) {} callback(json); }); } } ================================================ FILE: src/adapter.js ================================================ function setOrigin (origin) { ORIGIN_X = origin.x; ORIGIN_Y = origin.y; } function moveCam (offset) { CAM_X = CENTER_X + offset.x; CAM_Y = HEIGHT + offset.y; Layers.render(true); } function setSize (size) { WIDTH = size.width; HEIGHT = size.height; CENTER_X = WIDTH /2 <<0; CENTER_Y = HEIGHT/2 <<0; CAM_X = CENTER_X; CAM_Y = HEIGHT; Layers.setSize(WIDTH, HEIGHT); MAX_HEIGHT = CAM_Z-50; } function setZoom (z) { ZOOM = z; MAP_SIZE = MAP_TILE_SIZE < fadeIn() => Layers.render() } function onZoomStart () { IS_ZOOMING = true; } function onZoomEnd (e) { IS_ZOOMING = false; const factor = Math.pow(2, e.zoom-ZOOM); setZoom(e.zoom); // Layers.render(); // TODO: requestAnimationFrame() causes flickering because layers are already cleared // show on high zoom levels only if (ZOOM <= MIN_ZOOM) { Layers.clear(); return; } Data.scale(factor); Shadows.render(); Simplified.render(); Buildings.render(); Data.update(); // => fadeIn() } ================================================ FILE: src/engines/Leaflet.js ================================================ class OSMBuildings extends L.Layer { constructor (map) { super(map); this.offset = {x: 0, y: 0}; Layers.init(); if (map) { map.addLayer(this); } } addTo (map) { map.addLayer(this); return this; } onAdd (map) { this.map = map; Layers.appendTo(map._panes.overlayPane); let off = this.getOffset(), po = map.getPixelOrigin(); setSize({width: map._size.x, height: map._size.y}); setOrigin({x: po.x - off.x, y: po.y - off.y}); setZoom(map._zoom); Layers.setPosition(-off.x, -off.y); map.on({ move: this.onMove, moveend: this.onMoveEnd, zoomstart: this.onZoomStart, zoomend: this.onZoomEnd, resize: this.onResize, viewreset: this.onViewReset, click: this.onClick }, this); if (map.options.zoomAnimation) { map.on('zoomanim', this.onZoom, this); } if (map.attributionControl) { map.attributionControl.addAttribution(ATTRIBUTION); } Data.update(); } onRemove () { let map = this.map; if (map.attributionControl) { map.attributionControl.removeAttribution(ATTRIBUTION); } map.off({ move: this.onMove, moveend: this.onMoveEnd, zoomstart: this.onZoomStart, zoomend: this.onZoomEnd, resize: this.onResize, viewreset: this.onViewReset, click: this.onClick }, this); if (map.options.zoomAnimation) { map.off('zoomanim', this.onZoom, this); } Layers.remove(); map = null; } onMove (e) { let off = this.getOffset(); moveCam({x: this.offset.x - off.x, y: this.offset.y - off.y}); } onMoveEnd (e) { if (this.noMoveEnd) { // moveend is also fired after zoom this.noMoveEnd = false; return; } let map = this.map, off = this.getOffset(), po = map.getPixelOrigin(); this.offset = off; Layers.setPosition(-off.x, -off.y); moveCam({x: 0, y: 0}); setSize({width: map._size.x, height: map._size.y}); // in case this is triggered by resize setOrigin({x: po.x - off.x, y: po.y - off.y}); onMoveEnd(e); } onZoomStart (e) { onZoomStart(e); } onZoom (e) { let center = this.map.latLngToContainerPoint(e.center); let scale = Math.pow(2, e.zoom - ZOOM); let dx = WIDTH / 2 - center.x; let dy = HEIGHT / 2 - center.y; let x = WIDTH / 2; let y = HEIGHT / 2; if (e.zoom > ZOOM) { x -= dx * scale; y -= dy * scale; } else { x += dx; y += dy; } Layers.container.classList.add('zoom-animation'); Layers.container.style.transformOrigin = x + 'px ' + y + 'px'; Layers.container.style.transform = 'translate3d(0, 0, 0) scale(' + scale + ')'; } onZoomEnd (e) { Layers.clear(); Layers.container.classList.remove('zoom-animation'); Layers.container.style.transform = 'translate3d(0, 0, 0) scale(1)'; let map = this.map, off = this.getOffset(), po = map.getPixelOrigin(); setOrigin({x: po.x - off.x, y: po.y - off.y}); onZoomEnd({zoom: map._zoom}); this.noMoveEnd = true; } onResize () { } onViewReset () { let off = this.getOffset(); this.offset = off; Layers.setPosition(-off.x, -off.y); moveCam({x: 0, y: 0}); } onClick (e) { let id = Picking.getIdFromXY(e.containerPoint.x, e.containerPoint.y); if (id) { onClick({feature: id, lat: e.latlng.lat, lon: e.latlng.lng}); } } getOffset () { return L.DomUtil.getPosition(this.map._mapPane); } //*** COMMON PUBLIC METHODS *** style (style) { style = style || {}; let color; if ((color = style.color || style.wallColor)) { WALL_COLOR = Qolor.parse(color); WALL_COLOR_STR = '' + WALL_COLOR; ALT_COLOR = WALL_COLOR.lightness(0.8); ALT_COLOR_STR = '' + ALT_COLOR; ROOF_COLOR = WALL_COLOR.lightness(1.2); ROOF_COLOR_STR = '' + ROOF_COLOR; } if (style.roofColor) { ROOF_COLOR = Qolor.parse(style.roofColor); ROOF_COLOR_STR = '' + ROOF_COLOR; } Layers.render(); return this; } date (date) { Shadows.date = date; Shadows.render(); return this; } load (url) { Data.load(url); return this; } set (data) { Data.set(data); return this; } each (handler) { onEach = function (payload) { return handler(payload); }; return this; } click (handler) { onClick = function (payload) { return handler(payload); }; return this; } } OSMBuildings.VERSION = VERSION; OSMBuildings.ATTRIBUTION = ATTRIBUTION; ================================================ FILE: src/engines/OpenLayers.js ================================================ // based on a pull request from Jérémy Judéaux (https://github.com/Volune) class OSMBuildings extends ol.layer.Layer { constructor (map) { super(OSMBuildings.name, {projection: 'EPSG:900913'}); this.offset = {x: 0, y: 0}; // cumulative cam offset during moveBy() Layers.init(); if (map) { map.addLayer(this); } } addTo (map) { this.setMap(map); return this; } setOrigin () { let map = this.map, origin = map.getLonLatFromPixel(new OpenLayers.Pixel(0, 0)), res = map.resolution, ext = this.maxExtent, x = (origin.lon - ext.left) / res << 0, y = (ext.top - origin.lat) / res << 0; setOrigin({x: x, y: y}); } setMap (map) { if (!this.map) { super.setMap.call(this, map); } Layers.appendTo(this.div); setSize({width: map.size.w, height: map.size.h}); setZoom(map.zoom); this.setOrigin(); let layerProjection = this.projection; map.events.register('click', map, e => { let id = Picking.getIdFromXY(e.xy.x, e.xy.y); if (id) { let geo = map.getLonLatFromPixel(e.xy).transform(layerProjection, this.projection); onClick({feature: id, lat: geo.lat, lon: geo.lon}); } }); Data.update(); } removeMap (map) { Layers.remove(); super.removeMap.call(this, map); this.map = null; } onMapResize () { let map = this.map; super.onMapResize.call(this); onResize({width: map.size.w, height: map.size.h}); } moveTo (bounds, zoomChanged, isDragging) { let map = this.map, res = super.moveTo.call(this, bounds, zoomChanged, isDragging); if (!isDragging) { let offsetLeft = parseInt(map.layerContainerDiv.style.left, 10), offsetTop = parseInt(map.layerContainerDiv.style.top, 10); this.div.style.left = -offsetLeft + 'px'; this.div.style.top = -offsetTop + 'px'; } this.setOrigin(); this.offset.x = 0; this.offset.y = 0; moveCam(this.offset); if (zoomChanged) { onZoomEnd({zoom: map.zoom}); } else { onMoveEnd(); } return res; } moveByPx (dx, dy) { this.offset.x += dx; this.offset.y += dy; let res = super.moveByPx.call(this, dx, dy); moveCam(this.offset); return res; } } OSMBuildings.name = 'OSM Buildings'; OSMBuildings.attribution = ATTRIBUTION; OSMBuildings.isBaseLayer = false; OSMBuildings.alwaysInRange = true; ================================================ FILE: src/engines/index-Leaflet.html ================================================ OSM Buildings Classic for Leaflet
================================================ FILE: src/engines/index-OpenLayers.html ================================================ OSM Buildings Classic for OpenLayers
================================================ FILE: src/functions.js ================================================ function rad (deg) { return deg * PI / 180; } function deg (rad) { return rad / PI * 180; } function pixelToGeo (x, y) { const res = {}; x /= MAP_SIZE; y /= MAP_SIZE; res[LAT] = y <= 0 ? 90 : y >= 1 ? -90 : deg(2 * atan(exp(PI * (1 - 2*y))) - HALF_PI); res[LON] = (x === 1 ? 1 : (x%1 + 1) % 1) * 360 - 180; return res; } function geoToPixel (lat, lon) { const latitude = min(1, max(0, 0.5 - (log(tan(QUARTER_PI + HALF_PI * lat / 180)) / PI) / 2)), longitude = lon/360 + 0.5; return { x: longitude*MAP_SIZE <<0, y: latitude *MAP_SIZE <<0 }; } function fromRange (sVal, sMin, sMax, dMin, dMax) { sVal = min(max(sVal, sMin), sMax); const rel = (sVal-sMin) / (sMax-sMin), range = dMax-dMin; return min(max(dMin + rel*range, dMin), dMax); } function isVisible (polygon) { const maxX = WIDTH+ORIGIN_X, maxY = HEIGHT+ORIGIN_Y; // TODO: checking footprint is sufficient for visibility - NOT VALID FOR SHADOWS! for (let i = 0, il = polygon.length-3; i < il; i+=2) { if (polygon[i] > ORIGIN_X && polygon[i] < maxX && polygon[i+1] > ORIGIN_Y && polygon[i+1] < maxY) { return true; } } return false; } ================================================ FILE: src/geometry/Cylinder.js ================================================ class Cylinder { static draw (context, center, radius, topRadius, height, minHeight, color, altColor, roofColor) { let c = { x:center.x-ORIGIN_X, y:center.y-ORIGIN_Y }, scale = CAM_Z / (CAM_Z-height), minScale = CAM_Z / (CAM_Z-minHeight), apex = Buildings.project(c, scale), a1, a2; topRadius *= scale; if (minHeight) { c = Buildings.project(c, minScale); radius = radius*minScale; } // common tangents for ground and roof circle let tangents = this._tangents(c, radius, apex, topRadius); // no tangents? top circle is inside bottom circle if (!tangents) { a1 = 1.5*PI; a2 = 1.5*PI; } else { a1 = atan2(tangents[0].y1-c.y, tangents[0].x1-c.x); a2 = atan2(tangents[1].y1-c.y, tangents[1].x1-c.x); } context.fillStyle = color; context.beginPath(); context.arc(apex.x, apex.y, topRadius, HALF_PI, a1, true); context.arc(c.x, c.y, radius, a1, HALF_PI); context.closePath(); context.fill(); context.fillStyle = altColor; context.beginPath(); context.arc(apex.x, apex.y, topRadius, a2, HALF_PI, true); context.arc(c.x, c.y, radius, HALF_PI, a2); context.closePath(); context.fill(); context.fillStyle = roofColor; this._circle(context, apex, topRadius); } static simplified (context, center, radius) { this._circle(context, { x:center.x-ORIGIN_X, y:center.y-ORIGIN_Y }, radius); } static shadow (context, center, radius, topRadius, height, minHeight) { let c = { x:center.x-ORIGIN_X, y:center.y-ORIGIN_Y }, apex = Shadows.project(c, height), p1, p2; if (minHeight) { c = Shadows.project(c, minHeight); } // common tangents for ground and roof circle let tangents = this._tangents(c, radius, apex, topRadius); // TODO: no tangents? roof overlaps everything near cam position if (tangents) { p1 = atan2(tangents[0].y1-c.y, tangents[0].x1-c.x); p2 = atan2(tangents[1].y1-c.y, tangents[1].x1-c.x); context.moveTo(tangents[1].x2, tangents[1].y2); context.arc(apex.x, apex.y, topRadius, p2, p1); context.arc(c.x, c.y, radius, p1, p2); } else { context.moveTo(c.x+radius, c.y); context.arc(c.x, c.y, radius, 0, 2*PI); } } static hitArea (context, center, radius, topRadius, height, minHeight, color) { let c = { x:center.x-ORIGIN_X, y:center.y-ORIGIN_Y }, scale = CAM_Z / (CAM_Z-height), minScale = CAM_Z / (CAM_Z-minHeight), apex = Buildings.project(c, scale), p1, p2; topRadius *= scale; if (minHeight) { c = Buildings.project(c, minScale); radius = radius*minScale; } // common tangents for ground and roof circle let tangents = this._tangents(c, radius, apex, topRadius); context.fillStyle = color; context.beginPath(); // TODO: no tangents? roof overlaps everything near cam position if (tangents) { p1 = atan2(tangents[0].y1-c.y, tangents[0].x1-c.x); p2 = atan2(tangents[1].y1-c.y, tangents[1].x1-c.x); context.moveTo(tangents[1].x2, tangents[1].y2); context.arc(apex.x, apex.y, topRadius, p2, p1); context.arc(c.x, c.y, radius, p1, p2); } else { context.moveTo(c.x+radius, c.y); context.arc(c.x, c.y, radius, 0, 2*PI); } context.closePath(); context.fill(); } static _circle (context, center, radius) { context.beginPath(); context.arc(center.x, center.y, radius, 0, PI*2); context.fill(); } // http://en.wikibooks.org/wiki/Algorithm_Implementation/Geometry/Tangents_between_two_circles static _tangents (c1, r1, c2, r2) { let dx = c1.x-c2.x, dy = c1.y-c2.y, dr = r1-r2, sqdist = (dx*dx) + (dy*dy); if (sqdist <= dr*dr) { return; } let dist = sqrt(sqdist), vx = -dx/dist, vy = -dy/dist, c = dr/dist, res = [], h, nx, ny; // Let A, B be the centers, and C, D be points at which the tangent // touches first and second circle, and n be the normal vector to it. // // We have the system: // n * n = 1 (n is a unit vector) // C = A + r1 * n // D = B + r2 * n // n * CD = 0 (common orthogonality) // // n * CD = n * (AB + r2*n - r1*n) = AB*n - (r1 -/+ r2) = 0, <=> // AB * n = (r1 -/+ r2), <=> // v * n = (r1 -/+ r2) / d, where v = AB/|AB| = AB/d // This is a linear equation in unknown vector n. // Now we're just intersecting a line with a circle: v*n=c, n*n=1 h = sqrt(max(0, 1 - c*c)); for (let sign = 1; sign >= -1; sign -= 2) { nx = vx*c - sign*h*vy; ny = vy*c + sign*h*vx; res.push({ x1: c1.x + r1*nx <<0, y1: c1.y + r1*ny <<0, x2: c2.x + r2*nx <<0, y2: c2.y + r2*ny <<0 }); } return res; } } ================================================ FILE: src/geometry/Extrusion.js ================================================ class Extrusion { static draw (context, polygon, innerPolygons, height, minHeight, color, altColor, roofColor) { let roof = this._extrude(context, polygon, height, minHeight, color, altColor), innerRoofs = []; if (innerPolygons) { for (let i = 0, il = innerPolygons.length; i < il; i++) { innerRoofs[i] = this._extrude(context, innerPolygons[i], height, minHeight, color, altColor); } } context.fillStyle = roofColor; context.beginPath(); this._ring(context, roof); if (innerPolygons) { for (let i = 0, il = innerRoofs.length; i < il; i++) { this._ring(context, innerRoofs[i]); } } context.closePath(); context.fill(); } static _extrude (context, polygon, height, minHeight, color, altColor) { let scale = CAM_Z / (CAM_Z-height), minScale = CAM_Z / (CAM_Z-minHeight), a = { x:0, y:0 }, b = { x:0, y:0 }, _a, _b, roof = []; for (let i = 0, il = polygon.length-3; i < il; i += 2) { a.x = polygon[i ]-ORIGIN_X; a.y = polygon[i+1]-ORIGIN_Y; b.x = polygon[i+2]-ORIGIN_X; b.y = polygon[i+3]-ORIGIN_Y; _a = Buildings.project(a, scale); _b = Buildings.project(b, scale); if (minHeight) { a = Buildings.project(a, minScale); b = Buildings.project(b, minScale); } // backface culling check if ((b.x-a.x) * (_a.y-a.y) > (_a.x-a.x) * (b.y-a.y)) { // depending on direction, set wall shading if ((a.x < b.x && a.y < b.y) || (a.x > b.x && a.y > b.y)) { context.fillStyle = altColor; } else { context.fillStyle = color; } context.beginPath(); this._ring(context, [ b.x, b.y, a.x, a.y, _a.x, _a.y, _b.x, _b.y ]); context.closePath(); context.fill(); } roof[i] = _a.x; roof[i+1] = _a.y; } return roof; } static _ring (context, polygon) { context.moveTo(polygon[0], polygon[1]); for (let i = 2, il = polygon.length-1; i < il; i += 2) { context.lineTo(polygon[i], polygon[i+1]); } } static simplified (context, polygon, innerPolygons) { context.beginPath(); this._ringAbs(context, polygon); if (innerPolygons) { for (let i = 0, il = innerPolygons.length; i < il; i++) { this._ringAbs(context, innerPolygons[i]); } } context.closePath(); context.fill(); } static _ringAbs (context, polygon) { context.moveTo(polygon[0]-ORIGIN_X, polygon[1]-ORIGIN_Y); for (let i = 2, il = polygon.length-1; i < il; i += 2) { context.lineTo(polygon[i]-ORIGIN_X, polygon[i+1]-ORIGIN_Y); } } static shadow (context, polygon, innerPolygons, height, minHeight) { let mode = null, a = { x:0, y:0 }, b = { x:0, y:0 }, _a, _b; for (let i = 0, il = polygon.length-3; i < il; i += 2) { a.x = polygon[i ]-ORIGIN_X; a.y = polygon[i+1]-ORIGIN_Y; b.x = polygon[i+2]-ORIGIN_X; b.y = polygon[i+3]-ORIGIN_Y; _a = Shadows.project(a, height); _b = Shadows.project(b, height); if (minHeight) { a = Shadows.project(a, minHeight); b = Shadows.project(b, minHeight); } // mode 0: floor edges, mode 1: roof edges if ((b.x-a.x) * (_a.y-a.y) > (_a.x-a.x) * (b.y-a.y)) { if (mode === 1) { context.lineTo(a.x, a.y); } mode = 0; if (!i) { context.moveTo(a.x, a.y); } context.lineTo(b.x, b.y); } else { if (mode === 0) { context.lineTo(_a.x, _a.y); } mode = 1; if (!i) { context.moveTo(_a.x, _a.y); } context.lineTo(_b.x, _b.y); } } if (innerPolygons) { for (let i = 0, il = innerPolygons.length; i < il; i++) { this._ringAbs(context, innerPolygons[i]); } } } static hitArea (context, polygon, innerPolygons, height, minHeight, color) { let mode = null, a = { x:0, y:0 }, b = { x:0, y:0 }, scale = CAM_Z / (CAM_Z-height), minScale = CAM_Z / (CAM_Z-minHeight), _a, _b; context.fillStyle = color; context.beginPath(); for (let i = 0, il = polygon.length-3; i < il; i += 2) { a.x = polygon[i ]-ORIGIN_X; a.y = polygon[i+1]-ORIGIN_Y; b.x = polygon[i+2]-ORIGIN_X; b.y = polygon[i+3]-ORIGIN_Y; _a = Buildings.project(a, scale); _b = Buildings.project(b, scale); if (minHeight) { a = Buildings.project(a, minScale); b = Buildings.project(b, minScale); } // mode 0: floor edges, mode 1: roof edges if ((b.x-a.x) * (_a.y-a.y) > (_a.x-a.x) * (b.y-a.y)) { if (mode === 1) { // mode is initially undefined context.lineTo(a.x, a.y); } mode = 0; if (!i) { context.moveTo(a.x, a.y); } context.lineTo(b.x, b.y); } else { if (mode === 0) { // mode is initially undefined context.lineTo(_a.x, _a.y); } mode = 1; if (!i) { context.moveTo(_a.x, _a.y); } context.lineTo(_b.x, _b.y); } } context.closePath(); context.fill(); } } ================================================ FILE: src/geometry/Pyramid.js ================================================ class Pyramid { static draw (context, polygon, center, height, minHeight, color, altColor) { let c = { x:center.x-ORIGIN_X, y:center.y-ORIGIN_Y }, scale = CAM_Z / (CAM_Z-height), minScale = CAM_Z / (CAM_Z-minHeight), apex = Buildings.project(c, scale), a = { x:0, y:0 }, b = { x:0, y:0 }; for (let i = 0, il = polygon.length-3; i < il; i += 2) { a.x = polygon[i ]-ORIGIN_X; a.y = polygon[i+1]-ORIGIN_Y; b.x = polygon[i+2]-ORIGIN_X; b.y = polygon[i+3]-ORIGIN_Y; if (minHeight) { a = Buildings.project(a, minScale); b = Buildings.project(b, minScale); } // backface culling check if ((b.x-a.x) * (apex.y-a.y) > (apex.x-a.x) * (b.y-a.y)) { // depending on direction, set shading if ((a.x < b.x && a.y < b.y) || (a.x > b.x && a.y > b.y)) { context.fillStyle = altColor; } else { context.fillStyle = color; } context.beginPath(); this._triangle(context, a, b, apex); context.closePath(); context.fill(); } } } static _triangle (context, a, b, c) { context.moveTo(a.x, a.y); context.lineTo(b.x, b.y); context.lineTo(c.x, c.y); } static _ring (context, polygon) { context.moveTo(polygon[0]-ORIGIN_X, polygon[1]-ORIGIN_Y); for (let i = 2, il = polygon.length-1; i < il; i += 2) { context.lineTo(polygon[i]-ORIGIN_X, polygon[i+1]-ORIGIN_Y); } } static shadow (context, polygon, center, height, minHeight) { let a = { x:0, y:0 }, b = { x:0, y:0 }, c = { x:center.x-ORIGIN_X, y:center.y-ORIGIN_Y }, apex = Shadows.project(c, height); for (let i = 0, il = polygon.length-3; i < il; i += 2) { a.x = polygon[i ]-ORIGIN_X; a.y = polygon[i+1]-ORIGIN_Y; b.x = polygon[i+2]-ORIGIN_X; b.y = polygon[i+3]-ORIGIN_Y; if (minHeight) { a = Shadows.project(a, minHeight); b = Shadows.project(b, minHeight); } // backface culling check if ((b.x-a.x) * (apex.y-a.y) > (apex.x-a.x) * (b.y-a.y)) { // depending on direction, set shading this._triangle(context, a, b, apex); } } } static hitArea (context, polygon, center, height, minHeight, color) { let c = { x:center.x-ORIGIN_X, y:center.y-ORIGIN_Y }, scale = CAM_Z / (CAM_Z-height), minScale = CAM_Z / (CAM_Z-minHeight), apex = Buildings.project(c, scale), a = { x:0, y:0 }, b = { x:0, y:0 }; context.fillStyle = color; context.beginPath(); for (let i = 0, il = polygon.length-3; i < il; i += 2) { a.x = polygon[i ]-ORIGIN_X; a.y = polygon[i+1]-ORIGIN_Y; b.x = polygon[i+2]-ORIGIN_X; b.y = polygon[i+3]-ORIGIN_Y; if (minHeight) { a = Buildings.project(a, minScale); b = Buildings.project(b, minScale); } // backface culling check if ((b.x-a.x) * (apex.y-a.y) > (apex.x-a.x) * (b.y-a.y)) { this._triangle(context, a, b, apex); } } context.closePath(); context.fill(); } } ================================================ FILE: src/geometry/__Dome.js ================================================ function rotation (p, c, a) { let ms = sin(a), mc = cos(a); p.x -= c.x; p.y -= c.y; return { x: p.x* mc + p.y*ms + c.x, y: p.x*-ms + p.y*mc + c.y }; } let KAPPA = 0.5522847498; function dome (c, r, h, minHeight) { if (!h) { h = r; } minHeight = minHeight || 0; // VERTICAL TANGENT POINTS ON SPHERE: // side view at scenario: // sphere at c.x,c.y & radius => circle at c.y,minHeight // cam at CAM_X/CAM_Y/CAM_Z => point at CAM_Y/CAM_Z let t = getEllipseTangent(r, h, CAM_Y-c.y, CAM_Z-minHeight); t.x += c.y; t.y += minHeight; if (minHeight) { c = project(c.x, c.y, CAM_Z / (CAM_Z-minHeight)); r *= CAM_Z / (CAM_Z-minHeight); } // radialGradient(c, r, roofColorAlpha) drawCircle(c, r, true); let _h = CAM_Z / (CAM_Z-h), hfK = CAM_Z / (CAM_Z-(h*KAPPA)); let apex = project(c.x, c.y, _h); debugMarker(apex); let angle = atan((CAM_X-c.x)/(CAM_Y-c.y)); context.beginPath(); // ausgerichteter sichtrand! let _th = CAM_Z / (CAM_Z-t.y); let p = rotation({ x:c.x, y:t.x }, c, angle); let _p = project(p.x, p.y, _th); //debugMarker(_p); let p1h = rotation({ x:c.x-r, y:t.x }, c, angle); let _p1h = project(p1h.x, p1h.y, _th); //debugMarker(_p1h); let p2h = rotation({ x:c.x+r, y:t.x }, c, angle); let _p2h = project(p2h.x, p2h.y, _th); //debugMarker(_p2h); let p1v = rotation({ x:c.x-r, y:c.y }, c, angle); //debugMarker(p1v); let p2v = rotation({ x:c.x+r, y:c.y }, c, angle); //debugMarker(p2v); context.moveTo(p1v.x, p1v.y); context.bezierCurveTo( p1v.x + (_p1h.x-p1v.x) * KAPPA, p1v.y + (_p1h.y-p1v.y) * KAPPA, _p.x + (_p1h.x-_p.x) * KAPPA, _p.y + (_p1h.y-_p.y) * KAPPA, _p.x, _p.y); context.moveTo(p2v.x, p2v.y); context.bezierCurveTo( p2v.x + (_p1h.x-p1v.x) * KAPPA, p2v.y + (_p1h.y-p1v.y) * KAPPA, _p.x + (_p2h.x-_p.x) * KAPPA, _p.y + (_p2h.y-_p.y) * KAPPA, _p.x, _p.y); // drawMeridian(c, r, _h, hfK, apex, rad(45)); // drawMeridian(c, r, _h, hfK, apex, rad(135)); for (let i = 0; i <= 180; i+=30) { drawMeridian(c, r, _h, hfK, apex, rad(i)); } // for (let i = 0; i <= 180; i+=30) { // drawMeridian(c, r, _h, hfK, apex, rad(i)); // } // context.fill(); context.stroke(); } function drawMeridian (c, r, _h, hfK, apex, angle) { drawHalfMeridian(c, r, _h, hfK, apex, angle); drawHalfMeridian(c, r, _h, hfK, apex, angle + PI); } function drawHalfMeridian (c, r, _h, hfK, apex, angle) { let p1 = rotation({ x:c.x, y:c.y-r }, c, angle); let p2 = rotation({ x:c.x, y:c.y-r*KAPPA }, c, angle); let _p1 = project(p1.x, p1.y, hfK); let _p2 = project(p2.x, p2.y, _h); context.moveTo(p1.x, p1.y); context.bezierCurveTo(_p1.x, _p1.y, _p2.x, _p2.y, apex.x, apex.y); } function getEllipseTangent (a, b, x, y) { let C = (x*x) / (a*a) + (y*y) / (b*b), R = Math.sqrt(C-1), yabR = y*(a/b)*R, xbaR = x*(b/a)*R; return { x: (x + ( yabR < 0 ? yabR : -yabR)) / C, y: (y + (y+xbaR > 0 ? xbaR : -xbaR)) / C }; } ================================================ FILE: src/geometry.js ================================================ function getDistance (p1, p2) { const dx = p1.x-p2.x, dy = p1.y-p2.y; return dx*dx + dy*dy; } function isRotational (polygon) { const length = polygon.length; if (length < 16) { return false; } let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; for (let i = 0; i < length-1; i+=2) { minX = Math.min(minX, polygon[i]); maxX = Math.max(maxX, polygon[i]); minY = Math.min(minY, polygon[i+1]); maxY = Math.max(maxY, polygon[i+1]); } const width = maxX-minX, height = (maxY-minY), ratio = width/height; if (ratio < 0.85 || ratio > 1.15) { return false; } const center = { x:minX+width/2, y:minY+height/2 }, radius = (width+height)/4, sqRadius = radius*radius; for (let i = 0; i < length-1; i+=2) { const dist = getDistance({ x:polygon[i], y:polygon[i+1] }, center); if (dist/sqRadius < 0.8 || dist/sqRadius > 1.2) { return false; } } return true; } function getSquareSegmentDistance (px, py, p1x, p1y, p2x, p2y) { let dx = p2x-p1x, dy = p2y-p1y, t; if (dx !== 0 || dy !== 0) { t = ((px-p1x) * dx + (py-p1y) * dy) / (dx*dx + dy*dy); if (t > 1) { p1x = p2x; p1y = p2y; } else if (t > 0) { p1x += dx*t; p1y += dy*t; } } dx = px-p1x; dy = py-p1y; return dx*dx + dy*dy; } function simplifyPolygon (buffer) { let sqTolerance = 2, len = buffer.length/2, markers = new Uint8Array(len), first = 0, last = len-1, maxSqDist, sqDist, index, firstStack = [], lastStack = [], newBuffer = []; markers[first] = markers[last] = 1; while (last) { maxSqDist = 0; for (let i = first+1; i < last; i++) { sqDist = getSquareSegmentDistance( buffer[i *2], buffer[i *2 + 1], buffer[first*2], buffer[first*2 + 1], buffer[last *2], buffer[last *2 + 1] ); if (sqDist > maxSqDist) { index = i; maxSqDist = sqDist; } } if (maxSqDist > sqTolerance) { markers[index] = 1; firstStack.push(first); lastStack.push(index); firstStack.push(index); lastStack.push(last); } first = firstStack.pop(); last = lastStack.pop(); } for (let i = 0; i < len; i++) { if (markers[i]) { newBuffer.push(buffer[i*2], buffer[i*2 + 1]); } } return newBuffer; } function getCenter (footprint) { let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; for (let i = 0, il = footprint.length-3; i < il; i += 2) { minX = min(minX, footprint[i]); maxX = max(maxX, footprint[i]); minY = min(minY, footprint[i+1]); maxY = max(maxY, footprint[i+1]); } return { x:minX+(maxX-minX)/2 <<0, y:minY+(maxY-minY)/2 <<0 }; } let EARTH_RADIUS = 6378137; function getLonDelta (footprint) { let minLon = 180, maxLon = -180; for (let i = 0, il = footprint.length; i < il; i += 2) { minLon = min(minLon, footprint[i+1]); maxLon = max(maxLon, footprint[i+1]); } return (maxLon-minLon)/2; } ================================================ FILE: src/layers/Buildings.js ================================================ class Buildings { static init (context) { this.context = context; } static clear () { this.context.clearRect(0, 0, WIDTH, HEIGHT); } static setOpacity (opacity) { this.context.canvas.style.opacity = opacity; } static project (p, m) { return { x: (p.x-CAM_X) * m + CAM_X <<0, y: (p.y-CAM_Y) * m + CAM_Y <<0 }; } static render () { this.clear(); let context = this.context, item, h, mh, sortCam = { x:CAM_X+ORIGIN_X, y:CAM_Y+ORIGIN_Y }, footprint, wallColor, altColor, roofColor, dataItems = Data.items; dataItems.sort((a, b) => { return (a.minHeight-b.minHeight) || getDistance(b.center, sortCam) - getDistance(a.center, sortCam) || (b.height-a.height); }); for (let i = 0, il = dataItems.length; i < il; i++) { item = dataItems[i]; if (Simplified.isSimple(item)) { continue; } footprint = item.footprint; if (!isVisible(footprint)) { continue; } // when fading in, use a dynamic height h = item.scale < 1 ? item.height*item.scale : item.height; mh = 0; if (item.minHeight) { mh = item.scale < 1 ? item.minHeight*item.scale : item.minHeight; } wallColor = item.wallColor || WALL_COLOR_STR; altColor = item.altColor || ALT_COLOR_STR; roofColor = item.roofColor || ROOF_COLOR_STR; context.strokeStyle = altColor; switch (item.shape) { case 'cylinder': Cylinder.draw(context, item.center, item.radius, item.radius, h, mh, wallColor, altColor, roofColor); break; case 'cone': Cylinder.draw(context, item.center, item.radius, 0, h, mh, wallColor, altColor); break; case 'dome': Cylinder.draw(context, item.center, item.radius, item.radius/2, h, mh, wallColor, altColor); break; case 'sphere': Cylinder.draw(context, item.center, item.radius, item.radius, h, mh, wallColor, altColor, roofColor); break; case 'pyramid': Pyramid.draw(context, footprint, item.center, h, mh, wallColor, altColor); break; default: Extrusion.draw(context, footprint, item.holes, h, mh, wallColor, altColor, roofColor); } switch (item.roofShape) { case 'cone': Cylinder.draw(context, item.center, item.radius, 0, h+item.roofHeight, h, roofColor, ''+ Qolor.parse(roofColor).lightness(0.9)); break; case 'dome': Cylinder.draw(context, item.center, item.radius, item.radius/2, h+item.roofHeight, h, roofColor, ''+ Qolor.parse(roofColor).lightness(0.9)); break; case 'pyramid': Pyramid.draw(context, footprint, item.center, h+item.roofHeight, h, roofColor, Qolor.parse(roofColor).lightness(0.9)); break; } } } } ================================================ FILE: src/layers/Picking.js ================================================ class Picking { static init (context) { this.context = context; } static setOpacity (opacity) {} static clear () {} static reset () { this._idMapping = [null]; } static render () { if (this._timer) { return; } let self = this; this._timer = setTimeout(t => { self._timer = null; self._render(); }, 500); } static _render () { this.clear(); let context = this.context, item, h, mh, sortCam = { x:CAM_X+ORIGIN_X, y:CAM_Y+ORIGIN_Y }, footprint, color, dataItems = Data.items; dataItems.sort((a, b) => { return (a.minHeight-b.minHeight) || getDistance(b.center, sortCam) - getDistance(a.center, sortCam) || (b.height-a.height); }); for (let i = 0, il = dataItems.length; i < il; i++) { item = dataItems[i]; if (!(color = item.hitColor)) { continue; } footprint = item.footprint; if (!isVisible(footprint)) { continue; } h = item.height; mh = 0; if (item.minHeight) { mh = item.minHeight; } switch (item.shape) { case 'cylinder': Cylinder.hitArea(context, item.center, item.radius, item.radius, h, mh, color); break; case 'cone': Cylinder.hitArea(context, item.center, item.radius, 0, h, mh, color); break; case 'dome': Cylinder.hitArea(context, item.center, item.radius, item.radius/2, h, mh, color); break; case 'sphere': Cylinder.hitArea(context, item.center, item.radius, item.radius, h, mh, color); break; case 'pyramid': Pyramid.hitArea(context, footprint, item.center, h, mh, color); break; default: Extrusion.hitArea(context, footprint, item.holes, h, mh, color); } switch (item.roofShape) { case 'cone': Cylinder.hitArea(context, item.center, item.radius, 0, h+item.roofHeight, h, color); break; case 'dome': Cylinder.hitArea(context, item.center, item.radius, item.radius/2, h+item.roofHeight, h, color); break; case 'pyramid': Pyramid.hitArea(context, footprint, item.center, h+item.roofHeight, h, color); break; } } // otherwise fails on size 0 if (WIDTH && HEIGHT) { this._imageData = this.context.getImageData(0, 0, WIDTH, HEIGHT).data; } } static getIdFromXY (x, y) { let imageData = this._imageData; if (!imageData) { return; } let pos = 4*((y|0) * WIDTH + (x|0)); let index = imageData[pos] | (imageData[pos+1]<<8) | (imageData[pos+2]<<16); return this._idMapping[index]; } static idToColor (id) { let index = this._idMapping.indexOf(id); if (index === -1) { this._idMapping.push(id); index = this._idMapping.length-1; } let r = index & 0xff; let g = (index >>8) & 0xff; let b = (index >>16) & 0xff; return 'rgb('+ [r, g, b].join(',') +')'; } } Picking._idMapping = [null]; ================================================ FILE: src/layers/Shadows.js ================================================ class Shadows { static init (context) { this.context = context; } static clear () { this.context.clearRect(0, 0, WIDTH, HEIGHT); } static setOpacity (opacity) { this.opacity = opacity; } static project (p, h) { return { x: p.x + this.direction.x*h, y: p.y + this.direction.y*h }; } static render () { this.clear(); let context = this.context, screenCenter, sun, length, alpha; // TODO: calculate this just on demand screenCenter = pixelToGeo(CENTER_X+ORIGIN_X, CENTER_Y+ORIGIN_Y); sun = getSunPosition(this.date, screenCenter.latitude, screenCenter.longitude); if (sun.altitude <= 0) { return; } length = 1 / tan(sun.altitude); alpha = length < 5 ? 0.75 : 1/length*5; this.direction.x = cos(sun.azimuth) * length; this.direction.y = sin(sun.azimuth) * length; let i, il, item, h, mh, footprint, dataItems = Data.items; context.canvas.style.opacity = alpha / (this.opacity * 2); context.shadowColor = this.blurColor; context.fillStyle = this.color; context.beginPath(); for (i = 0, il = dataItems.length; i < il; i++) { item = dataItems[i]; footprint = item.footprint; if (!isVisible(footprint)) { continue; } // when fading in, use a dynamic height h = item.scale < 1 ? item.height*item.scale : item.height; mh = 0; if (item.minHeight) { mh = item.scale < 1 ? item.minHeight*item.scale : item.minHeight; } switch (item.shape) { case 'cylinder': Cylinder.shadow(context, item.center, item.radius, item.radius, h, mh); break; case 'cone': Cylinder.shadow(context, item.center, item.radius, 0, h, mh); break; case 'dome': Cylinder.shadow(context, item.center, item.radius, item.radius/2, h, mh); break; case 'sphere': Cylinder.shadow(context, item.center, item.radius, item.radius, h, mh); break; case 'pyramid': Pyramid.shadow(context, footprint, item.center, h, mh); break; default: Extrusion.shadow(context, footprint, item.holes, h, mh); } switch (item.roofShape) { case 'cone': Cylinder.shadow(context, item.center, item.radius, 0, h+item.roofHeight, h); break; case 'dome': Cylinder.shadow(context, item.center, item.radius, item.radius/2, h+item.roofHeight, h); break; case 'pyramid': Pyramid.shadow(context, footprint, item.center, h+item.roofHeight, h); break; } } context.closePath(); context.fill(); } } Shadows.color = '#666666'; Shadows.blurColor = '#000000'; Shadows.date = new Date(); Shadows.direction = { x:0, y:0 }; Shadows.opacity = 1; ================================================ FILE: src/layers/Simplified.js ================================================ class Simplified { static init (context) { this.context = context; } static clear () { this.context.clearRect(0, 0, WIDTH, HEIGHT); } static setOpacity (opacity) { this.context.canvas.style.opacity = opacity; } static isSimple (item) { return (ZOOM <= Simplified.MAX_ZOOM && item.height+item.roofHeight < Simplified.MAX_HEIGHT); } static render () { this.clear(); let context = this.context; // show on high zoom levels only and avoid rendering during zoom if (ZOOM > Simplified.MAX_ZOOM) { return; } let item, footprint, dataItems = Data.items; for (let i = 0, il = dataItems.length; i < il; i++) { item = dataItems[i]; if (item.height >= Simplified.MAX_HEIGHT) { continue; } footprint = item.footprint; if (!isVisible(footprint)) { continue; } context.strokeStyle = item.altColor || ALT_COLOR_STR; context.fillStyle = item.roofColor || ROOF_COLOR_STR; switch (item.shape) { case 'cylinder': case 'cone': case 'dome': case 'sphere': Cylinder.simplified(context, item.center, item.radius); break; default: Extrusion.simplified(context, footprint, item.holes); } } } } Simplified.MAX_ZOOM = 16; // max zoom where buildings could render simplified Simplified.MAX_HEIGHT = 5; // max building height in order to be simple ================================================ FILE: src/layers/index.js ================================================ let animTimer; function fadeIn() { if (animTimer) { return; } animTimer = setInterval(t => { let dataItems = Data.items, isNeeded = false; for (let i = 0, il = dataItems.length; i < il; i++) { if (dataItems[i].scale < 1) { dataItems[i].scale += 0.5*0.2; // amount*easing if (dataItems[i].scale > 1) { dataItems[i].scale = 1; } isNeeded = true; } } Layers.render(); if (!isNeeded) { clearInterval(animTimer); animTimer = null; } }, 33); } class Layers { static init () { Layers.container.className = 'osmb-container'; // TODO: improve this Shadows.init(Layers.createContext(Layers.container)); Simplified.init(Layers.createContext(Layers.container)); Buildings.init(Layers.createContext(Layers.container)); Picking.init(Layers.createContext()); } static clear () { Shadows.clear(); Simplified.clear(); Buildings.clear(); Picking.clear(); } static setOpacity (opacity) { Shadows.setOpacity(opacity); Simplified.setOpacity(opacity); Buildings.setOpacity(opacity); Picking.setOpacity(opacity); } static render (quick) { // show on high zoom levels only if (ZOOM < MIN_ZOOM) { Layers.clear(); return; } // don't render during zoom if (IS_ZOOMING) { return; } requestAnimationFrame(f => { if (!quick) { Shadows.render(); Simplified.render(); //HitAreas.render(); // TODO: do this on demand } Buildings.render(); }); } static createContext (container) { let canvas = document.createElement('CANVAS'); canvas.className = 'osmb-layer'; let context = canvas.getContext('2d'); context.lineCap = 'round'; context.lineJoin = 'round'; context.lineWidth = 1; context.imageSmoothingEnabled = false; Layers.items.push(canvas); if (container) { container.appendChild(canvas); } return context; } static appendTo (parentNode) { parentNode.appendChild(Layers.container); } static remove () { Layers.container.parentNode.removeChild(Layers.container); } static setSize (width, height) { Layers.items.forEach(canvas => { canvas.width = width; canvas.height = height; }); } // usually called after move: container jumps by move delta, cam is reset static setPosition (x, y) { Layers.container.style.left = x +'px'; Layers.container.style.top = y +'px'; } } Layers.container = document.createElement('DIV'); Layers.items = []; ================================================ FILE: src/lib/getSunPosition.js ================================================ // calculations are based on http://aa.quae.nl/en/reken/zonpositie.html // code credits to Vladimir Agafonkin (@mourner) function getSunPosition () { const m = Math, PI = m.PI, sin = m.sin, cos = m.cos, tan = m.tan, asin = m.asin, atan = m.atan2; const rad = PI/180, dayMs = 1000*60*60*24, J1970 = 2440588, J2000 = 2451545, e = rad*23.4397; // obliquity of the Earth function toJulian(date) { return date.valueOf()/dayMs - 0.5+J1970; } function toDays(date) { return toJulian(date)-J2000; } function getRightAscension(l, b) { return atan(sin(l)*cos(e) - tan(b)*sin(e), cos(l)); } function getDeclination(l, b) { return asin(sin(b)*cos(e) + cos(b)*sin(e)*sin(l)); } function getAzimuth(H, phi, dec) { return atan(sin(H), cos(H)*sin(phi) - tan(dec)*cos(phi)); } function getAltitude(H, phi, dec) { return asin(sin(phi)*sin(dec) + cos(phi)*cos(dec)*cos(H)); } function getSiderealTime(d, lw) { return rad * (280.16 + 360.9856235*d) - lw; } function getSolarMeanAnomaly(d) { return rad * (357.5291 + 0.98560028*d); } function getEquationOfCenter(M) { return rad * (1.9148*sin(M) + 0.0200 * sin(2*M) + 0.0003 * sin(3*M)); } function getEclipticLongitude(M, C) { const P = rad*102.9372; // perihelion of the Earth return M+C+P+PI; } return function getSunPosition(date, lat, lon) { const lw = rad*-lon, phi = rad*lat, d = toDays(date), M = getSolarMeanAnomaly(d), C = getEquationOfCenter(M), L = getEclipticLongitude(M, C), D = getDeclination(L, 0), A = getRightAscension(L, 0), t = getSiderealTime(d, lw), H = t-A; return { altitude: getAltitude(H, phi, D), azimuth: getAzimuth(H, phi, D) - PI/2 // origin: north }; }; } ================================================ FILE: src/shortcuts.js ================================================ const m = Math, exp = m.exp, log = m.log, sin = m.sin, cos = m.cos, tan = m.tan, atan = m.atan, atan2 = m.atan2, min = m.min, max = m.max, sqrt = m.sqrt, ceil = m.ceil, pow = m.pow; ================================================ FILE: src/variables.js ================================================ let VERSION = '0.3.2', ATTRIBUTION = '© OSM Buildings', DATA_SRC = 'https://{s}.data.osmbuildings.org/0.2/{k}/tile/{z}/{x}/{y}.json', PI = Math.PI, HALF_PI = PI/2, QUARTER_PI = PI/4, MAP_TILE_SIZE = 256, // map tile size in pixels ZOOM, MAP_SIZE, MIN_ZOOM = 15, LAT = 'latitude', LON = 'longitude', WIDTH = 0, HEIGHT = 0, CENTER_X = 0, CENTER_Y = 0, ORIGIN_X = 0, ORIGIN_Y = 0, WALL_COLOR = Qolor.parse('rgba(200, 190, 180)'), ALT_COLOR = WALL_COLOR.lightness(0.8), ROOF_COLOR = WALL_COLOR.lightness(1.2), WALL_COLOR_STR = ''+ WALL_COLOR, ALT_COLOR_STR = ''+ ALT_COLOR, ROOF_COLOR_STR = ''+ ROOF_COLOR, PIXEL_PER_DEG = 0, MAX_HEIGHT, // taller buildings will be cut to this DEFAULT_HEIGHT = 5, CAM_X, CAM_Y, CAM_Z = 450, IS_ZOOMING; function onEach () {} function onClick () {} ================================================ FILE: tests/openlayers-5.3.0/OSMBuildings-OL5.js ================================================ /** * Copyright (C) 2019 OSM Buildings, Jan Marsch * A JavaScript library for visualizing building geometry on interactive maps. * @osmbuildings, http://osmbuildings.org */ import { Vector as VectorLayer } from "ol/layer.js"; import VectorSource from "ol/source/Vector.js"; import { inherits as olInherits } from "ol/util.js"; import * as olProj from "ol/proj.js"; //****** file: Block.js ****** class Block { constructor() {} draw( context, polygon, innerPolygons, height, minHeight, color, altColor, roofColor ) { var i, il, roof = this._extrude( context, polygon, height, minHeight, color, altColor ), innerRoofs = []; if (innerPolygons) { for (i = 0, il = innerPolygons.length; i < il; i++) { innerRoofs[i] = this._extrude( context, innerPolygons[i], height, minHeight, color, altColor ); } } context.fillStyle = roofColor; context.beginPath(); this._ring(context, roof); if (innerPolygons) { for (i = 0, il = innerRoofs.length; i < il; i++) { this._ring(context, innerRoofs[i]); } } context.closePath(); context.stroke(); context.fill(); } _extrude(context, polygon, height, minHeight, color, altColor) { var scale = CAM_Z / (CAM_Z - height), minScale = CAM_Z / (CAM_Z - minHeight), a = { x: 0, y: 0 }, b = { x: 0, y: 0 }, _a, _b, roof = []; for (var i = 0, il = polygon.length - 3; i < il; i += 2) { a.x = polygon[i] - ORIGIN_X; a.y = polygon[i + 1] - ORIGIN_Y; b.x = polygon[i + 2] - ORIGIN_X; b.y = polygon[i + 3] - ORIGIN_Y; _a = buildings.project(a, scale); _b = buildings.project(b, scale); if (minHeight) { a = buildings.project(a, minScale); b = buildings.project(b, minScale); } // backface culling check if ((b.x - a.x) * (_a.y - a.y) > (_a.x - a.x) * (b.y - a.y)) { // depending on direction, set wall shading if ((a.x < b.x && a.y < b.y) || (a.x > b.x && a.y > b.y)) { context.fillStyle = altColor; } else { context.fillStyle = color; } context.beginPath(); this._ring(context, [b.x, b.y, a.x, a.y, _a.x, _a.y, _b.x, _b.y]); context.closePath(); context.fill(); } roof[i] = _a.x; roof[i + 1] = _a.y; } return roof; } _ring(context, polygon) { context.moveTo(polygon[0], polygon[1]); for (var i = 2, il = polygon.length - 1; i < il; i += 2) { context.lineTo(polygon[i], polygon[i + 1]); } } simplified(context, polygon, innerPolygons) { context.beginPath(); this._ringAbs(context, polygon); if (innerPolygons) { for (var i = 0, il = innerPolygons.length; i < il; i++) { this._ringAbs(context, innerPolygons[i]); } } context.closePath(); context.stroke(); context.fill(); } _ringAbs(context, polygon) { context.moveTo(polygon[0] - ORIGIN_X, polygon[1] - ORIGIN_Y); for (var i = 2, il = polygon.length - 1; i < il; i += 2) { context.lineTo(polygon[i] - ORIGIN_X, polygon[i + 1] - ORIGIN_Y); } } shadow(context, polygon, innerPolygons, height, minHeight) { var mode = null, a = { x: 0, y: 0 }, b = { x: 0, y: 0 }, _a, _b; for (var i = 0, il = polygon.length - 3; i < il; i += 2) { a.x = polygon[i] - ORIGIN_X; a.y = polygon[i + 1] - ORIGIN_Y; b.x = polygon[i + 2] - ORIGIN_X; b.y = polygon[i + 3] - ORIGIN_Y; _a = shadows.project(a, height); _b = shadows.project(b, height); if (minHeight) { a = shadows.project(a, minHeight); b = shadows.project(b, minHeight); } // mode 0: floor edges, mode 1: roof edges if ((b.x - a.x) * (_a.y - a.y) > (_a.x - a.x) * (b.y - a.y)) { if (mode === 1) { context.lineTo(a.x, a.y); } mode = 0; if (!i) { context.moveTo(a.x, a.y); } context.lineTo(b.x, b.y); } else { if (mode === 0) { context.lineTo(_a.x, _a.y); } mode = 1; if (!i) { context.moveTo(_a.x, _a.y); } context.lineTo(_b.x, _b.y); } } if (innerPolygons) { for (i = 0, il = innerPolygons.length; i < il; i++) { this._ringAbs(context, innerPolygons[i]); } } } shadowMask(context, polygon, innerPolygons) { this._ringAbs(context, polygon); if (innerPolygons) { for (var i = 0, il = innerPolygons.length; i < il; i++) { this._ringAbs(context, innerPolygons[i]); } } } hitArea(context, polygon, innerPolygons, height, minHeight, color) { var mode = null, a = { x: 0, y: 0 }, b = { x: 0, y: 0 }, scale = CAM_Z / (CAM_Z - height), minScale = CAM_Z / (CAM_Z - minHeight), _a, _b; context.fillStyle = color; context.beginPath(); for (var i = 0, il = polygon.length - 3; i < il; i += 2) { a.x = polygon[i] - ORIGIN_X; a.y = polygon[i + 1] - ORIGIN_Y; b.x = polygon[i + 2] - ORIGIN_X; b.y = polygon[i + 3] - ORIGIN_Y; _a = buildings.project(a, scale); _b = buildings.project(b, scale); if (minHeight) { a = buildings.project(a, minScale); b = buildings.project(b, minScale); } // mode 0: floor edges, mode 1: roof edges if ((b.x - a.x) * (_a.y - a.y) > (_a.x - a.x) * (b.y - a.y)) { if (mode === 1) { // mode is initially undefined context.lineTo(a.x, a.y); } mode = 0; if (!i) { context.moveTo(a.x, a.y); } context.lineTo(b.x, b.y); } else { if (mode === 0) { // mode is initially undefined context.lineTo(_a.x, _a.y); } mode = 1; if (!i) { context.moveTo(_a.x, _a.y); } context.lineTo(_b.x, _b.y); } } context.closePath(); context.fill(); } } //****** file: Buildings.js ****** class Buildings { constructor() { this.data; } setData(data) { this.data = data; } project(p, m) { return { x: ((p.x - CAM_X) * m + CAM_X) << 0, y: ((p.y - CAM_Y) * m + CAM_Y) << 0 }; } render() { var context = this.context; context.clearRect(0, 0, WIDTH, HEIGHT); // show on high zoom levels only and avoid rendering during zoom if (ZOOM < MIN_ZOOM || isZooming) { return; } var item, h, mh, sortCam = { x: CAM_X + ORIGIN_X, y: CAM_Y + ORIGIN_Y }, footprint, wallColor, altColor, roofColor, dataItems = this.data.getItems(); dataItems.sort(function(a, b) { return ( a.minHeight - b.minHeight || Geometry.getDistance(b.center, sortCam) - Geometry.getDistance(a.center, sortCam) || b.height - a.height ); }); var cylinder = new Cylinder(); var pyramid = new Pyramid(); var block = new Block(); for (var i = 0, il = dataItems.length; i < il; i++) { item = dataItems[i]; if (simplified.isSimple(item)) { continue; } footprint = item.footprint; if (!Functions.isVisible(footprint)) { continue; } // when fading in, use a dynamic height h = item.scale < 1 ? item.height * item.scale : item.height; mh = 0; if (item.minHeight) { mh = item.scale < 1 ? item.minHeight * item.scale : item.minHeight; } wallColor = item.wallColor || WALL_COLOR_STR; altColor = item.altColor || ALT_COLOR_STR; roofColor = item.roofColor || ROOF_COLOR_STR; context.strokeStyle = altColor; switch (item.shape) { case "cylinder": cylinder.draw( context, item.center, item.radius, item.radius, h, mh, wallColor, altColor, roofColor ); break; case "cone": cylinder.draw( context, item.center, item.radius, 0, h, mh, wallColor, altColor ); break; case "dome": cylinder.draw( context, item.center, item.radius, item.radius / 2, h, mh, wallColor, altColor ); break; case "sphere": cylinder.draw( context, item.center, item.radius, item.radius, h, mh, wallColor, altColor, roofColor ); break; case "pyramid": pyramid.draw( context, footprint, item.center, h, mh, wallColor, altColor ); break; default: block.draw( context, footprint, item.holes, h, mh, wallColor, altColor, roofColor ); } switch (item.roofShape) { case "cone": cylinder.draw( context, item.center, item.radius, 0, h + item.roofHeight, h, roofColor, "" + Color.parse(roofColor).lightness(0.9) ); break; case "dome": cylinder.draw( context, item.center, item.radius, item.radius / 2, h + item.roofHeight, h, roofColor, "" + Color.parse(roofColor).lightness(0.9) ); break; case "pyramid": pyramid.draw( context, footprint, item.center, h + item.roofHeight, h, roofColor, Color.parse(roofColor).lightness(0.9) ); break; } } } setContext(context) { this.context = context; } } //****** file: Color.debug.js ****** class Color { constructor(h, s, l, a) { this.H = h; this.S = s; this.L = l; this.A = a; } hue2rgb(p, q, t) { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1 / 6) return p + (q - p) * 6 * t; if (t < 1 / 2) return q; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; return p; } clamp(v, max) { return Math.min(max, Math.max(0, v)); } /* * str can be in any of these: * #0099ff rgb(64, 128, 255) rgba(64, 128, 255, 0.5) */ static parse(str) { var r = 0, g = 0, b = 0, a = 1, m; // Static variable var w3cColors = { aqua: "#00ffff", black: "#000000", blue: "#0000ff", fuchsia: "#ff00ff", gray: "#808080", grey: "#808080", green: "#008000", lime: "#00ff00", maroon: "#800000", navy: "#000080", olive: "#808000", orange: "#ffa500", purple: "#800080", red: "#ff0000", silver: "#c0c0c0", teal: "#008080", white: "#ffffff", yellow: "#ffff00" }; str = ("" + str).toLowerCase(); str = w3cColors[str] || str; if ((m = str.match(/^#(\w{2})(\w{2})(\w{2})$/))) { r = parseInt(m[1], 16); g = parseInt(m[2], 16); b = parseInt(m[3], 16); } else if ( (m = str.match(/rgba?\((\d+)\D+(\d+)\D+(\d+)(\D+([\d.]+))?\)/)) ) { r = parseInt(m[1], 10); g = parseInt(m[2], 10); b = parseInt(m[3], 10); a = m[4] ? parseFloat(m[5]) : 1; } else { return; } return this.fromRGBA(r, g, b, a); } toRGBA() { var h = this.clamp(this.H, 360), s = this.clamp(this.S, 1), l = this.clamp(this.L, 1), rgba = { a: this.clamp(this.A, 1) }; // achromatic if (s === 0) { rgba.r = l; rgba.g = l; rgba.b = l; } else { var q = l < 0.5 ? l * (1 + s) : l + s - l * s, p = 2 * l - q; h /= 360; rgba.r = this.hue2rgb(p, q, h + 1 / 3); rgba.g = this.hue2rgb(p, q, h); rgba.b = this.hue2rgb(p, q, h - 1 / 3); } return { r: Math.round(rgba.r * 255), g: Math.round(rgba.g * 255), b: Math.round(rgba.b * 255), a: rgba.a }; } static fromRGBA(r, g, b, a) { if (typeof r === "object") { g = r.g / 255; b = r.b / 255; a = r.a; r = r.r / 255; } else { r /= 255; g /= 255; b /= 255; } var max = Math.max(r, g, b), min = Math.min(r, g, b), h, s, l = (max + min) / 2, d = max - min; if (!d) { h = s = 0; // achromatic } else { s = l > 0.5 ? d / (2 - max - min) : d / (max + min); switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; } h *= 60; } return new Color(h, s, l, a); } toString() { var rgba = this.toRGBA(); if (rgba.a === 1) { return ( "#" + ((1 << 24) + (rgba.r << 16) + (rgba.g << 8) + rgba.b) .toString(16) .slice(1, 7) ); } return ( "rgba(" + [rgba.r, rgba.g, rgba.b, rgba.a.toFixed(2)].join(",") + ")" ); } hue(h) { return new Color(this.H * h, this.S, this.L, this.A); } saturation(s) { return new Color(this.H, this.S * s, this.L, this.A); } lightness(l) { return new Color(this.H, this.S, this.L * l, this.A); } alpha(a) { return new Color(this.H, this.S, this.L, this.A * a); } } //****** file: Cylinder.js ****** class Cylinder { constructor() {} draw( context, center, radius, topRadius, height, minHeight, color, altColor, roofColor ) { var c = { x: center.x - ORIGIN_X, y: center.y - ORIGIN_Y }, scale = CAM_Z / (CAM_Z - height), minScale = CAM_Z / (CAM_Z - minHeight), apex = buildings.project(c, scale), a1, a2; topRadius *= scale; if (minHeight) { c = buildings.project(c, minScale); radius = radius * minScale; } // common tangents for ground and roof circle var tangents = this._tangents(c, radius, apex, topRadius); // no tangents? top circle is inside bottom circle if (!tangents) { a1 = 1.5 * PI; a2 = 1.5 * PI; } else { a1 = atan2(tangents[0].y1 - c.y, tangents[0].x1 - c.x); a2 = atan2(tangents[1].y1 - c.y, tangents[1].x1 - c.x); } context.fillStyle = color; context.beginPath(); context.arc(apex.x, apex.y, topRadius, HALF_PI, a1, true); context.arc(c.x, c.y, radius, a1, HALF_PI); context.closePath(); context.fill(); context.fillStyle = altColor; context.beginPath(); context.arc(apex.x, apex.y, topRadius, a2, HALF_PI, true); context.arc(c.x, c.y, radius, HALF_PI, a2); context.closePath(); context.fill(); context.fillStyle = roofColor; this._circle(context, apex, topRadius); } simplified(context, center, radius) { this._circle( context, { x: center.x - ORIGIN_X, y: center.y - ORIGIN_Y }, radius ); } shadow(context, center, radius, topRadius, height, minHeight) { var c = { x: center.x - ORIGIN_X, y: center.y - ORIGIN_Y }, apex = shadows.project(c, height), p1, p2; if (minHeight) { c = shadows.project(c, minHeight); } // common tangents for ground and roof circle var tangents = this._tangents(c, radius, apex, topRadius); // TODO: no tangents? roof overlaps everything near cam position if (tangents) { p1 = atan2(tangents[0].y1 - c.y, tangents[0].x1 - c.x); p2 = atan2(tangents[1].y1 - c.y, tangents[1].x1 - c.x); context.moveTo(tangents[1].x2, tangents[1].y2); context.arc(apex.x, apex.y, topRadius, p2, p1); context.arc(c.x, c.y, radius, p1, p2); } else { context.moveTo(c.x + radius, c.y); context.arc(c.x, c.y, radius, 0, 2 * PI); } } shadowMask(context, center, radius) { var c = { x: center.x - ORIGIN_X, y: center.y - ORIGIN_Y }; context.moveTo(c.x + radius, c.y); context.arc(c.x, c.y, radius, 0, PI * 2); } hitArea(context, center, radius, topRadius, height, minHeight, color) { var c = { x: center.x - ORIGIN_X, y: center.y - ORIGIN_Y }, scale = CAM_Z / (CAM_Z - height), minScale = CAM_Z / (CAM_Z - minHeight), apex = buildings.project(c, scale), p1, p2; topRadius *= scale; if (minHeight) { c = buildings.project(c, minScale); radius = radius * minScale; } // common tangents for ground and roof circle var tangents = this._tangents(c, radius, apex, topRadius); context.fillStyle = color; context.beginPath(); // TODO: no tangents? roof overlaps everything near cam position if (tangents) { p1 = atan2(tangents[0].y1 - c.y, tangents[0].x1 - c.x); p2 = atan2(tangents[1].y1 - c.y, tangents[1].x1 - c.x); context.moveTo(tangents[1].x2, tangents[1].y2); context.arc(apex.x, apex.y, topRadius, p2, p1); context.arc(c.x, c.y, radius, p1, p2); } else { context.moveTo(c.x + radius, c.y); context.arc(c.x, c.y, radius, 0, 2 * PI); } context.closePath(); context.fill(); } _circle(context, center, radius) { context.beginPath(); context.arc(center.x, center.y, radius, 0, PI * 2); context.stroke(); context.fill(); } // http://en.wikibooks.org/wiki/Algorithm_Implementation/Geometry/Tangents_between_two_circles _tangents(c1, r1, c2, r2) { var dx = c1.x - c2.x, dy = c1.y - c2.y, dr = r1 - r2, sqdist = dx * dx + dy * dy; if (sqdist <= dr * dr) { return; } var dist = sqrt(sqdist), vx = -dx / dist, vy = -dy / dist, c = dr / dist, res = [], h, nx, ny; // Let A, B be the centers, and C, D be points at which the tangent // touches first and second circle, and n be the normal vector to it. // // We have the system: // n * n = 1 (n is a unit vector) // C = A + r1 * n // D = B + r2 * n // n * CD = 0 (common orthogonality) // // n * CD = n * (AB + r2*n - r1*n) = AB*n - (r1 -/+ r2) = 0, <=> // AB * n = (r1 -/+ r2), <=> // v * n = (r1 -/+ r2) / d, where v = AB/|AB| = AB/d // This is a linear equation in unknown vector n. // Now we're just intersecting a line with a circle: v*n=c, n*n=1 h = sqrt(max(0, 1 - c * c)); for (var sign = 1; sign >= -1; sign -= 2) { nx = vx * c - sign * h * vy; ny = vy * c + sign * h * vx; res.push({ x1: (c1.x + r1 * nx) << 0, y1: (c1.y + r1 * ny) << 0, x2: (c2.x + r2 * nx) << 0, y2: (c2.y + r2 * ny) << 0 }); } return res; } } //****** file: Data.js ****** class Data { constructor() { this.geoJSON = new GeoJSON(); shadows.setData(this); simplified.setData(this); buildings.setData(this); hitAreas.setData(this); this.DATA_SRC = "https://{s}.data.osmbuildings.org/0.2/{k}/tile/{z}/{x}/{y}.json"; this.animTimer; // Static variables this.loadedItems = {}; // maintain a list of cached items in order to avoid duplicates on tile borders this.items = []; this.request = new Request(); } getPixelFootprint(buffer) { var footprint = new Int32Array(buffer.length), px; for (var i = 0, il = buffer.length - 1; i < il; i += 2) { px = Functions.geoToPixel(buffer[i], buffer[i + 1]); footprint[i] = px.x; footprint[i + 1] = px.y; } footprint = Geometry.simplifyPolygon(footprint); if (footprint.length < 8) { // 3 points & end==start (*2) return; } return footprint; } getItems() { return this.items; } resetItems() { this.items = []; this.loadedItems = {}; hitAreas.reset(); } fadeIn() { if (this.animTimer) { return; } var scope = this; this.animTimer = setInterval(function() { var dataItems = scope.items; var isNeeded = false; for (var i = 0, il = dataItems.length; i < il; i++) { if (dataItems[i].scale < 1) { dataItems[i].scale += 0.5 * 0.2; // amount*easing if (dataItems[i].scale > 1) { dataItems[i].scale = 1; } isNeeded = true; } } requestAnimFrame(function() { shadows.render(); simplified.render(); hitAreas.render(); buildings.render(); }); if (!isNeeded) { clearInterval(this.animTimer); this.animTimer = null; } }, 33); } addRenderItems(data, allAreNew) { var item, scaledItem, id; var geojson = this.geoJSON.read(data); for (var i = 0, il = geojson.length; i < il; i++) { item = geojson[i]; id = item.id || [ item.footprint[0], item.footprint[1], item.height, item.minHeight ].join(","); if (!this.loadedItems[id]) { if ((scaledItem = this.scale(item))) { scaledItem.scale = allAreNew ? 0 : 1; this.items.push(scaledItem); this.loadedItems[id] = 1; } } } this.fadeIn(); } scale(item) { var res = {}, // TODO: calculate this on zoom change only zoomScale = 6 / pow(2, ZOOM - MIN_ZOOM); // TODO: consider using HEIGHT / (global.devicePixelRatio || 1) if (item.id) { res.id = item.id; } res.height = min(item.height / zoomScale, MAX_HEIGHT); res.realHeight = item.height; res.minHeight = isNaN(item.minHeight) ? 0 : item.minHeight / zoomScale; if (res.minHeight > MAX_HEIGHT) { return; } res.footprint = this.getPixelFootprint(item.footprint); if (!res.footprint) { return; } res.center = Geometry.getCenter(res.footprint); if (item.radius) { res.radius = item.radius * PIXEL_PER_DEG; } if (item.shape) { res.shape = item.shape; } if (item.roofShape) { res.roofShape = item.roofShape; } if ( (res.roofShape === "cone" || res.roofShape === "dome") && !res.shape && Geometry.isRotational(res.footprint) ) { res.shape = "cylinder"; } if (item.holes) { res.holes = []; var innerFootprint; for (var i = 0, il = item.holes.length; i < il; i++) { // TODO: simplify if ((innerFootprint = this.getPixelFootprint(item.holes[i]))) { res.holes.push(innerFootprint); } } } var color; if (item.wallColor) { if ((color = Color.parse(item.wallColor))) { color = color.alpha(ZOOM_FACTOR); res.altColor = "" + color.lightness(0.8); res.wallColor = "" + color; } } if (item.roofColor) { if ((color = Color.parse(item.roofColor))) { res.roofColor = "" + color.alpha(ZOOM_FACTOR); } } if (item.relationId) { res.relationId = item.relationId; } res.hitColor = hitAreas.idToColor(item.relationId || item.id); res.roofHeight = isNaN(item.roofHeight) ? 0 : item.roofHeight / zoomScale; if (res.height + res.roofHeight <= res.minHeight) { return; } return res; } set(data) { // Make sure valid json try { JSON.parse(data); } catch (e) { return; } this.isStatic = true; this.resetItems(); this._staticData = data; this.addRenderItems(this._staticData, true); } load(src, key) { this.src = src || this.DATA_SRC.replace("{k}", key || "anonymous"); this.update(); } update() { this.resetItems(); if (ZOOM < MIN_ZOOM) { return; } if (this.isStatic && this._staticData) { this.addRenderItems(this._staticData); return; } if (!this.src) { return; } var tileZoom = 16, tileSize = 256, zoomedTileSize = ZOOM > tileZoom ? tileSize << (ZOOM - tileZoom) : tileSize >> (tileZoom - ZOOM), minX = (ORIGIN_X / zoomedTileSize) << 0, minY = (ORIGIN_Y / zoomedTileSize) << 0, maxX = ceil((ORIGIN_X + WIDTH) / zoomedTileSize), maxY = ceil((ORIGIN_Y + HEIGHT) / zoomedTileSize), x, y; var scope = this; function callback(json) { scope.addRenderItems(json); } for (y = minY; y <= maxY; y++) { for (x = minX; x <= maxX; x++) { this.loadTile(x, y, tileZoom, callback); } } } loadTile(x, y, zoom, callback) { var s = "abcd" [(x + y) % 4]; var url = this.src .replace("{s}", s) .replace("{x}", x) .replace("{y}", y) .replace("{z}", zoom); return this.request.loadJSON(url, callback); } } //****** file: Debug.js ****** class Debug { constructor() {} point(x, y, color, size) { var context = this.context; context.fillStyle = color || "#ffcc00"; context.beginPath(); context.arc(x, y, size || 3, 0, 2 * PI); context.closePath(); context.fill(); } line(ax, ay, bx, by, color) { var context = this.context; context.strokeStyle = color || "#ffcc00"; context.beginPath(); context.moveTo(ax, ay); context.lineTo(bx, by); context.closePath(); context.stroke(); } } //****** file: functions.js ****** class Functions { static rad(deg) { return (deg * PI) / 180; } static deg(rad) { return (rad / PI) * 180; } static pixelToGeo(x, y) { var res = {}; x /= MAP_SIZE; y /= MAP_SIZE; res[LAT] = y <= 0 ? 90 : y >= 1 ? -90 : Functions.deg(2 * atan(exp(PI * (1 - 2 * y))) - HALF_PI); res[LON] = (x === 1 ? 1 : ((x % 1) + 1) % 1) * 360 - 180; return res; } static geoToPixel(lat, lon) { var latitude = min( 1, max(0, 0.5 - log(tan(QUARTER_PI + (HALF_PI * lat) / 180)) / PI / 2) ), longitude = lon / 360 + 0.5; return { x: (longitude * MAP_SIZE) << 0, y: (latitude * MAP_SIZE) << 0 }; } static fromRange(sVal, sMin, sMax, dMin, dMax) { sVal = min(max(sVal, sMin), sMax); var rel = (sVal - sMin) / (sMax - sMin), range = dMax - dMin; return min(max(dMin + rel * range, dMin), dMax); } static isVisible(polygon) { var maxX = WIDTH + ORIGIN_X, maxY = HEIGHT + ORIGIN_Y; // TODO: checking footprint is sufficient for visibility - NOT VALID FOR SHADOWS! for (var i = 0, il = polygon.length - 3; i < il; i += 2) { if ( polygon[i] > ORIGIN_X && polygon[i] < maxX && polygon[i + 1] > ORIGIN_Y && polygon[i + 1] < maxY ) { return true; } } return false; } } //****** file: GeoJSON.js ****** class GeoJSON { constructor() { this.METERS_PER_LEVEL = 3; this.materialColors = { brick: "#cc7755", bronze: "#ffeecc", canvas: "#fff8f0", concrete: "#999999", copper: "#a0e0d0", glass: "#e8f8f8", gold: "#ffcc00", plants: "#009933", metal: "#aaaaaa", panel: "#fff8f0", plaster: "#999999", roof_tiles: "#f08060", silver: "#cccccc", slate: "#666666", stone: "#996666", tar_paper: "#333333", wood: "#deb887" }; this.baseMaterials = { asphalt: "tar_paper", bitumen: "tar_paper", block: "stone", bricks: "brick", glas: "glass", glassfront: "glass", grass: "plants", masonry: "stone", granite: "stone", panels: "panel", paving_stones: "stone", plastered: "plaster", rooftiles: "roof_tiles", roofingfelt: "tar_paper", sandstone: "stone", sheet: "canvas", sheets: "canvas", shingle: "tar_paper", shingles: "tar_paper", slates: "slate", steel: "metal", tar: "tar_paper", tent: "canvas", thatch: "plants", tile: "roof_tiles", tiles: "roof_tiles" }; this.WINDING_CLOCKWISE = "CW"; this.WINDING_COUNTER_CLOCKWISE = "CCW"; // cardboard // eternit // limestone // straw } getMaterialColor(str) { str = str.toLowerCase(); if (str[0] === "#") { return str; } return this.materialColors[this.baseMaterials[str] || str] || null; } // detect winding direction: clockwise or counter clockwise getWinding(points) { var x1, y1, x2, y2, a = 0, i, il; for (i = 0, il = points.length - 3; i < il; i += 2) { x1 = points[i]; y1 = points[i + 1]; x2 = points[i + 2]; y2 = points[i + 3]; a += x1 * y2 - x2 * y1; } return a / 2 > 0 ? this.WINDING_CLOCKWISE : this.WINDING_COUNTER_CLOCKWISE; } // enforce a polygon winding direcetion. Needed for proper backface culling. makeWinding(points, direction) { var winding = this.getWinding(points); if (winding === direction) { return points; } var revPoints = []; for (var i = points.length - 2; i >= 0; i -= 2) { revPoints.push(points[i], points[i + 1]); } return revPoints; } alignProperties(prop) { var item = {}; prop = prop || {}; item.height = prop.height || (prop.levels ? prop.levels * this.METERS_PER_LEVEL : DEFAULT_HEIGHT); item.minHeight = prop.minHeight || (prop.minLevel ? prop.minLevel * this.METERS_PER_LEVEL : 0); var wallColor = prop.material ? this.getMaterialColor(prop.material) : prop.wallColor || prop.color; if (wallColor) { item.wallColor = wallColor; } var roofColor = prop.roofMaterial ? this.getMaterialColor(prop.roofMaterial) : prop.roofColor; if (roofColor) { item.roofColor = roofColor; } switch (prop.shape) { case "cylinder": case "cone": case "dome": case "sphere": item.shape = prop.shape; item.isRotational = true; break; case "pyramid": item.shape = prop.shape; break; } switch (prop.roofShape) { case "cone": case "dome": item.roofShape = prop.roofShape; item.isRotational = true; break; case "pyramid": item.roofShape = prop.roofShape; break; } if (item.roofShape && prop.roofHeight) { item.roofHeight = prop.roofHeight; item.height = max(0, item.height - item.roofHeight); } else { item.roofHeight = 0; } return item; } getGeometries(geometry) { var i, il, polygon, geometries = [], sub; switch (geometry.type) { case "GeometryCollection": geometries = []; for (i = 0, il = geometry.geometries.length; i < il; i++) { if ((sub = getGeometries(geometry.geometries[i]))) { geometries.push.apply(geometries, sub); } } return geometries; case "MultiPolygon": geometries = []; for (i = 0, il = geometry.coordinates.length; i < il; i++) { if ( (sub = getGeometries({ type: "Polygon", coordinates: geometry.coordinates[i] })) ) { geometries.push.apply(geometries, sub); } } return geometries; case "Polygon": polygon = geometry.coordinates; break; default: return []; } var j, jl, p, lat = 1, lon = 0, outer = [], inner = []; p = polygon[0]; for (i = 0, il = p.length; i < il; i++) { outer.push(p[i][lat], p[i][lon]); } outer = this.makeWinding(outer, this.WINDING_CLOCKWISE); for (i = 0, il = polygon.length - 1; i < il; i++) { p = polygon[i + 1]; inner[i] = []; for (j = 0, jl = p.length; j < jl; j++) { inner[i].push(p[j][lat], p[j][lon]); } inner[i] = this.makeWinding(inner[i], this.WINDING_COUNTER_CLOCKWISE); } return [{ outer: outer, inner: inner.length ? inner : null }]; } clone(obj) { var res = {}; for (var p in obj) { if (obj.hasOwnProperty(p)) { res[p] = obj[p]; } } return res; } read(geojson) { if (!geojson || geojson.type !== "FeatureCollection") { return []; } var collection = geojson.features, i, il, j, jl, res = [], feature, geometries, baseItem, item; for (i = 0, il = collection.length; i < il; i++) { feature = collection[i]; // TODO review this commented out code if (feature.type !== "Feature") { // || onEach(feature) === false) { continue; } baseItem = this.alignProperties(feature.properties); geometries = this.getGeometries(feature.geometry); for (j = 0, jl = geometries.length; j < jl; j++) { item = this.clone(baseItem); item.footprint = geometries[j].outer; if (item.isRotational) { item.radius = Geometry.getLonDelta(item.footprint); } if (geometries[j].inner) { item.holes = geometries[j].inner; } if (feature.id || feature.properties.id) { item.id = feature.id || feature.properties.id; } if (feature.properties.relationId) { item.relationId = feature.properties.relationId; } res.push(item); // TODO: clone base properties! } } return res; } } //****** file: geometry.js ****** class Geometry { static getDistance(p1, p2) { var dx = p1.x - p2.x, dy = p1.y - p2.y; return dx * dx + dy * dy; } static isRotational(polygon) { var length = polygon.length; if (length < 16) { return false; } var i; var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; for (i = 0; i < length - 1; i += 2) { minX = Math.min(minX, polygon[i]); maxX = Math.max(maxX, polygon[i]); minY = Math.min(minY, polygon[i + 1]); maxY = Math.max(maxY, polygon[i + 1]); } var width = maxX - minX, height = maxY - minY, ratio = width / height; if (ratio < 0.85 || ratio > 1.15) { return false; } var center = { x: minX + width / 2, y: minY + height / 2 }, radius = (width + height) / 4, sqRadius = radius * radius; for (i = 0; i < length - 1; i += 2) { var dist = Geometry.getDistance({ x: polygon[i], y: polygon[i + 1] }, center); if (dist / sqRadius < 0.8 || dist / sqRadius > 1.2) { return false; } } return true; } static getSquareSegmentDistance(px, py, p1x, p1y, p2x, p2y) { var dx = p2x - p1x, dy = p2y - p1y, t; if (dx !== 0 || dy !== 0) { t = ((px - p1x) * dx + (py - p1y) * dy) / (dx * dx + dy * dy); if (t > 1) { p1x = p2x; p1y = p2y; } else if (t > 0) { p1x += dx * t; p1y += dy * t; } } dx = px - p1x; dy = py - p1y; return dx * dx + dy * dy; } static simplifyPolygon(buffer) { var sqTolerance = 2, len = buffer.length / 2, markers = new Uint8Array(len), first = 0, last = len - 1, i, maxSqDist, sqDist, index, firstStack = [], lastStack = [], newBuffer = []; markers[first] = markers[last] = 1; while (last) { maxSqDist = 0; for (i = first + 1; i < last; i++) { sqDist = Geometry.getSquareSegmentDistance( buffer[i * 2], buffer[i * 2 + 1], buffer[first * 2], buffer[first * 2 + 1], buffer[last * 2], buffer[last * 2 + 1] ); if (sqDist > maxSqDist) { index = i; maxSqDist = sqDist; } } if (maxSqDist > sqTolerance) { markers[index] = 1; firstStack.push(first); lastStack.push(index); firstStack.push(index); lastStack.push(last); } first = firstStack.pop(); last = lastStack.pop(); } for (i = 0; i < len; i++) { if (markers[i]) { newBuffer.push(buffer[i * 2], buffer[i * 2 + 1]); } } return newBuffer; } static getCenter(footprint) { var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; for (var i = 0, il = footprint.length - 3; i < il; i += 2) { minX = min(minX, footprint[i]); maxX = max(maxX, footprint[i]); minY = min(minY, footprint[i + 1]); maxY = max(maxY, footprint[i + 1]); } return { x: (minX + (maxX - minX) / 2) << 0, y: (minY + (maxY - minY) / 2) << 0 }; } static getLonDelta(footprint) { var minLon = 180, maxLon = -180; for (var i = 0, il = footprint.length; i < il; i += 2) { minLon = min(minLon, footprint[i + 1]); maxLon = max(maxLon, footprint[i + 1]); } return (maxLon - minLon) / 2; } } //****** file: HitAreas.js ****** class HitAreas { constructor() { this.data; this._idMapping = [null]; } setData(data) { this.data = data; } reset() { this._idMapping = [null]; } render() { if (this._timer) { return; } var self = this; this._timer = setTimeout(function() { self._timer = null; self._render(); }, 500); } _render() { var context = this.context; context.clearRect(0, 0, WIDTH, HEIGHT); // show on high zoom levels only and avoid rendering during zoom if (ZOOM < MIN_ZOOM || isZooming) { return; } var item, h, mh, sortCam = { x: CAM_X + ORIGIN_X, y: CAM_Y + ORIGIN_Y }, footprint, color, dataItems = this.data.getItems(); dataItems.sort(function(a, b) { return ( a.minHeight - b.minHeight || Geometry.getDistance(b.center, sortCam) - Geometry.getDistance(a.center, sortCam) || b.height - a.height ); }); var cylinder = new Cylinder(); var pyramid = new Pyramid(); var block = new Block(); for (var i = 0, il = dataItems.length; i < il; i++) { item = dataItems[i]; if (!(color = item.hitColor)) { continue; } footprint = item.footprint; if (!Functions.isVisible(footprint)) { continue; } h = item.height; mh = 0; if (item.minHeight) { mh = item.minHeight; } switch (item.shape) { case "cylinder": cylinder.hitArea( context, item.center, item.radius, item.radius, h, mh, color ); break; case "cone": cylinder.hitArea(context, item.center, item.radius, 0, h, mh, color); break; case "dome": cylinder.hitArea( context, item.center, item.radius, item.radius / 2, h, mh, color ); break; case "sphere": cylinder.hitArea( context, item.center, item.radius, item.radius, h, mh, color ); break; case "pyramid": pyramid.hitArea(context, footprint, item.center, h, mh, color); break; default: block.hitArea(context, footprint, item.holes, h, mh, color); } switch (item.roofShape) { case "cone": cylinder.hitArea( context, item.center, item.radius, 0, h + item.roofHeight, h, color ); break; case "dome": cylinder.hitArea( context, item.center, item.radius, item.radius / 2, h + item.roofHeight, h, color ); break; case "pyramid": pyramid.hitArea( context, footprint, item.center, h + item.roofHeight, h, color ); break; } } // otherwise fails on size 0 if (WIDTH && HEIGHT) { this._imageData = this.context.getImageData(0, 0, WIDTH, HEIGHT).data; } } getIdFromXY(x, y) { var imageData = this._imageData; if (!imageData) { return; } var pos = 4 * ((y | 0) * WIDTH + (x | 0)); var index = imageData[pos] | (imageData[pos + 1] << 8) | (imageData[pos + 2] << 16); return this._idMapping[index]; } idToColor(id) { var index = this._idMapping.indexOf(id); if (index === -1) { this._idMapping.push(id); index = this._idMapping.length - 1; } var r = index & 0xff; var g = (index >> 8) & 0xff; var b = (index >> 16) & 0xff; return "rgb(" + [r, g, b].join(",") + ")"; } setContext(context) { this.context = context; } } //****** file: Layers.js ****** class Layers { constructor() { this.container = document.createElement("DIV"); this.items = []; this.container.style.pointerEvents = "none"; this.container.style.position = "absolute"; this.container.style.left = 0; this.container.style.top = 0; // TODO: improve this to .setContext(context) shadows.setContext(this.createContext(this.container)); simplified.setContext(this.createContext(this.container)); buildings.setContext(this.createContext(this.container)); hitAreas.setContext(this.createContext()); // Debug.context = this.createContext(this.container); } render(quick) { requestAnimFrame(function() { if (!quick) { shadows.render(); simplified.render(); hitAreas.render(); } buildings.render(); }); } createContext(container) { var canvas = document.createElement("CANVAS"); canvas.style.transform = "translate3d(0, 0, 0)"; // turn on hw acceleration canvas.style.imageRendering = "optimizeSpeed"; canvas.style.position = "absolute"; canvas.style.left = 0; canvas.style.top = 0; var context = canvas.getContext("2d"); context.lineCap = "round"; context.lineJoin = "round"; context.lineWidth = 1; context.imageSmoothingEnabled = false; this.items.push(canvas); if (container) { container.appendChild(canvas); } return context; } appendTo(parentNode) { parentNode.appendChild(this.container); } remove() { this.container.parentNode.removeChild(this.container); } setSize(width, height) { for (var i = 0, il = this.items.length; i < il; i++) { this.items[i].width = width; this.items[i].height = height; } } // usually called after move: container jumps by move delta, cam is reset setPosition(x, y) { this.container.style.left = x + "px"; this.container.style.top = y + "px"; } } //****** file: Pyramid.js ****** class Pyramid { constructor() {} draw(context, polygon, center, height, minHeight, color, altColor) { var c = { x: center.x - ORIGIN_X, y: center.y - ORIGIN_Y }, scale = CAM_Z / (CAM_Z - height), minScale = CAM_Z / (CAM_Z - minHeight), apex = buildings.project(c, scale), a = { x: 0, y: 0 }, b = { x: 0, y: 0 }; for (var i = 0, il = polygon.length - 3; i < il; i += 2) { a.x = polygon[i] - ORIGIN_X; a.y = polygon[i + 1] - ORIGIN_Y; b.x = polygon[i + 2] - ORIGIN_X; b.y = polygon[i + 3] - ORIGIN_Y; if (minHeight) { a = buildings.project(a, minScale); b = buildings.project(b, minScale); } // backface culling check if ((b.x - a.x) * (apex.y - a.y) > (apex.x - a.x) * (b.y - a.y)) { // depending on direction, set shading if ((a.x < b.x && a.y < b.y) || (a.x > b.x && a.y > b.y)) { context.fillStyle = altColor; } else { context.fillStyle = color; } context.beginPath(); this._triangle(context, a, b, apex); context.closePath(); context.fill(); } } } _triangle(context, a, b, c) { context.moveTo(a.x, a.y); context.lineTo(b.x, b.y); context.lineTo(c.x, c.y); } _ring(context, polygon) { context.moveTo(polygon[0] - ORIGIN_X, polygon[1] - ORIGIN_Y); for (var i = 2, il = polygon.length - 1; i < il; i += 2) { context.lineTo(polygon[i] - ORIGIN_X, polygon[i + 1] - ORIGIN_Y); } } shadow(context, polygon, center, height, minHeight) { var a = { x: 0, y: 0 }, b = { x: 0, y: 0 }, c = { x: center.x - ORIGIN_X, y: center.y - ORIGIN_Y }, apex = shadows.project(c, height); for (var i = 0, il = polygon.length - 3; i < il; i += 2) { a.x = polygon[i] - ORIGIN_X; a.y = polygon[i + 1] - ORIGIN_Y; b.x = polygon[i + 2] - ORIGIN_X; b.y = polygon[i + 3] - ORIGIN_Y; if (minHeight) { a = shadows.project(a, minHeight); b = shadows.project(b, minHeight); } // backface culling check if ((b.x - a.x) * (apex.y - a.y) > (apex.x - a.x) * (b.y - a.y)) { // depending on direction, set shading this._triangle(context, a, b, apex); } } } shadowMask(context, polygon) { _ring(context, polygon); } hitArea(context, polygon, center, height, minHeight, color) { var c = { x: center.x - ORIGIN_X, y: center.y - ORIGIN_Y }, scale = CAM_Z / (CAM_Z - height), minScale = CAM_Z / (CAM_Z - minHeight), apex = buildings.project(c, scale), a = { x: 0, y: 0 }, b = { x: 0, y: 0 }; context.fillStyle = color; context.beginPath(); for (var i = 0, il = polygon.length - 3; i < il; i += 2) { a.x = polygon[i] - ORIGIN_X; a.y = polygon[i + 1] - ORIGIN_Y; b.x = polygon[i + 2] - ORIGIN_X; b.y = polygon[i + 3] - ORIGIN_Y; if (minHeight) { a = buildings.project(a, minScale); b = buildings.project(b, minScale); } // backface culling check if ((b.x - a.x) * (apex.y - a.y) > (apex.x - a.x) * (b.y - a.y)) { this._triangle(context, a, b, apex); } } context.closePath(); context.fill(); } } //****** file: Request.js ****** class Request { constructor() { this.cacheData = {}; this.cacheIndex = []; this.cacheSize = 0; this.maxCacheSize = 1024 * 1024 * 5; // 5MB } xhr(url, callback) { if (this.cacheData[url]) { if (callback) { callback(this.cacheData[url]); } return; } var req = new XMLHttpRequest(); var scope = this; req.onreadystatechange = function() { if (req.readyState !== 4) { return; } if (!req.status || req.status < 200 || req.status > 299) { return; } if (callback && req.responseText) { var responseText = req.responseText; scope.cacheData[url] = responseText; scope.cacheIndex.push({ url: url, size: responseText.length }); scope.cacheSize += responseText.length; callback(responseText); while (scope.cacheSize > scope.maxCacheSize) { var item = scope.cacheIndex.shift(); scope.cacheSize -= item.size; delete scope.cacheData[item.url]; } } }; req.open("GET", url); req.send(null); return req; } loadJSON(url, callback) { return this.xhr(url, function(responseText) { var json; try { json = JSON.parse(responseText); } catch (ex) {} callback(json); }); } } //****** file: Shadows.js ****** class Shadows { constructor() { this.sunPosition = new SunPosition(); this.enabled = true; this.color = "#666666"; this.blurColor = "#000000"; this.blurSize = 15; this.date = new Date(); this.direction = { x: 0, y: 0 }; this.context; this.data; } project(p, h) { return { x: p.x + this.direction.x * h, y: p.y + this.direction.y * h }; } setData(data) { this.data = data; } render() { var context = this.context, screenCenter, sun, length, alpha; context.clearRect(0, 0, WIDTH, HEIGHT); // show on high zoom levels only and avoid rendering during zoom if (!this.enabled || ZOOM < MIN_ZOOM || isZooming) { return; } // TODO: calculate this just on demand screenCenter = Functions.pixelToGeo( CENTER_X + ORIGIN_X, CENTER_Y + ORIGIN_Y ); sun = this.sunPosition.getSunPosition( this.date, screenCenter.latitude, screenCenter.longitude ); if (sun.altitude <= 0) { return; } length = 1 / tan(sun.altitude); alpha = length < 5 ? 0.75 : (1 / length) * 5; this.direction.x = cos(sun.azimuth) * length; this.direction.y = sin(sun.azimuth) * length; var i, il, item, h, mh, footprint, dataItems = this.data.getItems(); context.canvas.style.opacity = alpha / (ZOOM_FACTOR * 2); context.shadowColor = this.blurColor; context.shadowBlur = this.blurSize * (ZOOM_FACTOR / 2); context.fillStyle = this.color; context.beginPath(); var cylinder = new Cylinder(); var pyramid = new Pyramid(); var block = new Block(); for (i = 0, il = dataItems.length; i < il; i++) { item = dataItems[i]; footprint = item.footprint; if (!Functions.isVisible(footprint)) { continue; } // when fading in, use a dynamic height h = item.scale < 1 ? item.height * item.scale : item.height; mh = 0; if (item.minHeight) { mh = item.scale < 1 ? item.minHeight * item.scale : item.minHeight; } switch (item.shape) { case "cylinder": cylinder.shadow( context, item.center, item.radius, item.radius, h, mh ); break; case "cone": cylinder.shadow(context, item.center, item.radius, 0, h, mh); break; case "dome": cylinder.shadow( context, item.center, item.radius, item.radius / 2, h, mh ); break; case "sphere": cylinder.shadow( context, item.center, item.radius, item.radius, h, mh ); break; case "pyramid": pyramid.shadow(context, footprint, item.center, h, mh); break; default: block.shadow(context, footprint, item.holes, h, mh); } switch (item.roofShape) { case "cone": cylinder.shadow( context, item.center, item.radius, 0, h + item.roofHeight, h ); break; case "dome": cylinder.shadow( context, item.center, item.radius, item.radius / 2, h + item.roofHeight, h ); break; case "pyramid": pyramid.shadow( context, footprint, item.center, h + item.roofHeight, h ); break; } } context.closePath(); context.fill(); context.shadowBlur = null; // now draw all the footprints as negative clipping mask context.globalCompositeOperation = "destination-out"; context.beginPath(); for (i = 0, il = dataItems.length; i < il; i++) { item = dataItems[i]; footprint = item.footprint; if (!Functions.isVisible(footprint)) { continue; } // if object is hovered, there is no need to clip it's footprint if (item.minHeight) { continue; } switch (item.shape) { case "cylinder": case "cone": case "dome": cylinder.shadowMask(context, item.center, item.radius); break; default: block.shadowMask(context, footprint, item.holes); } } context.fillStyle = "#00ff00"; context.fill(); context.globalCompositeOperation = "source-over"; } setContext(context) { this.context = context; } } //****** file: Simplified.js ****** class Simplified { constructor() { this.data; this.init(); } setData(data) { this.data = data; } init() { this.maxZoom = this.MIN_ZOOM + 2; this.maxHeight = 5; } isSimple(item) { return ( ZOOM <= this.maxZoom && item.height + item.roofHeight < this.maxHeight ); } render() { var context = this.context; context.clearRect(0, 0, WIDTH, HEIGHT); // show on high zoom levels only and avoid rendering during zoom if (ZOOM < MIN_ZOOM || isZooming || ZOOM > this.maxZoom) { return; } var item, footprint, dataItems = this.data.getItems(); var cylinder = new Cylinder(); var block = new Block(); for (var i = 0, il = dataItems.length; i < il; i++) { item = dataItems[i]; if (item.height >= this.maxHeight) { continue; } footprint = item.footprint; if (!Functions.isVisible(footprint)) { continue; } context.strokeStyle = item.altColor || ALT_COLOR_STR; context.fillStyle = item.roofColor || ROOF_COLOR_STR; switch (item.shape) { case "cylinder": case "cone": case "dome": case "sphere": cylinder.simplified(context, item.center, item.radius); break; default: block.simplified(context, footprint, item.holes); } } } setContext(context) { this.context = context; } } //****** file: SunPosition.js ****** // calculations are based on http://aa.quae.nl/en/reken/zonpositie.html // code credits to Vladimir Agafonkin (@mourner) class SunPosition { constructor() { this.init(); } init() { this.m = Math; (this.PI = m.PI), (this.sin = m.sin); this.cos = m.cos; this.tan = m.tan; this.asin = m.asin; this.atan = m.atan2; this.rad = PI / 180; this.dayMs = 1000 * 60 * 60 * 24; this.J1970 = 2440588; this.J2000 = 2451545; this.e = this.rad * 23.4397; // obliquity of the Earth } toJulian(date) { return date.valueOf() / this.dayMs - 0.5 + this.J1970; } toDays(date) { return this.toJulian(date) - this.J2000; } getRightAscension(l, b) { return this.atan( this.sin(l) * this.cos(this.e) - this.tan(b) * this.sin(this.e), this.cos(l) ); } getDeclination(l, b) { return this.asin( this.sin(b) * this.cos(this.e) + this.cos(b) * this.sin(this.e) * this.sin(l) ); } getAzimuth(H, phi, dec) { return this.atan( this.sin(H), this.cos(H) * this.sin(phi) - this.tan(dec) * this.cos(phi) ); } getAltitude(H, phi, dec) { return this.asin( this.sin(phi) * this.sin(dec) + this.cos(phi) * this.cos(dec) * this.cos(H) ); } getSiderealTime(d, lw) { return this.rad * (280.16 + 360.9856235 * d) - lw; } getSolarMeanAnomaly(d) { return this.rad * (357.5291 + 0.98560028 * d); } getEquationOfCenter(M) { return ( this.rad * (1.9148 * this.sin(M) + 0.02 * this.sin(2 * M) + 0.0003 * this.sin(3 * M)) ); } getEclipticLongitude(M, C) { var P = this.rad * 102.9372; // perihelion of the Earth return M + C + P + this.PI; } getSunPosition(date, lat, lon) { var lw = this.rad * -lon, phi = this.rad * lat, d = this.toDays(date), M = this.getSolarMeanAnomaly(d), C = this.getEquationOfCenter(M), L = this.getEclipticLongitude(M, C), D = this.getDeclination(L, 0), A = this.getRightAscension(L, 0), t = this.getSiderealTime(d, lw), H = t - A; return { altitude: this.getAltitude(H, phi, D), azimuth: this.getAzimuth(H, phi, D) - this.PI / 2 // origin: north }; } } //****** file: prefix.js ****** export default class OSMBuildings extends VectorLayer { constructor(map) { super(new VectorSource({ projection: olProj.get("EPSG:900913") })); this.map = map; this.maxExtent = [-20037508.34, -20037508.34, 20037508.34, 20037508.34]; // MaxExtent of layer try { this.setMap(map); map.addLayer(this); } catch (e) { console.log(e); } //****** file: variables.js ****** this.VERSION = "0.2.2b"; this.ATTRIBUTION = '© OSM Buildings'; } //****** file: adapter.js ****** setGlobalOrigin(origin) { ORIGIN_X = origin.x; ORIGIN_Y = origin.y; } moveCam(offset) { CAM_X = CENTER_X + offset.x; CAM_Y = HEIGHT + offset.y; layers.render(true); } setSize(size) { WIDTH = size.width; HEIGHT = size.height; CENTER_X = (WIDTH / 2) << 0; CENTER_Y = (HEIGHT / 2) << 0; CAM_X = CENTER_X; CAM_Y = HEIGHT; layers.setSize(WIDTH, HEIGHT); MAX_HEIGHT = CAM_Z - 50; } setZoom(z) { ZOOM = z; MAP_SIZE = MAP_TILE_SIZE << ZOOM; var center = Functions.pixelToGeo(ORIGIN_X + CENTER_X, ORIGIN_Y + CENTER_Y); var a = Functions.geoToPixel(center.latitude, 0); var b = Functions.geoToPixel(center.latitude, 1); PIXEL_PER_DEG = b.x - a.x; ZOOM_FACTOR = pow(0.95, ZOOM - MIN_ZOOM); WALL_COLOR_STR = "" + WALL_COLOR.alpha(ZOOM_FACTOR); ALT_COLOR_STR = "" + ALT_COLOR.alpha(ZOOM_FACTOR); ROOF_COLOR_STR = "" + ROOF_COLOR.alpha(ZOOM_FACTOR); } onResize(e) { setSize(e); layers.render(); data.update(); } onMoveEnd(e) { layers.render(); data.update(); // => fadeIn() => layers.render() } onZoomStart() { isZooming = true; // effectively clears because of isZooming flag // TODO: introduce explicit clear() layers.render(); } onZoomEnd(e) { isZooming = false; setZoom(e.zoom); data.update(); // => fadeIn() layers.render(); } setOrigin() { //console.log("setOrigin"); var map = this.map; try { var origin = map.getCoordinateFromPixel([0, 0]); var res = map.getView().getResolution(); var ext = this.maxExtent; var x = ((origin[0] - ext[0]) / res) << 0; var y = ((ext[3] - origin[1]) / res) << 0; this.setGlobalOrigin({ x: x, y: y }); } catch (e) { console.log(e); } }; setMap(map) { //console.log("setMap"); var scope = this; layers.appendTo(document.getElementById(map.getTargetElement().id)); this.setSize({ width: map.getSize()[0], height: map.getSize()[1] }); var layerProjection = map.getView().getProjection(); map.on("click", function(e) { var id = hitAreas.getIdFromXY(e.pixel[0], e.pixel[1]); if (id) { var geo = olProj.transform( map.getCoordinateFromPixel([e.pixel[0], e.pixel[1]]), layerProjection, map.getView().getProjection() ); scope.onClick({ feature: id, lat: geo[0], lon: geo[1] }); } }); // map.on('moveend', scope.onMoveEnd); // map.on('zoomend', scope.onZoomStart); // map.on('zoomstart', scope.onZoomEnd); // TODO why doesn't scope.on work like in OL3 map.on("precompose", function(e) { //console.log("precompose"); scope.setZoom(map.getView().getZoom()); scope.setOrigin(); data.resetItems(); data.update(); }); } //****** file: public.js ****** style = function(style) { //console.log("style"); style = style || {}; var color; if ((color = style.color || style.wallColor)) { WALL_COLOR = Color.parse(color); WALL_COLOR_STR = "" + WALL_COLOR.alpha(ZOOM_FACTOR); ALT_COLOR = WALL_COLOR.lightness(0.8); ALT_COLOR_STR = "" + ALT_COLOR.alpha(ZOOM_FACTOR); ROOF_COLOR = WALL_COLOR.lightness(1.2); ROOF_COLOR_STR = "" + ROOF_COLOR.alpha(ZOOM_FACTOR); } if (style.roofColor) { ROOF_COLOR = Color.parse(style.roofColor); ROOF_COLOR_STR = "" + ROOF_COLOR.alpha(ZOOM_FACTOR); } if (style.shadows !== undefined) { shadows.enabled = !!style.shadows; } layers.render(); return this; }; date = function(date) { shadows.date = date; shadows.render(); return this; }; load = function(url) { data.load(url); return this; }; set(dataToSet) { data.set(dataToSet); return this; }; getDataItems(){ return data.getItems(); }; onEach = function() {}; each = function(handler) { this.onEach = function(payload) { return handler(payload); }; return this; }; onClick = function(){}; click = function(handler) { this.onClick = function(payload) { return handler(payload); }; return this; }; } // Global vars var PI = Math.PI; var HALF_PI = PI / 2; var QUARTER_PI = PI / 4; var DATA_TILE_SIZE = 0.0075; // data tile size in geo coordinates, smaller: less data to load but more requests var ZOOM; var MAP_SIZE; var MAP_TILE_SIZE = 256; // map tile size in pixels var MIN_ZOOM = 15; var LAT = "latitude"; var LON = "longitude"; var TRUE = true; var FALSE = false; var WIDTH = 0; var HEIGHT = 0; var CENTER_X = 0; var CENTER_Y = 0; var ORIGIN_X = 0; var ORIGIN_Y = 0; var WALL_COLOR = Color.parse("rgba(200, 190, 180)"); var ALT_COLOR = WALL_COLOR.lightness(0.8); var ROOF_COLOR = WALL_COLOR.lightness(1.2); var WALL_COLOR_STR = "" + WALL_COLOR; var ALT_COLOR_STR = "" + ALT_COLOR; var ROOF_COLOR_STR = "" + ROOF_COLOR; var PIXEL_PER_DEG = 0; var ZOOM_FACTOR = 1; var MAX_HEIGHT; // taller buildings will be cut to this var DEFAULT_HEIGHT = 5; var CAM_X; var CAM_Y; var CAM_Z = 450; var isZooming; var EARTH_RADIUS = 6378137; //****** file: shortcuts.js ****** // object access shortcuts var m = Math; var exp = m.exp; var log = m.log; var sin = m.sin; var cos = m.cos; var tan = m.tan; var atan = m.atan; var atan2 = m.atan2; var min = m.min; var max = m.max; var sqrt = m.sqrt; var ceil = m.ceil; var floor = m.floor; var round = m.round; var pow = m.pow; // polyfills var Int32Array = Int32Array || Array; var Uint8Array = Uint8Array || Array; var IS_IOS = /iP(ad|hone|od)/g.test(navigator.userAgent); var IS_MSIE = !!~navigator.userAgent.indexOf("Trident"); var requestAnimFrame = window.requestAnimationFrame && !IS_IOS && !IS_MSIE ? window.requestAnimationFrame : function(callback) { callback(); }; // Objects use for constructing different aspects of buildings var shadows = new Shadows(); var simplified = new Simplified(); var buildings = new Buildings(); var hitAreas = new HitAreas(); // Object holds the json data var data = new Data(); // Renders the buildings and properties (e.g. shadows, etc.) var layers = new Layers(); ================================================ FILE: tests/openlayers-5.3.0/README.md ================================================ # OpenLayers 5 OSM Buildings Support 1. Install dependencies from package.json. ```bash npm install ``` 2. Run local server for testing live updates at localhost:1234. ```bash npm run-script start ``` 3. Build the production bundle. Copy the dist/ folder to your production server. ```bash npm run-script build ``` # Example code (See index.js and index.html for full example) ```javascript import OSMBuildings from './OSMBuildings-OL5.js'; ... let osmBuildings = new OSMBuildings(map); osmBuildings.date(new Date(2017, 5, 15, 17, 30)) osmBuildings.load(); osmBuildings.click(function(e) { let result = osmBuildings.getDataItems().filter(obj => { return obj.id === e.feature }) alert("Height (m): " + result[0].realHeight); }); ``` ================================================ FILE: tests/openlayers-5.3.0/index.js ================================================ // Map import Map from 'ol/Map.js'; import View from 'ol/View.js'; // Layers import { Tile as TileLayer} from 'ol/layer.js'; // Sources import OSM from 'ol/source/OSM.js'; // Controls import { defaults as defaultControls, Control } from 'ol/control.js'; // Proj import * as olProj from "ol/proj.js"; // OSM Buildings import OSMBuildings from './OSMBuildings-OL5.js'; let map = new Map({ layers: [ new TileLayer({ source: new OSM() }) ], controls: defaultControls({ attributionOptions: /** @type {olx.control.AttributionOptions} */ ({ collapsible: false }) }), target: 'map', view: new View({ center: olProj.transform([13.33522, 52.50440], 'EPSG:4326', 'EPSG:3857'), zoom: 16 }) }); // Building example let osmBuildings = new OSMBuildings(map); osmBuildings.date(new Date(2017, 5, 15, 17, 30)) osmBuildings.load(); osmBuildings.click(function(e) { let result = osmBuildings.getDataItems().filter(obj => { return obj.id === e.feature }) alert("Height (m): " + result[0].realHeight); //console.log(result); }); ================================================ FILE: tests/shadows.html ================================================ OSM Buildings - Shadows