diff options
author | Mike Morris <michael.patrick.morris@gmail.com> | 2015-08-21 15:53:24 -0400 |
---|---|---|
committer | Mike Morris <michael.patrick.morris@gmail.com> | 2015-08-21 16:22:04 -0400 |
commit | 6e6387e089d2f8cf998f6726fec8eef25028627f (patch) | |
tree | 2abf4da384fa29f7c64cd3773a0afa432ef9b71a /platform | |
parent | a8d9b921d71a91d7f8eff82e5a584aaab8b7d1c6 (diff) | |
download | qtlocation-mapboxgl-6e6387e089d2f8cf998f6726fec8eef25028627f.tar.gz |
move node-mbgl to platform/node/ directory
Diffstat (limited to 'platform')
37 files changed, 2620 insertions, 0 deletions
diff --git a/platform/node/.clang-format b/platform/node/.clang-format new file mode 100644 index 0000000000..109b562b59 --- /dev/null +++ b/platform/node/.clang-format @@ -0,0 +1,18 @@ +Standard: Cpp11 +IndentWidth: 4 +AccessModifierOffset: -4 +UseTab: Never +BinPackParameters: true +AllowShortIfStatementsOnASingleLine: false +AllowShortLoopsOnASingleLine: false +AllowShortBlocksOnASingleLine: false +AllowShortFunctionsOnASingleLine: false +ConstructorInitializerAllOnOneLineOrOnePerLine: true +AlwaysBreakTemplateDeclarations: true +NamespaceIndentation: None +PointerBindsToType: false +SpacesInParentheses: false +BreakBeforeBraces: Attach +ColumnLimit: 100 +Cpp11BracedListStyle: false +SpacesBeforeTrailingComments: 1 diff --git a/platform/node/.gitignore b/platform/node/.gitignore new file mode 100644 index 0000000000..2f53e5d179 --- /dev/null +++ b/platform/node/.gitignore @@ -0,0 +1,5 @@ +build +lib +node_modules +test/actual +test/diff diff --git a/platform/node/.travis.yml b/platform/node/.travis.yml new file mode 100644 index 0000000000..891496e038 --- /dev/null +++ b/platform/node/.travis.yml @@ -0,0 +1,115 @@ +# to prevent Travis from exporting CXX after matrix env +language: c + +sudo: false + +matrix: + include: + - os: linux + compiler: clang + env: + - CXX: clang++-3.5 + - NODE_EXE: "iojs" + - NODE_VERSION: 2.0.1 + addons: + apt: + sources: [ 'ubuntu-toolchain-r-test', 'llvm-toolchain-precise-3.5' ] + packages: [ 'gdb', 'clang-3.5', 'libstdc++-4.9-dev', 'libstdc++6', 'libllvm3.4', 'xutils-dev', 'libxxf86vm-dev', 'x11proto-xf86vidmode-dev', 'mesa-utils' ] + - os: linux + compiler: clang + env: + - CXX: clang++-3.5 + - NODE_EXE: "node" + - NODE_VERSION: 0.12.0 + addons: + apt: + sources: [ 'ubuntu-toolchain-r-test', 'llvm-toolchain-precise-3.5' ] + packages: [ 'gdb', 'clang-3.5', 'libstdc++-4.9-dev', 'libstdc++6', 'libllvm3.4', 'xutils-dev', 'libxxf86vm-dev', 'x11proto-xf86vidmode-dev', 'mesa-utils' ] + - os: linux + compiler: clang + env: + - CXX: clang++-3.5 + - NODE_EXE: "node" + - NODE_VERSION: 0.10.36 + addons: + apt: + sources: [ 'ubuntu-toolchain-r-test', 'llvm-toolchain-precise-3.5' ] + packages: [ 'gdb', 'clang-3.5', 'libstdc++-4.9-dev', 'libstdc++6', 'libllvm3.4', 'xutils-dev', 'libxxf86vm-dev', 'x11proto-xf86vidmode-dev', 'mesa-utils' ] + - os: linux + compiler: gcc + env: + - CXX: g++-4.9 + - NODE_EXE: "iojs" + - NODE_VERSION: 2.0.1 + addons: + apt: + sources: [ 'ubuntu-toolchain-r-test' ] + packages: [ 'gdb', 'g++-4.9', 'gcc-4.9', 'libllvm3.4', 'xutils-dev', 'libxxf86vm-dev', 'x11proto-xf86vidmode-dev', 'mesa-utils' ] + - os: linux + compiler: gcc + env: + - CXX: g++-4.9 + - NODE_EXE: "node" + - NODE_VERSION: 0.12.0 + addons: + apt: + sources: [ 'ubuntu-toolchain-r-test' ] + packages: [ 'gdb', 'g++-4.9', 'gcc-4.9', 'libllvm3.4', 'xutils-dev', 'libxxf86vm-dev', 'x11proto-xf86vidmode-dev', 'mesa-utils' ] + - os: linux + compiler: gcc + env: + - CXX: g++-4.9 + - NODE_EXE: "node" + - NODE_VERSION: 0.10.36 + addons: + apt: + sources: [ 'ubuntu-toolchain-r-test' ] + packages: [ 'gdb', 'g++-4.9', 'gcc-4.9', 'libllvm3.4', 'xutils-dev', 'libxxf86vm-dev', 'x11proto-xf86vidmode-dev', 'mesa-utils' ] + - os: osx + compiler: clang + env: + - CXX: clang++ + - NODE_EXE: "iojs" + - NODE_VERSION: 2.0.1 + - os: osx + compiler: clang + env: + - CXX: clang++ + - NODE_EXE: "node" + - NODE_VERSION: 0.12.0 + - os: osx + compiler: clang + env: + - CXX: clang++ + - NODE_EXE: "node" + - NODE_VERSION: 0.10.36 + +env: + global: + - LD_LIBRARY_PATH: "/usr/local/lib" + - secure: pz/HAMQpnde//JJi3f+RcW32APN6g3QyFAH41JlZwgsM5Daj9RRoXqUcNg4hEyTvlThtc5t+wQQ2ejYAjMwDu00GAzRFJ03Sm45w2fPvith9fu4crXsyPUvWUfWPC0ajTXzorN4cwFwOoMoeo9DihLwb0EC0n4T6jWdBCae3k+s= + - secure: jYFAOQoMZkZVyc5AFPBKhR9oDqp5CciwCxFhVEHVjdImM+8V60loKazyw+bVLIjzKLnQbKKdiDudWL9TE1ylK/XSlF3K7o4gU2vIh5WzosKnU70Sanxd6tHt/Ui5eK+bAymKHbLdGiXIZtBZE0tML6+wgJ9vhV4ZKna5dM9tps4= + +before_install: +- source ./vendor/mbgl/scripts/travis_helper.sh +- pushd ./vendor/mbgl +- source ./scripts/${TRAVIS_OS_NAME}/install.sh +- source ./scripts/${TRAVIS_OS_NAME}/setup.sh +- popd +- source ./scripts/install_node.sh + +install: +- npm install --build-from-source + +before_script: +- ulimit -c unlimited -S +- ulimit -a + +script: +- "./scripts/travis_test.sh" + +after_success: +- "./scripts/travis_publish.sh" + +git: + submodules: true diff --git a/platform/node/CHANGELOG.md b/platform/node/CHANGELOG.md new file mode 100644 index 0000000000..a86be910ef --- /dev/null +++ b/platform/node/CHANGELOG.md @@ -0,0 +1,52 @@ +# 1.1.3 + +- Removes deprecated mbgl::Environment from NodeLogObserver. + +# 1.1.2 + +- Check libuv version semver-ishly, fixes segfaults in Node.js 0.12.x + and iojs. +- Fixes segfault, throws JavaScript error instead when attempting to + render without first loading a style. +- Bumps mbgl submodule to v0.4.0 + +# 1.1.1 + +- Bumps mbgl submodule to v0.3.5 + +# 1.1.0 + +- Adds Node.js v0.12.x and io.js support. +- Adds `map.release()` method for manual cleanup of map resources. +- Fixes garbage collection of NodeMap objects. +- Returns an error callback for failed NodeFileSource requests. +- Fixes handling of corrupt NodeFileSource request data. +- Implements request coalescing to fix NodeRequest cancellation. +- Removes `setAccessToken` method from NodeMap, `mapbox://` URLs + should be handled through `NodeFileSource` now. +- Updates build scripts and Travis CI configuration. +- Logs Environment ID and thread name when in an Environment scope. +- Refactors NodeLog to implement mbgl::Log::Observer. +- Fixes uncaught exception from missing sprites. +- Fixes Unicode glyph range end. + +# 1.0.3 + +- Fixes crash during garbage collection by assigning FileSource handle + to a v8::Persistent in NodeMap constructor. + +# 1.0.2 + +- Initialize shared display connection at module load time to avoid + race condition when display connection is initialized on-demand. + +# 1.0.1 + +- Adapts NodeFileSource around mbgl::Environment additions. +- Adapts to minor changes in mapbox-gl-test-suite. +- Adds tests for gzipped vector tile handling. +- Cleans up documentation. + +# 1.0.0 + +- Initial release. diff --git a/platform/node/CONTRIBUTING.md b/platform/node/CONTRIBUTING.md new file mode 100644 index 0000000000..166929e33f --- /dev/null +++ b/platform/node/CONTRIBUTING.md @@ -0,0 +1 @@ +Please run [`clang-format`](http://clang.llvm.org/docs/ClangFormat.html) and verify that code formatting will remain consistent before submitting a pull request. diff --git a/platform/node/LICENSE.txt b/platform/node/LICENSE.txt new file mode 100644 index 0000000000..c9018a1390 --- /dev/null +++ b/platform/node/LICENSE.txt @@ -0,0 +1,27 @@ +Copyright (c) 2014, Mapbox +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of [project] nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/platform/node/Makefile b/platform/node/Makefile new file mode 100644 index 0000000000..74b911a142 --- /dev/null +++ b/platform/node/Makefile @@ -0,0 +1,78 @@ +BUILDTYPE ?= Release +MBGL ?= vendor/mbgl + +DEBUG_FLAG = +ifeq ($(BUILDTYPE), Debug) +DEBUG_FLAG = -d +endif + +ifeq ($(shell uname -s), Darwin) +HOST ?= osx +ifeq ($(JOBS),) +JOBS = $(shell sysctl -n hw.ncpu) +endif +endif +ifeq ($(shell uname -s), Linux) +HOST ?= linux +ifeq ($(JOBS),) +JOBS = $(shell nproc) +endif +endif + +NODE_PRE_GYP = $(shell which node-pre-gyp) +ifeq ($(NODE_PRE_GYP),) +NODE_PRE_GYP = $(shell npm bin)/node-pre-gyp +endif + +# Explicitly disable the default FileSource implementation +ASSET = none +HTTP = none +CACHE = none + +include $(MBGL)/config/defaults.mk + +global: build + +.PHONY: build +build: build/Makefile + @$(NODE_PRE_GYP) build $(DEBUG_FLAG) --clang -- -j$(JOBS) + +vendor/mbgl: + git submodule update --init + +GYP_SETTINGS = -Dmbgl=$(MBGL) \ + -Dhost=$(HOST) \ + -I$(MBGL)/config/$(HOST).gypi \ + $(LIBS_$(HOST)) \ + -Duv_static_libs= -Duv_ldflags= + +.PHONY: build/Makefile +build/Makefile: $(MBGL)/config/$(HOST).gypi + @$(NODE_PRE_GYP) configure --clang -- $(GYP_SETTINGS) -f make + +.PHONY: build/binding.xcodeproj +build/binding.xcodeproj: $(MBGL)/config/$(HOST).gypi + @$(NODE_PRE_GYP) configure --clang -- $(GYP_SETTINGS) -f xcode + +.PHONY: xproj +xproj: build/binding.xcodeproj + open build/binding.xcodeproj + +$(MBGL)/config/%.gypi: $(MBGL) $(MBGL)/configure + make -C $(MBGL) config/$*.gypi geojsonvt + +.PHONY: test-suite +test-suite: + -@(`npm bin`/tape test/render.test.js | `npm bin`/faucet) + +.PHONY: test-js +test-js: + @(`npm bin`/tape test/js/**/*.test.js | `npm bin`/faucet) + +.PHONY: test +test: test-js test-suite + +.PHONY: clean +clean: + rm -rf build lib + rm -f $(MBGL)/config/$(HOST).gypi diff --git a/platform/node/README.md b/platform/node/README.md new file mode 100644 index 0000000000..1b7d4b2d6d --- /dev/null +++ b/platform/node/README.md @@ -0,0 +1,213 @@ +# node-mapbox-gl-native + +Renders map tiles with [Mapbox GL](https://github.com/mapbox/mapbox-gl-native). + + +[![NPM](https://nodei.co/npm/mapbox-gl-native.png)](https://npmjs.org/package/mapbox-gl-native) + +[![Build Status](https://travis-ci.org/mapbox/node-mapbox-gl-native.svg?branch=master)](https://travis-ci.org/mapbox/node-mapbox-gl-native) + +## Installing + +Requires a modern C++ runtime that supports C++14. + +By default, installs binaries. On these platforms no additional dependencies are needed. + +- 64 bit OS X or 64 bit Linux +- Node.js v0.10.x, Node.js v0.12.x, io.js v2.x + +Just run: + +``` +npm install mapbox-gl-native +``` + +Other platforms will fall back to a source compile with `npm run build`. To compile this module, make sure all submodules are initialized with `git submodule update --init` and install the [external dependencies required to build from source](https://github.com/mapbox/mapbox-gl-native#depends). + +## Rendering a map tile + +```js +var map = new mbgl.Map({ request: function() {} }); +map.load(require('./test/fixtures/style.json')); +map.render({}, function(err, image) { + if (err) throw err; + fs.writeFileSync('image.png', image); +}); +``` + +The first argument passed to `map.render` is an options object, all keys are optional: + +```js +{ + zoom: {zoom}, // number, defaults to 0 + width: {width}, // number (px), defaults to 512 + height: {height}, // number (px), defaults to 512 + center: [{latitude}, {longitude}], // array of numbers (coordinates), defaults to [0,0] + bearing: {bearing}, // number (in degrees, counter-clockwise from north), defaults to 0 + ratio: {ratio} // number (scale factor), defaults to 1.0, + classes: {classes} // array of strings, optional +} +``` + +_More about classes: https://github.com/mapbox/mapbox-gl-js/blob/master/API.md#working-with-style-classes_ + +When you are finished using a map object, you can call `map.release()` to dispose the internal map resources manually. This is not necessary, but can be helpful to optimize resource usage (memory, file sockets) on a more granualar level than v8's garbage collector. + +## Testing + +``` +npm test +``` + +## Implementing a file source + +When creating a `Map`, you must pass an options object (with a required `request` and optional `cancel` method) as the first parameter. + +```js +var map = new mbgl.Map({ + request: function(req) { + // TODO + }, + cancel: function(req) { + // TODO + } +}); +``` + +The `request()` method starts a new request to a file, while `cancel()` tells the FileSource to cancel the request (if possible). The `req` parameter has two properties: + +```json +{ + "url": "http://example.com", + "kind": 1 +} +``` + +The `kind` is an enum and defined in `mbgl.Resource`: + +```json +{ + "Unknown": 0, + "Style": 1, + "Source": 2, + "Tile": 3, + "Glyphs": 4, + "JSON": 5, + "Image": 6 +} +``` + +It has no significance for anything but serves as a hint to your implemention as to what sort of resource to expect. E.g., your implementation could choose caching strategies based on the expected file type. + +A sample implementation that reads files from disk would look like the following: + +```js +var map = new mbgl.Map({ + request: function(req) { + fs.readFile(path.join('base/path', req.url), function(err, data) { + req.respond(err, { data: data }); + }); + } +}); +``` + +This is a very barebones implementation and you'll probably want a better implementation. E.g. it passes the url verbatim to the file system, but you'd want add some logic that normalizes `http` URLs. You'll notice that once your implementation has obtained the requested file, you have to deliver it to the requestee by calling `req.respond()`, which takes either an error object or `null` and an object with several settings: + +```js +{ + modified: new Date(), + expires: new Date(), + etag: "string", + data: new Buffer() +}; +``` + +A sample implementation that uses [`request`](https://github.com/request/request) to query data from HTTP: + +```js +var mbgl = require('mapbox-gl-native'); +var request = require('request'); + +var map = new mbgl.Map({ + request: function(req) { + request({ + url: req.url, + encoding: null, + gzip: true + }, function (err, res, body) { + if (req.canceled) { + return; + } + + if (err) { + req.respond(err); + } else if (res.statusCode == 200) { + var response = {}; + + if (res.headers.modified) { response.modified = new Date(res.headers.modified); } + if (res.headers.expires) { response.expires = new Date(res.headers.expires); } + if (res.headers.etag) { response.etag = res.headers.etag; } + response.data = body; + req.respond(null, response); + } else { + req.respond(new Error(JSON.parse(body).message)); + } + }); + }, + cancel: function(req) { + req.canceled = true; + } +}); +``` + +Note that in reality, Mapbox GL uses two types of protocols: `asset://` for files that should be loaded from some local static system, and `http://` (and `https://`), which should be loaded from the internet. However, stylesheets are free to use other protocols too, if your implementation of `request` supports these; e.g. you could use `s3://` to indicate that files are supposed to be loaded from S3. + +## Mapbox API Access tokens + +To use styles that rely on Mapbox vector tiles, you must pass an [API access token](https://www.mapbox.com/developers/api/#access-tokens) in your `request` implementation with requests to `mapbox://` protocols. + +```js +var mbgl = require('mapbox-gl-native'); +var request = require('request'); +var url = require('url'); + +var options = {}; +options.request = function(req) { + var opts = { + url: req.url, + encoding: null, + gzip: true + }; + + if (url.parse(req.url).protocol === 'mapbox:') { + opts.qs = { access_token: process.env.MAPBOX_ACCESS_TOKEN}; + } + + request(opts, function (err, res, body) { + if (err) { + req.respond(err); + } else if (res.statusCode == 200) { + var response = {}; + + if (res.headers.modified) { response.modified = new Date(res.headers.modified); } + if (res.headers.expires) { response.expires = new Date(res.headers.expires); } + if (res.headers.etag) { response.etag = res.headers.etag; } + response.data = body; + req.respond(null, response); + } else { + req.respond(new Error(JSON.parse(body).message)); + } + }); +} + +var map = new mbgl.Map(options); +var style = mapboxStyle; // includes a datasource with a reference to something like `mapbox://mapbox.mapbox-streets-v6` + +map.load(style); +map.render({}, function(err, image) { + if (err) throw err; + fs.writeFileSync('image.png', image); +}); + +``` + diff --git a/platform/node/binding.gyp b/platform/node/binding.gyp new file mode 100644 index 0000000000..a5fb583399 --- /dev/null +++ b/platform/node/binding.gyp @@ -0,0 +1,79 @@ +{ + 'targets': [ + { 'target_name': '<(module_name)', + 'dependencies': [ + './<(mbgl)/mbgl.gyp:core', + './<(mbgl)/mbgl.gyp:platform-<(platform_lib)', + './<(mbgl)/mbgl.gyp:headless-<(headless_lib)', + ], + + 'include_dirs': [ "<!(node -e \"require('nan')\")" ], + + 'sources': [ + 'src/node_mapbox_gl_native.cpp', + 'src/node_log.hpp', + 'src/node_log.cpp', + 'src/node_file_source.hpp', + 'src/node_file_source.cpp', + 'src/node_map.hpp', + 'src/node_map.cpp', + 'src/node_request.hpp', + 'src/node_request.cpp', + 'src/util/async_queue.hpp', + ], + + 'conditions': [ + ['OS=="mac"', { + 'xcode_settings': { + 'CLANG_CXX_LIBRARY': 'libc++', + 'GCC_VERSION': 'com.apple.compilers.llvm.clang.1_0', + 'GCC_ENABLE_CPP_EXCEPTIONS': 'YES', + 'GCC_ENABLE_CPP_RTTI': 'YES', + 'OTHER_CPLUSPLUSFLAGS': [ + '-std=c++1y', + '-Werror', + '-Wall', + '-Wextra', + '-Wshadow', + '-Wno-variadic-macros', + '-Wno-error=unused-parameter', + '-frtti', + '-fexceptions', + ], + 'GCC_WARN_PEDANTIC': 'YES', + 'GCC_WARN_UNINITIALIZED_AUTOS': 'YES_AGGRESSIVE', + 'MACOSX_DEPLOYMENT_TARGET': '10.9', + }, + }, { + 'cflags_cc': [ + '-std=c++14', + '-Werror', + '-Wall', + '-Wextra', + '-Wno-variadic-macros', + '-Wno-error=unused-parameter', + '-frtti', + '-fexceptions', + ], + 'libraries': [ '<@(glfw3_ldflags)' ], + }], + ['OS=="linux"', { + 'cflags_cc': [ + '-Wno-unknown-pragmas', # We are using '#pragma mark', but it is only available on Darwin. + ], + }], + ], + }, + + { 'target_name': 'action_after_build', + 'type': 'none', + 'dependencies': [ '<(module_name)' ], + 'copies': [ + { + 'files': [ '<(PRODUCT_DIR)/<(module_name).node' ], + 'destination': '<(module_path)' + } + ] + } + ] +} diff --git a/platform/node/cloudformation/travis.template b/platform/node/cloudformation/travis.template new file mode 100644 index 0000000000..3ba9b9625e --- /dev/null +++ b/platform/node/cloudformation/travis.template @@ -0,0 +1,114 @@ + +{ + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "node-mapbox-gl-native travis resources", + "Resources": { + "BuildUser": { + "Type": "AWS::IAM::User", + "Properties": { + "Policies": [ + { + "PolicyName": "build", + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:DeleteObject", + "s3:GetObject", + "s3:GetObjectAcl", + "s3:PutObject", + "s3:PutObjectAcl" + ], + "Resource": [ + "arn:aws:s3:::mapbox-node-binary/mapbox-gl-native/*" + ], + "Effect": "Allow" + } + ] + } + }, + { + "PolicyName": "list", + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:ListBucket" + ], + "Resource": [ + "arn:aws:s3:::mapbox-node-binary" + ], + "Effect": "Allow" + } + ] + } + }, + { + "PolicyName": "build-testing", + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:DeleteObject", + "s3:GetObject", + "s3:GetObjectAcl", + "s3:PutObject", + "s3:PutObjectAcl" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:s3:::mapbox/node-mapbox-gl-native/*" + ] + } + ] + } + }, + { + "PolicyName": "list-testing", + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "s3:ListBucket" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:s3:::mapbox" + ], + "Condition": { + "StringLike": { + "s3:prefix": "node-mapbox-gl-native/*" + } + } + } + ] + } + } + ] + } + }, + "BuildUserKey": { + "Type": "AWS::IAM::AccessKey", + "Properties": { + "UserName": { + "Ref": "BuildUser" + } + } + } + }, + "Outputs": { + "AccessKeyId": { + "Value": { + "Ref": "BuildUserKey" + } + }, + "SecretAccessKey": { + "Value": { + "Fn::GetAtt": [ + "BuildUserKey", + "SecretAccessKey" + ] + } + } + } +} diff --git a/platform/node/examples/load-from-fs.js b/platform/node/examples/load-from-fs.js new file mode 100755 index 0000000000..352b015573 --- /dev/null +++ b/platform/node/examples/load-from-fs.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/* jshint node: true, unused: false */ +'use strict'; + +var mbgl = require('..'); +var fs = require('fs'); +var path = require('path'); +var PNG = require('pngjs').PNG; + +var base = path.join(path.dirname(process.mainModule.filename), '../test'); + +var map = new mbgl.Map({ + request: function(req) { + fs.readFile(path.join(base, req.url), function(err, data) { + req.respond(err, { data: data }); + }); + } +}); + +map.load(require('../test/fixtures/style.json')); + +map.render({}, function(err, data) { + if (err) throw err; + + var png = new PNG({ + width: data.width, + height: data.height + }); + + png.data = data.pixels; + + png.pack() + .pipe(fs.createWriteStream('image.png')) + .on('finish', function() { + console.warn('Written image.png'); + }); +}); diff --git a/platform/node/package.json b/platform/node/package.json new file mode 100644 index 0000000000..7ff254dce9 --- /dev/null +++ b/platform/node/package.json @@ -0,0 +1,51 @@ +{ + "name": "mapbox-gl-native", + "version": "1.1.3", + "description": "Renders map tiles with Mapbox GL", + "keywords": [ + "mapbox", + "gl" + ], + "main": "lib/mapbox-gl-native.node", + "repository": { + "type": "git", + "url": "git://github.com/mapbox/node-mapbox-gl-native.git" + }, + "licenses": [ + { + "type": "BSD", + "url": "https://github.com/mapbox/node-mapbox-gl-native/blob/master/LICENSE.txt" + } + ], + "dependencies": { + "nan": "^1.9.0", + "node-pre-gyp": "^0.6.7" + }, + "bundledDependencies": [ + "node-pre-gyp" + ], + "devDependencies": { + "aws-sdk": "^2.1.27", + "faucet": "0.0.1", + "mapbox-gl-test-suite": "git://github.com/mapbox/mapbox-gl-test-suite#7251829de9d2804fe02cd8f7acc0e30ea278429d", + "mkdirp": "^0.5.1", + "pngjs": "^0.4.0", + "request": "^2.55.0", + "st": "^0.5.3", + "tape": "^4.0.0" + }, + "scripts": { + "install": "node-pre-gyp install || make", + "test": "make test", + "rebuild": "make clean && make", + "build": "make" + }, + "gypfile": true, + "binary": { + "module_name": "mapbox-gl-native", + "module_path": "./lib/", + "host": "https://mapbox-node-binary.s3.amazonaws.com", + "remote_path": "./{name}/v{version}", + "package_name": "{node_abi}-{platform}-{arch}.tar.gz" + } +} diff --git a/platform/node/scripts/deploy_results.sh b/platform/node/scripts/deploy_results.sh new file mode 100755 index 0000000000..adbed4c2d8 --- /dev/null +++ b/platform/node/scripts/deploy_results.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +set -e +set -o pipefail + +if [ ! -z "${AWS_ACCESS_KEY_ID}" ] && [ ! -z "${AWS_SECRET_ACCESS_KEY}" ] ; then + # Install and add awscli to PATH for uploading the results + mapbox_time "install_awscli" \ + pip install --user awscli + export PATH="`python -m site --user-base`/bin:${PATH}" + + mapbox_time_start "deploy_results" + (cd ./node_modules/mapbox-gl-test-suite/ && ./bin/deploy_results.sh) + mapbox_time_finish +fi diff --git a/platform/node/scripts/install_node.sh b/platform/node/scripts/install_node.sh new file mode 100755 index 0000000000..311b004243 --- /dev/null +++ b/platform/node/scripts/install_node.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -e +set -o pipefail + +# Mason exists on PATH from sourcing mbgl install script +mapbox_time $NODE_EXE \ +mason install $NODE_EXE $NODE_VERSION + +export PATH="`mason prefix $NODE_EXE $NODE_VERSION`/bin":"$PATH" + +$NODE_EXE --version +npm --version diff --git a/platform/node/scripts/travis_publish.sh b/platform/node/scripts/travis_publish.sh new file mode 100755 index 0000000000..d06a2a8a30 --- /dev/null +++ b/platform/node/scripts/travis_publish.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +set -e + +# Inspect binary. +if [[ ${TRAVIS_OS_NAME} == "linux" ]]; then + ldd ./lib/mapbox-gl-native.node +else + otool -L ./lib/mapbox-gl-native.node +fi + +COMMIT_MESSAGE=$(git show -s --format=%B $TRAVIS_COMMIT | tr -d '\n') + +if test "${COMMIT_MESSAGE#*'[publish binary]'}" != "$COMMIT_MESSAGE" && [[ $CXX =~ ^clang* ]]; then + npm install aws-sdk + + ./node_modules/.bin/node-pre-gyp package + + if [[ ${TRAVIS_OS_NAME} == "linux" ]]; then + ./node_modules/.bin/node-pre-gyp testpackage + fi + + ./node_modules/.bin/node-pre-gyp publish info + + if [[ ${TRAVIS_OS_NAME} == "linux" ]]; then + rm -rf build + rm -rf lib + npm install --fallback-to-build=false + npm test + fi +fi diff --git a/platform/node/scripts/travis_test.sh b/platform/node/scripts/travis_test.sh new file mode 100755 index 0000000000..70fff88aa4 --- /dev/null +++ b/platform/node/scripts/travis_test.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +set -e +set -o pipefail + +# Travis OS X has no GPU +if [[ ${TRAVIS_OS_NAME} == "linux" ]]; then + npm test + ./scripts/deploy_results.sh +fi diff --git a/platform/node/src/node_file_source.cpp b/platform/node/src/node_file_source.cpp new file mode 100644 index 0000000000..204e85a126 --- /dev/null +++ b/platform/node/src/node_file_source.cpp @@ -0,0 +1,161 @@ +#include "node_file_source.hpp" +#include "node_request.hpp" +#include "util/async_queue.hpp" + +#include <mbgl/storage/request.hpp> + +namespace node_mbgl { + +struct NodeFileSource::Action { + const enum : bool { Add, Cancel } type; + mbgl::Resource const resource; +}; + +NodeFileSource::NodeFileSource(v8::Handle<v8::Object> options_) : + queue(new Queue(uv_default_loop(), [this](Action &action) { + if (action.type == Action::Add) { + processAdd(action.resource); + } else if (action.type == Action::Cancel) { + processCancel(action.resource); + } + })) +{ + NanAssignPersistent(options, options_->ToObject()); + + // Make sure that the queue doesn't block the loop from exiting. + queue->unref(); +} + +NodeFileSource::~NodeFileSource() { + queue->stop(); + queue = nullptr; + + NanDisposePersistent(options); +} + +mbgl::Request* NodeFileSource::request(const mbgl::Resource& resource, uv_loop_t* loop, Callback callback) { + auto req = new mbgl::Request(resource, loop, std::move(callback)); + + std::lock_guard<std::mutex> lock(observersMutex); + + assert(observers.find(resource) == observers.end()); + observers[resource] = req; + + // This function can be called from any thread. Make sure we're executing the actual call in the + // file source loop by sending it over the queue. It will be processed in processAction(). + queue->send(Action{ Action::Add, resource }); + + return req; +} + +void NodeFileSource::cancel(mbgl::Request* req) { + req->cancel(); + + std::lock_guard<std::mutex> lock(observersMutex); + + auto it = observers.find(req->resource); + if (it == observers.end()) { + return; + } + + observers.erase(it); + + // This function can be called from any thread. Make sure we're executing the actual call in the + // file source loop by sending it over the queue. It will be processed in processAction(). + queue->send(Action{ Action::Cancel, req->resource }); + + req->destruct(); +} + +void NodeFileSource::processAdd(const mbgl::Resource& resource) { + NanScope(); + + // Make sure the loop stays alive as long as request is pending. + if (pending.empty()) { + queue->ref(); + } + + auto requestHandle = NanNew<v8::Object>(NodeRequest::Create(this, resource)); + + v8::Persistent<v8::Object> requestPersistent; + NanAssignPersistent(requestPersistent, requestHandle); + pending.emplace(resource, std::move(requestPersistent)); + +#if (NODE_MODULE_VERSION > NODE_0_10_MODULE_VERSION) + auto requestFunction = v8::Local<v8::Object>::New(v8::Isolate::GetCurrent(), options)->Get(NanNew("request")).As<v8::Function>(); +#else + auto requestFunction = options->Get(NanNew("request")).As<v8::Function>(); +#endif + + v8::Local<v8::Value> argv[] = { requestHandle }; + NanMakeCallback(NanGetCurrentContext()->Global(), requestFunction, 1, argv); +} + +void NodeFileSource::processCancel(const mbgl::Resource& resource) { + NanScope(); + + auto it = pending.find(resource); + if (it == pending.end()) { + // The response callback was already fired. There is no point in calling the cancelation + // callback because the request is already completed. + } else { +#if (NODE_MODULE_VERSION > NODE_0_10_MODULE_VERSION) + auto requestHandle = v8::Local<v8::Object>::New(v8::Isolate::GetCurrent(), it->second); + it->second.Reset(); +#else + auto requestHandle = NanNew<v8::Object>(it->second); + NanDisposePersistent(it->second); +#endif + pending.erase(it); + + // Make sure the the loop can exit when there are no pending requests. + if (pending.empty()) { + queue->unref(); + } + +#if (NODE_MODULE_VERSION > NODE_0_10_MODULE_VERSION) + auto optionsObject = v8::Local<v8::Object>::New(v8::Isolate::GetCurrent(), options); + if (optionsObject->Has(NanNew("cancel"))) { + auto cancelFunction = optionsObject->Get(NanNew("cancel")).As<v8::Function>(); +#else + if (options->Has(NanNew("cancel"))) { + auto cancelFunction = options->Get(NanNew("cancel")).As<v8::Function>(); +#endif + v8::Local<v8::Value> argv[] = { requestHandle }; + NanMakeCallback(NanGetCurrentContext()->Global(), cancelFunction, 1, argv); + } + + // Set the request handle in the request wrapper handle to null + node::ObjectWrap::Unwrap<NodeRequest>(requestHandle)->cancel(); + } +} + +void NodeFileSource::notify(const mbgl::Resource& resource, const std::shared_ptr<const mbgl::Response>& response) { + // First, remove the request, since it might be destructed at any point now. + auto it = pending.find(resource); + if (it != pending.end()) { +#if (NODE_MODULE_VERSION > NODE_0_10_MODULE_VERSION) + it->second.Reset(); +#else + NanDisposePersistent(it->second); +#endif + pending.erase(it); + + // Make sure the the loop can exit when there are no pending requests. + if (pending.empty()) { + queue->unref(); + } + } + + std::lock_guard<std::mutex> lock(observersMutex); + + auto observersIt = observers.find(resource); + if (observersIt == observers.end()) { + return; + } + + observersIt->second->notify(response); + observers.erase(observersIt); +} + +} diff --git a/platform/node/src/node_file_source.hpp b/platform/node/src/node_file_source.hpp new file mode 100644 index 0000000000..f412bdee16 --- /dev/null +++ b/platform/node/src/node_file_source.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include <mbgl/storage/file_source.hpp> + +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-parameter" +#pragma GCC diagnostic ignored "-Wshadow" +#include <node.h> +#include <node_version.h> +#include <nan.h> +#pragma GCC diagnostic pop + +#include <memory> +#include <mutex> +#include <unordered_map> + +namespace node_mbgl { + +namespace util { template <typename T> class AsyncQueue; } + +class NodeFileSource : public mbgl::FileSource { +public: + NodeFileSource(v8::Handle<v8::Object>); + ~NodeFileSource(); + + mbgl::Request* request(const mbgl::Resource&, uv_loop_t*, Callback); + void cancel(mbgl::Request*); + + // visiblity? + void notify(const mbgl::Resource&, const std::shared_ptr<const mbgl::Response>&); + +private: + struct Action; + using Queue = util::AsyncQueue<Action>; + + void processAdd(const mbgl::Resource&); + void processCancel(const mbgl::Resource&); + + v8::Persistent<v8::Object> options; + +private: +#if (NODE_MODULE_VERSION > NODE_0_10_MODULE_VERSION) + std::unordered_map<mbgl::Resource, v8::Persistent<v8::Object, v8::CopyablePersistentTraits<v8::Object>>, mbgl::Resource::Hash> pending; +#else + std::unordered_map<mbgl::Resource, v8::Persistent<v8::Object>, mbgl::Resource::Hash> pending; +#endif + + // The observers list will hold pointers to all the requests waiting + // for a particular resource. The access must be guarded by a mutex + // because the list is also accessed by a thread from the mbgl::Map + // object and from the main thread when notifying requests of + // completion. Concurrent access is specially needed when + // canceling a request to avoid a deadlock (see #129). + std::unordered_map<mbgl::Resource, mbgl::Request*, mbgl::Resource::Hash> observers; + std::mutex observersMutex; + + Queue *queue = nullptr; +}; + +} diff --git a/platform/node/src/node_log.cpp b/platform/node/src/node_log.cpp new file mode 100644 index 0000000000..6375348070 --- /dev/null +++ b/platform/node/src/node_log.cpp @@ -0,0 +1,54 @@ +#include "node_log.hpp" +#include "util/async_queue.hpp" + +namespace node_mbgl { + +struct NodeLogObserver::LogMessage { + mbgl::EventSeverity severity; + mbgl::Event event; + int64_t code; + std::string text; + + LogMessage(mbgl::EventSeverity severity_, mbgl::Event event_, int64_t code_, std::string text_) + : severity(severity_), + event(event_), + code(code_), + text(text_) {} +}; + +NodeLogObserver::NodeLogObserver(v8::Handle<v8::Object> target) + : queue(new Queue(uv_default_loop(), [this](LogMessage &message) { + NanScope(); + + auto msg = NanNew<v8::Object>(); + msg->Set(NanNew("class"), NanNew(mbgl::EventClass(message.event).c_str())); + msg->Set(NanNew("severity"), NanNew(mbgl::EventSeverityClass(message.severity).c_str())); + if (message.code != -1) { + msg->Set(NanNew("code"), NanNew<v8::Number>(message.code)); + } + if (!message.text.empty()) { + msg->Set(NanNew("text"), NanNew(message.text)); + } + + v8::Local<v8::Value> argv[] = { NanNew("message"), msg }; + auto handle = NanNew<v8::Object>(module); + auto emit = handle->Get(NanNew("emit"))->ToObject(); + emit->CallAsFunction(handle, 2, argv); + })) { + NanScope(); + NanAssignPersistent(module, target); + + // Don't keep the event loop alive. + queue->unref(); +} + +NodeLogObserver::~NodeLogObserver() { + queue->stop(); +} + +bool NodeLogObserver::onRecord(mbgl::EventSeverity severity, mbgl::Event event, int64_t code, const std::string &text) { + queue->send({ severity, event, code, text }); + return true; +} + +} diff --git a/platform/node/src/node_log.hpp b/platform/node/src/node_log.hpp new file mode 100644 index 0000000000..5c0048d261 --- /dev/null +++ b/platform/node/src/node_log.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include <mbgl/platform/log.hpp> + +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-parameter" +#pragma GCC diagnostic ignored "-Wshadow" +#include <node.h> +#include <nan.h> +#pragma GCC diagnostic pop + +namespace node_mbgl { + +namespace util { template <typename T> class AsyncQueue; } + +class NodeLogObserver : public mbgl::Log::Observer { +public: + NodeLogObserver(v8::Handle<v8::Object> target); + ~NodeLogObserver(); + + // Log::Observer implementation + virtual bool onRecord(mbgl::EventSeverity severity, mbgl::Event event, int64_t code, const std::string &msg) override; + +private: + v8::Persistent<v8::Object> module; + + struct LogMessage; + using Queue = util::AsyncQueue<LogMessage>; + Queue *queue = nullptr; +}; + +} diff --git a/platform/node/src/node_map.cpp b/platform/node/src/node_map.cpp new file mode 100644 index 0000000000..a24f0c46b8 --- /dev/null +++ b/platform/node/src/node_map.cpp @@ -0,0 +1,349 @@ +#include "node_map.hpp" + +#include <mbgl/platform/default/headless_display.hpp> +#include <mbgl/map/still_image.hpp> +#include <mbgl/util/exception.hpp> + +#include <unistd.h> + +namespace node_mbgl { + +struct NodeMap::RenderOptions { + double zoom = 0; + double bearing = 0; + double latitude = 0; + double longitude = 0; + unsigned int width = 512; + unsigned int height = 512; + float ratio = 1.0f; + std::vector<std::string> classes; +}; + +//////////////////////////////////////////////////////////////////////////////////////////////// +// Static Node Methods + +v8::Persistent<v8::FunctionTemplate> NodeMap::constructorTemplate; + +static std::shared_ptr<mbgl::HeadlessDisplay> sharedDisplay() { + static auto display = std::make_shared<mbgl::HeadlessDisplay>(); + return display; +} + +const static char* releasedMessage() { + return "Map resources have already been released"; +} + +void NodeMap::Init(v8::Handle<v8::Object> target) { + NanScope(); + + v8::Local<v8::FunctionTemplate> t = NanNew<v8::FunctionTemplate>(New); + + t->InstanceTemplate()->SetInternalFieldCount(1); + t->SetClassName(NanNew("Map")); + + NODE_SET_PROTOTYPE_METHOD(t, "load", Load); + NODE_SET_PROTOTYPE_METHOD(t, "render", Render); + NODE_SET_PROTOTYPE_METHOD(t, "release", Release); + + NanAssignPersistent(constructorTemplate, t); + + target->Set(NanNew("Map"), t->GetFunction()); + + // Initialize display connection on module load. + sharedDisplay(); +} + +NAN_METHOD(NodeMap::New) { + NanScope(); + + if (!args.IsConstructCall()) { + return NanThrowTypeError("Use the new operator to create new Map objects"); + } + + if (args.Length() < 1 || !args[0]->IsObject()) { + return NanThrowTypeError("Requires an options object as first argument"); + } + + auto options = args[0]->ToObject(); + + // Check that 'request', 'cancel' and 'ratio' are defined. + if (!options->Has(NanNew("request")) || !options->Get(NanNew("request"))->IsFunction()) { + return NanThrowError("Options object must have a 'request' method"); + } + if (options->Has(NanNew("cancel")) && !options->Get(NanNew("cancel"))->IsFunction()) { + return NanThrowError("Options object 'cancel' property must be a function"); + } + + if (!options->Has(NanNew("ratio")) || !options->Get(NanNew("ratio"))->IsNumber()) { + return NanThrowError("Options object must have a numerical 'ratio' property"); + } + + try { + auto nodeMap = new NodeMap(options); + nodeMap->Wrap(args.This()); + } catch(std::exception &ex) { + return NanThrowError(ex.what()); + } + + NanReturnValue(args.This()); +} + +const std::string StringifyStyle(v8::Handle<v8::Value> styleHandle) { + NanScope(); + + auto JSON = NanGetCurrentContext()->Global()->Get(NanNew("JSON"))->ToObject(); + auto stringify = v8::Handle<v8::Function>::Cast(JSON->Get(NanNew("stringify"))); + + return *NanUtf8String(stringify->Call(JSON, 1, &styleHandle)); +} + +NAN_METHOD(NodeMap::Load) { + NanScope(); + + auto nodeMap = node::ObjectWrap::Unwrap<NodeMap>(args.Holder()); + + if (!nodeMap->isValid()) return NanThrowError(releasedMessage()); + + // Reset the flag as this could be the second time + // we are calling this (being the previous successful). + nodeMap->loaded = false; + + if (args.Length() < 1) { + return NanThrowError("Requires a map style as first argument"); + } + + std::string style; + + if (args[0]->IsObject()) { + style = StringifyStyle(args[0]); + } else if (args[0]->IsString()) { + style = *NanUtf8String(args[0]); + } else { + return NanThrowTypeError("First argument must be a string or object"); + } + + try { + nodeMap->map->setStyleJSON(style, "."); + } catch (const std::exception &ex) { + return NanThrowError(ex.what()); + } + + nodeMap->loaded = true; + + NanReturnUndefined(); +} + +std::unique_ptr<NodeMap::RenderOptions> NodeMap::ParseOptions(v8::Local<v8::Object> obj) { + NanScope(); + + auto options = std::make_unique<RenderOptions>(); + + if (obj->Has(NanNew("zoom"))) { options->zoom = obj->Get(NanNew("zoom"))->NumberValue(); } + if (obj->Has(NanNew("bearing"))) { options->bearing = obj->Get(NanNew("bearing"))->NumberValue(); } + if (obj->Has(NanNew("center"))) { + auto center = obj->Get(NanNew("center")).As<v8::Array>(); + if (center->Length() > 0) { options->latitude = center->Get(0)->NumberValue(); } + if (center->Length() > 1) { options->longitude = center->Get(1)->NumberValue(); } + } + if (obj->Has(NanNew("width"))) { options->width = obj->Get(NanNew("width"))->IntegerValue(); } + if (obj->Has(NanNew("height"))) { options->height = obj->Get(NanNew("height"))->IntegerValue(); } + if (obj->Has(NanNew("ratio"))) { options->ratio = obj->Get(NanNew("ratio"))->IntegerValue(); } + + if (obj->Has(NanNew("classes"))) { + auto classes = obj->Get(NanNew("classes"))->ToObject().As<v8::Array>(); + const int length = classes->Length(); + options->classes.reserve(length); + for (int i = 0; i < length; i++) { + options->classes.push_back(std::string { *NanUtf8String(classes->Get(i)->ToString()) }); + } + } + + return options; +} + +NAN_METHOD(NodeMap::Render) { + NanScope(); + + auto nodeMap = node::ObjectWrap::Unwrap<NodeMap>(args.Holder()); + + if (!nodeMap->isValid()) return NanThrowError(releasedMessage()); + + if (args.Length() <= 0 || !args[0]->IsObject()) { + return NanThrowTypeError("First argument must be an options object"); + } + + if (args.Length() <= 1 || !args[1]->IsFunction()) { + return NanThrowTypeError("Second argument must be a callback function"); + } + + if (!nodeMap->isLoaded()) { + return NanThrowTypeError("Style is not loaded"); + } + + auto options = ParseOptions(args[0]->ToObject()); + + assert(!nodeMap->callback); + assert(!nodeMap->image); + nodeMap->callback = std::unique_ptr<NanCallback>(new NanCallback(args[1].As<v8::Function>())); + + try { + nodeMap->startRender(std::move(options)); + } catch (mbgl::util::Exception &ex) { + return NanThrowError(ex.what()); + } + + NanReturnUndefined(); +} + +void NodeMap::startRender(std::unique_ptr<NodeMap::RenderOptions> options) { + view.resize(options->width, options->height); + map->update(mbgl::Update::Dimensions); + map->setClasses(options->classes); + map->setLatLngZoom(mbgl::LatLng(options->latitude, options->longitude), options->zoom); + map->setBearing(options->bearing); + + map->renderStill([this](const std::exception_ptr eptr, std::unique_ptr<const mbgl::StillImage> result) { + if (eptr) { + error = std::move(eptr); + uv_async_send(async); + } else { + assert(!image); + image = std::move(result); + uv_async_send(async); + } + }); + + // Retain this object, otherwise it might get destructed before we are finished rendering the + // still image. + Ref(); + + // Similarly, we're now waiting for the async to be called, so we need to make sure that it + // keeps the loop alive. + uv_ref(reinterpret_cast<uv_handle_t *>(async)); +} + +void NodeMap::renderFinished() { + NanScope(); + + // We're done with this render call, so we're unrefing so that the loop could close. + uv_unref(reinterpret_cast<uv_handle_t *>(async)); + + // There is no render pending anymore, we the GC could now delete this object if it went out + // of scope. + Unref(); + + // Move the callback and image out of the way so that the callback can start a new render call. + auto cb = std::move(callback); + auto img = std::move(image); + assert(cb); + + // These have to be empty to be prepared for the next render call. + assert(!callback); + assert(!image); + + if (error) { + std::string errorMessage; + + try { + std::rethrow_exception(error); + } catch (const std::exception& ex) { + errorMessage = ex.what(); + } + + v8::Local<v8::Value> argv[] = { + NanError(errorMessage.c_str()) + }; + + // This must be empty to be prepared for the next render call. + error = nullptr; + assert(!error); + + cb->Call(1, argv); + } else if (img) { + auto result = NanNew<v8::Object>(); + result->Set(NanNew("width"), NanNew(img->width)); + result->Set(NanNew("height"), NanNew(img->height)); + + v8::Local<v8::Object> pixels = NanNewBufferHandle( + reinterpret_cast<char *>(img->pixels.get()), + size_t(img->width) * size_t(img->height) * sizeof(mbgl::StillImage::Pixel), + + // Retain the StillImage object until the buffer is deleted. + [](char *, void *hint) { + delete reinterpret_cast<const mbgl::StillImage *>(hint); + }, + const_cast<mbgl::StillImage *>(img.get()) + ); + img.release(); + + result->Set(NanNew("pixels"), pixels); + + v8::Local<v8::Value> argv[] = { + NanNull(), + result, + }; + cb->Call(2, argv); + } else { + v8::Local<v8::Value> argv[] = { + NanError("Didn't get an image") + }; + cb->Call(1, argv); + } +} + +NAN_METHOD(NodeMap::Release) { + NanScope(); + + auto nodeMap = node::ObjectWrap::Unwrap<NodeMap>(args.Holder()); + + if (!nodeMap->isValid()) return NanThrowError(releasedMessage()); + + try { + nodeMap->release(); + } catch (const std::exception &ex) { + return NanThrowError(ex.what()); + } + + NanReturnUndefined(); +} + +void NodeMap::release() { + if (!isValid()) throw mbgl::util::Exception(releasedMessage()); + + valid = false; + + uv_close(reinterpret_cast<uv_handle_t *>(async), [] (uv_handle_t *handle) { + delete reinterpret_cast<uv_async_t *>(handle); + }); + + map.reset(nullptr); +} + + +//////////////////////////////////////////////////////////////////////////////////////////////// +// Instance + +NodeMap::NodeMap(v8::Handle<v8::Object> options) : + view(sharedDisplay(), options->Get(NanNew("ratio"))->NumberValue()), + fs(options), + map(std::make_unique<mbgl::Map>(view, fs, mbgl::MapMode::Still)), + async(new uv_async_t) { + + async->data = this; +#if UV_VERSION_MAJOR == 0 && UV_VERSION_MINOR <= 10 + uv_async_init(uv_default_loop(), async, [](uv_async_t *as, int) { +#else + uv_async_init(uv_default_loop(), async, [](uv_async_t *as) { +#endif + reinterpret_cast<NodeMap *>(as->data)->renderFinished(); + }); + + // Make sure the async handle doesn't keep the loop alive. + uv_unref(reinterpret_cast<uv_handle_t *>(async)); +} + +NodeMap::~NodeMap() { + if (valid) release(); +} + +} diff --git a/platform/node/src/node_map.hpp b/platform/node/src/node_map.hpp new file mode 100644 index 0000000000..2c87900d4d --- /dev/null +++ b/platform/node/src/node_map.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include "node_file_source.hpp" + +#include <mbgl/map/map.hpp> +#include <mbgl/platform/default/headless_view.hpp> + +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-parameter" +#pragma GCC diagnostic ignored "-Wshadow" +#include <node.h> +#include <nan.h> +#pragma GCC diagnostic pop + +#include <queue> + +namespace node_mbgl { + +class NodeMap : public node::ObjectWrap { + struct RenderOptions; + class RenderWorker; + + //////////////////////////////////////////////////////////////////////////////////////////////// + // Static Node Methods +public: + static void Init(v8::Handle<v8::Object> target); + static NAN_METHOD(New); + static NAN_METHOD(Load); + static NAN_METHOD(Render); + static NAN_METHOD(Release); + + void startRender(std::unique_ptr<NodeMap::RenderOptions> options); + void renderFinished(); + + void release(); + + inline bool isLoaded() { return loaded; } + inline bool isValid() { return valid; } + + static std::unique_ptr<NodeMap::RenderOptions> ParseOptions(v8::Local<v8::Object> obj); + + static v8::Persistent<v8::FunctionTemplate> constructorTemplate; + + //////////////////////////////////////////////////////////////////////////////////////////////// + // Instance +private: + NodeMap(v8::Handle<v8::Object>); + ~NodeMap(); + +private: + mbgl::HeadlessView view; + NodeFileSource fs; + std::unique_ptr<mbgl::Map> map; + + std::exception_ptr error; + std::unique_ptr<const mbgl::StillImage> image; + std::unique_ptr<NanCallback> callback; + + // Async for delivering the notifications of render completion. + uv_async_t *async; + + bool loaded = false; + bool valid = true; +}; + +} diff --git a/platform/node/src/node_mapbox_gl_native.cpp b/platform/node/src/node_mapbox_gl_native.cpp new file mode 100644 index 0000000000..9ba0abbd05 --- /dev/null +++ b/platform/node/src/node_mapbox_gl_native.cpp @@ -0,0 +1,36 @@ +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-parameter" +#pragma GCC diagnostic ignored "-Wshadow" +#include <node.h> +#include <nan.h> +#pragma GCC diagnostic pop + +#include "node_map.hpp" +#include "node_log.hpp" +#include "node_request.hpp" + +void RegisterModule(v8::Handle<v8::Object> exports) { + NanScope(); + + node_mbgl::NodeMap::Init(exports); + node_mbgl::NodeRequest::Init(exports); + + // Exports Resource constants. + auto ConstantProperty = static_cast<v8::PropertyAttribute>(v8::ReadOnly | v8::DontDelete); + auto resource = NanNew<v8::Object>(); + resource->ForceSet(NanNew("Unknown"), NanNew(mbgl::Resource::Unknown), ConstantProperty); + resource->ForceSet(NanNew("Tile"), NanNew(mbgl::Resource::Tile), ConstantProperty); + resource->ForceSet(NanNew("Glyphs"), NanNew(mbgl::Resource::Glyphs), ConstantProperty); + resource->ForceSet(NanNew("Image"), NanNew(mbgl::Resource::Image), ConstantProperty); + resource->ForceSet(NanNew("JSON"), NanNew(mbgl::Resource::JSON), ConstantProperty); + exports->ForceSet(NanNew("Resource"), resource, ConstantProperty); + + // Make the exported object inerhit from process.EventEmitter + auto process = NanGetCurrentContext()->Global()->Get(NanNew("process"))->ToObject(); + auto EventEmitter = process->Get(NanNew("EventEmitter"))->ToObject(); + exports->SetPrototype(EventEmitter->Get(NanNew("prototype"))); + + mbgl::Log::setObserver(std::make_unique<node_mbgl::NodeLogObserver>(exports)); +} + +NODE_MODULE(mapbox_gl_native, RegisterModule) diff --git a/platform/node/src/node_request.cpp b/platform/node/src/node_request.cpp new file mode 100644 index 0000000000..d2cf43790b --- /dev/null +++ b/platform/node/src/node_request.cpp @@ -0,0 +1,142 @@ +#include "node_request.hpp" +#include "node_file_source.hpp" +#include <mbgl/storage/request.hpp> +#include <mbgl/storage/response.hpp> + +#include <cmath> + +namespace node_mbgl { + +//////////////////////////////////////////////////////////////////////////////////////////////// +// Static Node Methods + +v8::Persistent<v8::FunctionTemplate> NodeRequest::constructorTemplate; + +void NodeRequest::Init(v8::Handle<v8::Object> target) { + NanScope(); + + v8::Local<v8::FunctionTemplate> t = NanNew<v8::FunctionTemplate>(New); + + t->InstanceTemplate()->SetInternalFieldCount(1); + t->SetClassName(NanNew("Request")); + + NODE_SET_PROTOTYPE_METHOD(t, "respond", Respond); + + NanAssignPersistent(constructorTemplate, t); + + target->Set(NanNew("Request"), t->GetFunction()); +} + +NAN_METHOD(NodeRequest::New) { + NanScope(); + + // Extract the pointer from the first argument + if (args.Length() < 2 || !args[0]->IsExternal() || !args[1]->IsExternal()) { + return NanThrowTypeError("Cannot create Request objects explicitly"); + } + + auto source = reinterpret_cast<NodeFileSource*>(args[0].As<v8::External>()->Value()); + auto resource = reinterpret_cast<mbgl::Resource*>(args[1].As<v8::External>()->Value()); + auto req = new NodeRequest(source, *resource); + req->Wrap(args.This()); + + NanReturnValue(args.This()); +} + +v8::Handle<v8::Object> NodeRequest::Create(NodeFileSource* source, const mbgl::Resource& resource) { + NanEscapableScope(); + + v8::Local<v8::Value> argv[] = { NanNew<v8::External>(const_cast<NodeFileSource*>(source)), + NanNew<v8::External>(const_cast<mbgl::Resource*>(&resource)) }; + auto instance = NanNew<v8::FunctionTemplate>(constructorTemplate)->GetFunction()->NewInstance(2, argv); + + instance->ForceSet(NanNew("url"), NanNew(resource.url), v8::ReadOnly); + instance->ForceSet(NanNew("kind"), NanNew<v8::Integer>(int(resource.kind)), v8::ReadOnly); + + return NanEscapeScope(instance); +} + +NAN_METHOD(NodeRequest::Respond) { + auto nodeRequest = ObjectWrap::Unwrap<NodeRequest>(args.Holder()); + + // Request has already been responded to, or was canceled, fail silently. + if (!nodeRequest->resource) NanReturnUndefined(); + + auto source = nodeRequest->source; + auto resource = std::move(nodeRequest->resource); + + if (args.Length() < 1) { + return NanThrowTypeError("First argument must be an error object"); + } else if (args[0]->BooleanValue()) { + auto response = std::make_shared<mbgl::Response>(); + + response->status = mbgl::Response::Error; + + // Store the error string. + const NanUtf8String message { args[0]->ToString() }; + response->message = std::string { *message, size_t(message.length()) }; + + source->notify(*resource, response); + } else if (args.Length() < 2 || !args[1]->IsObject()) { + return NanThrowTypeError("Second argument must be a response object"); + } else { + auto response = std::make_shared<mbgl::Response>(); + auto res = args[1]->ToObject(); + + response->status = mbgl::Response::Successful; + + if (res->Has(NanNew("modified"))) { + const double modified = res->Get(NanNew("modified"))->ToNumber()->Value(); + if (!std::isnan(modified)) { + response->modified = modified / 1000; // JS timestamps are milliseconds + } + } + + if (res->Has(NanNew("expires"))) { + const double expires = res->Get(NanNew("expires"))->ToNumber()->Value(); + if (!std::isnan(expires)) { + response->expires = expires / 1000; // JS timestamps are milliseconds + } + } + + if (res->Has(NanNew("etag"))) { + auto etagHandle = res->Get(NanNew("etag")); + if (etagHandle->BooleanValue()) { + const NanUtf8String etag { etagHandle->ToString() }; + response->etag = std::string { *etag, size_t(etag.length()) }; + } + } + + if (res->Has(NanNew("data"))) { + auto dataHandle = res->Get(NanNew("data")); + if (node::Buffer::HasInstance(dataHandle)) { + response->data = std::string { + node::Buffer::Data(dataHandle), + node::Buffer::Length(dataHandle) + }; + } else { + return NanThrowTypeError("Response data must be a Buffer"); + } + } + + // Send the response object to the NodeFileSource object + source->notify(*resource, response); + } + + NanReturnUndefined(); +} + +//////////////////////////////////////////////////////////////////////////////////////////////// +// Instance + +NodeRequest::NodeRequest(NodeFileSource* source_, const mbgl::Resource& resource_) + : source(source_), + resource(std::make_unique<mbgl::Resource>(resource_)) {} + +NodeRequest::~NodeRequest() {} + +void NodeRequest::cancel() { + resource.reset(); +} + +} diff --git a/platform/node/src/node_request.hpp b/platform/node/src/node_request.hpp new file mode 100644 index 0000000000..a690904ef2 --- /dev/null +++ b/platform/node/src/node_request.hpp @@ -0,0 +1,43 @@ +#pragma once + +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-parameter" +#pragma GCC diagnostic ignored "-Wshadow" +#include <node.h> +#include <nan.h> +#pragma GCC diagnostic pop + +#include <mbgl/storage/resource.hpp> + +#include <memory> + +namespace node_mbgl { + +class NodeFileSource; + +class NodeRequest : public node::ObjectWrap { + //////////////////////////////////////////////////////////////////////////////////////////////// + // Static Node Methods +public: + static void Init(v8::Handle<v8::Object> target); + static NAN_METHOD(New); + static NAN_METHOD(Respond); + + static v8::Handle<v8::Object> Create(NodeFileSource*, const mbgl::Resource&); + + static v8::Persistent<v8::FunctionTemplate> constructorTemplate; + + //////////////////////////////////////////////////////////////////////////////////////////////// + // Instance +public: + NodeRequest(NodeFileSource* source, const mbgl::Resource& resource); + ~NodeRequest(); + + void cancel(); + +private: + NodeFileSource* source; + std::unique_ptr<mbgl::Resource> resource; +}; + +} diff --git a/platform/node/src/util/async_queue.hpp b/platform/node/src/util/async_queue.hpp new file mode 100644 index 0000000000..81319da8c7 --- /dev/null +++ b/platform/node/src/util/async_queue.hpp @@ -0,0 +1,90 @@ +#pragma once + +#include <uv.h> + +#include <thread> +#include <mutex> +#include <functional> +#include <queue> +#include <string> + + +#if UV_VERSION_MAJOR == 0 && UV_VERSION_MINOR <= 10 +#define UV_ASYNC_PARAMS(handle) uv_async_t *handle, int +#else +#define UV_ASYNC_PARAMS(handle) uv_async_t *handle +#endif + +namespace node_mbgl { +namespace util { + +template <typename T> +class AsyncQueue { +public: + AsyncQueue(uv_loop_t *loop, std::function<void(T &)> fn) : + callback(fn) { + async.data = this; + uv_async_init(loop, &async, [](UV_ASYNC_PARAMS(handle)) { + auto q = reinterpret_cast<AsyncQueue *>(handle->data); + q->process(); + }); + } + + void send(T &&data) { + { + std::lock_guard<std::mutex> lock(mutex); + queue.push(std::make_unique<T>(std::move(data))); + } + uv_async_send(&async); + } + + void send(std::unique_ptr<T> data) { + { + std::lock_guard<std::mutex> lock(mutex); + queue.push(std::move(data)); + } + uv_async_send(&async); + } + + void stop() { + uv_close((uv_handle_t *)&async, [](uv_handle_t *handle) { + delete reinterpret_cast<AsyncQueue *>(handle->data); + }); + } + + void ref() { + uv_ref((uv_handle_t *)&async); + } + + void unref() { + uv_unref((uv_handle_t *)&async); + } + +private: + ~AsyncQueue() { + } + + void process() { + std::unique_ptr<T> item; + while (true) { + mutex.lock(); + if (queue.empty()) { + mutex.unlock(); + break; + } + item = std::move(queue.front()); + queue.pop(); + mutex.unlock(); + callback(*item); + } + } + +private: + std::mutex mutex; + uv_async_t async; + std::queue<std::unique_ptr<T>> queue; + std::function<void(T &)> callback; +}; + +} +} diff --git a/platform/node/test/compare.js b/platform/node/test/compare.js new file mode 100644 index 0000000000..3e5221de55 --- /dev/null +++ b/platform/node/test/compare.js @@ -0,0 +1,27 @@ +var spawn = require('child_process').spawn; + +module.exports = function compare(actual, expected, diff, t, callback) { + var compare = spawn('compare', ['-metric', 'MAE', actual, expected, diff]); + var error = ''; + + compare.stderr.on('data', function(data) { + error += data.toString(); + }); + + compare.on('error', function(err) { + t.error(err); + }); + + compare.on('exit', function(code) { + // The compare program returns 2 on error otherwise 0 if the images are similar or 1 if they are dissimilar. + if (code === 2) { + callback(error.trim(), Infinity); + } else { + var match = error.match(/^\d+(?:\.\d+)?\s+\(([^\)]+)\)\s*$/); + var difference = match ? parseFloat(match[1]) : Infinity; + callback(match ? '' : error, difference); + } + }); + + compare.stdin.end(); +}; diff --git a/platform/node/test/expected/gzip/success.png b/platform/node/test/expected/gzip/success.png Binary files differnew file mode 100644 index 0000000000..de41e0fe2b --- /dev/null +++ b/platform/node/test/expected/gzip/success.png diff --git a/platform/node/test/expected/gzip/unhandled.png b/platform/node/test/expected/gzip/unhandled.png Binary files differnew file mode 100644 index 0000000000..ddb90d8b8f --- /dev/null +++ b/platform/node/test/expected/gzip/unhandled.png diff --git a/platform/node/test/expected/map/image.png b/platform/node/test/expected/map/image.png Binary files differnew file mode 100644 index 0000000000..de41e0fe2b --- /dev/null +++ b/platform/node/test/expected/map/image.png diff --git a/platform/node/test/fixtures/style.json b/platform/node/test/fixtures/style.json new file mode 100644 index 0000000000..47a1990eb7 --- /dev/null +++ b/platform/node/test/fixtures/style.json @@ -0,0 +1,26 @@ +{ + "version": 7, + "name": "Empty", + "sources": { + "mapbox": { + "type": "vector", + "url": "./fixtures/tiles.tilejson", + "maxzoom": 15 + } + }, + "layers": [{ + "id": "background", + "type": "background", + "paint": { + "background-color": "white" + } + }, { + "id": "water", + "type": "fill", + "source": "mapbox", + "source-layer": "water", + "paint": { + "fill-color": "blue" + } + }] +} diff --git a/platform/node/test/fixtures/tiles.tilejson b/platform/node/test/fixtures/tiles.tilejson new file mode 100644 index 0000000000..320c17500e --- /dev/null +++ b/platform/node/test/fixtures/tiles.tilejson @@ -0,0 +1,13 @@ +{ + "bounds": [ -180, -85.0511, 180, 85.0511 ], + "center": [ 0, 0, 0 ], + "format": "pbf", + "id": "mapbox.mapbox-streets-v6-dev", + "maskLevel": 8, + "maxzoom": 15, + "minzoom": 0, + "name": "Mapbox Streets V6", + "scheme": "xyz", + "tilejson": "2.0.0", + "tiles": [ "./fixtures/tiles/{z}-{x}-{y}.vector.pbf" ] +} diff --git a/platform/node/test/fixtures/tiles/0-0-0.vector.pbf b/platform/node/test/fixtures/tiles/0-0-0.vector.pbf Binary files differnew file mode 100644 index 0000000000..87628e06cf --- /dev/null +++ b/platform/node/test/fixtures/tiles/0-0-0.vector.pbf diff --git a/platform/node/test/js/consecutive.test.js b/platform/node/test/js/consecutive.test.js new file mode 100644 index 0000000000..f493e2a31c --- /dev/null +++ b/platform/node/test/js/consecutive.test.js @@ -0,0 +1,83 @@ +'use strict'; + +/* jshint node:true */ + +var test = require('tape'); +var mbgl = require('../..'); +var fs = require('fs'); +var path = require('path'); + +var suitePath = path.dirname(require.resolve('mapbox-gl-test-suite/package.json')); + + +function renderTest(style, info, dir, key) { + return function (t) { + var completed = 0; + var remaining = 10; + var start = +new Date; + + var options = {}; + options.request = function(req) { + fs.readFile(path.join(suitePath, decodeURIComponent(req.url)), function(err, data) { + req.respond(err, { data: data }); + t.error(err); + }); + }; + options.cancel = function() {}; + options.ratio = 1.0; + + var map = new mbgl.Map(options); + map.load(style); + + function render() { + map.render(info[key], function(err, image) { + t.error(err); + + t.ok(true, 'render @ ' + ((+new Date) - start) + 'ms'); + if (++completed === remaining) { + map.release(); + t.end(); + } else { + render(); + } + }); + } + + render(); + }; +} + +function rewriteLocalSchema(uri) { + return uri.replace(/^local:\/\//, ''); +} + +test('Consecutive', function(t) { + var dir = 'line-join'; + var k = 'round'; + + var style = require(path.join(suitePath, 'tests', dir, 'style.json')), + info = require(path.join(suitePath, 'tests', dir, 'info.json')); + + for (var k in style.sources) { + var source = style.sources[k]; + + if (source.tiles) { + source.tiles = source.tiles.map(rewriteLocalSchema); + } + + if (source.url) { + source.url = rewriteLocalSchema(source.url); + } + } + + if (style.sprite) style.sprite = rewriteLocalSchema(style.sprite); + if (style.glyphs) style.glyphs = rewriteLocalSchema(style.glyphs); + + style = JSON.stringify(style); + + for (k in info) { + t.test(dir + ' ' + k, renderTest(style, info, dir, k)); + } + + t.end(); +}); diff --git a/platform/node/test/js/gzip.test.js b/platform/node/test/js/gzip.test.js new file mode 100644 index 0000000000..9e899604ae --- /dev/null +++ b/platform/node/test/js/gzip.test.js @@ -0,0 +1,126 @@ +'use strict'; + +/* jshint node: true */ + +var test = require('tape').test; +var mbgl = require('../..'); +var fs = require('fs'); +var path = require('path'); +var mkdirp = require('mkdirp'); +var http = require('http'); +var request = require('request'); +var st = require('st'); +var style = require('../../test/fixtures/style.json'); +var PNG = require('pngjs').PNG; +var compare = require('../compare.js'); + +var dirPath = path.join(path.dirname(require.resolve('../../package.json')), 'test'); +var server = http.createServer(st({ path: dirPath })); +server.listen(0); + +function filePath(name) { + return ['expected', 'actual', 'diff'].reduce(function(prev, key) { + var dir = path.join('test', key, 'gzip'); + mkdirp.sync(dir); + prev[key] = path.join(dir, name); + return prev; + }, {}); +} + +function setup(options, callback) { + callback(new mbgl.Map(options)); +} + +function getOptions(gzip, t) { + return { + request: function(req) { + var parts = req.url.split('.'); + var filetype = parts[parts.length - 1]; + + request({ + url: 'http://localhost:' + server.address().port + path.join('/', req.url), + encoding: null, + gzip: filetype === 'pbf' ? gzip : true, + headers: { + 'Accept-Encoding': 'gzip' + } + }, function (err, res, body) { + t.error(err); + var response = {}; + response.data = res.body; + req.respond(null, response); + }); + }, + ratio: 1.0 + }; +} + +test('gzip', function(t) { + t.test('success', function(t) { + mbgl.on('message', function(msg) { + if (msg.severity == 'ERROR') t.error(msg); + }); + + setup(getOptions(true, t), function(map) { + map.load(style); + map.render({}, function(err, data) { + mbgl.removeAllListeners('message'); + map.release(); + + t.error(err); + + var filename = filePath('success.png'); + + var png = new PNG({ + width: data.width, + height: data.height + }); + + png.data = data.pixels; + + if (process.env.UPDATE) { + png.pack() + .pipe(fs.createWriteStream(filename.expected)) + .on('finish', t.end); + } else { + png.pack() + .pipe(fs.createWriteStream(filename.actual)) + .on('finish', function() { + compare(filename.actual, filename.expected, filename.diff, t, function(err, diff) { + t.error(err); + t.ok(diff <= 0.01, 'actual matches expected'); + t.end(); + }); + }); + } + }); + }); + }); + + t.test('unhandled', function(t) { + mbgl.once('message', function(msg) { + if (msg.severity == 'ERROR') { + t.ok(msg, 'emits error'); + t.equal(msg.class, 'Style'); + t.equal(msg.severity, 'ERROR'); + t.ok(msg.text.match(/pbf unknown field type exception/), 'error text matches'); + } + }); + + setup(getOptions(false, t), function(map) { + map.load(style); + map.render({}, function(err, data) { + map.release(); + + t.ok(err, 'returns error'); + t.ok(err.message.match(/Failed to parse/), 'error text matches'); + + t.end(); + }); + }); + }); + + t.test('teardown', function(t) { + server.close(t.end); + }); +}); diff --git a/platform/node/test/js/map.test.js b/platform/node/test/js/map.test.js new file mode 100644 index 0000000000..4586ceeed4 --- /dev/null +++ b/platform/node/test/js/map.test.js @@ -0,0 +1,293 @@ +'use strict'; + +/* jshint node: true */ + +var test = require('tape'); +var mbgl = require('../..'); +var fs = require('fs'); +var path = require('path'); +var mkdirp = require('mkdirp'); +var style = require('../fixtures/style.json'); +var PNG = require('pngjs').PNG; +var compare = require('../compare.js'); + +function filePath(name) { + return ['expected', 'actual', 'diff'].reduce(function(prev, key) { + var dir = path.join('test', key, 'map'); + mkdirp.sync(dir); + prev[key] = path.join(dir, name); + return prev; + }, {}); +} + +function setup(options, callback) { + callback(new mbgl.Map(options)); +} + +test('Map', function(t) { + t.test('constructor', function(t) { + t.test('must be called with new', function(t) { + t.throws(function() { + mbgl.Map(); + }, /Use the new operator to create new Map objects/); + + t.end(); + }); + + t.test('should require an options object as first parameter', function(t) { + t.throws(function() { + new mbgl.Map(); + }, /Requires an options object as first argument/); + + t.throws(function() { + new mbgl.Map('options'); + }, /Requires an options object as first argument/); + + t.end(); + }); + + t.test('should require then options object to have request and cancel methods', function(t) { + var options = {}; + + t.throws(function() { + new mbgl.Map(options); + }, /Options object must have a 'request' method/); + + options.request = 'test'; + t.throws(function() { + new mbgl.Map(options); + }, /Options object must have a 'request' method/); + + options.request = function() {}; + options.cancel = 'test'; + t.throws(function() { + new mbgl.Map(options); + }, /Options object 'cancel' property must be a function/); + + options.cancel = function() {}; + t.throws(function() { + new mbgl.Map(options); + }, /Options object must have a numerical 'ratio' property/); + + options.ratio = 'test'; + t.throws(function() { + new mbgl.Map(options); + }, /Options object must have a numerical 'ratio' property/); + + options.ratio = 1.0; + t.doesNotThrow(function() { + new mbgl.Map(options); + }); + + t.end(); + }); + + t.end(); + }); + + t.test('load styles', function(t) { + var options = {}; + options.request = function() {}; + options.cancel = function() {}; + options.ratio = 1.0; + + t.test('requires a string or object as the first parameter', function(t) { + t.test('requires a map style as first argument', function(t) { + setup(options, function(map) { + t.throws(function() { + map.load(); + }, /Requires a map style as first argument/); + + map.release(); + + t.end(); + }); + }); + + t.test('expect either an object or array at root', { timeout: 1000 }, function(t) { + setup(options, function(map) { + mbgl.once('message', function(msg) { + t.equal(msg.severity, 'ERROR'); + t.equal(msg.class, 'ParseStyle'); + t.ok(msg.text.match(/Expect either an object or array at root/)); + + map.release(); + + t.end(); + }); + + map.load('invalid'); + }); + }); + + t.end(); + }); + + t.test('accepts an empty stylesheet string', function(t) { + setup(options, function(map) { + t.doesNotThrow(function() { + map.load('{}'); + }); + + map.release(); + + t.end(); + }); + }); + + t.test('accepts a JSON stylesheet', { timeout: 1000 }, function(t) { + setup(options, function(map) { + t.doesNotThrow(function() { + map.load(style); + }); + + map.release(); + + t.end(); + }); + }); + + t.test('accepts a stringified stylesheet', { timeout: 1000 }, function(t) { + setup(options, function(map) { + t.doesNotThrow(function() { + map.load(JSON.stringify(style)); + }); + + map.release(); + + t.end(); + }); + }); + + t.end(); + }); + + t.test('render argument requirements', function(t) { + var options = {}; + options.request = function(req) { + fs.readFile(path.join('test', req.url), function(err, data) { + req.respond(err, { data: data }); + }); + }; + options.cancel = function() {}; + options.ratio = 1.0; + + t.test('requires an object as the first parameter', function(t) { + setup(options, function(map) { + t.throws(function() { + map.render(); + }, /First argument must be an options object/); + + t.throws(function() { + map.render('invalid'); + }, /First argument must be an options object/); + + map.release(); + + t.end(); + }); + }); + + t.test('requires a callback as the second parameter', function(t) { + setup(options, function(map) { + t.throws(function() { + map.render({}); + }, /Second argument must be a callback function/); + + t.throws(function() { + map.render({}, 'invalid'); + }, /Second argument must be a callback function/); + + map.release(); + + t.end(); + }); + }); + + t.test('requires a style to be set', function(t) { + setup(options, function(map) { + t.throws(function() { + map.render({}, function() {}); + }, /Style is not loaded/); + + map.release(); + + t.end(); + }); + }); + + t.test('returns an error', function(t) { + mbgl.on('message', function(msg) { + t.ok(msg, 'emits error'); + t.equal(msg.class, 'Style'); + t.equal(msg.severity, 'ERROR'); + t.ok(msg.text.match(/Failed to load/), 'error text matches'); + }); + + setup(options, function(map) { + map.load(style); + map.render({ zoom: 1 }, function(err, data) { + mbgl.removeAllListeners('message'); + map.release(); + + t.ok(err, 'returns error'); + t.ok(err.message.match(/Failed to load/), 'error text matches'); + + t.end(); + }); + }); + }); + + t.test('double release', function(t) { + setup(options, function(map) { + map.release(); + + t.throws(function() { + map.release(); + }, /Map resources have already been released/); + + t.end(); + }); + }); + + t.test('returns an image', function(t) { + setup(options, function(map) { + map.load(style); + map.render({}, function(err, data) { + t.error(err); + + map.release(); + + var filename = filePath('image.png'); + + var png = new PNG({ + width: data.width, + height: data.height + }); + + png.data = data.pixels; + + if (process.env.UPDATE) { + png.pack() + .pipe(fs.createWriteStream(filename.expected)) + .on('finish', t.end); + } else { + png.pack() + .pipe(fs.createWriteStream(filename.actual)) + .on('finish', function() { + compare(filename.actual, filename.expected, filename.diff, t, function(err, diff) { + t.error(err); + t.ok(diff <= 0.01, 'actual matches expected'); + t.end(); + }); + }); + } + }); + }); + }); + + t.end(); + }); + + t.end(); +}); diff --git a/platform/node/test/render.test.js b/platform/node/test/render.test.js new file mode 100644 index 0000000000..b55841fef0 --- /dev/null +++ b/platform/node/test/render.test.js @@ -0,0 +1,160 @@ +'use strict'; + +/* jshint node:true */ + +var test = require('tape'); +var mbgl = require('..'); +var fs = require('fs'); +var path = require('path'); +var mkdirp = require('mkdirp'); +var PNG = require('pngjs').PNG; +var compare = require('./compare.js'); +var suitePath = path.dirname(require.resolve('mapbox-gl-test-suite/package.json')); + +function template(name) { + return fs.readFileSync(require.resolve('mapbox-gl-test-suite/templates/' + name + '.html.tmpl')).toString(); +} + +var results = ''; +var resultTemplate = template('result'); + +function format(tmpl, kwargs) { + return tmpl.replace(/\{\{|\}\}|\{([^}]+)\}/g, function(match, key) { + if (match === '{{') return '{'; + if (match === '}}') return '}'; + return kwargs[key]; + }); +} + +function renderTest(style, info, base, key) { + var dir = path.join(suitePath, 'tests', base, key); + mkdirp.sync(dir); + + return function(t) { + var watchdog = setTimeout(function() { + t.fail('timed out after 20 seconds'); + }, 20000); + + t.once('end', function() { + clearTimeout(watchdog); + }); + + var options = {}; + options.request = function(req) { + fs.readFile(path.join(suitePath, decodeURIComponent(req.url)), function(err, data) { + req.respond(err, { data: data }); + t.error(err); + }); + }; + options.cancel = function() {}; + options.ratio = 1.0; + + var map = new mbgl.Map(options); + map.load(style); + + map.render(info[key], function(err, data) { + t.error(err); + + map.release(); + + var expected = path.join(dir, 'expected.png'); + var actual = path.join(dir, 'actual.png'); + var diff = path.join(dir, 'diff.png'); + + var png = new PNG({ + width: data.width, + height: data.height + }); + + png.data = data.pixels; + + if (process.env.UPDATE) { + png.pack() + .pipe(fs.createWriteStream(expected)) + .on('finish', t.end); + } else { + png.pack() + .pipe(fs.createWriteStream(actual)) + .on('finish', function() { + compare(actual, expected, diff, t, function(err, diff) { + t.error(err); + + var allowed = ('diff' in info) ? info.diff : 0.001; + var color = diff <= allowed ? 'green' : 'red'; + + results += format(resultTemplate, { + name: base, + key: key, + color: color, + error: err ? '<p>' + err + '</p>' : '', + difference: diff, + zoom: info.zoom || 0, + center: info.center || [0, 0], + bearing: info.bearing || 0, + width: info.width || 512, + height: info.height || 512 + }); + + t.ok(diff <= allowed, 'actual matches expected'); + t.end(); + }); + }); + } + }); + }; +} + +function rewriteLocalSchema(url) { + var regex = /^local:\/\//; + if (url instanceof Array) { + return url.map(function(str) { + return str.replace(regex, ''); + }); + } else if (typeof url === "string") { + return url.replace(regex, ''); + } +} + +var tests; + +if (process.argv[1] === __filename) { + tests = process.argv.slice(2); +} + +test('Render', function(t) { + fs.readdirSync(path.join(suitePath, 'tests')).forEach(function(dir) { + if (dir === 'index.html' || dir[0] === '.') return; + if (tests && tests.length && tests.indexOf(dir) < 0) return; + + var style = require(path.join(suitePath, 'tests', dir, 'style.json')), + info = require(path.join(suitePath, 'tests', dir, 'info.json')); + + for (var k in style.sources) { + var source = style.sources[k]; + + if (source.tiles) { + source.tiles = source.tiles.map(rewriteLocalSchema); + } + + if (source.url) { + source.url = rewriteLocalSchema(source.url); + } + } + + if (style.sprite) style.sprite = rewriteLocalSchema(style.sprite); + if (style.glyphs) style.glyphs = rewriteLocalSchema(style.glyphs); + + style = JSON.stringify(style); + + for (k in info) { + (info[k].native === false ? t.skip : t.test)(dir + ' ' + k, renderTest(style, info, dir, k)); + } + }); + + t.test('results', function(t) { + var p = path.join(suitePath, 'tests', 'index.html'); + fs.writeFileSync(p, format(template('results'), {results: results})); + console.warn('Results at: ' + p); + t.end(); + }); +}); |