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,
};