├── assets ├── token-scope.png └── tileset-preview.png ├── index.js ├── src ├── convert.js ├── sync.js ├── utils.js ├── token.js ├── cli.js ├── config.js ├── estimate.js ├── services.js └── pricebook.js ├── LICENSE.md ├── package.json ├── .eslintrc.js ├── .gitignore └── README.md /assets/token-scope.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/mts-data-sync/HEAD/assets/token-scope.png -------------------------------------------------------------------------------- /assets/tileset-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/mts-data-sync/HEAD/assets/tileset-preview.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require("dotenv").config({ path: __dirname + "/.env" }); 4 | // eslint-disable-next-line no-global-assign 5 | require = require("esm")(module); 6 | require("./src/cli").cli(process.argv); 7 | -------------------------------------------------------------------------------- /src/convert.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import geojsonStream from "geojson-stream"; 3 | 4 | async function convertData(geojson) { 5 | try { 6 | const ldgeojson = fs.createWriteStream(geojson + "l"); 7 | console.log("Converting GeoJSON file..."); 8 | return new Promise((resolve, reject) => { 9 | fs.createReadStream(geojson) 10 | .pipe(geojsonStream.parse(row => { 11 | if (row.geometry.coordinates === null) { 12 | return null; 13 | } 14 | return (JSON.stringify(row) + "\r\n"); 15 | })) 16 | .pipe(ldgeojson) 17 | .on("finish", () => { 18 | console.log("Finished writing file..."); 19 | resolve(true); 20 | }) 21 | .on("error", reject); 22 | }); 23 | } catch (err) { 24 | console.log(err); 25 | } 26 | } 27 | 28 | export default async function convert(geojson) { 29 | const converted = await convertData(geojson); 30 | if (converted) { 31 | console.log("Line-delimited GeoJSON ready for upload."); 32 | return true; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | BSD 2-Clause License 4 | 5 | Copyright (c) 2019, Mapbox All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 8 | 9 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 10 | 11 | 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. 12 | 13 | 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. 14 | -------------------------------------------------------------------------------- /src/sync.js: -------------------------------------------------------------------------------- 1 | import sleep from "await-sleep"; 2 | import { readConfig, readRecipe } from "./utils"; 3 | import { 4 | initService, 5 | deleteTilesetSource, 6 | createTilesetSource, 7 | validateRecipe, 8 | createTileset, 9 | updateRecipe, 10 | publishTileset, 11 | checkStatus, 12 | tilesetExists 13 | } from "./services"; 14 | 15 | async function runServices(cnf, recipe) { 16 | 17 | try { 18 | await initService(); 19 | await sleep(1500); 20 | await deleteTilesetSource(cnf.tilesetSourceId); 21 | await sleep(1500); 22 | await createTilesetSource(cnf.tilesetSourceId, cnf.tilesetSourcePath); 23 | await sleep(1500); 24 | await validateRecipe(recipe); 25 | const tilesetAlreadyExists = await tilesetExists(cnf.tilesetId); 26 | // await sleep(1500); 27 | if (!tilesetAlreadyExists) { 28 | await createTileset(cnf.tilesetId, cnf.tilesetName, recipe); 29 | } else { 30 | await updateRecipe(cnf.tilesetId, recipe); 31 | } 32 | await sleep(1500); 33 | await publishTileset(cnf.tilesetId); 34 | await sleep(1500); 35 | await checkStatus(cnf.tilesetId); 36 | } catch (err) { 37 | console.log(err); 38 | } 39 | } 40 | 41 | export default function sync() { 42 | const pwd = process.cwd(); 43 | 44 | const cnf = readConfig(pwd); 45 | const recipe = readRecipe(pwd); 46 | 47 | if (cnf && recipe) { 48 | runServices(cnf, recipe); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | const checkFileExistsSync = function(filepath){ 5 | let flag = true; 6 | try { 7 | fs.accessSync(filepath, fs.constants.F_OK); 8 | } catch (e){ 9 | flag = false; 10 | } 11 | return flag; 12 | }; 13 | 14 | const readConfig = function (pwd) { 15 | const cnfFile = path.join(pwd, "mts-config.json"); 16 | if (checkFileExistsSync(cnfFile)) { 17 | try { 18 | const cnf = JSON.parse(fs.readFileSync(cnfFile)); 19 | return cnf; 20 | } catch (err) { 21 | console.log("Invalid mts-config.json: parsing failed."); 22 | console.log("Verify that mts-config.json is valid JSON."); 23 | } 24 | } else { 25 | console.log("Missing mts-config.json in this directory."); 26 | console.log("Run mtsds --config to generate."); 27 | } 28 | }; 29 | 30 | const readRecipe = function (pwd) { 31 | const recipeFile = path.join(pwd, "mts-recipe.json"); 32 | if (checkFileExistsSync(recipeFile)) { 33 | try { 34 | const recipe = JSON.parse(fs.readFileSync(recipeFile)); 35 | return recipe; 36 | } catch (err) { 37 | console.log("Invalid mts-recipe.json: parsing failed."); 38 | console.log("Verify that mts-recipe.json is valid JSON."); 39 | } 40 | } else { 41 | console.log("Missing mts-recipe.json in this directory."); 42 | console.log("Run mtsds --config to generate."); 43 | } 44 | }; 45 | 46 | export { 47 | readConfig, 48 | readRecipe 49 | }; 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mts-data-sync", 3 | "version": "0.1.1", 4 | "description": "Sync local geojson data to a Mapbox tileset using the MTS API", 5 | "main": "index.js", 6 | "bin": { 7 | "mtsds": "./index.js" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/mapbox/mts-data-sync.git" 15 | }, 16 | "author": "Mapbox", 17 | "license": "BSD-2-Clause", 18 | "bugs": { 19 | "url": "https://github.com/mapbox/mts-data-sync/issues" 20 | }, 21 | "homepage": "https://github.com/mapbox/mts-data-sync#readme", 22 | "devDependencies": { 23 | "eslint": "^7.2.0", 24 | "babel-eslint": "10.1.0", 25 | "eslint-plugin-html": "^6.0.2", 26 | "eslint-plugin-xss": "^0.1.10", 27 | "eslint-plugin-promise": "^4.2.1", 28 | "eslint-plugin-import": "^2.22.0", 29 | "@mapbox/eslint-config-mapbox": "^2.0.1" 30 | }, 31 | "dependencies": { 32 | "@mapbox/geojsonhint": "^3.0.0", 33 | "@mapbox/mapbox-sdk": "^0.10.0", 34 | "@mapbox/tile-cover": "^3.0.2", 35 | "@mapbox/tilebelt": "^1.0.1", 36 | "@turf/area": "^6.0.1", 37 | "arg": "^4.1.3", 38 | "await-sleep": "0.0.1", 39 | "d3-format": "^2.0.0", 40 | "dotenv": "^8.2.0", 41 | "esm": "^3.2.25", 42 | "geojson-stream": "^0.1.0", 43 | "inquirer": "^7.2.0", 44 | "ndjson": "^1.5.0", 45 | "node-fetch": "^2.6.0", 46 | "open": "^7.0.4" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/token.js: -------------------------------------------------------------------------------- 1 | import inquirer from "inquirer"; 2 | import path from "path"; 3 | import fs from "fs"; 4 | import os from "os"; 5 | 6 | const eol = os.EOL; 7 | 8 | async function promptForToken() { 9 | const questions = []; 10 | questions.push({ 11 | type: "input", 12 | name: "username", 13 | message: "Please enter your Mapbox username" 14 | }); 15 | questions.push({ 16 | type: "input", 17 | name: "sk", 18 | message: "Please enter your secret access token" 19 | }); 20 | 21 | const answers = await inquirer.prompt(questions); 22 | 23 | return { 24 | username: answers.username || "error", 25 | token: answers.sk || "error" 26 | }; 27 | } 28 | 29 | async function writeEnv(userData) { 30 | const confirmation = [{ 31 | type: "confirm", 32 | name: "confirm", 33 | message: "Does this information look correct?" 34 | }]; 35 | inquirer.prompt(confirmation).then(userInput => { 36 | if (userInput.confirm) { 37 | const env = `MTS_TOKEN=${userData.token}${eol}MTS_USERNAME=${userData.username}`; 38 | const envDir = path.resolve(__dirname, ".."); 39 | const envFile = path.join(envDir, ".env"); 40 | 41 | fs.writeFile(envFile, env, err => { 42 | if (err) { 43 | console.log(err); 44 | } else { 45 | console.log("Username and token saved successfully."); 46 | } 47 | }); 48 | } 49 | }); 50 | } 51 | 52 | export default async function setToken() { 53 | const userData = await promptForToken(); 54 | console.log(userData); 55 | await writeEnv(userData); 56 | } 57 | -------------------------------------------------------------------------------- /src/cli.js: -------------------------------------------------------------------------------- 1 | import arg from "arg"; 2 | import config from "./config"; 3 | import sync from "./sync"; 4 | import estimate from "./estimate"; 5 | import convert from "./convert"; 6 | import setToken from "./token"; 7 | import os from "os"; 8 | 9 | const eol = os.EOL; 10 | 11 | function argsToOptions(rawArgs) { 12 | const args = arg( 13 | { 14 | "--config": String, 15 | "--sync": Boolean, 16 | "--estimate": Boolean, 17 | "--convert": String, 18 | "--token": Boolean, 19 | "-c": "--config", 20 | "-s": "--sync", 21 | "-e": "--estimate" 22 | }, 23 | { 24 | argv: rawArgs.slice(2), 25 | permissive: true 26 | } 27 | ); 28 | return { 29 | config: args["--config"] || false, 30 | sync: args["--sync"] || false, 31 | estimate: args["--estimate"] || false, 32 | convert: args["--convert"] || false, 33 | token: args["--token"] || false 34 | }; 35 | } 36 | 37 | export async function cli(args) { 38 | if (!process.env.MTS_TOKEN || !process.env.MTS_USERNAME) { 39 | console.log("No access token or username in .env. Running token installation script."); 40 | setToken(); 41 | } else { 42 | 43 | let options = {}; 44 | try { 45 | options = argsToOptions(args); 46 | } catch (err) { 47 | console.log(err.message); 48 | } 49 | 50 | if (options.config) { 51 | config(options); 52 | } else if (options.sync) { 53 | sync(options); 54 | } else if (options.estimate) { 55 | estimate(); 56 | } else if (options.convert) { 57 | convert(options.convert); 58 | } else if (options.token) { 59 | setToken(); 60 | } else { 61 | console.log(`Run mtsds with valid options: ${eol}--config filename.geojson, ${eol}--convert filename.geojson, ${eol}--sync, ${eol}--token, ${eol}or --estimate`); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | "@mapbox/eslint-config-mapbox/import", 4 | "@mapbox/eslint-config-mapbox/promise" 5 | ], 6 | env: { 7 | browser: true, 8 | es6: true, 9 | node: true 10 | }, 11 | plugins: ["html", "xss"], 12 | parser: "babel-eslint", 13 | parserOptions: { 14 | ecmaVersion: 6, 15 | sourceType: "module" 16 | }, 17 | rules: { 18 | "arrow-parens": ["error", "as-needed"], 19 | "no-var": "error", 20 | "prefer-const": "error", 21 | "array-bracket-spacing": ["error", "never"], 22 | "brace-style": ["error", "1tbs"], 23 | "comma-spacing": [ 24 | "error", 25 | { 26 | before: false, 27 | after: true 28 | } 29 | ], 30 | "computed-property-spacing": ["error", "never"], 31 | curly: ["error", "multi-line"], 32 | "eol-last": "error", 33 | eqeqeq: ["error", "smart"], 34 | indent: [ 35 | "error", 36 | 2, 37 | { 38 | SwitchCase: 1 39 | } 40 | ], 41 | "no-console": "off", 42 | "no-confusing-arrow": [ 43 | "error", 44 | { 45 | allowParens: false 46 | } 47 | ], 48 | "no-extend-native": "error", 49 | "no-mixed-spaces-and-tabs": "error", 50 | "no-spaced-func": "error", 51 | "no-trailing-spaces": "error", 52 | "no-unused-vars": "error", 53 | "no-use-before-define": ["error", "nofunc"], 54 | "object-curly-spacing": ["error", "always"], 55 | quotes: [ 56 | "error", 57 | "double", 58 | { avoidEscape: true, allowTemplateLiterals: true } 59 | ], 60 | semi: ["error", "always"], 61 | "space-infix-ops": "error", 62 | "spaced-comment": ["error", "always"], 63 | "xss/no-mixed-html": "error", 64 | "xss/no-location-href-assign": "error", 65 | "keyword-spacing": [ 66 | "error", 67 | { 68 | before: true, 69 | after: true 70 | } 71 | ], 72 | strict: "error" 73 | } 74 | }; 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # MacOS 107 | .DS_Store 108 | 109 | # Config files 110 | *config.json 111 | *recipe.json -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | import inquirer from "inquirer"; 2 | import fs from "fs"; 3 | import handleData from "./convert"; 4 | 5 | async function promptForConfig(options) { 6 | const questions = []; 7 | if (options.config) { 8 | questions.push({ 9 | type: "input", 10 | name: "tilesetId", 11 | message: "Please enter an ID for your tileset", 12 | validate: value => { 13 | const reg = /^[a-zA-Z0-9-_]+$/g; 14 | if (!reg.test(value) || value.length > 32) { 15 | return ("Tileset IDs should only contain up to 32 alphanumeric characters, dashes, or underscore."); 16 | } else { 17 | return true; 18 | } 19 | } 20 | }); 21 | questions.push({ 22 | type: "input", 23 | name: "tilesetName", 24 | message: "Please enter a name for your tileset", 25 | validate: value => { 26 | if (value.length > 64) { 27 | return ("Tileset names are limited to 64 characters."); 28 | } else { 29 | return true; 30 | } 31 | } 32 | }); 33 | } 34 | 35 | const answers = await inquirer.prompt(questions); 36 | const tilesetSourceId = answers.tilesetId.slice(0, 27) + "-src"; 37 | const filePath = options.config + "l"; 38 | return { 39 | username: process.env.MTS_USERNAME || "error", 40 | tilesetSourceId: tilesetSourceId || "error", 41 | tilesetSourcePath: filePath || "error", 42 | tilesetId: answers.tilesetId || "error", 43 | tilesetName: answers.tilesetName || "error" 44 | }; 45 | } 46 | 47 | async function writeConfig(config) { 48 | const confirmation = [{ 49 | type: "confirm", 50 | name: "confirm", 51 | message: "Does this configuration look okay?" 52 | }]; 53 | inquirer.prompt(confirmation).then(userInput => { 54 | if (userInput.confirm) { 55 | fs.writeFile("./mts-config.json", JSON.stringify(config, null, " "), err => { 56 | if (err) { 57 | console.log(err); 58 | } else { 59 | console.log("Configuration written successfully."); 60 | } 61 | }); 62 | const recipe = { 63 | "version": 1, 64 | "layers": { 65 | [config.tilesetId]: { 66 | "source": `mapbox://tileset-source/${config.username}/${config.tilesetSourceId}`, 67 | "minzoom": 0, 68 | "maxzoom": 5 69 | } 70 | } 71 | }; 72 | fs.writeFile("./mts-recipe.json", JSON.stringify(recipe, null, " "), err => { 73 | if (err) { 74 | console.log(err); 75 | } else { 76 | console.log("Recipe written successfully."); 77 | } 78 | }); 79 | } 80 | }); 81 | } 82 | 83 | export default async function config(options) { 84 | if (!fs.existsSync(options.config)) { 85 | console.log("Source data not found."); 86 | return; 87 | } 88 | console.log("Preparing source data..."); 89 | const convertData = await handleData(options.config); 90 | if (convertData) { 91 | const config = await promptForConfig(options); 92 | console.log(config); 93 | await writeConfig(config); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/estimate.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { readConfig, readRecipe } from "./utils"; 3 | import ndjson from "ndjson"; 4 | import tarea from "@turf/area"; 5 | import tilebelt from "@mapbox/tilebelt"; 6 | import cover from "@mapbox/tile-cover"; 7 | import skuInvoice from "./pricebook"; 8 | import os from "os"; 9 | 10 | const eol = os.EOL; 11 | 12 | const calculateTileArea = function (quadkeys) { 13 | console.log(`Calculating tile area${eol}`); 14 | let area = 0; 15 | quadkeys.forEach(quadkey => { 16 | const tile = tilebelt.quadkeyToTile(quadkey); 17 | const tileGeo = tilebelt.tileToGeoJSON(tile); 18 | area += tarea(tileGeo) / 1000000; 19 | }); 20 | return area.toFixed(0); 21 | }; 22 | 23 | const getBillingMaxZoom = function (zoom) { 24 | switch (true) { 25 | case zoom < 6: 26 | return { maxZoom: 5, sku: "lowzoom_free" }; 27 | case zoom < 11: 28 | return { maxZoom: 6, sku: "processing10m" }; 29 | case zoom < 14: 30 | return { maxZoom: 11, sku: "processing1m" }; 31 | case zoom < 17: 32 | return { maxZoom: 14, sku: "processing30cm" }; 33 | default: 34 | return { maxZoom: 17, sku: "processing1cm" }; 35 | } 36 | }; 37 | 38 | const sizeJob = function (pwd, cnf, recipe) { 39 | const billingMaxZoom = getBillingMaxZoom( 40 | recipe.layers[cnf.tilesetId].maxzoom 41 | ); 42 | 43 | const maxZoom = billingMaxZoom.maxZoom; 44 | const sku = billingMaxZoom.sku; 45 | 46 | // TODO loop through all layers to get the max maxzoom 47 | const limits = { 48 | min_zoom: recipe.layers[cnf.tilesetId].minzoom, 49 | max_zoom: maxZoom 50 | }; 51 | 52 | const allQuads = []; 53 | 54 | console.log(`Estimating job size... this could take a while depending on your max zoom`); 55 | 56 | fs.createReadStream(cnf.tilesetSourcePath) 57 | .pipe(ndjson.parse()) 58 | .on("data", geo => { 59 | const tiles = cover.indexes(geo.geometry, limits); 60 | tiles.forEach(tile => { 61 | if (!allQuads.includes(tile)) { 62 | allQuads.push(tile); 63 | } 64 | }); 65 | }) 66 | .on("end", () => { 67 | console.log( 68 | `Estimated number of tiles at billing zoom ${limits.max_zoom}: ${allQuads.length.toLocaleString()}${eol}` 69 | ); 70 | const area = calculateTileArea(allQuads); 71 | 72 | if (sku === "lowzoom_free") { 73 | console.log(`This data is processed for free.${eol}`); 74 | } else { 75 | const priceEstimate = skuInvoice(sku, area); 76 | console.log(`Tiling ${priceEstimate.formattedTotalSubunits} in the ${priceEstimate.skuName} tier${eol}`); 77 | console.log(`Estimated total cost is: ${priceEstimate.previewFormattedTotalCharge}${eol}`); 78 | console.log(`This estimate does not take into account any prior MTS usage or discount tier.`); 79 | console.log(`To get a more accurate pricing estimate, add this area to your past usage this billing cycle.`); 80 | console.log(`Total area processed this month is visible at https://account.mapbox.com/statistics.`); 81 | console.log(`Input this total value into the MTS pricing calculator (https://www.mapbox.com/pricing/#tilesets)${eol}for a complete price estimate.`); 82 | } 83 | }); 84 | }; 85 | 86 | export default function estimate() { 87 | const pwd = process.cwd(); 88 | 89 | const cnf = readConfig(pwd); 90 | const recipe = readRecipe(pwd); 91 | 92 | if (cnf && recipe) { 93 | sizeJob(pwd, cnf, recipe); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/services.js: -------------------------------------------------------------------------------- 1 | import open from "open"; 2 | import mts from "@mapbox/mapbox-sdk/services/tilesets"; 3 | 4 | let accessToken = null; 5 | let mtsService = null; 6 | 7 | const initService = async function () { 8 | try { 9 | accessToken = process.env.MTS_TOKEN; 10 | mtsService = mts({ accessToken }); 11 | } catch (error) { 12 | console.log(error); 13 | } 14 | }; 15 | 16 | // kick off the sync process by deleting the tileset source 17 | const deleteTilesetSource = async function (tilesetSourceId) { 18 | try { 19 | const response = await mtsService.deleteTilesetSource({ id: tilesetSourceId }).send(); 20 | if (response.statusCode === 204) { 21 | console.log(`Preparing tileset source data: ${tilesetSourceId}`); 22 | return response; 23 | } 24 | } catch (error) { 25 | console.log(error); 26 | } 27 | }; 28 | 29 | // create a tileset source aka upload your data 30 | const createTilesetSource = async function (tilesetSourceId, tilesetSourcePath) { 31 | // TODO validate the source data first 32 | // TODO handle multiple files for upload 33 | console.log("Uploading the source data..."); 34 | try { 35 | const response = await mtsService.createTilesetSource({ id: tilesetSourceId, file: tilesetSourcePath }).send(); 36 | console.log(`Tileset source created: ${response.body.id}. Files ${response.body.files}, Size: ${response.body.file_size} bytes`); 37 | console.log(response.body); 38 | return response; 39 | } catch (error) { 40 | console.log(error); 41 | } 42 | }; 43 | 44 | // validate the recipe 45 | const validateRecipe = async function (recipe) { 46 | try { 47 | const response = await mtsService.validateRecipe({ recipe: recipe }).send(); 48 | if (response.body.valid) { 49 | console.log("Recipe validated"); 50 | return response; 51 | } else { 52 | throw response; 53 | } 54 | } catch (error) { 55 | console.log(error); 56 | } 57 | }; 58 | 59 | const tilesetExists = async function (tilesetId) { 60 | try { 61 | const response = await mtsService.listTilesets().send(); 62 | const exists = response.body.filter(tileset => tileset.id === `${process.env.MTS_USERNAME}.${tilesetId}`); 63 | if (exists.length > 0) { 64 | console.log("Tileset already exists"); 65 | console.log(exists); 66 | return response; 67 | } 68 | return false; 69 | } catch (error) { 70 | console.log(error); 71 | } 72 | }; 73 | 74 | // has the tileset been created? if not, create the tileset using the tileset source 75 | const createTileset = async function (tilesetId, tilesetName, recipe) { 76 | try { 77 | const response = await mtsService.createTileset({ 78 | tilesetId: `${process.env.MTS_USERNAME}.${tilesetId}`, 79 | recipe: recipe, 80 | name: tilesetName 81 | }).send(); 82 | console.log(`Tileset ${process.env.MTS_USERNAME}.${tilesetId} created`); 83 | console.log(response.body); 84 | return response; 85 | } catch (error) { 86 | console.log(error); 87 | } 88 | }; 89 | 90 | // if the tileset exists, make sure it has the latest recipe 91 | const updateRecipe = async function (tilesetId, recipe) { 92 | try { 93 | const response = await mtsService.updateRecipe({ 94 | tilesetId: `${process.env.MTS_USERNAME}.${tilesetId}`, 95 | recipe: recipe 96 | }).send(); 97 | console.log(response.body); 98 | return response; 99 | } catch (error) { 100 | console.log(error); 101 | } 102 | }; 103 | 104 | // publish the tileset 105 | const publishTileset = async function (tilesetId) { 106 | try { 107 | const publishRequest = mtsService.publishTileset({ 108 | tilesetId: `${process.env.MTS_USERNAME}.${tilesetId}` 109 | }); 110 | publishRequest.query = { pluginName: "MTSDataSync" }; 111 | const response = await publishRequest.send(); 112 | console.log(response.body); 113 | return response; 114 | } catch (error) { 115 | console.log(error); 116 | } 117 | }; 118 | 119 | // log the job id and status message 120 | const tilesetStatus = function (tilesetId) { 121 | // eslint-disable-next-line no-use-before-define 122 | setTimeout(checkStatus, 10000, tilesetId); 123 | }; 124 | 125 | // Check job status and report warnings 126 | const jobStatus = async function (tilesetId, jobId) { 127 | console.log("Checking completed job status..."); 128 | try { 129 | const response = await mtsService.tilesetJob({ tilesetId: `${process.env.MTS_USERNAME}.${tilesetId}`, jobId }).send(); 130 | return response.body; 131 | } catch (error) { 132 | console.log(error); 133 | return; 134 | } 135 | }; 136 | 137 | // request the status every 10s, logging the status to the console until it's 'success' 138 | // provide some kind of preview / visual inspector 139 | const checkStatus = async function (tilesetId) { 140 | try { 141 | const response = await mtsService.tilesetStatus({ 142 | tilesetId: `${process.env.MTS_USERNAME}.${tilesetId}` 143 | }).send(); 144 | if (response.body.status === "processing" || response.body.status === "queued") { 145 | console.log(`Status: ${response.body.status} ${response.body.id}`); 146 | console.log(response.body); 147 | tilesetStatus(tilesetId); 148 | } else if (response.body.status === "success") { 149 | console.log(await jobStatus(tilesetId, response.body.latest_job)); 150 | console.log(`Complete: opening https://studio.mapbox.com/tilesets/${response.body.id}/`); 151 | open(`https://studio.mapbox.com/tilesets/${response.body.id}/`, { url: true }); 152 | } else { 153 | console.log("Error creating tileset", response.body); 154 | console.log(await jobStatus(tilesetId, response.body.latest_job)); 155 | } 156 | } catch (error) { 157 | console.log(error); 158 | } 159 | }; 160 | 161 | export { 162 | initService, 163 | deleteTilesetSource, 164 | createTilesetSource, 165 | validateRecipe, 166 | tilesetExists, 167 | createTileset, 168 | updateRecipe, 169 | publishTileset, 170 | tilesetStatus, 171 | checkStatus 172 | }; 173 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mapbox Tiling Service (MTS) Data Sync 2 | 3 | Use this tool to publish data directly to the [Mapbox Tiling Service](https://docs.mapbox.com/mapbox-tiling-service/overview/) without writing any code. The tool runs Node.js from the terminal. It may be helpful to use a text editor like VS Code, Sublime Text, or Atom to view and edit “dot” files and recipe files. If you prefer to use Python or if you want more control over how your data pipeline works, check out the [Tilesets CLI](https://docs.mapbox.com/mapbox-tiling-service/overview/#tilesets-cli). 4 | 5 | ## Installation 6 | 7 | MTS Data Sync requires that Node.js and npm are installed. If you don't have Node installed, [nvm](https://github.com/nvm-sh/nvm) is a nice way to manage installation. 8 | 9 | 1. Clone or download this repo. Unzip the file if needed and move it to your documents or other directory that works for you. In your Terminal application, `cd` into the `mts-data-sync` directory. 10 | 11 | 2. Install the dependencies with: 12 | 13 | `npm install` 14 | 15 | 3. This application is designed to **run anywhere**, not just the directory where it's installed. This allows for a workflow where you can `cd` into any directory with GeoJSON and work from there. To have the command `mtsds` (MTS data sync) available anywhere, run: 16 | 17 | `npm link` 18 | 19 | 4. Create a secret access token in your [Mapbox.com account](https://account.mapbox.com/access-tokens/). This is different from your normal access tokens, because it has write access to your data. *Don’t use this token in your web apps*. You’ll need to add TILESETS:LIST, TILESETS:READ, and TILESETS:WRITE scopes to this token when you create it. Copy this token to your clipboard after creating it, or keep the page open during the next step. 20 | 21 | ![Mapbox token scopes](./assets/token-scope.png) 22 | 23 | 5. To complete installation, run: 24 | 25 | `mtsds --token` 26 | 27 | This will prompt you for your Mapbox username, and then for the secret access token you just created. Confirm what you entered, and your information will be saved to your computer. 28 | 29 | ## Usage 30 | 31 | Prepare your [GeoJSON data](https://docs.mapbox.com/mapbox-tiling-service/overview/tileset-sources/). **This tool converts your standard GeoJSON data to a line-delimited GeoJSON file, the format MTS requires.** This tool does not validate the input GeoJSON, if you are encountering errors, please make sure your GeoJSON data is valid. GeoJSON [must be in the WGS84](https://tools.ietf.org/html/rfc7946#section-4) (EPSG: 4326) coordinate reference system. 32 | 33 | There are five commands available: 34 | 35 | - `--config filename.geojson` 36 | - `--estimate` 37 | - `--sync` 38 | - `--convert filename.geojson` 39 | - `--token` 40 | 41 | In your terminal, `cd` to the directory with your spatial data. The following command will build the configuration and a minimal recipe for your tiling process, and convert your GeoJSON data to line-delimited GeoJSON. 42 | 43 | Run `mtsds --config path-to-your.geojson` to generate a .geojsonl file, mts-config.json and mts-recipe.json. Note, when typing your file path and name, you can type the first few letters and press `tab` to autocomplete the file name. 44 | 45 | Answer a couple of questions to generate the config and recipe: 46 | 47 | `Please enter an ID for your tileset`. This is the [ID you use to access tilesets](https://docs.mapbox.com/help/glossary/tileset-id/) in your account. You don't need to include your username as part of the ID. Tileset IDs are limited to 32 characters, alphanumeric with hyphens and underscores only. 48 | 49 | `Please enter a name for your tileset`. This is the pretty name that you'll see on your tileset page in your account. This value is limited to 64 characters, and can include spaces but no special characters. 50 | 51 | Example mts-config.json file: 52 | 53 | ``` 54 | { 55 | "username": "my-username", 56 | "tilesetSourceId": "tileset-id-src", 57 | "tilesetSourcePath": "data.geojsonl", 58 | "tilesetId": "tileset-id", 59 | "tilesetName": "My tileset on Mapbox.com" 60 | } 61 | ``` 62 | 63 | A [basic recipe](https://docs.mapbox.com/mapbox-tiling-service/overview/tileset-recipes/) example: 64 | 65 | ``` 66 | { 67 | "version": 1, 68 | "layers": { 69 | "tileset-id": { 70 | "source": "mapbox://tileset-source/my-username/tileset-id-src", 71 | "minzoom": 0, 72 | "maxzoom": 5 73 | } 74 | } 75 | } 76 | ``` 77 | 78 | You can always change these files later if you want to rename something. 79 | 80 | Note that the recipe is very basic, and defaults to a free-tier, low maximum zoom level of 5. At minimum, you may want to increase that [zoom level to match the detail](https://docs.mapbox.com/mapbox-tiling-service/overview/#precision-levels-and-square-kilometers) of your data. 81 | 82 | Open the new mts-recipe.json file in your text editor to customize [how your data should be tiled](https://docs.mapbox.com/mapbox-tiling-service/examples/). This is where you can set the min and max zoom levels, filter data into separate layers, union data at low zoom levels, and much more. 83 | 84 | Before publishing your data, you can estimate the [cost of the processing job](https://docs.mapbox.com/mapbox-tiling-service/guides/pricing/) and tile hosting by running: 85 | 86 | `mtsds --estimate` 87 | 88 | This will measure the area of tiles that intersect your data at the zoom level Mapbox uses to calculate billing (the minimum zoom level in each pricing band). Note that the estimation process is complex, and runs on your computer. If the data is large or if it covers a large area, and you want to estimate cost for high zoom levels, estimation may take a while. It is also dependent on your available RAM and CPU speed. 89 | 90 | This command produces the square kilometers you can use to estimate the cost of the tiling job: 91 | 92 | ``` 93 | counties $ mtsds --estimate 94 | Estimating job size... this could take a while depending on your max zoom 95 | Estimated number of tiles at billing zoom 11: 30,749 96 | 97 | Calculating tile area 98 | 99 | Tiling 14,542,804 square kilometers in the Tileset Processing 1m tier 100 | 101 | Estimated total cost is: $101.08 102 | 103 | This estimate does not take into account any prior MTS usage or discount tier. 104 | To get a more accurate pricing estimate, add this area to your past usage this billing cycle. 105 | Total area processed this month is visible at https://account.mapbox.com/statistics. 106 | Input this total value into the MTS pricing calculator (https://www.mapbox.com/pricing/#tilesets) 107 | for a complete price estimate. 108 | ``` 109 | 110 | Once your access token, config file, and recipe are ready, begin the tiling process with: 111 | 112 | `mtsds --sync` 113 | 114 | The script will print information to the console during the process. If the tiling process succeeds, the script will open the Mapbox Studio page to preview the tileset. 115 | 116 | Example of output logged for a successful publishing job: 117 | 118 | ``` 119 | $ mtsds --sync 120 | Preparing tileset source data:: noaa-warnings-alerts-source 121 | Uploading the source data... 122 | Tileset source created: mapbox://tileset-source/branigan/noaa-warnings-alerts-source. Files 1, Size: 14902394 bytes 123 | { id: 124 | 'mapbox://tileset-source/branigan/noaa-warnings-alerts-source', 125 | files: 1, 126 | source_size: 14902394, 127 | file_size: 14902394 } 128 | Recipe validated 129 | Tileset already exists 130 | [ { type: 'vector', 131 | id: 'branigan.noaa-warnings-alerts', 132 | name: 'Current NOAA weather warnings and alerts', 133 | center: [ -109.6875, 32.779346, 0 ], 134 | created: '2020-07-07T17:39:13.300Z', 135 | modified: '2020-07-28T16:03:03.703Z', 136 | visibility: 'private', 137 | description: '', 138 | filesize: 14902394, 139 | status: 'available' } ] 140 | { message: 'Processing branigan.noaa-warnings-alerts', 141 | jobId: 'ckd8ujz1o001c07mfdbnwacw4' } 142 | Status: processing branigan.noaa-warnings-alerts 143 | { id: 'branigan.noaa-warnings-alerts', 144 | latest_job: 'ckd8ujz1o001c07mfdbnwacw4', 145 | status: 'processing' } 146 | Status: processing branigan.noaa-warnings-alerts 147 | { id: 'branigan.noaa-warnings-alerts', 148 | latest_job: 'ckd8ujz1o001c07mfdbnwacw4', 149 | status: 'processing' } 150 | ... 151 | (the above message prints every 10 seconds as the code checks the status 152 | when the processing job is complete, it opens the Studio tileset preview page) 153 | Complete: opening https://studio.mapbox.com/tilesets/branigan.noaa-warnings-alerts/ 154 | ``` 155 | 156 | This page will open in your browser: 157 | 158 | ![Tileset preview page](./assets/tileset-preview.png) 159 | 160 | ## Additional Options 161 | 162 | This tool is designed to support workflows with data that changes periodically. When your data changes and you want to update your tileset (hence "data sync"), you can use the `--convert file.geojson` option. Assuming you modified your file in place, or overwrote it with new data, use this option to convert the GeoJSON to the line-delimited format. Once that's ready, you can use the `--sync` option to republish the data with the same recipe and settings. 163 | 164 | If you need to change your secret access token, use the `--token` option to enter a new token. 165 | -------------------------------------------------------------------------------- /src/pricebook.js: -------------------------------------------------------------------------------- 1 | // calculation code from https://github.com/mapbox/pricebook/blob/master/lib/sku-invoice.js 2 | // pricing from https://github.com/mapbox/pricebook/blob/master/lib/skus/skus-event.js 3 | // to update pricing, replace the skus object with the data in skus-event.js 4 | 5 | import { format } from "d3-format"; 6 | 7 | const THOUSAND = 1000; 8 | const MILLION = 1000 * THOUSAND; 9 | const BILLION = 1000 * MILLION; 10 | const TRILLION = 1000 * BILLION; 11 | 12 | 13 | const formatDollars = dollar => { 14 | // some sku pricing tiers can go below a cent 15 | if (dollar < 0.01 && dollar !== 0) { 16 | return format("$,.5~f")(dollar); 17 | } else { 18 | return format("$,.2f")(dollar); 19 | } 20 | }; 21 | 22 | const formatCents = cents => formatDollars(cents / 100); 23 | 24 | // , => separate thousands 25 | // d => format as an integer, prevent scientific notation 26 | const formatCount = format(",d"); 27 | 28 | const skus = { 29 | processing10m: { 30 | type: "event", 31 | id: "processing10m", 32 | name: "Tileset Processing 10m", 33 | description: 34 | "The event of creating a tileset with a max zoom between 6-10 or a GeoTIFF\ 35 | tileset with a max zoom between 7-11. Cost depends on the area of all tiles that\ 36 | were created. Tilesets created in Mapbox Studio are exempt.", 37 | documentationUrl: "https://mapbox.com/pricing/#processing10m", 38 | subunitDescription: "square kilometer", 39 | contactSalesThreshold: 1.5 * TRILLION, 40 | pricing: { 41 | latest: { 42 | subunitsPerBillingUnit: 1000, 43 | isPreview: true, 44 | pricingTiers: [ 45 | { min: 1, max: 1.5 * BILLION, price: 0 }, 46 | { min: 1.5 * BILLION + 1, max: 15 * BILLION, price: 0.04 }, 47 | { min: 15 * BILLION + 1, max: 150 * BILLION, price: 0.032 }, 48 | { min: 150 * BILLION + 1, max: 1.5 * TRILLION, price: 0.024 }, 49 | { min: 1.5 * TRILLION + 1, price: 0.016 } 50 | ] 51 | } 52 | } 53 | }, 54 | processing1m: { 55 | type: "event", 56 | id: "processing1m", 57 | name: "Tileset Processing 1m", 58 | description: 59 | "The event of creating a tileset with a max zoom between 11-13 or a GeoTIFF\ 60 | tileset with a max zoom between 12-14. Cost depends on the area of all tiles that\ 61 | were created. Tilesets created in Mapbox Studio are exempt.", 62 | documentationUrl: "https://mapbox.com/pricing/#processing1m", 63 | subunitDescription: "square kilometer", 64 | contactSalesThreshold: 1 * BILLION, 65 | pricing: { 66 | latest: { 67 | subunitsPerBillingUnit: 1000, 68 | isPreview: true, 69 | pricingTiers: [ 70 | { min: 1, max: 1 * MILLION, price: 0 }, 71 | { min: 1 * MILLION + 1, max: 10 * MILLION, price: 0.8 }, 72 | { min: 10 * MILLION + 1, max: 100 * MILLION, price: 0.64 }, 73 | { min: 100 * MILLION + 1, max: 1 * BILLION, price: 0.48 }, 74 | { min: 1 * BILLION + 1, price: 0.32 } 75 | ] 76 | } 77 | } 78 | }, 79 | processing30cm: { 80 | type: "event", 81 | id: "processing30cm", 82 | name: "Tileset Processing 30cm", 83 | description: 84 | "The event of creating a tileset with a max zoom between 14-16 or a GeoTIFF\ 85 | tileset with a max zoom between 15-17. Cost depends on the area of all tiles that\ 86 | were created. Tilesets created in Mapbox Studio are exempt.", 87 | documentationUrl: "https://mapbox.com/pricing/#processing30cm", 88 | subunitDescription: "square kilometer", 89 | contactSalesThreshold: 20 * MILLION, 90 | pricing: { 91 | latest: { 92 | subunitsPerBillingUnit: 1000, 93 | isPreview: true, 94 | pricingTiers: [ 95 | { min: 1, max: 20 * THOUSAND, price: 0 }, 96 | { min: 20 * THOUSAND + 1, max: 200 * THOUSAND, price: 25 }, 97 | { min: 200 * THOUSAND + 1, max: 2 * MILLION, price: 20 }, 98 | { min: 2 * MILLION + 1, max: 20 * MILLION, price: 15 }, 99 | { min: 20 * MILLION + 1, price: 10 } 100 | ] 101 | } 102 | } 103 | }, 104 | processing1cm: { 105 | type: "event", 106 | id: "processing1cm", 107 | name: "Tileset Processing 1cm", 108 | description: 109 | "The event of creating a tileset with a max zoom greater than 16 or a GeoTIFF\ 110 | tileset with a max zoom greater than 17. Cost depends on the area of all tiles that\ 111 | were created. Tilesets created in Mapbox Studio are exempt.", 112 | documentationUrl: "https://mapbox.com/pricing/#processing1cm", 113 | subunitDescription: "square kilometer", 114 | contactSalesThreshold: 350 * THOUSAND, 115 | pricing: { 116 | latest: { 117 | subunitsPerBillingUnit: 1, 118 | isPreview: true, 119 | pricingTiers: [ 120 | { min: 1, max: 350, price: 0 }, 121 | { min: 351, max: 3.5 * THOUSAND, price: 200 }, 122 | { min: 3.5 * THOUSAND + 1, max: 35 * THOUSAND, price: 160 }, 123 | { min: 35 * THOUSAND + 1, max: 350 * THOUSAND, price: 120 }, 124 | { min: 350 * THOUSAND + 1, price: 80 } 125 | ] 126 | } 127 | } 128 | }, 129 | hosting10m: { 130 | type: "event", 131 | id: "hosting10m", 132 | name: "Tileset Hosting 10m", 133 | description: 134 | "The event of storing a tileset with a max zoom between 6-10 or a GeoTIFF\ 135 | tileset with a max zoom between 7-11. Cost depends on the area of all tiles that\ 136 | were created multiplied by the number of days each tileset has existed in your\ 137 | account during your billing period. Mapbox default tilesets and tilesets created\ 138 | in Mapbox Studio are exempt.", 139 | documentationUrl: "https://mapbox.com/pricing/#hosting10m", 140 | subunitDescription: "square kilometer day", 141 | contactSalesThreshold: 1.5 * TRILLION, 142 | pricing: { 143 | latest: { 144 | subunitsPerBillingUnit: 1000000, 145 | isPreview: true, 146 | pricingTiers: [ 147 | { min: 1, max: 1.5 * BILLION, price: 0 }, 148 | { min: 1.5 * BILLION + 1, max: 15 * BILLION, price: 0.04 }, 149 | { min: 15 * BILLION + 1, max: 150 * BILLION, price: 0.032 }, 150 | { min: 150 * BILLION + 1, max: 1.5 * TRILLION, price: 0.024 }, 151 | { min: 1.5 * TRILLION + 1, price: 0.016 } 152 | ] 153 | } 154 | } 155 | }, 156 | hosting1m: { 157 | type: "event", 158 | id: "hosting1m", 159 | name: "Tileset Hosting 1m", 160 | description: 161 | "The event of storing a tileset with a max zoom between 11-13 or a GeoTIFF\ 162 | tileset with a max zoom between 12-14. Cost depends on the area of all tiles that\ 163 | were created multiplied by the number of days each tileset has existed in your\ 164 | account during your billing period. Mapbox default tilesets and tilesets created\ 165 | in Mapbox Studio are exempt.", 166 | documentationUrl: "https://mapbox.com/pricing/#hosting1m", 167 | subunitDescription: "square kilometer day", 168 | contactSalesThreshold: 1 * BILLION, 169 | pricing: { 170 | latest: { 171 | subunitsPerBillingUnit: 1000000, 172 | isPreview: true, 173 | pricingTiers: [ 174 | { min: 1, max: 1 * MILLION, price: 0 }, 175 | { min: 1 * MILLION + 1, max: 10 * MILLION, price: 0.8 }, 176 | { min: 10 * MILLION + 1, max: 100 * MILLION, price: 0.64 }, 177 | { min: 100 * MILLION + 1, max: 1 * BILLION, price: 0.48 }, 178 | { min: 1 * BILLION + 1, price: 0.32 } 179 | ] 180 | } 181 | } 182 | }, 183 | hosting30cm: { 184 | type: "event", 185 | id: "hosting30cm", 186 | name: "Tileset Hosting 30cm", 187 | description: 188 | "The event of storing a tileset with a max zoom between 14-16 or a GeoTIFF\ 189 | tileset with a max zoom between 15-17. Cost depends on the area of all tiles that\ 190 | were created multiplied by the number of days each tileset has existed in your\ 191 | account during your billing period. Mapbox default tilesets and tilesets create\ 192 | in Mapbox Studio are exempt.", 193 | documentationUrl: "https://mapbox.com/pricing/#hosting30cm", 194 | subunitDescription: "square kilometer day", 195 | contactSalesThreshold: 20 * MILLION, 196 | pricing: { 197 | latest: { 198 | subunitsPerBillingUnit: 1000000, 199 | isPreview: true, 200 | pricingTiers: [ 201 | { min: 1, max: 20 * THOUSAND, price: 0 }, 202 | { min: 20 * THOUSAND + 1, max: 200 * THOUSAND, price: 25 }, 203 | { min: 200 * THOUSAND + 1, max: 2 * MILLION, price: 20 }, 204 | { min: 2 * MILLION + 1, max: 20 * MILLION, price: 15 }, 205 | { min: 20 * MILLION + 1, price: 10 } 206 | ] 207 | } 208 | } 209 | }, 210 | hosting1cm: { 211 | type: "event", 212 | id: "hosting1cm", 213 | name: "Tileset Hosting 1cm", 214 | description: 215 | "The event of storing a tileset with a max zoom greater than 16 or a GeoTIFF\ 216 | tileset with a max zoom greater than 17. Cost depends on the area of all tiles that\ 217 | were created multiplied by the number of days each tileset has existed in your\ 218 | account during your billing period. Mapbox default tilesets and tilesets created\ 219 | in Mapbox Studio are exempt.", 220 | documentationUrl: "https://mapbox.com/pricing/#hosting1cm", 221 | subunitDescription: "square kilometer day", 222 | contactSalesThreshold: 350 * THOUSAND, 223 | pricing: { 224 | latest: { 225 | subunitsPerBillingUnit: 1, 226 | isPreview: true, 227 | pricingTiers: [ 228 | { min: 1, max: 350, price: 0 }, 229 | { min: 351, max: 3.5 * THOUSAND, price: 0.2 }, 230 | { min: 3.5 * THOUSAND + 1, max: 35 * THOUSAND, price: 0.16 }, 231 | { min: 35 * THOUSAND + 1, max: 350 * THOUSAND, price: 0.12 }, 232 | { min: 350 * THOUSAND + 1, price: 0.08 } 233 | ] 234 | } 235 | } 236 | }, 237 | testevent: { 238 | type: "event", 239 | id: "testevent", 240 | name: "Test event SKU", 241 | description: "TESTING PLACEHOLDER UNTIL WE GET A REAL EVENT SKU.", 242 | documentationUrl: "https://docs.mapbox.com/help/glossary/test-event/", 243 | subunitDescription: "thing", 244 | contactSalesThreshold: 1000000, 245 | pricing: { 246 | latest: { 247 | subunitsPerBillingUnit: 1000, 248 | pricingTiers: [ 249 | { min: 1, max: 50000, price: 0 }, 250 | { min: 50001, max: 100000, price: 500 }, 251 | { min: 100001, max: 200000, price: 400 }, 252 | { min: 200001, max: 1000000, price: 300 }, 253 | { min: 1000001, max: 2000000, price: 250 }, 254 | { min: 2000001, max: 20000000, price: 125 }, 255 | { min: 20000001, price: 100 } 256 | ] 257 | }, 258 | v1: { 259 | subunitsPerBillingUnit: 1000, 260 | description: "Older preview pricing", 261 | isPreview: true, 262 | pricingTiers: [ 263 | { min: 1, max: 50000, price: 0 }, 264 | { min: 50001, max: 100000, price: 500 }, 265 | { min: 100001, max: 200000, price: 400 }, 266 | { min: 200001, max: 1000000, price: 300 }, 267 | { min: 1000001, max: 2000000, price: 250 }, 268 | { min: 2000001, max: 20000000, price: 125 }, 269 | { min: 20000001, price: 100 } 270 | ] 271 | } 272 | } 273 | } 274 | }; 275 | 276 | 277 | export default function skuInvoice(skuId, subunitCount, { pricingVersion = "latest" } = {}) { 278 | const sku = skus[skuId]; 279 | 280 | if (subunitCount === undefined) { 281 | throw new Error("subunitCount is required"); 282 | } 283 | 284 | const pricingTiers = sku.pricing[pricingVersion].pricingTiers; 285 | const subunitsPerBillingUnit = 286 | sku.pricing[pricingVersion].subunitsPerBillingUnit; 287 | const isPreview = Boolean(sku.pricing[pricingVersion].isPreview); 288 | 289 | let outstandingSubunits = subunitCount; 290 | let totalCharge = 0; 291 | const invoiceTiers = []; 292 | for (let tierIndex = 0; tierIndex < pricingTiers.length; tierIndex++) { 293 | const tier = pricingTiers[tierIndex]; 294 | const tierMax = tier.max === undefined ? Infinity : tier.max; 295 | // We add 1 to max - min because both bounds are inclusive. 296 | // E.g. 1-10 is 10 units; 10 - 1 = 9, so add 1. 297 | const tierSubunits = Math.min(outstandingSubunits, tierMax - tier.min + 1); 298 | // We don't calculate fractional SKUs, so round up. 299 | const tierSkus = Math.ceil(tierSubunits / subunitsPerBillingUnit); 300 | // Since we cannot charge fractional value of a cent, round up. 301 | const tierCharge = Math.ceil(tierSkus * tier.price); 302 | totalCharge += tierCharge; 303 | 304 | invoiceTiers.push({ 305 | charge: tierCharge, 306 | formattedCharge: formatCents(tierCharge), 307 | subunits: tierSubunits, 308 | formattedSubunits: formatCount(tierSubunits), 309 | tierDescription: renderTierDescription(tier) 310 | }); 311 | 312 | outstandingSubunits = outstandingSubunits - tierSubunits; 313 | if (outstandingSubunits <= 0) break; 314 | } 315 | 316 | const formattedTotalSubunits = [ 317 | formatCount(subunitCount), 318 | pluralableSubunitDescription(sku, subunitCount) 319 | ].join(" "); 320 | 321 | const result = { 322 | totalCharge, 323 | formattedTotalCharge: formatCents(totalCharge), 324 | totalSubunits: subunitCount, 325 | formattedTotalSubunits, 326 | tiers: invoiceTiers, 327 | skuId: sku.id, 328 | skuName: sku.name 329 | }; 330 | 331 | if (isPreview) { 332 | return Object.assign({}, result, { 333 | totalCharge: 0, 334 | formattedTotalCharge: formatCents(0), 335 | // stripe metadata only accepts strings 336 | isPreview: "yes", 337 | previewTotalCharge: totalCharge, 338 | previewFormattedTotalCharge: formatCents(totalCharge) 339 | }); 340 | } 341 | 342 | return result; 343 | 344 | function renderTierDescription(tier) { 345 | if (tier.price === 0) { 346 | return [ 347 | `up to ${formatCount(tier.max)} ${pluralableSubunitDescription( 348 | sku, 349 | 2 350 | )}`, 351 | "free" 352 | ].join(" — "); 353 | } 354 | 355 | const maxPhrase = tier.max ? `to ${formatCount(tier.max)}` : "and up"; 356 | 357 | return [ 358 | `${formatCount(tier.min)} ${maxPhrase}`, 359 | `${formatCents(tier.price)} / ${formatCount(subunitsPerBillingUnit)}` 360 | ].join(" — "); 361 | } 362 | } 363 | 364 | // This is a very simple pluralization, so we need to be aware if 365 | // new SKU subunit descriptions require more deliberate plurals. 366 | function pluralableSubunitDescription(sku, count) { 367 | return count === 1 ? sku.subunitDescription : `${sku.subunitDescription}s`; 368 | } 369 | --------------------------------------------------------------------------------