diff --git a/.gitignore b/.gitignore index 218cfe3c9..6e583b971 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ build # storing some screenshots here screenshots + +# ignore custom vscode workspace config +.vscode \ No newline at end of file diff --git a/boil.rollup.config.js b/boil.rollup.config.js new file mode 100644 index 000000000..cb3d110a2 --- /dev/null +++ b/boil.rollup.config.js @@ -0,0 +1,99 @@ +import fs from 'fs'; +import { execSync } from 'child_process'; + +import babel from 'rollup-plugin-babel'; +import resolve from 'rollup-plugin-node-resolve'; +import commonjs from 'rollup-plugin-commonjs'; +import globals from 'rollup-plugin-node-globals'; +import builtins from 'rollup-plugin-node-builtins'; +import replace from 'rollup-plugin-replace'; +import sourcemaps from 'rollup-plugin-sourcemaps'; +import {terser} from 'rollup-plugin-terser'; +import json from 'rollup-plugin-json'; +import string from 'rollup-plugin-string'; + +import serve from 'rollup-plugin-serve'; +import livereload from 'rollup-plugin-livereload'; + +const ESM = (process.env.ESM !== 'false'); // default to ESM on +const MINIFY = (process.env.MINIFY === 'true'); +const SERVE = (process.env.SERVE === 'true'); + +const outputFile = `../react-webpack-tangram-boilerplate/node_modules/tangram/dist/tangram.${MINIFY ? 'min' : 'debug'}.${ESM ? 'm' : ''}js`; + +// Use two pass code splitting and re-bundling technique, for another example see: +// https://github.com/mapbox/mapbox-gl-js/blob/master/rollup.config.js + +const config = [{ + input: ['src/index.js', 'src/scene/scene_worker.js'], + output: { + dir: 'build', + format: 'amd', + sourcemap: 'inline', + indent: false, + chunkFileNames: 'shared.js', + }, + plugins: [ + resolve({ + browser: true, + preferBuiltins: false + }), + commonjs({ + // Avoids Webpack minification errors + ignoreGlobal: true, + // There hints are required for importing jszip + // See https://rollupjs.org/guide/en#error-name-is-not-exported-by-module- + namedExports: { + 'node_modules/process/browser.js': ['nextTick'], + 'node_modules/events/events.js': ['EventEmitter'], + } + }), + json(), // load JSON files + string({ + include: ['**/*.glsl'] // inline shader files + }), + + // These are needed for jszip node-environment compatibility, + // previously provided by browserify + globals(), + builtins(), + + MINIFY ? terser() : false, + babel({ + exclude: 'node_modules/**' + }) + ] +}, { + // Second pass: combine the chunks from the first pass into a single bundle + input: 'build/bundle.js', + output: { + name: 'Tangram', + file: outputFile, + format: ESM ? 'esm' : 'umd', + sourcemap: MINIFY ? false : true, + indent: false, + intro: fs.readFileSync(require.resolve('./build/intro.js'), 'utf8') + }, + treeshake: false, + plugins: [ + replace({ + _ESM: ESM, + _SHA: '\'' + String(execSync('git rev-parse HEAD')).trim(1) + '\'' + }), + sourcemaps(), // use source maps produced in the first pass + + // optionally start server and watch for rebuild + SERVE ? serve({ + port: 8000, + contentBase: '', + headers: { + 'Access-Control-Allow-Origin': '*' + } + }): false, + SERVE ? livereload({ + watch: 'dist' + }) : false + ], +}]; + +export default config diff --git a/demos/app/gui.js b/demos/app/gui.js index 9ac92c414..d4bd110bb 100644 --- a/demos/app/gui.js +++ b/demos/app/gui.js @@ -1,18 +1,8 @@ (function(){ - var scene = window.scene; var scene_key = 'Simple'; - window.addEventListener('load', function () { - // Add search control - L.control.geocoder('ge-3d066b6b1c398181', { - url: 'https://api.geocode.earth/v1', - layers: 'coarse', - expanded: true, - markers: false - }).addTo(window.map); - // Add GUI on scene load - layer.scene.subscribe({ + map.scene.subscribe({ load: function (msg) { addGUI(); } @@ -30,13 +20,13 @@ gui = new dat.GUI({ autoPlace: true }); gui.domElement.parentNode.style.zIndex = 10000; window.gui = gui; + scene = window.scene; setLanguage(gui, scene); setCamera(gui, scene); setScene(gui); setScreenshot(gui, scene); setMediaRecorder(gui, scene); - setFeatureDebug(gui); setLayers(gui, scene); } @@ -245,11 +235,4 @@ } } - - function setFeatureDebug(gui) { - gui.debug = scene.introspection; - gui.add(gui, 'debug').onChange(function(value) { - scene.setIntrospection(value); - }); - } })(); diff --git a/demos/app/key.js b/demos/app/key.js index 7c3ac170f..9ed7df632 100644 --- a/demos/app/key.js +++ b/demos/app/key.js @@ -1,7 +1,7 @@ (function(){ window.addEventListener('load', function () { // Inject demo API key on load or update - layer.scene.subscribe({ + map.scene.subscribe({ load: function (msg) { injectAPIKey(msg.config); }, @@ -12,7 +12,7 @@ }); function injectAPIKey(config) { - var demo_key = 'NaqqS33fTUmyQcvbuIUCKA'; + var demo_key = 'd161Q8KATMOhSOcVGNyQ8g'; if (config.global.sdk_api_key) { config.global.sdk_api_key = demo_key; } diff --git a/demos/app/url.js b/demos/app/url.js index 13968af95..4638346b9 100644 --- a/demos/app/url.js +++ b/demos/app/url.js @@ -1,6 +1,8 @@ (function(){ var url_hash = getValuesFromUrl(); - var map_start_location = url_hash ? url_hash.slice(0, 3) : [16, 40.70531887544228, -74.00976419448853]; + const defaultLocation = [16, 40.70531887544228, -74.00976419448853]; + var location = url_hash || defaultLocation; + var map_start_location = {lat: location[1], lng: location[2], zoom: location[0]}; /*** URL parsing ***/ // URL hash pattern #[zoom]/[lat]/[lng] @@ -8,6 +10,8 @@ var url_hash = window.location.hash.slice(1, window.location.hash.length).split('/'); if (url_hash.length < 3 || parseFloat(url_hash[0]) === 'number') { url_hash = false; + } else { + url_hash = url_hash.map(x => parseFloat(x)); } return url_hash; } @@ -20,17 +24,22 @@ clearTimeout(update_url_timeout); update_url_timeout = setTimeout(function() { var center = map.getCenter(); - var url_options = [map.getZoom(), center.lat, center.lng]; + var url_options = [Math.round(map.getZoom() * 1000)/1000, center.lat, center.lng]; window.location.hash = url_options.join('/'); }, update_url_throttle); } - map.on('move', updateURL); - map.setView(map_start_location.slice(1, 3), map_start_location[0]); - - layer.on('init', function(){ - updateURL(); - map.setView(map_start_location.slice(1, 3), map_start_location[0]); + window.addEventListener('load', function () { + // update URL scene load + map.scene.subscribe({ + load: onLoad(), + move: updateURL, + }); }); + + function onLoad() { + map.setView(map_start_location); + } + })(); diff --git a/demos/css/main.css b/demos/css/main.css index 4cf012f2b..b4062e5e5 100644 --- a/demos/css/main.css +++ b/demos/css/main.css @@ -11,10 +11,6 @@ body { height: 100%; } -div > .leaflet-pelias-control { - clear: right; -} - .featureTable { display: table; background: white; diff --git a/demos/main.js b/demos/main.js index 4c5d9ecda..8df41ff0f 100644 --- a/demos/main.js +++ b/demos/main.js @@ -4,155 +4,32 @@ - The Tangram team */ +import Tangram from '../dist/tangram.debug.mjs'; + (function () { var scene_url = 'demos/scene.yaml'; - // Create Tangram as a Leaflet layer - var layer = Tangram.leafletLayer({ - scene: scene_url, - events: { - hover: onHover, // hover event (defined below) - click: onClick // click event (defined below) - }, - // debug: { - // layer_stats: true // enable to collect detailed layer stats, access w/`scene.debug.layerStats()` - // }, - logLevel: 'debug', - attribution: 'Tangram | © OSM contributors | Nextzen' - }); - - // Create a Leaflet map - var map = L.map('map', { - maxZoom: 22, - zoomSnap: 0, - keyboard: false - }); + /*** Map ***/ - // Useful events to subscribe to - layer.scene.subscribe({ - load: function (msg) { - // scene was loaded - }, - update: function (msg) { - // scene updated - }, - pre_update: function (will_render) { - // before scene update - // zoom in/out if up/down arrows pressed - var zoom_step = 0.03; - if (key.isPressed('up')) { - map._move(map.getCenter(), map.getZoom() + zoom_step); - map._moveEnd(true); - } - else if (key.isPressed('down')) { - map._move(map.getCenter(), map.getZoom() - zoom_step); - map._moveEnd(true); - } - }, - post_update: function (will_render){ - // after scene update - }, - view_complete: function (msg) { - // new set of map tiles was rendered - }, - error: function (msg) { - // on error - }, - warning: function (msg) { - // on warning - } + // Create Tangram map in the element called 'map' + const map = Tangram.tangramLayer('map', { + scene: scene_url }); - // Feature selection - var tooltip = L.tooltip(); - layer.bindTooltip(tooltip); - map.on('zoom', function(){ layer.closeTooltip() }); // close tooltip when zooming - - function onHover (selection) { - var feature = selection.feature; - if (feature) { - if (selection.changed) { - var info; - if (scene.introspection) { - info = getFeaturePropsHTML(feature); - } - else { - var name = feature.properties.name || feature.properties.kind || - (Object.keys(feature.properties).length+' properties'); - name = ''+name+''; - name += '
(click for details)'; - name = '' + name + ''; - info = name; - } - - if (info) { - tooltip.setContent(info); - } - } - layer.openTooltip(selection.leaflet_event.latlng); - } - else { - layer.closeTooltip(); - } - } - - function onClick(selection) { - // Link to edit in Open Street Map on alt+click (opens popup window) - if (key.alt) { - var center = map.getCenter(); - var url = 'https://www.openstreetmap.org/edit?#map=' + map.getZoom() + '/' + center.lat + '/' + center.lng; - window.open(url, '_blank'); - return; - } - - if (scene.introspection) { - return; // click doesn't show additional details when introspection is on - } - - // Show feature details - var feature = selection.feature; - if (feature) { - var info = getFeaturePropsHTML(feature); - tooltip.setContent(info); - layer.openTooltip(selection.leaflet_event.latlng); - } - else { - layer.closeTooltip(); - } - } - - // Get an HTML fragment with feature properties - function getFeaturePropsHTML (feature) { - var props = ['name', 'kind', 'kind_detail', 'id']; // show these properties first if available - Object.keys(feature.properties) // show rest of proeprties alphabetized - .sort() - .forEach(function(p) { - if (props.indexOf(p) === -1) { - props.push(p); - } - }); + /*** Map ***/ - var info = '
'; - props.forEach(function(p) { - if (feature.properties[p]) { - info += '
' + p + '
' + - '
' + feature.properties[p] + '
'; - } - }); - info += '
scene layers
' + - '
' + feature.layers.join('
') + '
'; - info += '
'; - return info; - } + window.addEventListener('load', () => { + const options = { + maxZoom: 20, + zoomSnap: 0, + keyboard: false, + center: { lat: 40.70531887544228, lng: -74.00976419448853 }, + }; - /*** Map ***/ + map.initialize(options); - window.map = map; - window.layer = layer; - window.scene = layer.scene; + window.scene = map.scene; // set by tangramLayer - window.addEventListener('load', function() { - layer.addTo(map); - layer.bringToFront(); }); + window.map = map; }()); diff --git a/demos/scene.yaml b/demos/scene.yaml index 6b802f1d6..c0d470b8f 100755 --- a/demos/scene.yaml +++ b/demos/scene.yaml @@ -21,6 +21,9 @@ cameras: type: perspective vanishing_point: [0, -250px] # relative to center of screen, in pixels active: true + # rotation: + # x: 0 + # y: -.5 isometric: type: isometric @@ -34,6 +37,7 @@ cameras: scene: background: color: '#f0ebeb' + animated: false fonts: Montserrat: @@ -97,12 +101,16 @@ sources: type: TopoJSON url: https://tile.nextzen.org/tilezen/vector/v1/512/all/{z}/{x}/{y}.topojson tile_size: 512 - max_zoom: 16 - # zooms: [0, 2, 4, 6, 8, 10, 12, 14, 16] # only load tiles every 2 zooms (overrides max_zoom) + max_zoom: 15 url_params: - api_key: global.api_key + api_key: NaqqS33fTUmyQcvbuIUCKA # request_headers: # send custom headers with tile requests # Authorization: Bearer xxxxxxxxx + # # Data filtering demo with 'scripts' and 'transform' properties: + # # Tile data is passed through a 'transform' pre-processing function before Tangram geometry is built. + # # 'transform' adds an 'intersects_park' property to each road that intersects a park feature. + # # That feature is then filtered on below in the 'roads' layer. + # scripts: ['https://api.tiles.mapbox.com/mapbox.js/plugins/turf/v2.0.0/turf.min.js'] # Data filtering demo with 'scripts' and 'transform' properties: # Tile data is passed through a 'transform' pre-processing function before Tangram geometry is built. # 'transform' adds an 'intersects_park' property to each road that intersects a park feature. @@ -141,7 +149,6 @@ sources: # counties: # type: TopoJSON # url: https://gist.githubusercontent.com/mbostock/4090846/raw/c899e3d4f3353924e495667c842f54a07090cfab/us.json - # zooms: [0, 4, 8, 10] layers: diff --git a/index.html b/index.html index 6cdb790ae..3481081c9 100755 --- a/index.html +++ b/index.html @@ -27,7 +27,7 @@ - + @@ -40,6 +40,7 @@ --> + diff --git a/package.json b/package.json index 99ec7a1ae..11f221f35 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "scripts": { "start": "npm run watch", "build": "npm run build:nomodule && npm run build:nomodule:minify && npm run build:module && npm run build:module:minify && npm run build:size", + "build:boil": "ESM=true ./node_modules/.bin/rollup -c boil.rollup.config.js --watch", "build:module": "ESM=true ./node_modules/.bin/rollup -c", "build:nomodule": "ESM=false ./node_modules/.bin/rollup -c", "build:module:minify": "ESM=true MINIFY=true ./node_modules/.bin/rollup -c", diff --git a/src/camera-rotate.js b/src/camera-rotate.js new file mode 100644 index 000000000..04138345e --- /dev/null +++ b/src/camera-rotate.js @@ -0,0 +1,167 @@ +// camera-rotate.js + +import Geo from './geo'; +import { rotate } from 'gl-matrix/src/gl-matrix/mat4'; + +export function init(scene, camera) { + var view = scene.view; + view.interactionLayer = this; + var orbitSpeed = 0.1; // controls mouse-to-orbit speed + + // set event handlers + scene.canvas.onmousedown = handleMouseDown; + scene.canvas.onmouseup = handleMouseUp; + + + scene.canvas.onmouseleave = handleMouseLeave; + scene.canvas.onmousemove = handleMouseMove; + scene.container.onwheel = handleScroll; + + // track mouse state + var mouseDown = false; + var lastMouseX = null; + var lastMouseY = null; + + // track drag screen position + var startingX = 0; + var startingY = 0; + + // track drag distance from the starting position + var deltaX = 0; + var deltaY = 0; + + function degToRad(deg) { + return deg * Math.PI / 180; + } + function radToDeg(rad) { + return rad / Math.PI * 180; + } + + // track orbit drag distance, preset with any pre-existing orbit + var orbitDeltaX = radToDeg(camera.roll / orbitSpeed); + var orbitDeltaY = radToDeg(camera.pitch / orbitSpeed); + + // track drag starting map position + var startingLng = view.center ? view.center.meters.x : null; + var startingLat = view.center ? view.center.meters.y : null; + + // track drag distance from the starting map position + var metersDeltaX = null; + var metersDeltaY = null; + + // track modifier key state + var metaKeyDown = false; + + function handleMouseDown(event) { + mouseDown = true; + lastMouseX = event.clientX; + lastMouseY = event.clientY; + view.markUserInput(); + } + + function handleMouseUp(event) { + mouseDown = false; + lastMouseX = null; + lastMouseY = null; + // track last drag offset and apply that as offset to the next drag – + // otherwise camera resets position and rotation with each drag + startingX = orbitDeltaX; + startingY = orbitDeltaY; + startingLng = view.center.meters.x; + startingLat = view.center.meters.y; + deltaX = 0; + deltaY = 0; + view.setPanning(false); + scene.update(); + } + + function handleMouseLeave(event) { + if (!metaKeyDown) { // trigger mouseup on pan, but not orbit + handleMouseUp(event); + } + } + + function resetMouseEventVars(event) { + handleMouseUp(event); + handleMouseDown(event); + } + + function handleMouseMove(event) { + if (!mouseDown) { + return; + } + view.setPanning(false); // reset pan timer + var newX = event.clientX; + var newY = event.clientY; + + deltaX = newX - lastMouseX; + deltaY = newY - lastMouseY; + + // orbit camera + if (event.metaKey) { + if (!metaKeyDown) { // meta key pressed during drag, fake a mouseup/mousedown + resetMouseEventVars(event); + } + metaKeyDown = true; + orbitDeltaX = startingX + newX - lastMouseX; + orbitDeltaY = Math.min(startingY + newY - lastMouseY, 0); // enforce minimum pitch of 0 = straight down + camera.roll = degToRad(orbitDeltaX * orbitSpeed); + camera.pitch = degToRad(orbitDeltaY * orbitSpeed); + view.roll = camera.roll; + view.pitch = camera.pitch; + + } else { // basic pan + if (metaKeyDown) { // meta key was just released during drag, fake a mouseup/mousedown + resetMouseEventVars(event); + } else { + + metersDeltaX = deltaX * Geo.metersPerPixel(view.zoom); + metersDeltaY = deltaY * Geo.metersPerPixel(view.zoom); + + // compensate for roll + var cosRoll = Math.cos(view.roll); + var adjustedDeltaX = metersDeltaX * cosRoll + metersDeltaY * Math.sin(view.roll + Math.PI); + var adjustedDeltaY = metersDeltaY * cosRoll + metersDeltaX * Math.sin(view.roll); + + var deltaLatLng = Geo.metersToLatLng([startingLng - adjustedDeltaX, startingLat + adjustedDeltaY]); + view.setView({ lng: deltaLatLng[0], lat: deltaLatLng[1] }); + } + metaKeyDown = false; + } + view.setPanning(true); + view.markUserInput(); + scene.requestRedraw(); + } + + function handleScroll(event) { + var zoomFactor = 0.01; // sets zoom speed with scrollwheel/trackpad + var targetZoom = view.zoom - event.deltaY * zoomFactor; + + // zoom toward pointer location + var startPosition = [event.clientX, event.clientY]; + var containerCenter = [scene.container.clientWidth / 2, scene.container.clientHeight / 2]; + var offset = [startPosition[0] - containerCenter[0], startPosition[1] - containerCenter[1]]; + + // compensate for roll + var cosRoll = Math.cos(view.roll); + var adjustedOffset = [offset[0] * cosRoll + offset[1] * Math.sin(view.roll + Math.PI), + offset[1] * cosRoll + offset[0] * Math.sin(view.roll)]; + + var scrollTarget = [adjustedOffset[0] * Geo.metersPerPixel(view.zoom), adjustedOffset[1] * Geo.metersPerPixel(view.zoom)]; + var panFactor = (targetZoom - view.zoom) * 0.666; // I don't know why 0.666 is needed here + var target = [view.center.meters.x + scrollTarget[0] * panFactor, + view.center.meters.y - scrollTarget[1] * panFactor]; + target = Geo.metersToLatLng(target); + + view.setView({ lng: target[0], lat: target[1], zoom: targetZoom }); + scene.update(); + + // have to set these here too because scroll doesn't count as a mousedown + // so no mouseup will be triggered at the end + startingLng = view.center.meters.x; + startingLat = view.center.meters.y; + + // prevent scroll event bubbling + return false; + } +} diff --git a/src/index.js b/src/index.js index 842273176..c36a87175 100644 --- a/src/index.js +++ b/src/index.js @@ -2,6 +2,13 @@ // The leaflet layer plugin is currently the primary public API import {leafletLayer} from './leaflet_layer'; +// self-contained public API +import {tangramLayer} from './tangramLayer'; + +// marker API +import Marker from './marker'; + +// The scene worker is only activated when a worker thread is instantiated, but must always be loaded import Scene from './scene/scene'; // Additional modules are exposed for debugging @@ -21,7 +28,6 @@ import WorkerBroker from './utils/worker_broker'; import Task from './utils/task'; import {StyleManager} from './styles/style_manager'; import StyleParser from './styles/style_parser'; -import {TileID} from './tile/tile_id'; import Collision from './labels/collision'; import FeatureSelection from './selection/selection'; import TextCanvas from './styles/text/text_canvas'; @@ -48,7 +54,6 @@ const debug = { Task, StyleManager, StyleParser, - TileID, Collision, FeatureSelection, TextCanvas, @@ -57,6 +62,8 @@ const debug = { export default { leafletLayer, + tangramLayer, + Marker, debug, version }; diff --git a/src/interaction.js b/src/interaction.js new file mode 100644 index 000000000..d7a541a20 --- /dev/null +++ b/src/interaction.js @@ -0,0 +1,206 @@ +import Geo from './utils/geo'; + +export function init(scene) { + var view = scene.view; + var camera = view.camera; + view.interactionLayer = this; + var orbitSpeed = 0.1; // controls mouse-to-orbit speed + + // set event handlers + scene.canvas.onmousedown = handleMouseDown; + scene.canvas.onmouseup = handleMouseUp; + scene.canvas.onclick = handleClick; + scene.canvas.ondblclick = handleDoubleclick; + scene.canvas.onmouseleave = handleMouseLeave; + scene.canvas.onmousemove = handleMouseMove; + scene.container.onwheel = handleScroll; + + // track mouse state + var mouseDown = false; + var lastMouseX = null; + var lastMouseY = null; + + // track drag screen position + var startingX = 0; + var startingY = 0; + + // track drag distance from the starting position + var deltaX = 0; + var deltaY = 0; + + function degToRad(deg) { + return deg * Math.PI / 180; + } + function radToDeg(rad) { + return rad / Math.PI * 180; + } + + // track orbit drag distance, preset with any pre-existing orbit + var orbitDeltaX = radToDeg(camera.roll / orbitSpeed); + var orbitDeltaY = radToDeg(camera.pitch / orbitSpeed); + + // track drag starting map position + var startingLng = view.center ? view.center.meters.x : null; + var startingLat = view.center ? view.center.meters.y : null; + + // track drag distance from the starting map position + var metersDeltaX = null; + var metersDeltaY = null; + + // track modifier key state + var metaKeyDown = false; + + function handleMouseDown(event) { + mouseDown = true; + lastMouseX = event.clientX; + lastMouseY = event.clientY; + view.markUserInput(); + // startingX = view.center ? view.center.meters.x : null; + // startingY = view.center ? view.center.meters.y : null; + startingLng = view.center.meters.x; + startingLat = view.center.meters.y; + + // don't select UI text on a doubleclick + if (event.detail > 1) { + event.preventDefault(); + } + } + + // TODO + // function handleMouseUp(event) { + function handleMouseUp() { + mouseDown = false; + lastMouseX = null; + lastMouseY = null; + // track last drag offset and apply that as offset to the next drag – + // otherwise camera resets position and rotation with each drag + startingX = orbitDeltaX; + startingY = orbitDeltaY; + startingLng = view.center.meters.x; + startingLat = view.center.meters.y; + deltaX = 0; + deltaY = 0; + view.setPanning(false); + scene.update(); + } + + function handleClick(event) { + console.log('handleclick') + if (view.panning) { + view.setPanning(false); + return; + } + event.lngLat = map.unproject([event.clientX, event.clientY]); + view.onClick(event); + } + + function handleDoubleclick(event) { + var newX = event.clientX; + var newY = event.clientY; + + let deltaX = newX - window.innerWidth / 2; + let deltaY = newY - window.innerHeight / 2; + + let metersDeltaX = deltaX * Geo.metersPerPixel(view.zoom); + let metersDeltaY = deltaY * Geo.metersPerPixel(view.zoom); + + let destination = Geo.metersToLatLng([view.center.meters.x + metersDeltaX, view.center.meters.y - metersDeltaY]); + view.flyTo({ + start: { center: { lng: view.center.lng, lat: view.center.lat }, zoom: view.zoom }, + end: { center: { lng: destination[0], lat: destination[1] }, zoom: view.zoom + 1 } + }); + scene.update(); + } + + function handleMouseLeave(event) { + if (!metaKeyDown) { // trigger mouseup on pan, but not orbit + handleMouseUp(event); + } + } + + function resetMouseEventVars(event) { + handleMouseUp(event); + handleMouseDown(event); + } + + function handleMouseMove(event) { + if (!mouseDown) { + if (view.panning) { + view.setPanning(false); // reset pan timer + } + return; + } + var newX = event.clientX; + var newY = event.clientY; + + deltaX = newX - lastMouseX; + deltaY = newY - lastMouseY; + + // orbit camera + if (event.metaKey) { + if (!metaKeyDown) { // meta key pressed during drag, fake a mouseup/mousedown + resetMouseEventVars(event); + } + metaKeyDown = true; + orbitDeltaX = startingX + newX - lastMouseX; + orbitDeltaY = Math.min(startingY + newY - lastMouseY, 0); // enforce minimum pitch of 0 = straight down + camera.roll = degToRad(orbitDeltaX * orbitSpeed); + camera.pitch = degToRad(orbitDeltaY * orbitSpeed); + view.roll = camera.roll; + view.pitch = camera.pitch; + + } else { // basic pan + if (metaKeyDown) { // meta key was just released during drag, fake a mouseup/mousedown + resetMouseEventVars(event); + } else { + + metersDeltaX = deltaX * Geo.metersPerPixel(view.zoom); + metersDeltaY = deltaY * Geo.metersPerPixel(view.zoom); + + // compensate for roll + var cosRoll = Math.cos(view.roll); + var adjustedDeltaX = metersDeltaX * cosRoll + metersDeltaY * Math.sin(view.roll + Math.PI); + var adjustedDeltaY = metersDeltaY * cosRoll + metersDeltaX * Math.sin(view.roll); + + var deltaLatLng = Geo.metersToLatLng([startingLng - adjustedDeltaX, startingLat + adjustedDeltaY]); + view.setView({ lng: deltaLatLng[0], lat: deltaLatLng[1] }); + } + metaKeyDown = false; + } + view.setPanning(true); + view.markUserInput(); + scene.requestRedraw(); + } + + function handleScroll(event) { + var zoomFactor = 0.01; // sets zoom speed with scrollwheel/trackpad + var targetZoom = view.zoom - event.deltaY * zoomFactor; + + // zoom toward pointer location + var startPosition = [event.clientX, event.clientY]; + var containerCenter = [scene.container.clientWidth / 2, scene.container.clientHeight / 2]; + var offset = [startPosition[0] - containerCenter[0], startPosition[1] - containerCenter[1]]; + + // compensate for roll + var cosRoll = Math.cos(view.roll); + var adjustedOffset = [offset[0] * cosRoll + offset[1] * Math.sin(view.roll + Math.PI), + offset[1] * cosRoll + offset[0] * Math.sin(view.roll)]; + + var scrollTarget = [adjustedOffset[0] * Geo.metersPerPixel(view.zoom), adjustedOffset[1] * Geo.metersPerPixel(view.zoom)]; + var panFactor = (targetZoom - view.zoom) * 0.666; // TODO: learn why 0.666 is needed here + var target = [view.center.meters.x + scrollTarget[0] * panFactor, + view.center.meters.y - scrollTarget[1] * panFactor]; + target = Geo.metersToLatLng(target); + + view.setView({ lng: target[0], lat: target[1], zoom: targetZoom }); + scene.update(); + + // have to set these here too because scroll doesn't count as a mousedown + // so no mouseup will be triggered at the end + startingLng = view.center.meters.x; + startingLat = view.center.meters.y; + + // prevent scroll event bubbling + return false; + } +} diff --git a/src/marker.js b/src/marker.js new file mode 100644 index 000000000..067fbb516 --- /dev/null +++ b/src/marker.js @@ -0,0 +1,122 @@ + +// Tangram marker class - takes an html element as the base + +export default class Marker { + constructor(element, options = {}) { + this.element = element; + this.options = options; + this.offset = options.offset || {x: 0, y: 0}; + this.wrap = typeof options.wrap === 'undefined' ? null : options.wrap; // override wrapWorld + + // TODO - default styles + // this.element.classList.add('default-tangram-marker'); + + this.map = null; + } + + // add marker to the map + addTo(map) { + this.remove(); + this.map = map; + map.getCanvasContainer().appendChild(this.element); + + map.view.subscribe({ + move: () => { + this.update(); + } + }); + map.view.subscribe({ + moveend: () => { + this.update(); + } + }); + this.update(); + + return this; + } + + // remove marker from the map + remove() { + if (this.map) { + this.map.view.unsubscribe({move: this.update}); + this.map.view.unsubscribe({moveend: this.update}); + delete this.map; + } + this.element.remove(); + return this; + } + + // set the marker's location + setLocation(lnglat) { + this.lngLat = lnglat; + this.position = null; + this.update(); + return this; + } + + // get the marker's location + getLocation() { + return this.lngLat; + } + + // update marker position + update() { + if (!this.map) { + return; + } + if (this.wrap || (this.wrap === null && this.map.transform.wrapWorld)) { + this.lngLat = this.unWrap(this.lngLat, this.position, this.map.transform); + } + // get new position + this.position = this.map.project(this.lngLat).add(this.offset); + + // center marker to screen, then + // position the element's top left corner, then center the element over the marker + // TODO: the -50% y translation isn't working - height is 0 because of css reasons + this.element.style['transform'] = `translate(${this.position.x}px, ${this.position.y}px) translate(-50%, -50%)`; + } + + // set a css transform property + setTransform(el, value) { + el.style['transform'] = value; + } + + // get the marker's html element + getElement() { + return this.element; + } + + // find a good new marker position when the map is scrolled more than 360 degrees + unWrap(lngLat, prevLoc, transform) { + // Check to see if +/- 360 is closer to the previous location + if (prevLoc) { + var left = Object.assign({}, lngLat); + var right = Object.assign({}, lngLat); + left = Object.assign(left, {lng: lngLat.lng - 360, lat: lngLat.lat}); + right = Object.assign(right, {lng: lngLat.lng + 360, lat: lngLat.lat}); + const delta = this.map.project(lngLat).distSqr(prevLoc); + if (this.map.project(left).distSqr(prevLoc) < delta) { + lngLat = left; + } else if (this.map.project(right).distSqr(prevLoc) < delta) { + lngLat = right; + } + } + + // Wrap the lngLat until it's as close to the viewport as it can get + var count = 0; + while (Math.abs(lngLat.lng - this.map.center.lng) > 180 && count < 10) { + count++; + const pos = this.map.project(lngLat); + if (pos.x >= 0 && pos.y >= 0 && pos.x <= this.map.view.size.css.width && pos.y <= transform.height) { + break; + } + if (lngLat.lng > this.map.view.center.lng) { + lngLat.lng -= 360; + } else { + lngLat.lng += 360; + } + } + return lngLat; + } + +} diff --git a/src/scene/camera.js b/src/scene/camera.js index 2c461da7c..4de751467 100644 --- a/src/scene/camera.js +++ b/src/scene/camera.js @@ -2,13 +2,17 @@ import Utils from '../utils/utils'; import ShaderProgram from '../gl/shader_program'; import {mat4, mat3, vec3} from '../utils/gl-matrix'; + // Abstract base class export default class Camera { constructor(name, view, options = {}) { this.view = view; this.position = options.position; - this.zoom = options.zoom; + this.zoom = options.zoom || view ? view.zoom : null; + this.roll = options.roll || view ? view.roll : null; + this.pitch = options.pitch || view ? view.pitch : null; + // this.already = false; } // Create a camera by type name, factory-style @@ -43,12 +47,19 @@ export default class Camera { if (this.zoom) { view.zoom = this.zoom; } + if (this.roll) { + view.roll = this.roll; + } + if (this.pitch) { + view.pitch = this.pitch; + } this.view.setView(view); } } // Set model-view and normal matrices setupMatrices (matrices, program) { + // console.log('camera setupMatrices'); // Model view matrix - transform tile space into view space (meters, relative to camera) mat4.multiply(matrices.model_view32, this.view_matrix, matrices.model); program.uniform('Matrix4fv', 'u_modelView', matrices.model_view32); @@ -91,6 +102,8 @@ class PerspectiveCamera extends Camera { this.focal_length = [[16, 2], [17, 2.5], [18, 3], [19, 4], [20, 6]]; } + this.rotation = options.rotation || {x: 0, y: 0}; + this.vanishing_point = options.vanishing_point || [0, 0]; // [x, y] this.vanishing_point = this.vanishing_point.map(parseFloat); // we implicitly only support px units here this.vanishing_point_skew = []; @@ -141,9 +154,13 @@ class PerspectiveCamera extends Camera { } updateMatrices() { + // if (!this.already) { + // console.error('perspective cam updateMatrices'); + // this.already = true; + // } // TODO: only re-calculate these vars when necessary - // Height of the viewport in meters at current zoom + // Height of the viewport in meters at current zoom - assumes landscape mode var viewport_height = this.view.size.css.height * this.view.meters_per_pixel; // Compute camera properties to fit desired view @@ -191,6 +208,8 @@ class PerspectiveCamera extends Camera { // Include camera height in projection matrix mat4.translate(this.projection_matrix, this.projection_matrix, vec3.fromValues(0, 0, -height)); + mat4.rotate(this.projection_matrix, this.projection_matrix, this.pitch, vec3.fromValues(1, 0, 0)); + mat4.rotate(this.projection_matrix, this.projection_matrix, this.roll, vec3.fromValues(0, 0, 1)); } update() { @@ -207,7 +226,7 @@ class PerspectiveCamera extends Camera { } // Isometric-style projection -// Note: this is actually an "axonometric" projection, but I'm using the colloquial term isometric because it is more recognizable. +// Note: this is technically an "axonometric" projection, but we're using the colloquial term isometric. // An isometric projection is a specific subset of axonometric projections. // 'axis' determines the xy skew applied to a vertex based on its z coordinate, e.g. [0, 1] axis causes buildings to be drawn // straight upwards on screen at their true height, [0, .5] would draw them up at half-height, [1, 0] would be sideways, etc. diff --git a/src/scene/scene.js b/src/scene/scene.js index e8bdf95db..488383c14 100755 --- a/src/scene/scene.js +++ b/src/scene/scene.js @@ -39,7 +39,7 @@ export default class Scene { this.sources = {}; this.view = new View(this, options); - this.tile_manager = new TileManager({ scene: this }); + this.tile_manager = new TileManager({ scene: this, view: this.view }); this.num_workers = options.numWorkers || 2; if (options.disableVertexArrayObjects === true) { VertexArrayObject.disabled = true; @@ -121,7 +121,6 @@ export default class Scene { this.updating++; this.initialized = false; - this.view_complete = false; // track if a view complete event has been triggered yet this.times.frame = null; // clear first frame time this.times.build = null; // clear first scene build time @@ -235,12 +234,13 @@ export default class Scene { } this.container = this.container || document.body; + this.container.style.height = '100%'; // necessary for react? this.canvas = document.createElement('canvas'); this.canvas.style.position = 'absolute'; this.canvas.style.top = 0; this.canvas.style.left = 0; - // Force tangram canvas underneath all leaflet layers, and set background to transparent + // Force tangram canvas underneath all other layers, and set background to transparent this.container.style.backgroundColor = 'transparent'; this.container.appendChild(this.canvas); @@ -1133,7 +1133,7 @@ export default class Scene { // Disable animation is scene flag requests it, otherwise enable animation if any animated styles are in view return (this.config.scene.animated === false ? false : - this.style_manager.getActiveStyles().some(s => this.styles[s].animated)); + this.tile_manager.getActiveStyles().some(s => this.styles[s].animated)); } // Get active camera - for public API @@ -1224,10 +1224,11 @@ export default class Scene { // just normalize top-level textures - necessary for adding base path to globals SceneLoader.normalizeTextures(this.config, this.config_bundle); } - this.trigger(loading ? 'load' : 'update', { config: this.config }); - this.style_manager.init(); this.view.reset(); + this.view.updateBounds(); + this.style_manager.init(); // removed in latest master - still needed? + this.createLights(); this.createDataSources(loading); this.loadTextures(); @@ -1244,9 +1245,10 @@ export default class Scene { // Finish by updating bounds and re-rendering this.updating--; - this.view.updateBounds(); this.requestRedraw(); + this.trigger(loading ? 'load' : 'update', { config: this.config }); + return done.then(() => { this.last_render_count = 0; // force re-evaluation of selection map this.requestRedraw(); @@ -1337,8 +1339,7 @@ export default class Scene { this.tile_manager.allVisibleTilesLabeled()) { this.tile_manager.updateLabels(); this.last_complete_generation = this.generation; - this.trigger('view_complete', { first: (this.view_complete !== true) }); - this.view_complete = true; + this.trigger('view_complete'); } } diff --git a/src/scene/scene_loader.js b/src/scene/scene_loader.js index 295c9e40a..7c09c1e6f 100644 --- a/src/scene/scene_loader.js +++ b/src/scene/scene_loader.js @@ -12,6 +12,9 @@ export default SceneLoader = { // Load scenes definitions from URL & proprocess async loadScene(url, { path, type } = {}) { + if (typeof url === 'undefined') { + return Promise.reject(Error('No scene url found')); + } let errors = []; const scene = await this.loadSceneRecursive({ url, path, type }, null, errors); const { config, bundle } = this.finalize(scene); diff --git a/src/scene/view.js b/src/scene/view.js index 54131e903..f00cf8992 100644 --- a/src/scene/view.js +++ b/src/scene/view.js @@ -1,5 +1,5 @@ import Geo from '../utils/geo'; -import {TileID} from '../tile/tile_id'; +import { TileID } from '../tile/tile_id'; import Camera from './camera'; import Utils from '../utils/utils'; import subscribeMixin from '../utils/subscribe'; @@ -9,10 +9,11 @@ export const VIEW_PAN_SNAP_TIME = 0.5; export default class View { - constructor (scene, options) { + constructor(scene, options) { subscribeMixin(this); this.scene = scene; + this.interactionLayer = null; // optionally set by tangramLayer this.createMatrices(); this.zoom = null; @@ -20,6 +21,9 @@ export default class View { this.bounds = null; this.meters_per_pixel = null; + this.roll = null; + this.pitch = null; + this.panning = false; this.panning_stop_at = 0; this.pan_snap_timer = 0; @@ -46,21 +50,24 @@ export default class View { } // Reset state before scene config is updated - reset () { + reset() { this.createCamera(); } // Create camera - createCamera () { + createCamera() { let active_camera = this.getActiveCamera(); if (active_camera) { this.camera = Camera.create(active_camera, this, this.scene.config.cameras[active_camera]); + if (this.interactionLayer) { // if provided by tangramLayer -- otherwise handled separately + this.interactionLayer.init(this.scene, this.camera); + } this.camera.updateView(); } } // Get active camera - for public API - getActiveCamera () { + getActiveCamera() { if (this.scene.config && this.scene.config.cameras) { for (let name in this.scene.config.cameras) { if (this.scene.config.cameras[name].active) { @@ -71,11 +78,13 @@ export default class View { // If no camera set as active, use first one let keys = Object.keys(this.scene.config.cameras); return keys.length && keys[0]; + } else { + log('warn', 'No active camera could be found'); } } // Set active camera and recompile - for public API - setActiveCamera (name) { + setActiveCamera(name) { let prev = this.getActiveCamera(); if (prev === name) { return name; @@ -95,16 +104,40 @@ export default class View { } // Update method called once per frame - update () { + update() { if (this.camera != null && this.ready()) { this.camera.update(); } this.pan_snap_timer = ((+new Date()) - this.panning_stop_at) / 1000; this.user_input_active = ((+new Date() - this.user_input_at) < this.user_input_timeout); + if (this.panning) { + // sync any markers to map + this.trigger('move'); + } + } + + // trigger moveend event for subscribers + moveEnd() { + this.trigger('moveend'); + } + + // trigger click event for subscribers + onClick(e) { + e.lngLat.wrap = function () { + const n = this.lng; + const min = -180; + const max = 180; + const d = max - min; + const w = ((n - min) % d + d) % d + min; + const wrapped = (w === min) ? max : w; + // console.log('lng:', this.lng, 'wrapped:', wrapped); + return { lng: wrapped, lat: this.lat }; + }; + this.trigger('click', e); } // Set logical pixel size of viewport - setViewportSize (width, height) { + setViewportSize(width, height) { this.size.css = { width, height }; this.size.device = { width: Math.round(this.size.css.width * Utils.device_pixel_ratio), @@ -115,7 +148,7 @@ export default class View { } // Set the map view, can be passed an object with lat/lng and/or zoom - setView ({ lng, lat, zoom } = {}) { + jumpTo({ lng, lat, zoom } = {}) { var changed = false; // Set center @@ -129,16 +162,201 @@ export default class View { // Set zoom if (typeof zoom === 'number' && zoom !== this.zoom) { changed = true; + this.zoom = zoom; this.setZoom(zoom); } + if (changed) { + this.updateBounds(); + } + return changed; + } + // Set the map view, can be passed an object with lat/lng and/or zoom + setView({ lng, lat, zoom } = {}) { + var changed = false; + + // Set center + if (typeof lng === 'number' && typeof lat === 'number') { + if (!this.center || lng !== this.center.lng || lat !== this.center.lat) { + changed = true; + this.center = { lng, lat }; + } + } + + // Set zoom + if (typeof zoom === 'number' && zoom !== this.zoom) { + changed = true; + this.zoom = zoom; + this.setZoom(zoom); + } if (changed) { this.updateBounds(); } return changed; } - setZoom (zoom) { + // create flyTo function + getFlyToFunction(start, end) { + function sq(n) { return n * n; } + function dist(a, b) { return Math.sqrt(sq(b[0] - a[0]) + sq(b[1] - a[1])); } + function lerp1(a, b, p) { return a + ((b - a) * p); } + function lerp2(a, b, p) { return { x: lerp1(a[0], b[0], p), y: lerp1(a[1], b[1], p) }; } + function cosh(x) { return (Math.pow(Math.E, x) + Math.pow(Math.E, -x)) / 2; } + function sinh(x) { return (Math.pow(Math.E, x) - Math.pow(Math.E, -x)) / 2; } + function tanh(x) { return sinh(x) / cosh(x); } + + // Implementation of https://www.win.tue.nl/~vanwijk/zoompan.pdf + // based on Tangram-ES and https://gist.github.com/RandomEtc/599724 + + var changed = false; + + // User preference for zoom/move curve sqrt(2) + const rho = 1.414; + + const scale = Math.pow(2.0, end.z - start.z); + + // Current view bounds in Mercator Meters + var rect = this.bounds; + var width = Math.abs(rect.sw.x - rect.ne.x); + var height = Math.abs(rect.sw.y - rect.ne.y); + + const w0 = Math.max(width, height); + const w1 = w0 / scale; + + const startPos = [start.x, start.y]; + const endPos = [end.x, end.y]; + + var u0 = 0; + const u1 = dist(startPos, endPos); + // i = 0 or 1 + function b(i) { + var n = sq(w1) - sq(w0) + ((i ? -1 : 1) * Math.pow(rho, 4) * sq(u1 - u0)); + var d = 2 * (i ? w1 : w0) * sq(rho) * (u1 - u0); + return n / d; + } + + // give this a b(0) or b(1) + function r(b) { + return Math.log(-b + Math.sqrt(sq(b) + 1)); + } + + // Parameterization of the elliptic path to pass through (u0,w0) and (u1,w1) + const r0 = r(b(0)); + const r1 = r(b(1)); + var S = (r1 - r0) / rho; // "distance" + + S = isNaN(S) ? Math.abs(start.z - end.z) * 0.5 : S; + + // u, w define the elliptic path. + function u(s) { + if (s === 0) { return 0; } + var a = w0 / sq(rho), + b = a * cosh(r0) * tanh(rho * s + r0), + c = a * sinh(r0); + return b - c + u0; + } + + function w(s) { + return w0 * cosh(r0) / cosh(rho * s + r0); + } + + // Check if movement is large enough to derive the fly-to curve + var move = u1 > 1; + + var returnFunction = (t) => { + if (t >= 1.0) { + this.updateBounds(); + return changed; + } else if (move) { + var s = S * t; + var us = u(s); + var pos = lerp2(startPos, endPos, (us - u0) / (u1 - u0)); + const base = 2; + const what = w0 / w(s); // flip curve + var zoom = start.z + Math.log(what) / Math.log(base); + } else { + // linear interpolation + pos = lerp2(startPos, endPos, t); + zoom = lerp1(start.z, end.z, t); + } + return { x: pos.x, y: pos.y, z: zoom }; + }; + return returnFunction; + } + + // change the map view with a flyto animation, can be passed an object with lat/lng and/or zoom + // can also be passed a duration, in seconds – default is based on distance + flyTo({ start, end } = {}, duration) { + var lngStart = start.center.lng, + latStart = start.center.lat, + zStart = start.zoom, + lngEnd = end.center.lng, + latEnd = end.center.lat, + zEnd = end.zoom; + + // Ease over the smallest angular distance needed + // var radiansDelta = this.camera.rotation - rStart % TWO_PI; + // if (radiansDelta > PI) { radiansDelta -= TWO_PI; } + // var rEnd = rStart + radiansDelta; + + var dLongitude = lngEnd - lngStart; + if (dLongitude > 180.0) { + lngEnd -= 360.0; + } else if (dLongitude < -180.0) { + lngEnd += 360.0; + } + var metersStart = Geo.latLngToMeters([lngStart, latStart]); + var metersEnd = Geo.latLngToMeters([lngEnd, latEnd]); + + var fn = this.getFlyToFunction({ x: metersStart[0], y: metersStart[1], z: zStart }, { x: metersEnd[0], y: metersEnd[1], z: zEnd }); + + // define a couple of helper functions for the distance calculation + function sq(n) { return n * n; } + function dist(a, b) { return Math.sqrt(sq(b[0] - a[0]) + sq(b[1] - a[1])); } + + if (typeof duration === 'undefined') { + var distance = dist([metersStart[0], metersStart[1]], [metersEnd[0], metersEnd[1]]); + // TODO: replace magic numbers with parameters for speed + duration = Math.max(0.05 * Math.log(distance), 0) + Math.max(0.05 * Math.log(Math.abs(zStart - zEnd)), 0.25); + } + var t0 = Date.now(); + var interval = setInterval(() => { + var t1 = Date.now(); + var t = (t1 - t0) / 1000.0; // number of seconds elapsed + var s = t / duration; // progress through the trip + + if (s > 1) { // 1 === done + clearInterval(interval); + } + + var pos = fn(s); + if (!isNaN(pos.x) && !isNaN(pos.y) && !isNaN(pos.z)) { + var latLngPos = Geo.metersToLatLng([pos.x, pos.y]); + this.setView({ lng: latLngPos[0], lat: latLngPos[1], zoom: pos.z }); + this.setZoom(pos.z); + this.updateBounds(); + this.scene.requestRedraw(); + } else if (pos) { + log('warn', 'Invalid position: '+pos); + } + }, 17); // 17 = 60fps + + // TODO: add easing type options + // var easeType = "cubic"; + // var cb = (t) => { + // var pos = fn(t); + // this.setPosition(pos.x, pos.y); + // this.setZoom(pos.z); + // // this.setRoll(ease(rStart, rEnd, t, easeType)); + // // this.setPitch(ease(tStart, this.camera.tilt, t, easeType)); + // requestRender(); + // }; + // var _speed = 1; + // if (_speed <= 0.) { _speed = 1.; } + } + + + setZoom(zoom) { let last_tile_zoom = this.tile_zoom; let tile_zoom = this.baseZoom(zoom); if (!this.continuous_zoom) { @@ -146,33 +364,35 @@ export default class View { } if (tile_zoom !== last_tile_zoom) { - this.zoom_direction = tile_zoom > last_tile_zoom ? 1 : -1; + this.zoom_direction = tile_zoom > last_tile_zoom ? 1 : -1; // 1 = zooming in, -1 = zooming out } this.zoom = zoom; this.tile_zoom = tile_zoom; + this.trigger('zoom'); + this.updateBounds(); this.scene.requestRedraw(); } // Choose the base zoom level to use for a given fractional zoom - baseZoom (zoom) { + baseZoom(zoom) { return Math.floor(zoom); } - setPanning (panning) { + setPanning(panning) { this.panning = panning; if (!this.panning) { this.panning_stop_at = (+new Date()); } } - markUserInput () { + markUserInput() { this.user_input_at = (+new Date()); } - ready () { + ready() { // TODO: better concept of "readiness" state? if (typeof this.size.css.width !== 'number' || typeof this.size.css.height !== 'number' || @@ -184,11 +404,72 @@ export default class View { } // Calculate viewport bounds based on current center and zoom - updateBounds () { + calculateBounds(center = this.center, zoom = this.zoom) { + this.meters_per_pixel = Geo.metersPerPixel(zoom); + + // Center of viewport in meters, and tile + let [x, y] = Geo.latLngToMeters([center.lng, center.lat]); + center.meters = { x, y }; + + center.tile = Geo.tileForMeters([center.meters.x, center.meters.y], this.tile_zoom); + + // Size of the half-viewport in meters at current zoom + this.size.meters = { + x: this.size.css.width * this.meters_per_pixel, + y: this.size.css.height * this.meters_per_pixel + }; + + // Bounds in meters + this.bounds = { + sw: { + x: center.meters.x - this.size.meters.x / 2, + y: center.meters.y - this.size.meters.y / 2 + }, + ne: { + x: center.meters.x + this.size.meters.x / 2, + y: center.meters.y + this.size.meters.y / 2 + } + }; + let boundsLatLng = {}; + boundsLatLng.sw = Geo.metersToLatLng([this.bounds.sw.x, this.bounds.sw.y]); + boundsLatLng.ne = Geo.metersToLatLng([this.bounds.ne.x, this.bounds.ne.y]); + this.bounds.latLng = { + sw: { + lng: boundsLatLng.sw[0], + lat: boundsLatLng.sw[1] + }, + ne: { + lng: boundsLatLng.ne[0], + lat: boundsLatLng.ne[1] + } + }; + this.bounds.getNorth = function () { + return this.latLng.ne.lat; + }; + this.bounds.getSouth = function () { + return this.latLng.sw.lat; + }; + this.bounds.getEast = function () { + return this.latLng.ne.lng; + }; + this.bounds.getWest = function () { + return this.latLng.sw.lng; + }; + this.bounds.getSouthWest = function () { + return this.latLng.sw; + }; + this.bounds.getNorthEast = function () { + return { lng: this.latLng.ne.lng, lat: this.latLng.ne.lat }; + }; + return this.bounds; + } + + // Calculate viewport bounds based on current center and zoom + updateBounds() { if (!this.ready()) { return; } - + this.calculateBounds(); this.meters_per_pixel = Geo.metersPerPixel(this.zoom); // Size of the half-viewport in meters at current zoom @@ -204,6 +485,7 @@ export default class View { this.center.tile = Geo.tileForMeters([this.center.meters.x, this.center.meters.y], this.tile_zoom); // Bounds in meters + // TODO: a real latitude projection to account for projection this.bounds = { sw: { x: this.center.meters.x - this.size.meters.x / 2, @@ -214,14 +496,13 @@ export default class View { y: this.center.meters.y + this.size.meters.y / 2 } }; - this.scene.tile_manager.updateTilesForView(); - - this.trigger('move'); - this.scene.requestRedraw(); // TODO automate via move event? + if (!this.panning) { + this.trigger('moveend'); + } } - findVisibleTileCoordinates () { + findVisibleTileCoordinates() { if (!this.bounds) { return []; } @@ -250,12 +531,18 @@ export default class View { } // Remove tiles too far outside of view - pruneTilesForView () { + pruneTilesForView() { // TODO: will this function ever be called when view isn't ready? if (!this.ready()) { return; } + // Remove tiles that are a specified # of tiles outside of the viewport border + let border_tiles = [ + Math.ceil((Math.floor(this.size.css.width / Geo.tile_size) + 2) / 2), + Math.ceil((Math.floor(this.size.css.height / Geo.tile_size) + 2) / 2) + ]; + this.scene.tile_manager.removeTiles(tile => { // Ignore visible tiles if (tile.visible || tile.isProxy()) { @@ -275,30 +562,16 @@ export default class View { return true; } - // Discard tiles outside an area surrounding the viewport, handling tiles at different zooms - // Get min and max tiles for the viewport, at the scale of the tile currently being evaluated - const view_buffer = this.meters_per_pixel * Geo.tile_size; // buffer area to keep tiles surrounding viewport - const view_tile_min = TileID.coordAtZoom( - Geo.tileForMeters( - [ - this.center.meters.x - this.size.meters.x/2 - view_buffer, - this.center.meters.y + this.size.meters.y/2 + view_buffer - ], - this.tile_zoom), - tile.coords.z); - const view_tile_max = TileID.coordAtZoom( - Geo.tileForMeters( - [ - this.center.meters.x + this.size.meters.x/2 + view_buffer, - this.center.meters.y - this.size.meters.y/2 - view_buffer - ], - this.tile_zoom), - tile.coords.z); - - if (tile.coords.x < view_tile_min.x || tile.coords.x > view_tile_max.x || - tile.coords.y < view_tile_min.y || tile.coords.y > view_tile_max.y) { - log('trace', `View: remove tile ${tile.key} (as ${tile.coords.key}) ` + - `for being too far out of visible area (${view_tile_min.key}, ${view_tile_max.key})`); + // Handle tiles at different zooms + let coords = TileID.coordAtZoom(tile.coords, this.tile_zoom); + + // Discard tiles outside an area surrounding the viewport + if (Math.abs(coords.x - this.center.tile.x) - border_tiles[0] > this.buffer) { + log('trace', `View: remove tile ${tile.key} (as ${coords.x}/${coords.y}/${this.tile_zoom}) for being too far out of visible area ***`); + return true; + } + else if (Math.abs(coords.y - this.center.tile.y) - border_tiles[1] > this.buffer) { + log('trace', `View: remove tile ${tile.key} (as ${coords.x}/${coords.y}/${this.tile_zoom}) for being too far out of visible area ***`); return true; } return false; @@ -308,7 +581,7 @@ export default class View { // Allocate model-view matrices // 64-bit versions are for CPU calcuations // 32-bit versions are downsampled and sent to GPU - createMatrices () { + createMatrices() { this.matrices = {}; this.matrices.model = new Float64Array(16); this.matrices.model32 = new Float32Array(16); @@ -320,7 +593,7 @@ export default class View { } // Calculate and set model/view and normal matrices for a tile - setupTile (tile, program) { + setupTile(tile, program) { // Tile-specific state // TODO: calc these once per tile (currently being needlessly re-calculated per-tile-per-style) tile.setupProgram(this.matrices, program); @@ -330,7 +603,7 @@ export default class View { } // Set general uniforms that must be updated once per program - setupProgram (program) { + setupProgram(program) { program.uniform('2fv', 'u_resolution', [this.size.device.width, this.size.device.height]); program.uniform('3fv', 'u_map_position', [this.center.meters.x, this.center.meters.y, this.zoom]); program.uniform('1f', 'u_meters_per_pixel', this.meters_per_pixel); @@ -342,7 +615,7 @@ export default class View { } // View requires some animation, such as after panning stops - isAnimating () { + isAnimating() { return (this.pan_snap_timer <= VIEW_PAN_SNAP_TIME); } diff --git a/src/tangramLayer.js b/src/tangramLayer.js new file mode 100644 index 000000000..3b27f0108 --- /dev/null +++ b/src/tangramLayer.js @@ -0,0 +1,285 @@ +// Tangram main API +// +// example: +// +// `var map = Tangram.tangramLayer('map');` +// +// This will create a Tangram map in the DOM element (normally a div) called 'map'. + +import Thread from './utils/thread'; +import Scene from './scene/scene'; +import Geo from './utils/geo'; +import { mat4 } from './utils/gl-matrix'; +import * as interaction from './interaction'; + +export function tangramLayer(element, options = {}) { + if (Thread.is_main) { + const container = typeof element === 'string' ? document.getElementById(element) : element; + if (!container) { + throw new Error(`DOM element "${element}" could not be found.`); + } + return { + container: container, + initialize(initOptions = {}) { + this.isloaded = false; + // if options were defined in both the layer instantiation and the initialize call, merge them + // (initialization options will override layer options) + for (var attribute in initOptions) { options[attribute] = initOptions[attribute]; } + // Defaults + if (!this.hasOwnProperty('options')) { + this.options = options; + } + for (var i in options) { + this.options[i] = options[i]; + } + let center = this.center || options.center || { lat: 40.70531, lng: -74.00976 }; // default - NYC + let zoom = this.zoom || options.zoom || 12; // default + let scrollWheelZoom = this.scrollWheelZoom || options.scrollWheelZoom || true; // default + + if (!this.options.scene) { + this.options.scene = 'demos/scene.yaml'; // default scene + } + + this.center = center; + this.zoom = zoom; + this.scrollWheelZoom = scrollWheelZoom; + + this.scene = Scene.create(); + this.view = this.scene.view; + this.view.center = this.center; + this.bounds = this.view.calculateBounds(); + this.view.bounds = this.bounds; + + this.parent = this.options.parent; + + // stub - TODO: separate into a new module? + this.transform = { + // TODO: this should be overridable per-marker + wrapWorld: true, + zoomScale: function (zoom) { return Math.pow(2, zoom); }, + }; + + // Add GL canvas to map this.container + this.scene.container = this.container; + + // Initial view + this.scene.load( + this.options.scene, {} + ).then(() => { + // this happens after the 'load' subscription is triggered + this.view.setView({ lng: this.center.lng, lat: this.center.lat, zoom: this.zoom }); + this.updateView(this); + this.updateSize(this); + + // Interaction layer initialization + interaction.init(this.scene, this.view.camera); + + }).catch(error => { + throw (error); + }); + + this.scene.subscribe({ + load: () => { + // this happens before the scene.load() promise returns + // Initial view + this.isloaded = true; + // force calculation of camera matrices, to allow apps to use latLngToPixel + // before all the tiles finish building + this.view.update(); + } + }); + + }, + + getCenter: function () { + return this.view.center; + }, + + getZoom: function () { + return this.view.zoom; + }, + + setView: function (view) { + this.view.setView(view); + }, + + updateView: function (map) { + var view = map.center; + view.zoom = map.zoom; + map.scene.view.setView(view); + }, + + updateSize: function (map) { + var size = { x: this.container.clientWidth, y: this.container.clientHeight }; + map.scene.resizeMap(size.x, size.y); + }, + + loaded: function () { + return this.isloaded; + }, + + jumpTo: function (opts) { + this.setView({ lng: opts.lng, lat: opts.lat, zoom: opts.zoom }); + }, + + flyTo: function (opts) { + this.view.flyTo(opts); + }, + + getBounds: function () { + this.bounds = this.view.calculateBounds(); + this.view.bounds = this.bounds; + return this.bounds; + }, + + getBoundsLatLng: function () { + let boundsLatLng = {}; + boundsLatLng.sw = Geo.metersToLatLng([this.bounds.sw.x, this.bounds.sw.y]); + boundsLatLng.ne = Geo.metersToLatLng([this.bounds.ne.x, this.bounds.ne.y]); + this.bounds.latLng = { + _sw: { + lng: boundsLatLng.sw[0], + lat: boundsLatLng.sw[1] + }, + _ne: { + lng: boundsLatLng.ne[0], + lat: boundsLatLng.ne[1] + } + }; + + return this.bounds.latLng; + }, + + scrollWheelZoom: { + isEnabled: function () { + return this.scrollWheelZoom; + }, + enable: function () { + this.scrollWheelZoom = true; + return true; + }, + disable: function () { + this.scrollWheelZoom = false; + return true; + } + }, + + // convert lngLat to screenspace pixels, ES version: https://github.com/tangrams/tangram-es/blob/6fa12a7a84f71adb3e8d9a473d538b1ac49bca7b/core/src/view/view.cpp#L429 + project: function (lngLat) { + let view = this.view; + + let meters = Geo.latLngToMeters([lngLat.lng, lngLat.lat]); + let metersPos = [meters[0], meters[1], 0, 1]; + + let point = { + x: null, + y: null + }; + + // used by marker.js for unwrap + point.distSqr = function (pos) { + var a = point.x - pos.x; + var b = point.y - pos.y; + return a * a + b * b; + }; + + // used by marker.js for offset + point.add = function (offset) { + point.x += offset.x; + point.y += offset.y; + return point; + }; + + // round point position to integers + point.round = function () { + point.x = Math.round(point.x); + point.y = Math.round(point.y); + return point; + }; + + if (typeof view.camera !== 'undefined') { + if (typeof view.camera.view_matrix === 'undefined') { + // no view.camera.view_matrix + return point; + } + } else { + // no view.camera + return point; + } + // otherwise carry on + + let m_proj = view.camera.projection_matrix; + let m_view = view.camera.view_matrix; + + let m_viewProj = new Float64Array(16); + m_viewProj = mat4.multiply(m_viewProj, m_proj, m_view); + + let screenSize = { x: view.size.css.width, y: view.size.css.height }; + let clipped = false; + let screenPosition = worldToScreenSpace(m_viewProj, metersPos, screenSize, clipped); + + function worldToScreenSpace(mvp, worldPosition, screenSize) { + let clipSpace = new Array(4); + clipSpace = worldToClipSpace(clipSpace, mvp, worldPosition); + let screenSpace = clipToScreenSpace(clipSpace, screenSize); + return screenSpace; + } + + function worldToClipSpace(clipSpace, mvp, worldPosition) { + clipSpace = transformMat4(clipSpace, mvp, worldPosition); + return { x: clipSpace[0], y: clipSpace[1], z: clipSpace[2], w: clipSpace[3] }; + } + + function transformMat4(out, m, a) { + let x = a[0], y = a[1], z = a[2], w = a[3]; + out[0] = m[0] * x + m[4] * y + m[8] * z + m[12] * w; + out[1] = m[1] * x + m[5] * y + m[9] * z + m[13] * w; + out[2] = m[2] * x + m[6] * y + m[10] * z + m[14] * w; + out[3] = m[3] * x + m[7] * y + m[11] * z + m[15] * w; + return out; + } + + function clipToScreenSpace(clipCoords, screenSize) { + let halfScreen = { x: screenSize.x * 0.5, y: screenSize.y * 0.5 }; + // from normalized device coordinates to screen space coordinate system + // top-left screen axis, y pointing down + + let screenPos = + { + x: (clipCoords.x / clipCoords.w) + 1, + y: 1 - (clipCoords.y / clipCoords.w) + }; + + return { x: screenPos.x * halfScreen.x, y: screenPos.y * halfScreen.y }; + } + + point.x = screenPosition.x; + point.y = screenPosition.y; + return point; + }, + + // convert screenspace pixel coordinates to lngLat + unproject: function (point) { + let view = this.view; + + let deltaX = point[0] - window.innerWidth / 2; + let deltaY = point[1] - window.innerHeight / 2; + + let metersDeltaX = deltaX * Geo.metersPerPixel(view.zoom); + let metersDeltaY = deltaY * Geo.metersPerPixel(view.zoom); + + let lngLat = Geo.metersToLatLng([view.center.meters.x + metersDeltaX, view.center.meters.y - metersDeltaY]); + return { lng: lngLat[0], lat: lngLat[1] }; + }, + + fitBounds: function (bounds, options, eventData) { + return this.view.fitBounds(bounds, options, eventData); + }, + + // TODO: necessary for react apps? + // onMove: function(evt) { + // } + + }; + } +} diff --git a/src/utils/gl-matrix.js b/src/utils/gl-matrix.js index 1544f5407..f4f35cb0b 100644 --- a/src/utils/gl-matrix.js +++ b/src/utils/gl-matrix.js @@ -32,20 +32,24 @@ const mat3 = { import {default as mat4_multiply} from 'gl-mat4/multiply'; import {default as mat4_translate} from 'gl-mat4/translate'; +import {default as mat4_rotate} from 'gl-mat4/rotate'; import {default as mat4_scale} from 'gl-mat4/scale'; import {default as mat4_perspective} from 'gl-mat4/perspective'; import {default as mat4_lookAt} from 'gl-mat4/lookAt'; import {default as mat4_identity} from 'gl-mat4/identity'; import {default as mat4_copy} from 'gl-mat4/copy'; +import {default as mat4_invert} from 'gl-mat4/invert'; const mat4 = { multiply: mat4_multiply, translate: mat4_translate, + rotate: mat4_rotate, scale: mat4_scale, perspective: mat4_perspective, lookAt: mat4_lookAt, identity: mat4_identity, - copy: mat4_copy + copy: mat4_copy, + invert: mat4_invert, };