├── .editorconfig ├── docs ├── .gitignore ├── _includes │ └── head.html ├── tsconfig.json ├── Gemfile ├── rollup.config.mjs ├── _config.yml ├── _layouts │ └── default.html ├── assets │ └── css │ │ └── style.scss ├── index.markdown ├── src │ ├── main.ts │ └── sampledata.json └── Gemfile.lock ├── typedoc.json ├── jest.config.ts ├── prettier.config.js ├── eslint.config.mjs ├── tsconfig.bundle.json ├── src ├── types.ts ├── screen.test.ts ├── index.test.ts ├── screen.ts └── index.ts ├── tsconfig.json ├── LICENSE ├── .github └── workflows │ ├── test.yml │ └── jekyll-gh-pages.yml ├── .gitignore ├── rollup.config.mjs ├── test └── fixtures │ ├── points1.json │ └── points2.json ├── package.json └── README.md /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | insert_final_newline = true 4 | end_of_line = lf 5 | indent_style = space 6 | indent_size = 2 7 | max_line_length = 120 8 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | _site 2 | .sass-cache 3 | .jekyll-cache 4 | .jekyll-metadata 5 | vendor 6 | 7 | # Generated Asset files 8 | assets/css/main.css 9 | assets/main.js 10 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "out": "./docs", 3 | "name": "[libraryNameWithSpacesAndUpperCases]", 4 | "plugin": ["typedoc-plugin-markdown"], 5 | "readme": "none", 6 | "tsconfig": "./tsconfig.bundle.json" 7 | } 8 | -------------------------------------------------------------------------------- /docs/_includes/head.html: -------------------------------------------------------------------------------- 1 | {% include head-custom-google-analytics.html %} 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { createDefaultEsmPreset, type JestConfigWithTsJest } from 'ts-jest'; 2 | 3 | const presetConfig = createDefaultEsmPreset({}); 4 | 5 | const jestConfig: JestConfigWithTsJest = { 6 | ...presetConfig, 7 | collectCoverage: true, 8 | coverageReporters: ['lcov', 'text'], 9 | }; 10 | 11 | export default jestConfig; 12 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /* global module -- Global defined by Node.js */ 4 | 5 | /** 6 | * An object with Prettier.js options. 7 | * @type {import('prettier').Options} 8 | */ 9 | const options = { 10 | bracketSameLine: true, 11 | quoteProps: 'consistent', 12 | singleQuote: true, 13 | trailingComma: 'all', 14 | }; 15 | 16 | module.exports = options; 17 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import ESLint from '@eslint/js'; 4 | import Prettier from 'eslint-plugin-prettier/recommended'; 5 | import TypeScriptESLint from 'typescript-eslint'; 6 | 7 | export default TypeScriptESLint.config( 8 | { 9 | ignores: ['docs/**', 'dist/**', 'types/**'], 10 | }, 11 | Prettier, 12 | ESLint.configs.recommended, 13 | ...TypeScriptESLint.configs.recommended, 14 | ); 15 | -------------------------------------------------------------------------------- /tsconfig.bundle.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | // Output settings. 5 | "newLine": "lf", 6 | "outDir": "./dist", 7 | "sourceMap": true, 8 | "target": "es2015", 9 | 10 | // Type declaration settings. 11 | "declaration": true, 12 | "declarationDir": "./types", 13 | "declarationMap": true, 14 | "isolatedDeclarations": true, 15 | 16 | // Type-checking settings. 17 | "skipLibCheck": false, 18 | "types": [] 19 | }, 20 | "exclude": ["./src/**/*.test.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.2", 3 | "compilerOptions": { 4 | "lib": ["es5", "es2015.promise", "dom", "es2015"], 5 | "target": "es5", 6 | "typeRoots": [ 7 | "node_modules/@types" 8 | ], 9 | "outDir": "assets/js", 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "emitDecoratorMetadata": true, 14 | "experimentalDecorators": true, 15 | "allowSyntheticDefaultImports": true, 16 | "declaration": false, 17 | "sourceMap": true 18 | }, 19 | "include": ["./src/**/*.ts"], 20 | "exclude": [ 21 | "node_modules" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /docs/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "github-pages", "~> 232", group: :jekyll_plugins 4 | 5 | group :jekyll_plugins do 6 | gem "jekyll-feed", "~> 0.12" 7 | end 8 | 9 | # Windows and JRuby does not include zoneinfo files, so bundle the tzinfo-data gem 10 | # and associated library. 11 | platforms :mingw, :x64_mingw, :mswin, :jruby do 12 | gem "tzinfo", ">= 1", "< 3" 13 | gem "tzinfo-data" 14 | end 15 | 16 | # Performance-booster for watching directories on Windows 17 | gem "wdm", "~> 0.1", :platforms => [:mingw, :x64_mingw, :mswin] 18 | 19 | # Lock `http_parser.rb` gem to `v0.6.x` on JRuby builds since newer versions of the gem 20 | # do not have a Java counterpart. 21 | gem "http_parser.rb", "~> 0.6.0", :platforms => [:jruby] 22 | -------------------------------------------------------------------------------- /docs/rollup.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import typescript2 from 'rollup-plugin-typescript2'; 4 | import nodeResolve from '@rollup/plugin-node-resolve'; 5 | import commonjs from '@rollup/plugin-commonjs'; 6 | import json from '@rollup/plugin-json'; 7 | import css from "rollup-plugin-import-css"; 8 | 9 | /** 10 | * @type {import('rollup').RollupOptions} 11 | */ 12 | const options = { 13 | input: './src/main.ts', 14 | output: [ 15 | { 16 | file: './assets/main.js', 17 | format: 'es' 18 | } 19 | ], 20 | plugins: [ 21 | typescript2({ 22 | clean: true, 23 | useTsconfigDeclarationDir: true, 24 | tsconfig: './tsconfig.json', 25 | }), 26 | commonjs(), 27 | nodeResolve({ 28 | browser: true 29 | }), 30 | json(), 31 | css({ 32 | minify: true, 33 | output: "css/main.css", 34 | }), 35 | ] 36 | }; 37 | 38 | export default options; 39 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Feature, LineString, Polygon } from 'geojson'; 2 | 3 | export type XY = [number, number]; 4 | export type LngLat = [number, number]; 5 | 6 | export interface mapFitOptions { 7 | tileSize?: number; 8 | preferredBearing?: number; 9 | maxZoom?: number; 10 | floatZoom?: boolean; 11 | padding?: mapFitPadding; 12 | } 13 | 14 | export interface mapFitPadding { 15 | left?: number; 16 | right?: number; 17 | top?: number; 18 | bottom?: number; 19 | } 20 | 21 | export interface mapFitResult { 22 | bearing: number; 23 | zoom: number; 24 | center: LngLat; 25 | } 26 | 27 | export interface rectangleOrientation { 28 | shortSide: Feature | undefined; 29 | longSide: Feature | undefined; 30 | } 31 | 32 | export interface boundingOrientation { 33 | bearing: number | undefined; 34 | orientation: rectangleOrientation; 35 | envelope: Feature | undefined; 36 | } 37 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | title: GeoJSON Map Fit Mercator 2 | email: tristan@tristandavey.com 3 | description: >- # this means to ignore newlines until "baseurl:" 4 | Typescript library for finding the optimal bearing, zoom and center point for fitting a set of GeoJSON features in a 5 | Mapbox GL or LibreMap Mercator map viewport. 6 | baseurl: "/geojson-map-fit-mercator" # the subpath of your site, e.g. /blog 7 | url: "https://tjdavey.github.io" # the base hostname & protocol for your site, e.g. http://example.com 8 | github_username: tjdavey 9 | 10 | # Build settings 11 | remote_theme: pages-themes/cayman@v0.2.0 12 | plugins: 13 | - jekyll-remote-theme # add this line to the plugins list if you already have one 14 | 15 | exclude: 16 | - src 17 | 18 | install_instructions_npm: npm install geojson-map-fit-mercator 19 | install_instructions_yarn: yarn add geojson-map-fit-mercator 20 | 21 | api_docs_url: https://github.com/tjdavey/geojson-map-fit-mercator?tab=readme-ov-file#api 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Module settings. 4 | "target": "es5", 5 | "esModuleInterop": true, 6 | "module": "ES2020", 7 | "moduleResolution": "bundler", 8 | "allowSyntheticDefaultImports": true, 9 | "moduleDetection": "force", 10 | "isolatedModules": true, 11 | "resolveJsonModule": true, 12 | 13 | // Strictness and quality settings. 14 | "exactOptionalPropertyTypes": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "noImplicitOverride": true, 18 | "noImplicitReturns": true, 19 | "noPropertyAccessFromIndexSignature": true, 20 | "noUncheckedIndexedAccess": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "strict": true, 24 | 25 | // Type-checking settings. 26 | "lib": ["es2023"], 27 | "skipLibCheck": true, 28 | "types": ["jest", "node"], 29 | }, 30 | "include": ["./src/**/*.ts"] 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Tristan Davey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v2 16 | 17 | - name: Set up Node.js 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 'latest' 21 | 22 | - name: Install Coveralls Universal Reporter 23 | run: curl -L https://coveralls.io/coveralls-linux.tar.gz | tar -xz -C /usr/local/bin 24 | 25 | - name: Install dependencies 26 | run: npm install 27 | 28 | - name: Run Jest tests 29 | run: npm test 30 | 31 | - name: Run Build 32 | run: npm run build 33 | 34 | - name: Archive Build 35 | uses: actions/upload-artifact@v4 36 | with: 37 | name: build 38 | path: dist 39 | 40 | - name: Collect Coverage 41 | env: 42 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 43 | if: ${{ env.COVERALLS_REPO_TOKEN != '' }} 44 | run: coveralls report 45 | 46 | -------------------------------------------------------------------------------- /src/screen.test.ts: -------------------------------------------------------------------------------- 1 | import { findScreenBearing } from './screen'; 2 | 3 | describe('findScreenBearing', () => { 4 | it('should return the north-oriented bearing of the long side of the bounding rectangle if the screen is portrait', () => { 5 | const result = findScreenBearing(23.564, 0, 0.5); 6 | 7 | expect(result).toEqual(23.564); 8 | }); 9 | 10 | it('should return the north-oriented bearing of the most long side of the bounding rectangle if the screen is portrait but the boundingRectangleBearing is facing south', () => { 11 | const result = findScreenBearing(180.1584,0, 0.5); 12 | 13 | expect(result).toEqual(0.15840000000002874); 14 | }); 15 | 16 | it('should return the bearing 90 off the most north-oriented long side of the bounding rectangle if the screen is landscape', () => { 17 | const result = findScreenBearing(23.564, 0, 1.2); 18 | 19 | expect(result).toEqual(293.56399999999996); 20 | }); 21 | 22 | it('should return the south-oriented bearing of the most long side of the bounding rectangle if the screen is landscape and preferredBearing 180 is given', () => { 23 | const result = findScreenBearing(23.564, 180, 1.2); 24 | 25 | expect(result).toEqual(113.564); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | *.lcov 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (https://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules/ 31 | jspm_packages/ 32 | 33 | # TypeScript cache 34 | *.tsbuildinfo 35 | 36 | # Optional npm cache directory 37 | .npm 38 | 39 | # Optional eslint cache 40 | .eslintcache 41 | 42 | # Optional stylelint cache 43 | .stylelintcache 44 | 45 | # Microbundle cache 46 | .rpt2_cache/ 47 | .rts2_cache_cjs/ 48 | .rts2_cache_es/ 49 | .rts2_cache_umd/ 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # yarn v2 61 | .yarn/cache 62 | .yarn/unplugged 63 | .yarn/build-state.yml 64 | .yarn/install-state.gz 65 | .pnp.* 66 | 67 | # Distribution directories 68 | dist 69 | types 70 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import terser from '@rollup/plugin-terser'; 4 | import typescript2 from 'rollup-plugin-typescript2'; 5 | import packageJSON from './package.json' with { type: 'json' }; 6 | 7 | /** 8 | * Comment with library information to be appended in the generated bundles. 9 | */ 10 | const banner = `/*! 11 | * ${packageJSON.name} v${packageJSON.version} 12 | * (c) ${packageJSON.author.name} 13 | * Released under the ${packageJSON.license} License. 14 | */ 15 | `; 16 | 17 | /** 18 | * Creates an output options object for Rollup.js. 19 | * @param {import('rollup').OutputOptions} options 20 | * @returns {import('rollup').OutputOptions} 21 | */ 22 | function createOutputOptions(options) { 23 | return { 24 | banner, 25 | name: '[libraryCamelCaseName]', 26 | exports: 'named', 27 | sourcemap: true, 28 | ...options, 29 | }; 30 | } 31 | 32 | /** 33 | * @type {import('rollup').RollupOptions} 34 | */ 35 | const options = { 36 | input: './src/index.ts', 37 | output: [ 38 | createOutputOptions({ 39 | file: './dist/index.cjs', 40 | format: 'commonjs', 41 | }), 42 | createOutputOptions({ 43 | file: './dist/index.mjs', 44 | format: 'esm', 45 | }) 46 | ], 47 | plugins: [ 48 | typescript2({ 49 | clean: true, 50 | useTsconfigDeclarationDir: true, 51 | tsconfig: './tsconfig.bundle.json', 52 | }), 53 | ], 54 | external: ['geojson-minimum-bounding-rectangle', '@mapbox/sphericalmercator', '@turf/turf'] 55 | }; 56 | 57 | export default options; 58 | -------------------------------------------------------------------------------- /.github/workflows/jekyll-gh-pages.yml: -------------------------------------------------------------------------------- 1 | # Sample workflow for building and deploying a Jekyll site to GitHub Pages 2 | name: Deploy Jekyll with GitHub Pages dependencies preinstalled 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Build job 26 | build: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | - name: Setup Pages 32 | uses: actions/configure-pages@v5 33 | - name: Install NPM dependencies 34 | run: npm install 35 | - name: Build Docs Site Javascript 36 | run: npm run docs:js 37 | - name: Build with Jekyll 38 | uses: actions/jekyll-build-pages@v1 39 | with: 40 | source: ./docs 41 | destination: ./docs/_site 42 | - name: Upload artifact 43 | uses: actions/upload-pages-artifact@v3 44 | with: 45 | path: docs/_site 46 | 47 | # Deployment job 48 | deploy: 49 | environment: 50 | name: github-pages 51 | url: ${{ steps.deployment.outputs.page_url }} 52 | runs-on: ubuntu-latest 53 | needs: build 54 | steps: 55 | - name: Deploy to GitHub Pages 56 | id: deployment 57 | uses: actions/deploy-pages@v4 58 | -------------------------------------------------------------------------------- /test/fixtures/points1.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "features": [ 4 | { 5 | "type": "Feature", 6 | "properties": {}, 7 | "geometry": { 8 | "coordinates": [ 9 | 153.09252286566618, 10 | -27.495394486394595 11 | ], 12 | "type": "Point" 13 | } 14 | }, 15 | { 16 | "type": "Feature", 17 | "properties": {}, 18 | "geometry": { 19 | "coordinates": [ 20 | 153.0337116981393, 21 | -27.491652576234742 22 | ], 23 | "type": "Point" 24 | } 25 | }, 26 | { 27 | "type": "Feature", 28 | "properties": {}, 29 | "geometry": { 30 | "coordinates": [ 31 | 153.0318641388523, 32 | -27.43757805419375 33 | ], 34 | "type": "Point" 35 | } 36 | }, 37 | { 38 | "type": "Feature", 39 | "properties": {}, 40 | "geometry": { 41 | "coordinates": [ 42 | 153.02368134921517, 43 | -27.373205252204414 44 | ], 45 | "type": "Point" 46 | } 47 | }, 48 | { 49 | "type": "Feature", 50 | "properties": {}, 51 | "geometry": { 52 | "coordinates": [ 53 | 152.98182319256733, 54 | -27.56561898288239 55 | ], 56 | "type": "Point" 57 | } 58 | }, 59 | { 60 | "type": "Feature", 61 | "properties": {}, 62 | "geometry": { 63 | "coordinates": [ 64 | 153.05070191888734, 65 | -27.61245578553057 66 | ], 67 | "type": "Point" 68 | } 69 | }, 70 | { 71 | "type": "Feature", 72 | "properties": {}, 73 | "geometry": { 74 | "coordinates": [ 75 | 153.01336438666812, 76 | -27.317815338646703 77 | ], 78 | "type": "Point" 79 | } 80 | }, 81 | { 82 | "type": "Feature", 83 | "properties": {}, 84 | "geometry": { 85 | "coordinates": [ 86 | 153.18725454555317, 87 | -27.685153072833792 88 | ], 89 | "type": "Point" 90 | } 91 | } 92 | ] 93 | } 94 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { mapFitFeatures } from './'; 2 | import * as fs from 'node:fs'; 3 | import type { FeatureCollection } from 'geojson'; 4 | 5 | const fixturePoints1 = JSON.parse(fs.readFileSync('test/fixtures/points1.json', 'utf-8')) as FeatureCollection; 6 | const fixturePoints2 = JSON.parse(fs.readFileSync('test/fixtures/points2.json', 'utf-8')) as FeatureCollection; 7 | 8 | describe('mapFitFeatures', () => { 9 | it('should fit the map to a set of features', () => { 10 | const result = mapFitFeatures(fixturePoints1, [800, 600]); 11 | 12 | expect(result).toEqual({ 13 | bearing: 67.26319164329749, 14 | zoom: 10.28961353474488, 15 | center: [153.0370867491144, -27.525132573088545], 16 | }); 17 | }); 18 | 19 | it('should fit the map to a different set of features', () => { 20 | const result = mapFitFeatures(fixturePoints2, [800, 600]); 21 | 22 | expect(result).toEqual({ 23 | bearing: 53.33512569699616, 24 | zoom: 5.268314076490395, 25 | center: [148.57471973355217, -23.25823876839225], 26 | }); 27 | }); 28 | 29 | it('should fit the map to a set of features with different screen ratio', () => { 30 | const result = mapFitFeatures(fixturePoints1, [400, 1200]); 31 | 32 | expect(result).toEqual({ 33 | bearing: 337.2631916432975, 34 | zoom: 10.874576035466035, 35 | center: [153.0370867491144, -27.52513257308853], 36 | }); 37 | }); 38 | 39 | it('should fit the map to a set of features with padding', () => { 40 | const result = mapFitFeatures(fixturePoints1, [800, 600], { 41 | padding: { 42 | left: 0, 43 | right: 100, 44 | top: 50, 45 | bottom: 100, 46 | }, 47 | }); 48 | 49 | expect(result).toEqual({ 50 | bearing: 67.26319164329749, 51 | zoom: 10.096968456802484, 52 | center: [153.03229437366502, -27.588626358968774], 53 | }); 54 | }); 55 | 56 | it('should fit the map to a set of features with a preferred bearing', () => { 57 | // This ficture set produces a bearing of 67.26319164329749, but if we set a preferred bearing of 210, it should be 247.2631916432975 58 | const result = mapFitFeatures(fixturePoints1, [800, 600], { 59 | preferredBearing: 210, 60 | }); 61 | 62 | expect(result).toEqual({ 63 | bearing: 247.2631916432975, 64 | zoom: 10.28961353474488, 65 | center: [153.0370867491144, -27.525132573088545], 66 | }); 67 | }); 68 | }); 69 | 70 | 71 | -------------------------------------------------------------------------------- /docs/_layouts/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% seo %} 7 | 8 | 9 | 10 | 11 | 12 | 13 | {% include head.html %} 14 | 15 | 16 | Skip to the content. 17 | 18 | 48 | 49 |
50 | {{ content }} 51 | 52 | 58 |
59 | 60 | 61 | -------------------------------------------------------------------------------- /docs/assets/css/style.scss: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | @import "{{ site.theme }}"; 5 | 6 | .header-content { 7 | max-width: 64rem; 8 | padding: 0 6rem; 9 | margin: 0 auto; 10 | } 11 | 12 | .header-options { 13 | display: flex; 14 | flex-direction: row; 15 | gap: 1em; 16 | justify-content: space-between; 17 | } 18 | 19 | .install-instructions { 20 | display: flex; 21 | flex-direction: row; 22 | gap: 1em; 23 | padding: 1em; 24 | } 25 | 26 | .install-instructions-tool { 27 | line-height: 2em; 28 | } 29 | 30 | .install-instructions-command { 31 | line-height: 1em; 32 | font-family: monospace; 33 | border-radius: 0.3rem; 34 | background-color: rgba(255, 255, 255, 0.08); 35 | padding: 0.5em; 36 | line-height: normal; 37 | } 38 | 39 | .install-instructions-command pre { 40 | margin: 0; 41 | } 42 | 43 | .header-buttons { 44 | border-left: 1px solid rgba(255, 255, 255, 0.2); 45 | padding: 0.5em 1em; 46 | } 47 | 48 | .header-buttons .btn, .header-buttons .btn+btn { 49 | margin: 0.5em 0; 50 | width: 100%; 51 | } 52 | 53 | @media only screen and (max-width: 1000px) { 54 | .header-content { 55 | max-width: 100%; 56 | width: 100%; 57 | padding: 0 6rem; 58 | margin: 0 auto; 59 | } 60 | 61 | .header-options { 62 | flex-direction: column; 63 | gap: 1em; 64 | } 65 | 66 | .header-buttons { 67 | border-left: none; 68 | padding: 0; 69 | } 70 | } 71 | 72 | @media only screen and (max-width: 780px) { 73 | .header-content { 74 | max-width: 100%; 75 | width: 100%; 76 | padding: 0 4rem; 77 | } 78 | } 79 | 80 | @media only screen and (max-width: 672px) { 81 | .header-content { 82 | max-width: 100%; 83 | width: 100%; 84 | padding: 0 2rem; 85 | } 86 | } 87 | 88 | @media only screen and (max-width: 520px) { 89 | .header-content { 90 | max-width: 100%; 91 | width: 100%; 92 | padding: 0; 93 | } 94 | 95 | .install-instructions { 96 | display: none; 97 | } 98 | } 99 | 100 | .map-preview { 101 | margin: 0 auto; 102 | border-radius: 0.3rem; 103 | height: 300px; 104 | width: 100%; 105 | max-width: 600px; 106 | } 107 | 108 | #map-main-preview-area { 109 | border: 1px solid #ddd; 110 | background-color: #f3f6fa; 111 | width: 100%; 112 | max-width: 620px; 113 | padding: 10px; 114 | margin: 0 auto; 115 | border-radius: 0.3rem; 116 | } 117 | 118 | .map-main-preview-fit-label { 119 | margin: 0 1em 0 0.2em; 120 | } 121 | 122 | #map-main-preview { 123 | margin: 1em 0; 124 | } 125 | 126 | #map-main-preview-info { 127 | display: flex; 128 | flex-direction: row; 129 | flex-wrap: wrap; 130 | gap: 0.5em 3em; 131 | font-size: 0.8em; 132 | } 133 | 134 | #map-main-preview-info div { 135 | display: flex; 136 | flex-direction: row; 137 | flex-wrap: nowrap; 138 | gap: 0.5em; 139 | } 140 | 141 | .map-main-preview-info-label { 142 | font-weight: bold; 143 | } 144 | 145 | #code-preview-area { 146 | border: 1px solid #ddd; 147 | background-color: #f3f6fa; 148 | padding: 10px; 149 | margin: 0 auto; 150 | border-radius: 0.3rem; 151 | } 152 | 153 | #code-preview-area pre { 154 | background-color: #ffffff; 155 | margin: 0.8em 0 0 0; 156 | } 157 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "geojson-map-fit-mercator", 3 | "version": "1.1.0", 4 | "description": "Finds the optimal bearing, zoom and center point for fitting a set of GeoJSON features in a Mapbox GL or LibreMap Mercator map.", 5 | "main": "dist/index.cjs", 6 | "types": "types/index.d.ts", 7 | "module": "dist/index.mjs", 8 | "files": [ 9 | "dist/", 10 | "types/" 11 | ], 12 | "exports": { 13 | ".": [ 14 | { 15 | "types": "./types/index.d.ts", 16 | "import": "./dist/index.mjs", 17 | "require": "./dist/index.cjs", 18 | "default": "./dist/index.cjs" 19 | }, 20 | "./dist/index.cjs" 21 | ] 22 | }, 23 | "scripts": { 24 | "test": "jest", 25 | "lint:types": "tsc --project ./tsconfig.json --noEmit", 26 | "lint:ts": "eslint src", 27 | "lint": "npm run lint:types && npm run lint:ts", 28 | "build": "rollup --config ./rollup.config.mjs", 29 | "docs:js": "cd docs && rollup --config ./rollup.config.mjs", 30 | "docs:jekyll": "cd docs && bundle exec jekyll build", 31 | "docs:typedoc": "typedoc --out docs src", 32 | "docs:local": "npm run docs:js && cd docs && bundle exec jekyll serve", 33 | "docs": "npm run docs:js && npm run docs:typedoc && npm run docs:jekyll", 34 | "prepare": "npm run build" 35 | }, 36 | "devDependencies": { 37 | "@eslint/js": "^9.10.0", 38 | "@rollup/plugin-commonjs": "^28.0.3", 39 | "@rollup/plugin-json": "^6.1.0", 40 | "@rollup/plugin-node-resolve": "^16.0.0", 41 | "@rollup/plugin-terser": "^0.4.4", 42 | "@types/eslint__js": "^8.42.3", 43 | "@types/jest": "^29.5.13", 44 | "eslint": "^9.10.0", 45 | "eslint-config-prettier": "^9.1.0", 46 | "eslint-plugin-prettier": "^5.2.1", 47 | "jest": "^29.7.0", 48 | "maplibre-gl": "^5.2.0", 49 | "prettier": "^3.3.3", 50 | "rollup": "^4.22.0", 51 | "rollup-plugin-import-css": "^3.5.8", 52 | "rollup-plugin-typescript2": "^0.36.0", 53 | "ts-jest": "^29.2.5", 54 | "ts-jest-resolver": "^2.0.1", 55 | "ts-node": "^10.9.2", 56 | "typedoc": "^0.27.9", 57 | "typedoc-plugin-markdown": "^4.2.7", 58 | "typescript": "~5.5.4", 59 | "typescript-eslint": "^8.6.0" 60 | }, 61 | "repository": { 62 | "type": "git", 63 | "url": "git+https://github.com/tjdavey/geojson-map-fit-mercator.git" 64 | }, 65 | "keywords": [ 66 | "Mapbox", 67 | "MapLibre", 68 | "GL", 69 | "Mercator", 70 | "Bounding", 71 | "Box", 72 | "Screen", 73 | "Fit", 74 | "Fit-to-screen", 75 | "bbox" 76 | ], 77 | "author": "Tristan Davey ", 78 | "license": "MIT", 79 | "bugs": { 80 | "url": "https://github.com/tjdavey/geojson-map-fit-mercator/issues" 81 | }, 82 | "homepage": "https://tjdavey.github.io/geojson-map-fit-mercator/", 83 | "dependencies": { 84 | "@mapbox/sphericalmercator": "^2.0.1", 85 | "@turf/bearing": "^7.2.0", 86 | "@turf/centroid": "^7.2.0", 87 | "@turf/convex": "^7.2.0", 88 | "@turf/envelope": "^7.2.0", 89 | "@turf/helpers": "^7.2.0", 90 | "@turf/invariant": "^7.2.0", 91 | "@turf/length": "^7.2.0", 92 | "@turf/meta": "^7.2.0", 93 | "@turf/polygon-to-line": "^7.2.0", 94 | "@turf/transform-rotate": "^7.2.0", 95 | "geojson": "^0.5.0" 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/screen.ts: -------------------------------------------------------------------------------- 1 | import type { Feature, Polygon, Position } from 'geojson'; 2 | import { SphericalMercator } from '@mapbox/sphericalmercator'; 3 | import { XY, LngLat, mapFitPadding, rectangleOrientation } from './types'; 4 | import { getCoords } from '@turf/invariant'; 5 | 6 | export function findScreenZoom( 7 | paddedScreenDimensions: XY, 8 | paddedScreenRatio: number, 9 | boundingRectangleOrientation: rectangleOrientation, 10 | maxZoom: number, 11 | floatZoom: boolean, 12 | merc: SphericalMercator, 13 | ): number { 14 | const { shortSide, longSide } = boundingRectangleOrientation; 15 | const longSideCoords = getCoords(longSide!); 16 | const shortSideCoords = getCoords(shortSide!); 17 | 18 | // We need to determine the ratio required for the zoom level. To do this we are going to approximate the length 19 | // of the longest and shortest sides of the polygon in pixels (This doesn't account for projection distortion but is 20 | // a good estimation) 21 | const longPx: [XY, XY] = [merc.px(longSideCoords[0], maxZoom), merc.px(longSideCoords[1], maxZoom)]; 22 | const shortPx: [XY, XY] = [merc.px(shortSideCoords[0], maxZoom), merc.px(shortSideCoords[1], maxZoom)]; 23 | 24 | // Because these points aren't aligned to the axis, we use the Pythagorean theorem to calculate the distance 25 | const longPxX = longPx[0][0] - longPx[1][0]; 26 | const longPxY = longPx[0][1] - longPx[1][1]; 27 | const shortPxX = shortPx[0][0] - shortPx[1][0]; 28 | const shortPxY = shortPx[0][1] - shortPx[1][1]; 29 | const longPxDistance = Math.sqrt(Math.pow(longPxX, 2) + Math.pow(longPxY, 2)); 30 | const shortPxDistance = Math.sqrt(Math.pow(shortPxX, 2) + Math.pow(shortPxY, 2)); 31 | 32 | let xPx = longPxDistance; 33 | let yPx = shortPxDistance; 34 | 35 | // If the screen is taller than it is wide, swap the x and y values 36 | if (paddedScreenRatio < 1) { 37 | xPx = shortPxDistance; 38 | yPx = longPxDistance; 39 | } 40 | 41 | const ratios: XY = [Math.abs(xPx / paddedScreenDimensions[0]), Math.abs(yPx / paddedScreenDimensions[1])]; 42 | const zoom = Math.min(maxZoom - Math.log(ratios[0]) / Math.log(2), maxZoom - Math.log(ratios[1]) / Math.log(2)); 43 | return floatZoom ? zoom : Math.floor(zoom); 44 | } 45 | 46 | export function findScreenBearing( 47 | boundingRectangleBearing: number, 48 | preferredBearing: number, 49 | screenRatio: number, 50 | ): number { 51 | let bearing = boundingRectangleBearing; 52 | // Rotate the bearing by 90 degrees if the screen is wider than it is tall 53 | if (screenRatio > 1) { 54 | bearing = bearing + (90 % 360); 55 | } 56 | 57 | // Rotate the bearing 180 degrees if the preferred bearing is on the opposite side of the screen 58 | if (bearing < (preferredBearing - 90) % 360 || bearing > (preferredBearing + 90) % 360) { 59 | bearing = (bearing + 180) % 360; 60 | } 61 | 62 | return bearing; 63 | } 64 | 65 | export function findScreenCenter( 66 | boundingRectangle: Feature, 67 | bearing: number, 68 | zoom: number, 69 | padding: mapFitPadding, 70 | merc: SphericalMercator, 71 | ): LngLat { 72 | const { left = 0, right = 0, top = 0, bottom = 0 } = padding; 73 | 74 | // Use the bounding rectangle's pixel location to calculate the centre of the 75 | // map. This allows us to account for mercator projection distortion. 76 | const coords = getCoords(boundingRectangle); 77 | const uniqCoords = coords[0].reduce((uniq: Position[], coord: [number, number]) => { 78 | if (!uniq.find((c) => c[0] === coord[0] && c[1] === coord[1])) { 79 | uniq.push(coord); 80 | } 81 | return uniq; 82 | }, []); 83 | 84 | const sumCoords = uniqCoords.reduce( 85 | (acc: [number, number], coord: [number, number]) => { 86 | const [x, y] = merc.px(coord as LngLat, zoom); 87 | acc[0] = acc[0] + x; 88 | acc[1] = acc[1] + y; 89 | return acc; 90 | }, 91 | [0, 0], 92 | ); 93 | 94 | const midX = sumCoords[0] / uniqCoords.length; 95 | const midY = sumCoords[1] / uniqCoords.length; 96 | 97 | const xPaddingOffset = right - left; 98 | const yPaddingOffset = bottom - top; 99 | 100 | const bearingRadians = bearing * (Math.PI / 180); 101 | 102 | const centerXOffset = xPaddingOffset * Math.cos(bearingRadians) - yPaddingOffset * Math.sin(bearingRadians); 103 | const centerYOffset = xPaddingOffset * Math.sin(bearingRadians) + yPaddingOffset * Math.cos(bearingRadians); 104 | 105 | return merc.ll([midX + centerXOffset, midY + centerYOffset], zoom); 106 | } 107 | -------------------------------------------------------------------------------- /test/fixtures/points2.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "features": [ 4 | { 5 | "type": "Feature", 6 | "properties": {}, 7 | "geometry": { 8 | "coordinates": [ 9 | 146.7665300502244, 10 | -19.28932335452835 11 | ], 12 | "type": "Point" 13 | } 14 | }, 15 | { 16 | "type": "Feature", 17 | "properties": {}, 18 | "geometry": { 19 | "coordinates": [ 20 | 145.75827957856762, 21 | -16.928591596631975 22 | ], 23 | "type": "Point" 24 | } 25 | }, 26 | { 27 | "type": "Feature", 28 | "properties": {}, 29 | "geometry": { 30 | "coordinates": [ 31 | 149.17324606122747, 32 | -21.14343512762234 33 | ], 34 | "type": "Point" 35 | } 36 | }, 37 | { 38 | "type": "Feature", 39 | "properties": {}, 40 | "geometry": { 41 | "coordinates": [ 42 | 150.51042193855005, 43 | -23.3702460594846 44 | ], 45 | "type": "Point" 46 | } 47 | }, 48 | { 49 | "type": "Feature", 50 | "properties": {}, 51 | "geometry": { 52 | "coordinates": [ 53 | 152.70092008974513, 54 | -25.53198361333078 55 | ], 56 | "type": "Point" 57 | } 58 | }, 59 | { 60 | "type": "Feature", 61 | "properties": {}, 62 | "geometry": { 63 | "coordinates": [ 64 | 152.34986090637824, 65 | -24.869715360378706 66 | ], 67 | "type": "Point" 68 | } 69 | }, 70 | { 71 | "type": "Feature", 72 | "properties": {}, 73 | "geometry": { 74 | "coordinates": [ 75 | 148.16203441847352, 76 | -23.526073214293802 77 | ], 78 | "type": "Point" 79 | } 80 | }, 81 | { 82 | "type": "Feature", 83 | "properties": {}, 84 | "geometry": { 85 | "coordinates": [ 86 | 146.26588439365344, 87 | -20.072722679702068 88 | ], 89 | "type": "Point" 90 | } 91 | }, 92 | { 93 | "type": "Feature", 94 | "properties": {}, 95 | "geometry": { 96 | "coordinates": [ 97 | 148.79157341094418, 98 | -26.5702048856907 99 | ], 100 | "type": "Point" 101 | } 102 | }, 103 | { 104 | "type": "Feature", 105 | "properties": {}, 106 | "geometry": { 107 | "coordinates": [ 108 | 151.95009396048255, 109 | -27.555900462893092 110 | ], 111 | "type": "Point" 112 | } 113 | }, 114 | { 115 | "type": "Feature", 116 | "properties": {}, 117 | "geometry": { 118 | "coordinates": [ 119 | 152.75893964285797, 120 | -27.62321600790343 121 | ], 122 | "type": "Point" 123 | } 124 | }, 125 | { 126 | "type": "Feature", 127 | "properties": {}, 128 | "geometry": { 129 | "coordinates": [ 130 | 153.03286162135822, 131 | -27.462015832034325 132 | ], 133 | "type": "Point" 134 | } 135 | }, 136 | { 137 | "type": "Feature", 138 | "properties": {}, 139 | "geometry": { 140 | "coordinates": [ 141 | 153.11924823037424, 142 | -27.641063818600394 143 | ], 144 | "type": "Point" 145 | } 146 | }, 147 | { 148 | "type": "Feature", 149 | "properties": {}, 150 | "geometry": { 151 | "coordinates": [ 152 | 152.95095298850498, 153 | -27.08438825374084 154 | ], 155 | "type": "Point" 156 | } 157 | }, 158 | { 159 | "type": "Feature", 160 | "properties": {}, 161 | "geometry": { 162 | "coordinates": [ 163 | 153.12078416455518, 164 | -26.793896436328083 165 | ], 166 | "type": "Point" 167 | } 168 | }, 169 | { 170 | "type": "Feature", 171 | "properties": {}, 172 | "geometry": { 173 | "coordinates": [ 174 | 152.66491740796482, 175 | -26.187900609342627 176 | ], 177 | "type": "Point" 178 | } 179 | }, 180 | { 181 | "type": "Feature", 182 | "properties": {}, 183 | "geometry": { 184 | "coordinates": [ 185 | 153.39572556482267, 186 | -28.010500675476756 187 | ], 188 | "type": "Point" 189 | } 190 | } 191 | ] 192 | } 193 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GeoJSON Map Fit Mercator 2 | 3 | [![npm version](https://badge.fury.io/js/geojson-map-fit-mercator.svg)](https://badge.fury.io/js/geojson-map-fit-mercator) 4 | [![CI Build](https://github.com/tjdavey/geojson-map-fit-mercator/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/tjdavey/geojson-map-fit-mercator/actions/workflows/test.yml) 5 | [![Coverage Status](https://coveralls.io/repos/github/tjdavey/geojson-map-fit-mercator/badge.svg?branch=main)](https://coveralls.io/github/tjdavey/geojson-map-fit-mercator?branch=main) 6 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Ftjdavey%2Fgeojson-map-fit-mercator.svg?type=shield&issueType=license)](https://app.fossa.com/projects/git%2Bgithub.com%2Ftjdavey%2Fgeojson-map-fit-mercator?ref=badge_shield&issueType=license) 7 | 8 | [GeoJSON Map Fit Mercator](https://tjdavey.github.io/geojson-map-fit-mercator/) finds the optimal bearing, zoom and 9 | center point for fitting a set of [GeoJSON](https://geojson.org/) features in a 10 | [Mapbox GL](https://docs.mapbox.com/mapbox-gl-js/guides) or [MapLibre GL](https://maplibre.org/) viewport. The optimal 11 | viewport is calculated by determining the optimal bearing and zoom level to present a 12 | [minimum bounding rectangle (MBR)](https://en.wikipedia.org/wiki/Minimum_bounding_rectangle) all the given GeoJSON 13 | features. This can allow you to render more detailed, better fitting maps than the default [bounding box](https://docs.mapbox.com/help/glossary/bounding-box/) 14 | behaviour which only describes a x/y aligned minimum bounding rectangle. 15 | 16 | Checkout the [demo](https://tjdavey.github.io/geojson-map-fit-mercator/#preview) to see the library in action. 17 | 18 | ## Installation 19 | 20 | ### NPM 21 | 22 | ```bash 23 | npm install geojson-map-fit-mercator 24 | ``` 25 | 26 | ### Yarn 27 | 28 | ```bash 29 | yarn add geojson-map-fit-mercator 30 | ``` 31 | 32 | ## Usage 33 | 34 | ### EcmaScript Module (ESM) 35 | 36 | ```javascript 37 | import { mapFitFeatures } from 'geojson-map-fit-mercator'; 38 | ``` 39 | 40 | ### CommonJS Module 41 | 42 | ```javascript 43 | const { mapFitFeatures } = require('geojson-map-fit-mercator'); 44 | ``` 45 | 46 | ### Map Initialisation Example 47 | 48 | `mapFitFeatures` returns an object describing the optimal center point (`center`), bearing (`bearing`) and zoom level 49 | (`zoom`) to display the given GeoJSON features in a given resolution map. 50 | 51 | ```javascript 52 | import { mapFitFeatures } from 'geojson-map-fit-mercator'; 53 | import maplibregl from 'maplibre-gl'; 54 | import mapData from './map-data.json' with { type: 'json' }; // Load GeoJSON data 55 | 56 | const { bearing, center, zoom } = mapFitFeatures(mapData, [600, 400]); 57 | 58 | const map = new new maplibregl.Map({ 59 | container: 'map', // container ID for a 600px by 400px element 60 | style: 'https://demotiles.maplibre.org/style.json', 61 | center: center, // starting position [lng, lat] 62 | zoom: zoom, // starting zoom 63 | bearing: bearing // starting bearing 64 | }); 65 | 66 | ... 67 | ``` 68 | 69 | Additional options for `mapFitFeatures` such as map padding, zoom and bearing preferences and tile-size 70 | are described in the [API](#api) section below. 71 | 72 | ## API 73 | 74 | ### `mapFitFeatures(geojson: GeoJSON, dimensions: [number, number], options: mapFitOptions): { bearing: number, center: LonLat, zoom: number }` 75 | 76 | Finds the optimal bearing, zoom and center point for fitting all features in a map viewport. 77 | 78 | #### Parameters 79 | 80 | - `geojson: GeoJSON` - A GeoJSON object containing features to fit in the map viewport. 81 | - `dimensions: [number, number]` - The dimensions of the map viewport in pixels as a [x, y] array. 82 | - `options: mapFitOptions` - Options for the map fitting algorithm and tile calculations. 83 | 84 | #### Returns 85 | 86 | - `center: LonLat` - The optimal center point for the map viewport expressed as [lon, lat] array. 87 | - `bearing: number` - The optimal bearing for the map viewport. 88 | - `zoom: number` - The optimal zoom level for the map viewport. A number between 0 and `options.maxZoom`. If the `options.floatZoom` is set to `false` it will return only a whole number. 89 | 90 | ### `mapFitOptions` 91 | 92 | Options for the map fitting algorithm and tile calculations. 93 | 94 | #### Properties 95 | 96 | - `padding: mapFitPadding` - The padding to apply to the map viewport. The feature fitting will scale the map down to ensure this many pixels pad the features on each side of the map. Default: `{ top: 0, bottom: 0, left: 0, right: 0 }`. 97 | - `maxZoom: number` - The maximum zoom level to allow. Default: `23`. 98 | - `floatZoom: boolean` - If `true` the zoom level will be a floating point number. If set to `false` only whole number zoom levels will be returned. Default: `true`. 99 | - `preferredBearing: number` - Determines which orientation of the bounding rectangle the algorithm will attempt to orient upwards. Eg. `0` will keep north pointing in an upwards angle, while `180` will mean south is pointing at an upwards angle. Default: `0`. 100 | - `tileSize: number` - The size of the map tiles in pixels. By default for Mapbox GL JS and MapLibre GL JS this is `512`. Default: `512`. 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { SphericalMercator } from '@mapbox/sphericalmercator'; 2 | import { bearing } from '@turf/bearing'; 3 | import { convex } from '@turf/convex'; 4 | import { getCoords } from '@turf/invariant'; 5 | import type { Polygon, Feature, FeatureCollection, LineString } from 'geojson'; 6 | import { findScreenCenter, findScreenBearing, findScreenZoom } from './screen'; 7 | import { XY, mapFitPadding, mapFitOptions, mapFitResult, rectangleOrientation, boundingOrientation } from './types'; 8 | import transformRotate from '@turf/transform-rotate'; 9 | import centroid from '@turf/centroid'; 10 | import { segmentReduce } from '@turf/meta'; 11 | import polygonToLine from '@turf/polygon-to-line'; 12 | import envelope from '@turf/envelope'; 13 | import { length } from '@turf/length'; 14 | import type { AllGeoJSON } from '@turf/helpers'; 15 | 16 | function mapFitFeatures( 17 | features: FeatureCollection, 18 | screenDimensions: XY, 19 | options: mapFitOptions = {} as mapFitOptions, 20 | ): mapFitResult { 21 | // Set default options 22 | const { 23 | tileSize = 512, 24 | preferredBearing = 0, 25 | padding = {} as mapFitPadding, 26 | maxZoom = 23, 27 | floatZoom = true, 28 | } = options; 29 | 30 | // Create a mercator projection. SphericalMercator caches its calculations so it's safe to create a new instance each run 31 | const merc: SphericalMercator = new SphericalMercator({ size: tileSize, antimeridian: true }); 32 | const [screenWidth, screenHeight] = screenDimensions; 33 | const { left = 0, right = 0, top = 0, bottom = 0 } = padding; 34 | const paddedScreenWidth = screenWidth - left - right; 35 | const paddedScreenHeight = screenHeight - top - bottom; 36 | const paddedScreenRatio = paddedScreenWidth / paddedScreenHeight; 37 | 38 | // Calculate the bounding rectangle of the features 39 | const { 40 | boundsOrientation: { orientation, bearing: baseBearing }, 41 | boundingRectangle, 42 | } = minimumBoundingRectangle(features); 43 | 44 | if (!boundingRectangle) { 45 | throw new Error('Unable to calculate bounding rectangle'); 46 | } 47 | 48 | // Determine how to fit the bounding rectangle to the screen 49 | const zoom = findScreenZoom( 50 | [paddedScreenWidth, paddedScreenHeight], 51 | paddedScreenRatio, 52 | orientation, 53 | maxZoom, 54 | floatZoom, 55 | merc, 56 | ); 57 | const bearing = findScreenBearing(baseBearing!, preferredBearing, paddedScreenRatio); 58 | const center = findScreenCenter(boundingRectangle, bearing, zoom, padding, merc); 59 | 60 | return { bearing, zoom, center }; 61 | } 62 | 63 | export function minimumBoundingRectangle(geoJsonInput: AllGeoJSON): { 64 | boundsOrientation: boundingOrientation; 65 | boundingRectangle: Feature; 66 | } { 67 | // Create a convex hull around the input geometry 68 | const convexHull = convex(geoJsonInput); 69 | if (!convexHull) throw new Error("Can't determine minimumBoundingRectangle for given geometry"); 70 | 71 | // Break the hull into its constituent edges and find the smallest 72 | const hullLines = polygonToLine(convexHull); 73 | const smallestHullBoundsOrientation = segmentReduce( 74 | hullLines, 75 | (smallestEnvelope: boundingOrientation | undefined, segment) => { 76 | return smallestHullEnvelopeReducer(smallestEnvelope, segment!, convexHull); 77 | }, 78 | { bearing: undefined, orientation: { shortSide: undefined, longSide: undefined }, envelope: undefined }, 79 | ); 80 | 81 | const boundingRectangle = transformRotate( 82 | envelope(smallestHullBoundsOrientation.envelope!), 83 | smallestHullBoundsOrientation.bearing!, 84 | { 85 | pivot: centroid(convexHull), 86 | }, 87 | ); 88 | 89 | return { 90 | boundsOrientation: smallestHullBoundsOrientation, 91 | boundingRectangle, 92 | }; 93 | } 94 | 95 | function smallestHullEnvelopeReducer( 96 | smallestEnvelope: boundingOrientation | undefined, 97 | segment: Feature, 98 | hull: Feature, 99 | ): boundingOrientation { 100 | const segmentCoords = getCoords(segment); 101 | const segmentBearing = bearing(segmentCoords[0], segmentCoords[1]); 102 | 103 | const rotatedHull = transformRotate(hull, -1.0 * segmentBearing, { 104 | pivot: centroid(hull), 105 | }); 106 | const envelopeOfHull = envelope(rotatedHull); 107 | 108 | const rectangleOrientation = findRectangleOrientation(envelopeOfHull); 109 | const shortSideLength = length(rectangleOrientation.shortSide!); 110 | 111 | if ( 112 | smallestEnvelope!.orientation.shortSide == undefined || 113 | shortSideLength < length(smallestEnvelope!.orientation.shortSide) 114 | ) { 115 | return { bearing: segmentBearing, orientation: rectangleOrientation, envelope: envelopeOfHull }; 116 | } 117 | 118 | return smallestEnvelope!; 119 | } 120 | 121 | function findRectangleOrientation(rectangle: Feature): rectangleOrientation { 122 | const rectangleSides = polygonToLine(rectangle); 123 | return segmentReduce( 124 | rectangleSides, 125 | (sideOrientation: rectangleOrientation | undefined, segment): rectangleOrientation => { 126 | const segmentLength = length(segment!); 127 | 128 | if (sideOrientation!.shortSide == undefined || length(sideOrientation!.shortSide) > segmentLength) { 129 | sideOrientation!.shortSide = segment!; 130 | } 131 | 132 | if (sideOrientation!.longSide == undefined || length(sideOrientation!.longSide) < segmentLength) { 133 | sideOrientation!.longSide = segment!; 134 | } 135 | 136 | return sideOrientation!; 137 | }, 138 | { shortSide: undefined, longSide: undefined }, 139 | ); 140 | } 141 | 142 | export { mapFitFeatures }; 143 | -------------------------------------------------------------------------------- /docs/index.markdown: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | Given a set of [GeoJSON](https://geojson.org/) features finds the optimal bearing, zoom and center point for fitting 5 | all features in a [Mapbox GL](https://docs.mapbox.com/mapbox-gl-js/guides) or [MapLibre GL](https://maplibre.org/) 6 | viewport. The optimal viewport is calculated by determining the optimal bearing and zoom level to present a 7 | [minimum bounding rectangle (MBR)](https://en.wikipedia.org/wiki/Minimum_bounding_rectangle) of all the given GeoJSON 8 | features. This can allow you to render more detailed, better fitting maps than the default 9 | [bounding box](https://docs.mapbox.com/help/glossary/bounding-box/) behaviour which only describes a x/y aligned 10 | minimum bounding rectangle. 11 | 12 | ### Preview 13 | 14 |
15 |
16 |
17 | Fitting Options 18 | 19 | 20 | 21 | 22 |
23 | 24 |
25 | 26 |
27 |
28 |
Center:
29 |
30 |
31 |
32 |
Bearing:
33 |
34 |
35 |
36 |
Zoom:
37 |
38 |
39 |
40 |
41 |
42 | 43 | ## Usage 44 | 45 | `mapFitFeatures` returns an object describing the optimal center point (`center`), bearing (`bearing`) and zoom level 46 | (`zoom`) to display the given GeoJSON features in a given resolution map. 47 | 48 |
49 |
50 | Mapping Library 51 | 52 | 53 | 54 | 55 |
56 |
 57 |     
 58 | import { mapFitFeatures } from 'geojson-map-fit-mercator';
 59 | import maplibregl from 'maplibre-gl';
 60 | import mapData from './map-data.json' with { type: 'json' }; // Load GeoJSON data
 61 | 
 62 | const { bearing, center, zoom } = mapFitFeatures(
 63 |   mapData,
 64 |   [600, 300]
 65 | );
 66 | 
 67 | const map = new new maplibregl.Map({
 68 |   container: 'map', // container ID for a 600px by 400px element
 69 |   style: 'https://demotiles.maplibre.org/style.json',
 70 |   center: center, // starting position [lng, lat]
 71 |   zoom: zoom, // starting zoom
 72 |   bearing: bearing // starting bearing
 73 | });
 74 | 
 75 | ...
 76 |     
 77 |   
78 | 99 |
100 | 101 | #### Padding 102 | 103 | You can add padding around the features in the viewport by passing an object to the `padding` option of `mapFitFeatures`. 104 | This padding object is identical to the [Mapbox GL JS `paddingOptions` object](https://docs.mapbox.com/mapbox-gl-js/api/properties/#paddingoptions) and [MapLibre GL JS `paddingOptions` object](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/PaddingOptions/). 105 | 106 | ```javascript 107 | mapFitFeatures( 108 | mapData, 109 | [600, 300], 110 | { 111 | padding: { 112 | top: 50, 113 | bottom: 50, 114 | left: 100, 115 | right: 100 116 | } 117 | } 118 | ); 119 | ``` 120 | 121 |
122 | 123 | #### Bearing Preference 124 | 125 | You can specify a preferred bearing for the map by passing the `bearing` option to `mapFitFeatures`. The preferred 126 | bearing will be used to pick the preferred orientation of the map to fit the minimum bounding rectangle of the features. 127 | This defaults to `0` which prefers to point the map in a northerly bearing. 128 | 129 | ```javascript 130 | mapFitFeatures( 131 | mapData, 132 | [600, 300], 133 | { 134 | preferredBearing: 180 // Prefer to point the map south 135 | } 136 | ); 137 | ``` 138 | 139 |
140 | 141 | ### Additional Features and Documentation 142 | 143 | A full set of options and parameter descriptions for `mapFitFeatures` such as tile-size and zoom preferences are 144 | described in the [API section of the Readme]({{ site.api_docs_url }}). 145 | -------------------------------------------------------------------------------- /docs/src/main.ts: -------------------------------------------------------------------------------- 1 | import { Map } from 'maplibre-gl'; 2 | import { mapFitFeatures } from '../../src/index'; 3 | import bbox from '@turf/bbox'; 4 | import type { GeoJSON } from 'geojson'; 5 | import type { LngLatBoundsLike } from 'maplibre-gl'; 6 | 7 | import sampleData from './sampledata.json' with {type: 'json'}; 8 | 9 | import 'maplibre-gl/dist/maplibre-gl.css'; 10 | 11 | window.addEventListener("load", (event) => { 12 | setupMapMainPreview(); 13 | addMap('map-padding-preview', {padding: {top: 50, bottom: 50, left: 100, right: 100}}); 14 | addMap('map-bearing-preview', {preferredBearing: 45}); 15 | setupCodePreview(); 16 | }); 17 | 18 | function setupMapMainPreview() { 19 | const padding = { 20 | left: 10, 21 | right: 10, 22 | top: 10, 23 | bottom: 10 24 | } 25 | const mapId = 'map-main-preview'; 26 | 27 | const previewMap = addMap(mapId, {padding}); 28 | 29 | document.getElementById('map-main-preview-fit-bestFit').addEventListener('change', function() { 30 | const radioButton = this as HTMLInputElement; 31 | 32 | const bestFit = mapFitFeatures( 33 | sampleData as GeoJSON.FeatureCollection, 34 | [document.getElementById(mapId).clientWidth, document.getElementById(mapId).clientHeight], 35 | {maxZoom: 8, padding}); 36 | 37 | if (radioButton.checked) { 38 | previewMap.easeTo({ 39 | center: bestFit.center, 40 | bearing: bestFit.bearing, 41 | zoom: bestFit.zoom 42 | }); 43 | } 44 | }); 45 | 46 | document.getElementById('map-main-preview-fit-bbox').addEventListener('change', function() { 47 | const radioButton = this as HTMLInputElement; 48 | 49 | const boundingBox = bbox(sampleData as GeoJSON.FeatureCollection); 50 | 51 | if (radioButton.checked) { 52 | previewMap.fitBounds(boundingBox as LngLatBoundsLike, {padding}); 53 | } 54 | }); 55 | 56 | function updateMapInfo() { 57 | document.getElementById('map-main-preview-info-center').innerText = `${previewMap.getCenter().toArray().map((coord => coord.toFixed(4))).join(', ')}`; 58 | document.getElementById('map-main-preview-info-zoom').innerText = `${previewMap.getZoom().toFixed(2)}`; 59 | document.getElementById('map-main-preview-info-bearing').innerText = `${previewMap.getBearing().toFixed(2)}`; 60 | } 61 | 62 | previewMap.on('move', () => { 63 | updateMapInfo(); 64 | }); 65 | 66 | updateMapInfo(); 67 | } 68 | 69 | function setupPaddingMap() { 70 | const padding ={ 71 | top: 50, 72 | bottom: 50, 73 | left: 100, 74 | right: 100 75 | } 76 | const mapId = 'map-padding-preview'; 77 | 78 | const fit = mapFitFeatures( 79 | sampleData as GeoJSON.FeatureCollection, 80 | [document.getElementById(mapId).clientWidth, document.getElementById(mapId).clientHeight], 81 | { 82 | padding 83 | } 84 | ); 85 | 86 | const paddingMap = new Map({ 87 | container: mapId, 88 | style: 'https://demotiles.maplibre.org/style.json', 89 | center: fit.center, 90 | bearing: fit.bearing, 91 | zoom: fit.zoom, 92 | interactive: false, 93 | }); 94 | 95 | addSampleDataToMap(paddingMap); 96 | setupMapResize(paddingMap, 'map-padding-preview', padding); 97 | } 98 | 99 | function setupBearingMap() { 100 | 101 | } 102 | 103 | function setupCodePreview() { 104 | document.getElementById('code-preview-library-maplibre').addEventListener('change', function() { 105 | const radioButton = this as HTMLInputElement; 106 | 107 | if (radioButton.checked) { 108 | document.getElementById('code-preview-maplibre').style.display = 'block'; 109 | document.getElementById('code-preview-mapbox').style.display = 'none'; 110 | } 111 | }); 112 | 113 | document.getElementById('code-preview-library-mapbox').addEventListener('change', function() { 114 | const radioButton = this as HTMLInputElement; 115 | 116 | if (radioButton.checked) { 117 | document.getElementById('code-preview-mapbox').style.display = 'block'; 118 | document.getElementById('code-preview-maplibre').style.display = 'none'; 119 | } 120 | }); 121 | } 122 | 123 | function addMap(mapId, fitOptions) { 124 | const fit = mapFitFeatures( 125 | sampleData as GeoJSON.FeatureCollection, 126 | [document.getElementById(mapId).clientWidth, document.getElementById(mapId).clientHeight], 127 | fitOptions 128 | ); 129 | 130 | const map = new Map({ 131 | container: mapId, 132 | style: 'https://demotiles.maplibre.org/style.json', 133 | center: fit.center, 134 | bearing: fit.bearing, 135 | zoom: fit.zoom, 136 | interactive: false, 137 | }); 138 | 139 | addSampleDataToMap(map); 140 | setupMapResize(map, mapId, fitOptions.padding || {}); 141 | 142 | return map; 143 | } 144 | 145 | function addSampleDataToMap(map) { 146 | map.on('load', () => { 147 | map.addSource('sample-data', { 148 | type: 'geojson', 149 | data: sampleData as GeoJSON.FeatureCollection 150 | }); 151 | 152 | map.addLayer({ 153 | id: 'sample-data', 154 | type: 'circle', 155 | source: 'sample-data', 156 | paint: { 157 | 'circle-radius': 4, 158 | 'circle-color': '#FFFFFF', 159 | 'circle-stroke-width': 1, 160 | 'circle-stroke-color': '#000000' 161 | } 162 | }); 163 | }); 164 | } 165 | 166 | function setupMapResize(map, id, padding) { 167 | window.addEventListener('resize', () => { 168 | if(window.innerWidth < 720) { 169 | // Window is small enough to begin impacting the width of the maps. Recalculate the map size to fit the new window size. 170 | const fit = mapFitFeatures( 171 | sampleData as GeoJSON.FeatureCollection, 172 | [document.getElementById(id).clientWidth, document.getElementById(id).clientHeight], 173 | {maxZoom: 8, padding}); 174 | 175 | map.easeTo(fit); 176 | } 177 | 178 | }); 179 | } 180 | 181 | 182 | -------------------------------------------------------------------------------- /docs/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (8.0.1) 5 | base64 6 | benchmark (>= 0.3) 7 | bigdecimal 8 | concurrent-ruby (~> 1.0, >= 1.3.1) 9 | connection_pool (>= 2.2.5) 10 | drb 11 | i18n (>= 1.6, < 2) 12 | logger (>= 1.4.2) 13 | minitest (>= 5.1) 14 | securerandom (>= 0.3) 15 | tzinfo (~> 2.0, >= 2.0.5) 16 | uri (>= 0.13.1) 17 | addressable (2.8.7) 18 | public_suffix (>= 2.0.2, < 7.0) 19 | base64 (0.2.0) 20 | benchmark (0.4.0) 21 | bigdecimal (3.1.9) 22 | coffee-script (2.4.1) 23 | coffee-script-source 24 | execjs 25 | coffee-script-source (1.12.2) 26 | colorator (1.1.0) 27 | commonmarker (0.23.11) 28 | concurrent-ruby (1.3.5) 29 | connection_pool (2.5.0) 30 | csv (3.3.2) 31 | dnsruby (1.72.4) 32 | base64 (~> 0.2.0) 33 | logger (~> 1.6.5) 34 | simpleidn (~> 0.2.1) 35 | drb (2.2.1) 36 | em-websocket (0.5.3) 37 | eventmachine (>= 0.12.9) 38 | http_parser.rb (~> 0) 39 | ethon (0.16.0) 40 | ffi (>= 1.15.0) 41 | eventmachine (1.2.7) 42 | execjs (2.10.0) 43 | faraday (2.12.2) 44 | faraday-net_http (>= 2.0, < 3.5) 45 | json 46 | logger 47 | faraday-net_http (3.4.0) 48 | net-http (>= 0.5.0) 49 | ffi (1.17.1-aarch64-linux-gnu) 50 | ffi (1.17.1-aarch64-linux-musl) 51 | ffi (1.17.1-arm-linux-gnu) 52 | ffi (1.17.1-arm-linux-musl) 53 | ffi (1.17.1-arm64-darwin) 54 | ffi (1.17.1-x86_64-darwin) 55 | ffi (1.17.1-x86_64-linux-gnu) 56 | ffi (1.17.1-x86_64-linux-musl) 57 | forwardable-extended (2.6.0) 58 | gemoji (4.1.0) 59 | github-pages (232) 60 | github-pages-health-check (= 1.18.2) 61 | jekyll (= 3.10.0) 62 | jekyll-avatar (= 0.8.0) 63 | jekyll-coffeescript (= 1.2.2) 64 | jekyll-commonmark-ghpages (= 0.5.1) 65 | jekyll-default-layout (= 0.1.5) 66 | jekyll-feed (= 0.17.0) 67 | jekyll-gist (= 1.5.0) 68 | jekyll-github-metadata (= 2.16.1) 69 | jekyll-include-cache (= 0.2.1) 70 | jekyll-mentions (= 1.6.0) 71 | jekyll-optional-front-matter (= 0.3.2) 72 | jekyll-paginate (= 1.1.0) 73 | jekyll-readme-index (= 0.3.0) 74 | jekyll-redirect-from (= 0.16.0) 75 | jekyll-relative-links (= 0.6.1) 76 | jekyll-remote-theme (= 0.4.3) 77 | jekyll-sass-converter (= 1.5.2) 78 | jekyll-seo-tag (= 2.8.0) 79 | jekyll-sitemap (= 1.4.0) 80 | jekyll-swiss (= 1.0.0) 81 | jekyll-theme-architect (= 0.2.0) 82 | jekyll-theme-cayman (= 0.2.0) 83 | jekyll-theme-dinky (= 0.2.0) 84 | jekyll-theme-hacker (= 0.2.0) 85 | jekyll-theme-leap-day (= 0.2.0) 86 | jekyll-theme-merlot (= 0.2.0) 87 | jekyll-theme-midnight (= 0.2.0) 88 | jekyll-theme-minimal (= 0.2.0) 89 | jekyll-theme-modernist (= 0.2.0) 90 | jekyll-theme-primer (= 0.6.0) 91 | jekyll-theme-slate (= 0.2.0) 92 | jekyll-theme-tactile (= 0.2.0) 93 | jekyll-theme-time-machine (= 0.2.0) 94 | jekyll-titles-from-headings (= 0.5.3) 95 | jemoji (= 0.13.0) 96 | kramdown (= 2.4.0) 97 | kramdown-parser-gfm (= 1.1.0) 98 | liquid (= 4.0.4) 99 | mercenary (~> 0.3) 100 | minima (= 2.5.1) 101 | nokogiri (>= 1.16.2, < 2.0) 102 | rouge (= 3.30.0) 103 | terminal-table (~> 1.4) 104 | webrick (~> 1.8) 105 | github-pages-health-check (1.18.2) 106 | addressable (~> 2.3) 107 | dnsruby (~> 1.60) 108 | octokit (>= 4, < 8) 109 | public_suffix (>= 3.0, < 6.0) 110 | typhoeus (~> 1.3) 111 | html-pipeline (2.14.3) 112 | activesupport (>= 2) 113 | nokogiri (>= 1.4) 114 | http_parser.rb (0.8.0) 115 | i18n (1.14.7) 116 | concurrent-ruby (~> 1.0) 117 | jekyll (3.10.0) 118 | addressable (~> 2.4) 119 | colorator (~> 1.0) 120 | csv (~> 3.0) 121 | em-websocket (~> 0.5) 122 | i18n (>= 0.7, < 2) 123 | jekyll-sass-converter (~> 1.0) 124 | jekyll-watch (~> 2.0) 125 | kramdown (>= 1.17, < 3) 126 | liquid (~> 4.0) 127 | mercenary (~> 0.3.3) 128 | pathutil (~> 0.9) 129 | rouge (>= 1.7, < 4) 130 | safe_yaml (~> 1.0) 131 | webrick (>= 1.0) 132 | jekyll-avatar (0.8.0) 133 | jekyll (>= 3.0, < 5.0) 134 | jekyll-coffeescript (1.2.2) 135 | coffee-script (~> 2.2) 136 | coffee-script-source (~> 1.12) 137 | jekyll-commonmark (1.4.0) 138 | commonmarker (~> 0.22) 139 | jekyll-commonmark-ghpages (0.5.1) 140 | commonmarker (>= 0.23.7, < 1.1.0) 141 | jekyll (>= 3.9, < 4.0) 142 | jekyll-commonmark (~> 1.4.0) 143 | rouge (>= 2.0, < 5.0) 144 | jekyll-default-layout (0.1.5) 145 | jekyll (>= 3.0, < 5.0) 146 | jekyll-feed (0.17.0) 147 | jekyll (>= 3.7, < 5.0) 148 | jekyll-gist (1.5.0) 149 | octokit (~> 4.2) 150 | jekyll-github-metadata (2.16.1) 151 | jekyll (>= 3.4, < 5.0) 152 | octokit (>= 4, < 7, != 4.4.0) 153 | jekyll-include-cache (0.2.1) 154 | jekyll (>= 3.7, < 5.0) 155 | jekyll-mentions (1.6.0) 156 | html-pipeline (~> 2.3) 157 | jekyll (>= 3.7, < 5.0) 158 | jekyll-optional-front-matter (0.3.2) 159 | jekyll (>= 3.0, < 5.0) 160 | jekyll-paginate (1.1.0) 161 | jekyll-readme-index (0.3.0) 162 | jekyll (>= 3.0, < 5.0) 163 | jekyll-redirect-from (0.16.0) 164 | jekyll (>= 3.3, < 5.0) 165 | jekyll-relative-links (0.6.1) 166 | jekyll (>= 3.3, < 5.0) 167 | jekyll-remote-theme (0.4.3) 168 | addressable (~> 2.0) 169 | jekyll (>= 3.5, < 5.0) 170 | jekyll-sass-converter (>= 1.0, <= 3.0.0, != 2.0.0) 171 | rubyzip (>= 1.3.0, < 3.0) 172 | jekyll-sass-converter (1.5.2) 173 | sass (~> 3.4) 174 | jekyll-seo-tag (2.8.0) 175 | jekyll (>= 3.8, < 5.0) 176 | jekyll-sitemap (1.4.0) 177 | jekyll (>= 3.7, < 5.0) 178 | jekyll-swiss (1.0.0) 179 | jekyll-theme-architect (0.2.0) 180 | jekyll (> 3.5, < 5.0) 181 | jekyll-seo-tag (~> 2.0) 182 | jekyll-theme-cayman (0.2.0) 183 | jekyll (> 3.5, < 5.0) 184 | jekyll-seo-tag (~> 2.0) 185 | jekyll-theme-dinky (0.2.0) 186 | jekyll (> 3.5, < 5.0) 187 | jekyll-seo-tag (~> 2.0) 188 | jekyll-theme-hacker (0.2.0) 189 | jekyll (> 3.5, < 5.0) 190 | jekyll-seo-tag (~> 2.0) 191 | jekyll-theme-leap-day (0.2.0) 192 | jekyll (> 3.5, < 5.0) 193 | jekyll-seo-tag (~> 2.0) 194 | jekyll-theme-merlot (0.2.0) 195 | jekyll (> 3.5, < 5.0) 196 | jekyll-seo-tag (~> 2.0) 197 | jekyll-theme-midnight (0.2.0) 198 | jekyll (> 3.5, < 5.0) 199 | jekyll-seo-tag (~> 2.0) 200 | jekyll-theme-minimal (0.2.0) 201 | jekyll (> 3.5, < 5.0) 202 | jekyll-seo-tag (~> 2.0) 203 | jekyll-theme-modernist (0.2.0) 204 | jekyll (> 3.5, < 5.0) 205 | jekyll-seo-tag (~> 2.0) 206 | jekyll-theme-primer (0.6.0) 207 | jekyll (> 3.5, < 5.0) 208 | jekyll-github-metadata (~> 2.9) 209 | jekyll-seo-tag (~> 2.0) 210 | jekyll-theme-slate (0.2.0) 211 | jekyll (> 3.5, < 5.0) 212 | jekyll-seo-tag (~> 2.0) 213 | jekyll-theme-tactile (0.2.0) 214 | jekyll (> 3.5, < 5.0) 215 | jekyll-seo-tag (~> 2.0) 216 | jekyll-theme-time-machine (0.2.0) 217 | jekyll (> 3.5, < 5.0) 218 | jekyll-seo-tag (~> 2.0) 219 | jekyll-titles-from-headings (0.5.3) 220 | jekyll (>= 3.3, < 5.0) 221 | jekyll-watch (2.2.1) 222 | listen (~> 3.0) 223 | jemoji (0.13.0) 224 | gemoji (>= 3, < 5) 225 | html-pipeline (~> 2.2) 226 | jekyll (>= 3.0, < 5.0) 227 | json (2.10.2) 228 | kramdown (2.4.0) 229 | rexml 230 | kramdown-parser-gfm (1.1.0) 231 | kramdown (~> 2.0) 232 | liquid (4.0.4) 233 | listen (3.9.0) 234 | rb-fsevent (~> 0.10, >= 0.10.3) 235 | rb-inotify (~> 0.9, >= 0.9.10) 236 | logger (1.6.6) 237 | mercenary (0.3.6) 238 | minima (2.5.1) 239 | jekyll (>= 3.5, < 5.0) 240 | jekyll-feed (~> 0.9) 241 | jekyll-seo-tag (~> 2.1) 242 | minitest (5.25.4) 243 | net-http (0.6.0) 244 | uri 245 | nokogiri (1.18.8-aarch64-linux-gnu) 246 | racc (~> 1.4) 247 | nokogiri (1.18.8-aarch64-linux-musl) 248 | racc (~> 1.4) 249 | nokogiri (1.18.8-arm-linux-gnu) 250 | racc (~> 1.4) 251 | nokogiri (1.18.8-arm-linux-musl) 252 | racc (~> 1.4) 253 | nokogiri (1.18.8-arm64-darwin) 254 | racc (~> 1.4) 255 | nokogiri (1.18.8-x86_64-darwin) 256 | racc (~> 1.4) 257 | nokogiri (1.18.8-x86_64-linux-gnu) 258 | racc (~> 1.4) 259 | nokogiri (1.18.8-x86_64-linux-musl) 260 | racc (~> 1.4) 261 | octokit (4.25.1) 262 | faraday (>= 1, < 3) 263 | sawyer (~> 0.9) 264 | pathutil (0.16.2) 265 | forwardable-extended (~> 2.6) 266 | public_suffix (5.1.1) 267 | racc (1.8.1) 268 | rb-fsevent (0.11.2) 269 | rb-inotify (0.11.1) 270 | ffi (~> 1.0) 271 | rexml (3.4.1) 272 | rouge (3.30.0) 273 | rubyzip (2.4.1) 274 | safe_yaml (1.0.5) 275 | sass (3.7.4) 276 | sass-listen (~> 4.0.0) 277 | sass-listen (4.0.0) 278 | rb-fsevent (~> 0.9, >= 0.9.4) 279 | rb-inotify (~> 0.9, >= 0.9.7) 280 | sawyer (0.9.2) 281 | addressable (>= 2.3.5) 282 | faraday (>= 0.17.3, < 3) 283 | securerandom (0.4.1) 284 | simpleidn (0.2.3) 285 | terminal-table (1.8.0) 286 | unicode-display_width (~> 1.1, >= 1.1.1) 287 | typhoeus (1.4.1) 288 | ethon (>= 0.9.0) 289 | tzinfo (2.0.6) 290 | concurrent-ruby (~> 1.0) 291 | unicode-display_width (1.8.0) 292 | uri (1.0.3) 293 | webrick (1.9.1) 294 | 295 | PLATFORMS 296 | aarch64-linux-gnu 297 | aarch64-linux-musl 298 | arm-linux-gnu 299 | arm-linux-musl 300 | arm64-darwin 301 | x86_64-darwin 302 | x86_64-linux-gnu 303 | x86_64-linux-musl 304 | 305 | DEPENDENCIES 306 | github-pages (~> 232) 307 | http_parser.rb (~> 0.6.0) 308 | jekyll-feed (~> 0.12) 309 | tzinfo (>= 1, < 3) 310 | tzinfo-data 311 | wdm (~> 0.1) 312 | 313 | BUNDLED WITH 314 | 2.6.2 315 | -------------------------------------------------------------------------------- /docs/src/sampledata.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "features": [ 4 | { 5 | "type": "Feature", 6 | "properties": { 7 | "code": "AKJ", 8 | "name": "Asahikawa Airport", 9 | "city": "Asahikawa-shi", 10 | "state": "Hokkaido Prefecture", 11 | "country": "Japan", 12 | "icao": "RJEC", 13 | "direct_flights": "4", 14 | "carriers": "5" 15 | }, 16 | "geometry": { 17 | "type": "Point", 18 | "coordinates": [ 19 | 142.453, 20 | 43.665 21 | ] 22 | } 23 | }, 24 | { 25 | "type": "Feature", 26 | "properties": { 27 | "code": "AOJ", 28 | "name": "Aomori Airport", 29 | "city": "Aomori-shi", 30 | "state": "Aomori Prefecture", 31 | "country": "Japan", 32 | "icao": "RJSA", 33 | "direct_flights": "6", 34 | "carriers": "3" 35 | }, 36 | "geometry": { 37 | "type": "Point", 38 | "coordinates": [ 39 | 140.69, 40 | 40.7357 41 | ] 42 | } 43 | }, 44 | { 45 | "type": "Feature", 46 | "properties": { 47 | "code": "ASJ", 48 | "name": "Amami Airport", 49 | "city": "Amami O Shima", 50 | "state": "Mie Prefecture", 51 | "country": "Japan", 52 | "icao": "RJKA", 53 | "direct_flights": "3", 54 | "carriers": "2" 55 | }, 56 | "geometry": { 57 | "type": "Point", 58 | "coordinates": [ 59 | 129.712, 60 | 28.431 61 | ] 62 | } 63 | }, 64 | { 65 | "type": "Feature", 66 | "properties": { 67 | "code": "AXT", 68 | "name": "Akita Airport", 69 | "city": "Akita-shi", 70 | "state": "Akita Prefecture", 71 | "country": "Japan", 72 | "icao": "RJSK", 73 | "direct_flights": "7", 74 | "carriers": "5" 75 | }, 76 | "geometry": { 77 | "type": "Point", 78 | "coordinates": [ 79 | 140.219, 80 | 39.6153 81 | ] 82 | } 83 | }, 84 | { 85 | "type": "Feature", 86 | "properties": { 87 | "code": "CTS", 88 | "name": "New Chitose Airport", 89 | "city": "Chitose-shi", 90 | "state": "Hokkaido Prefecture", 91 | "country": "Japan", 92 | "icao": "RJCC", 93 | "direct_flights": "30", 94 | "carriers": "28" 95 | }, 96 | "geometry": { 97 | "type": "Point", 98 | "coordinates": [ 99 | 141.691, 100 | 42.7757 101 | ] 102 | } 103 | }, 104 | { 105 | "type": "Feature", 106 | "properties": { 107 | "code": "FKS", 108 | "name": "Fukushima Airport", 109 | "city": "Sukagawa-shi", 110 | "state": "Fukushima Prefecture", 111 | "country": "Japan", 112 | "icao": "KFKS", 113 | "direct_flights": "6", 114 | "carriers": "5" 115 | }, 116 | "geometry": { 117 | "type": "Point", 118 | "coordinates": [ 119 | 140.429, 120 | 37.2314 121 | ] 122 | } 123 | }, 124 | { 125 | "type": "Feature", 126 | "properties": { 127 | "code": "FUJ", 128 | "name": "Fukue Airport", 129 | "city": "Goto-shi", 130 | "state": "Nagasaki Prefecture", 131 | "country": "Japan", 132 | "icao": "", 133 | "direct_flights": "1", 134 | "carriers": "1" 135 | }, 136 | "geometry": { 137 | "type": "Point", 138 | "coordinates": [ 139 | 128.836, 140 | 32.6726 141 | ] 142 | } 143 | }, 144 | { 145 | "type": "Feature", 146 | "properties": { 147 | "code": "FUK", 148 | "name": "Fukuoka Airport", 149 | "city": "Fukuoka-shi", 150 | "state": "Fukuoka Prefecture", 151 | "country": "Japan", 152 | "icao": "RJFF", 153 | "direct_flights": "36", 154 | "carriers": "31" 155 | }, 156 | "geometry": { 157 | "type": "Point", 158 | "coordinates": [ 159 | 130.443, 160 | 33.5971 161 | ] 162 | } 163 | }, 164 | { 165 | "type": "Feature", 166 | "properties": { 167 | "code": "GAJ", 168 | "name": "Yamagata Airport", 169 | "city": "Higashine-shi", 170 | "state": "Yamagata Prefecture", 171 | "country": "Japan", 172 | "icao": "", 173 | "direct_flights": "4", 174 | "carriers": "1" 175 | }, 176 | "geometry": { 177 | "type": "Point", 178 | "coordinates": [ 179 | 140.37, 180 | 38.4109 181 | ] 182 | } 183 | }, 184 | { 185 | "type": "Feature", 186 | "properties": { 187 | "code": "HAC", 188 | "name": "Hachijojima Airport", 189 | "city": "Hachijo-machi", 190 | "state": "Tokyo Prefecture", 191 | "country": "Japan", 192 | "icao": "", 193 | "direct_flights": "2", 194 | "carriers": "1" 195 | }, 196 | "geometry": { 197 | "type": "Point", 198 | "coordinates": [ 199 | 139.784, 200 | 33.1153 201 | ] 202 | } 203 | }, 204 | { 205 | "type": "Feature", 206 | "properties": { 207 | "code": "HIJ", 208 | "name": "Hiroshima Airport", 209 | "city": "Mihara-shi", 210 | "state": "Hiroshima Prefecture", 211 | "country": "Japan", 212 | "icao": "", 213 | "direct_flights": "12", 214 | "carriers": "12" 215 | }, 216 | "geometry": { 217 | "type": "Point", 218 | "coordinates": [ 219 | 132.922, 220 | 34.4368 221 | ] 222 | } 223 | }, 224 | { 225 | "type": "Feature", 226 | "properties": { 227 | "code": "HKD", 228 | "name": "Hakodate Airport", 229 | "city": "Hakodate-shi", 230 | "state": "Hokkaido Prefecture", 231 | "country": "Japan", 232 | "icao": "RJCH", 233 | "direct_flights": "6", 234 | "carriers": "6" 235 | }, 236 | "geometry": { 237 | "type": "Point", 238 | "coordinates": [ 239 | 140.824, 240 | 41.7706 241 | ] 242 | } 243 | }, 244 | { 245 | "type": "Feature", 246 | "properties": { 247 | "code": "HNA", 248 | "name": "Hanamaki Airport", 249 | "city": "Hanamaki-shi", 250 | "state": "Iwate Prefecture", 251 | "country": "Japan", 252 | "icao": "", 253 | "direct_flights": "4", 254 | "carriers": "2" 255 | }, 256 | "geometry": { 257 | "type": "Point", 258 | "coordinates": [ 259 | 141.135, 260 | 39.4315 261 | ] 262 | } 263 | }, 264 | { 265 | "type": "Feature", 266 | "properties": { 267 | "code": "HND", 268 | "name": "Tokyo International Airport", 269 | "city": "Tokyo", 270 | "state": "Tokyo Prefecture", 271 | "country": "Japan", 272 | "icao": "KHND", 273 | "direct_flights": "55", 274 | "carriers": "19" 275 | }, 276 | "geometry": { 277 | "type": "Point", 278 | "coordinates": [ 279 | 139.771, 280 | 35.5533 281 | ] 282 | } 283 | }, 284 | { 285 | "type": "Feature", 286 | "properties": { 287 | "code": "HSG", 288 | "name": "Saga Airport", 289 | "city": "Saga", 290 | "state": "Tokyo Prefecture", 291 | "country": "Japan", 292 | "icao": "", 293 | "direct_flights": "3", 294 | "carriers": "1" 295 | }, 296 | "geometry": { 297 | "type": "Point", 298 | "coordinates": [ 299 | 130.302, 300 | 33.1508 301 | ] 302 | } 303 | }, 304 | { 305 | "type": "Feature", 306 | "properties": { 307 | "code": "ISG", 308 | "name": "Ishigaki Airport", 309 | "city": "Ishigaki-shi", 310 | "state": "Okinawa Prefecture", 311 | "country": "Japan", 312 | "icao": "", 313 | "direct_flights": "3", 314 | "carriers": "2" 315 | }, 316 | "geometry": { 317 | "type": "Point", 318 | "coordinates": [ 319 | 124.186, 320 | 24.3456 321 | ] 322 | } 323 | }, 324 | { 325 | "type": "Feature", 326 | "properties": { 327 | "code": "ITM", 328 | "name": "Osaka International Airport", 329 | "city": "Itami-shi", 330 | "state": "Hyogo Prefecture", 331 | "country": "Japan", 332 | "icao": "RJOO", 333 | "direct_flights": "25", 334 | "carriers": "10" 335 | }, 336 | "geometry": { 337 | "type": "Point", 338 | "coordinates": [ 339 | 135.439, 340 | 34.7857 341 | ] 342 | } 343 | }, 344 | { 345 | "type": "Feature", 346 | "properties": { 347 | "code": "IZO", 348 | "name": "Izumo Airport", 349 | "city": "Hikawa-cho", 350 | "state": "Shimane Prefecture", 351 | "country": "Japan", 352 | "icao": "", 353 | "direct_flights": "1", 354 | "carriers": "1" 355 | }, 356 | "geometry": { 357 | "type": "Point", 358 | "coordinates": [ 359 | 132.887, 360 | 35.4131 361 | ] 362 | } 363 | }, 364 | { 365 | "type": "Feature", 366 | "properties": { 367 | "code": "KCZ", 368 | "name": "Kochi Airport", 369 | "city": "Nankoku-shi", 370 | "state": "Kochi Prefecture", 371 | "country": "Japan", 372 | "icao": "", 373 | "direct_flights": "6", 374 | "carriers": "4" 375 | }, 376 | "geometry": { 377 | "type": "Point", 378 | "coordinates": [ 379 | 133.672, 380 | 33.5468 381 | ] 382 | } 383 | }, 384 | { 385 | "type": "Feature", 386 | "properties": { 387 | "code": "KIJ", 388 | "name": "Niigata Airport", 389 | "city": "Niigata-shi", 390 | "state": "Niigata Prefecture", 391 | "country": "Japan", 392 | "icao": "RJSN", 393 | "direct_flights": "13", 394 | "carriers": "12" 395 | }, 396 | "geometry": { 397 | "type": "Point", 398 | "coordinates": [ 399 | 139.113, 400 | 37.9553 401 | ] 402 | } 403 | }, 404 | { 405 | "type": "Feature", 406 | "properties": { 407 | "code": "KIX", 408 | "name": "Kansai International Airport", 409 | "city": "Tajiri-cho", 410 | "state": "Osaka Prefecture", 411 | "country": "Japan", 412 | "icao": "RJBB", 413 | "direct_flights": "78", 414 | "carriers": "61" 415 | }, 416 | "geometry": { 417 | "type": "Point", 418 | "coordinates": [ 419 | 135.244, 420 | 34.4295 421 | ] 422 | } 423 | }, 424 | { 425 | "type": "Feature", 426 | "properties": { 427 | "code": "KKJ", 428 | "name": "New Kitakyushu Airport", 429 | "city": "Kita Kyushu", 430 | "state": "Kagoshima Prefecture", 431 | "country": "Japan", 432 | "icao": "", 433 | "direct_flights": "4", 434 | "carriers": "7" 435 | }, 436 | "geometry": { 437 | "type": "Point", 438 | "coordinates": [ 439 | 131.033, 440 | 33.839 441 | ] 442 | } 443 | }, 444 | { 445 | "type": "Feature", 446 | "properties": { 447 | "code": "KMI", 448 | "name": "Miyazaki Airport", 449 | "city": "Miyazaki-shi", 450 | "state": "Miyazaki Prefecture", 451 | "country": "Japan", 452 | "icao": "", 453 | "direct_flights": "7", 454 | "carriers": "6" 455 | }, 456 | "geometry": { 457 | "type": "Point", 458 | "coordinates": [ 459 | 131.449, 460 | 31.8771 461 | ] 462 | } 463 | }, 464 | { 465 | "type": "Feature", 466 | "properties": { 467 | "code": "KMJ", 468 | "name": "Kumamoto Airport", 469 | "city": "Kikuyo-machi", 470 | "state": "Kumamoto Prefecture", 471 | "country": "Japan", 472 | "icao": "RJFT", 473 | "direct_flights": "7", 474 | "carriers": "5" 475 | }, 476 | "geometry": { 477 | "type": "Point", 478 | "coordinates": [ 479 | 130.849, 480 | 32.8346 481 | ] 482 | } 483 | }, 484 | { 485 | "type": "Feature", 486 | "properties": { 487 | "code": "KMQ", 488 | "name": "Komatsu Airport", 489 | "city": "Komatsu-shi", 490 | "state": "Ishikawa Prefecture", 491 | "country": "Japan", 492 | "icao": "", 493 | "direct_flights": "10", 494 | "carriers": "10" 495 | }, 496 | "geometry": { 497 | "type": "Point", 498 | "coordinates": [ 499 | 136.408, 500 | 36.394 501 | ] 502 | } 503 | }, 504 | { 505 | "type": "Feature", 506 | "properties": { 507 | "code": "KOJ", 508 | "name": "Kagoshima Airport", 509 | "city": "Kirishima-shi", 510 | "state": "Kagoshima Prefecture", 511 | "country": "Japan", 512 | "icao": "RJFK", 513 | "direct_flights": "11", 514 | "carriers": "9" 515 | }, 516 | "geometry": { 517 | "type": "Point", 518 | "coordinates": [ 519 | 130.718, 520 | 31.8 521 | ] 522 | } 523 | }, 524 | { 525 | "type": "Feature", 526 | "properties": { 527 | "code": "KUH", 528 | "name": "Kushiro Airport", 529 | "city": "Kushiro", 530 | "state": "Hokkaido Prefecture", 531 | "country": "Japan", 532 | "icao": "", 533 | "direct_flights": "4", 534 | "carriers": "2" 535 | }, 536 | "geometry": { 537 | "type": "Point", 538 | "coordinates": [ 539 | 144.194, 540 | 43.043 541 | ] 542 | } 543 | }, 544 | { 545 | "type": "Feature", 546 | "properties": { 547 | "code": "MBE", 548 | "name": "Okhotsk-Monbetsu Airport", 549 | "city": "Monbetsu-shi", 550 | "state": "Hokkaido Prefecture", 551 | "country": "Japan", 552 | "icao": "", 553 | "direct_flights": "1", 554 | "carriers": "1" 555 | }, 556 | "geometry": { 557 | "type": "Point", 558 | "coordinates": [ 559 | 143.404, 560 | 44.3046 561 | ] 562 | } 563 | }, 564 | { 565 | "type": "Feature", 566 | "properties": { 567 | "code": "MMB", 568 | "name": "Memanbetsu Airport", 569 | "city": "Ozora-cho", 570 | "state": "Hokkaido Prefecture", 571 | "country": "Japan", 572 | "icao": "", 573 | "direct_flights": "5", 574 | "carriers": "3" 575 | }, 576 | "geometry": { 577 | "type": "Point", 578 | "coordinates": [ 579 | 144.163, 580 | 43.8814 581 | ] 582 | } 583 | }, 584 | { 585 | "type": "Feature", 586 | "properties": { 587 | "code": "MMY", 588 | "name": "Miyako Airport", 589 | "city": "Miyako Jima", 590 | "state": "Iwate Prefecture", 591 | "country": "Japan", 592 | "icao": "", 593 | "direct_flights": "4", 594 | "carriers": "2" 595 | }, 596 | "geometry": { 597 | "type": "Point", 598 | "coordinates": [ 599 | 125.295, 600 | 24.7824 601 | ] 602 | } 603 | }, 604 | { 605 | "type": "Feature", 606 | "properties": { 607 | "code": "MSJ", 608 | "name": "Misawa Airport", 609 | "city": "Misawa-shi", 610 | "state": "Aomori Prefecture", 611 | "country": "Japan", 612 | "icao": "", 613 | "direct_flights": "2", 614 | "carriers": "1" 615 | }, 616 | "geometry": { 617 | "type": "Point", 618 | "coordinates": [ 619 | 141.361, 620 | 40.7053 621 | ] 622 | } 623 | }, 624 | { 625 | "type": "Feature", 626 | "properties": { 627 | "code": "MYE", 628 | "name": "Miyakejima Airport", 629 | "city": "Miyake-mura", 630 | "state": "Tokyo Prefecture", 631 | "country": "Japan", 632 | "icao": "", 633 | "direct_flights": "1", 634 | "carriers": "1" 635 | }, 636 | "geometry": { 637 | "type": "Point", 638 | "coordinates": [ 639 | 139.56, 640 | 34.0726 641 | ] 642 | } 643 | }, 644 | { 645 | "type": "Feature", 646 | "properties": { 647 | "code": "MYJ", 648 | "name": "Matsuyama Airport", 649 | "city": "Matsuyama-shi", 650 | "state": "Ehime Prefecture", 651 | "country": "Japan", 652 | "icao": "KMYJ", 653 | "direct_flights": "10", 654 | "carriers": "8" 655 | }, 656 | "geometry": { 657 | "type": "Point", 658 | "coordinates": [ 659 | 132.7, 660 | 33.8276 661 | ] 662 | } 663 | }, 664 | { 665 | "type": "Feature", 666 | "properties": { 667 | "code": "NGO", 668 | "name": "Chubu International Airport", 669 | "city": "Tokoname-shi", 670 | "state": "Aichi Prefecture", 671 | "country": "Japan", 672 | "icao": "RJGG", 673 | "direct_flights": "55", 674 | "carriers": "37" 675 | }, 676 | "geometry": { 677 | "type": "Point", 678 | "coordinates": [ 679 | 136.811, 680 | 34.8624 681 | ] 682 | } 683 | }, 684 | { 685 | "type": "Feature", 686 | "properties": { 687 | "code": "NGS", 688 | "name": "Nagasaki Airport", 689 | "city": "Omura-shi", 690 | "state": "Nagasaki Prefecture", 691 | "country": "Japan", 692 | "icao": "KNGS", 693 | "direct_flights": "8", 694 | "carriers": "8" 695 | }, 696 | "geometry": { 697 | "type": "Point", 698 | "coordinates": [ 699 | 129.917, 700 | 32.9141 701 | ] 702 | } 703 | }, 704 | { 705 | "type": "Feature", 706 | "properties": { 707 | "code": "NKM", 708 | "name": "Nagoya Airport", 709 | "city": "Toyoyama-cho", 710 | "state": "Aichi Prefecture", 711 | "country": "Japan", 712 | "icao": "RJNA", 713 | "direct_flights": "9", 714 | "carriers": "1" 715 | }, 716 | "geometry": { 717 | "type": "Point", 718 | "coordinates": [ 719 | 136.924, 720 | 35.2527 721 | ] 722 | } 723 | }, 724 | { 725 | "type": "Feature", 726 | "properties": { 727 | "code": "NRT", 728 | "name": "Narita International Airport", 729 | "city": "Narita-shi", 730 | "state": "Chiba Prefecture", 731 | "country": "Japan", 732 | "icao": "RJAA", 733 | "direct_flights": "97", 734 | "carriers": "63" 735 | }, 736 | "geometry": { 737 | "type": "Point", 738 | "coordinates": [ 739 | 140.389, 740 | 35.7491 741 | ] 742 | } 743 | }, 744 | { 745 | "type": "Feature", 746 | "properties": { 747 | "code": "NTQ", 748 | "name": "Noto Airport", 749 | "city": "Anamizu-machi", 750 | "state": "Ishikawa Prefecture", 751 | "country": "Japan", 752 | "icao": "", 753 | "direct_flights": "1", 754 | "carriers": "1" 755 | }, 756 | "geometry": { 757 | "type": "Point", 758 | "coordinates": [ 759 | 136.957, 760 | 37.2917 761 | ] 762 | } 763 | }, 764 | { 765 | "type": "Feature", 766 | "properties": { 767 | "code": "OBO", 768 | "name": "Obihiro Airport", 769 | "city": "Obihiro-shi", 770 | "state": "Hokkaido Prefecture", 771 | "country": "Japan", 772 | "icao": "", 773 | "direct_flights": "3", 774 | "carriers": "1" 775 | }, 776 | "geometry": { 777 | "type": "Point", 778 | "coordinates": [ 779 | 143.216, 780 | 42.7343 781 | ] 782 | } 783 | }, 784 | { 785 | "type": "Feature", 786 | "properties": { 787 | "code": "OGN", 788 | "name": "Yonaguni Airport", 789 | "city": "Yonaguni-cho", 790 | "state": "Okinawa Prefecture", 791 | "country": "Japan", 792 | "icao": "", 793 | "direct_flights": "1", 794 | "carriers": "1" 795 | }, 796 | "geometry": { 797 | "type": "Point", 798 | "coordinates": [ 799 | 122.979, 800 | 24.4674 801 | ] 802 | } 803 | }, 804 | { 805 | "type": "Feature", 806 | "properties": { 807 | "code": "OIM", 808 | "name": "Oshima Airport", 809 | "city": "Oshima-machi", 810 | "state": "Tokyo Prefecture", 811 | "country": "Japan", 812 | "icao": "", 813 | "direct_flights": "2", 814 | "carriers": "1" 815 | }, 816 | "geometry": { 817 | "type": "Point", 818 | "coordinates": [ 819 | 139.361, 820 | 34.7828 821 | ] 822 | } 823 | }, 824 | { 825 | "type": "Feature", 826 | "properties": { 827 | "code": "OIT", 828 | "name": "Oita Airport", 829 | "city": "Kunisaki-shi", 830 | "state": "Oita Prefecture", 831 | "country": "Japan", 832 | "icao": "RJFO", 833 | "direct_flights": "4", 834 | "carriers": "5" 835 | }, 836 | "geometry": { 837 | "type": "Point", 838 | "coordinates": [ 839 | 131.737, 840 | 33.4801 841 | ] 842 | } 843 | }, 844 | { 845 | "type": "Feature", 846 | "properties": { 847 | "code": "OKA", 848 | "name": "Shimojishima Airport", 849 | "city": "Naha-shi", 850 | "state": "Okinawa Prefecture", 851 | "country": "Japan", 852 | "icao": "ROAH", 853 | "direct_flights": "28", 854 | "carriers": "13" 855 | }, 856 | "geometry": { 857 | "type": "Point", 858 | "coordinates": [ 859 | 125.146, 860 | 24.8289 861 | ] 862 | } 863 | }, 864 | { 865 | "type": "Feature", 866 | "properties": { 867 | "code": "OKD", 868 | "name": "Okadama Airport", 869 | "city": "Sapporo-shi", 870 | "state": "Hokkaido Prefecture", 871 | "country": "Japan", 872 | "icao": "", 873 | "direct_flights": "5", 874 | "carriers": "1" 875 | }, 876 | "geometry": { 877 | "type": "Point", 878 | "coordinates": [ 879 | 141.382, 880 | 43.1162 881 | ] 882 | } 883 | }, 884 | { 885 | "type": "Feature", 886 | "properties": { 887 | "code": "OKJ", 888 | "name": "Okayama Airport", 889 | "city": "Okayama-shi", 890 | "state": "Okayama Prefecture", 891 | "country": "Japan", 892 | "icao": "RJOB", 893 | "direct_flights": "7", 894 | "carriers": "7" 895 | }, 896 | "geometry": { 897 | "type": "Point", 898 | "coordinates": [ 899 | 133.855, 900 | 34.7579 901 | ] 902 | } 903 | }, 904 | { 905 | "type": "Feature", 906 | "properties": { 907 | "code": "ONJ", 908 | "name": "Odate-Noshiro Airport", 909 | "city": "Kitakita-shi", 910 | "state": "Akita Prefecture", 911 | "country": "Japan", 912 | "icao": "", 913 | "direct_flights": "2", 914 | "carriers": "1" 915 | }, 916 | "geometry": { 917 | "type": "Point", 918 | "coordinates": [ 919 | 140.366, 920 | 40.1931 921 | ] 922 | } 923 | }, 924 | { 925 | "type": "Feature", 926 | "properties": { 927 | "code": "RIS", 928 | "name": "Rishiri Airport", 929 | "city": "Rishirifuji-cho", 930 | "state": "Hokkaido Prefecture", 931 | "country": "Japan", 932 | "icao": "", 933 | "direct_flights": "1", 934 | "carriers": "1" 935 | }, 936 | "geometry": { 937 | "type": "Point", 938 | "coordinates": [ 939 | 141.186, 940 | 45.2419 941 | ] 942 | } 943 | }, 944 | { 945 | "type": "Feature", 946 | "properties": { 947 | "code": "SDJ", 948 | "name": "Sendai Airport", 949 | "city": "Natori-shi", 950 | "state": "Miyagi Prefecture", 951 | "country": "Japan", 952 | "icao": "RJSS", 953 | "direct_flights": "16", 954 | "carriers": "14" 955 | }, 956 | "geometry": { 957 | "type": "Point", 958 | "coordinates": [ 959 | 140.918, 960 | 38.1401 961 | ] 962 | } 963 | }, 964 | { 965 | "type": "Feature", 966 | "properties": { 967 | "code": "SHB", 968 | "name": "Nakashibetsu Airport", 969 | "city": "Nakashibetsu-cho", 970 | "state": "Hokkaido Prefecture", 971 | "country": "Japan", 972 | "icao": "", 973 | "direct_flights": "2", 974 | "carriers": "1" 975 | }, 976 | "geometry": { 977 | "type": "Point", 978 | "coordinates": [ 979 | 144.958, 980 | 43.5767 981 | ] 982 | } 983 | }, 984 | { 985 | "type": "Feature", 986 | "properties": { 987 | "code": "SHM", 988 | "name": "Nanki-Shirahama Airport", 989 | "city": "Shirahama-cho", 990 | "state": "Wakayama Prefecture", 991 | "country": "Japan", 992 | "icao": "", 993 | "direct_flights": "1", 994 | "carriers": "1" 995 | }, 996 | "geometry": { 997 | "type": "Point", 998 | "coordinates": [ 999 | 135.362, 1000 | 33.664 1001 | ] 1002 | } 1003 | }, 1004 | { 1005 | "type": "Feature", 1006 | "properties": { 1007 | "code": "SYO", 1008 | "name": "Shonai", 1009 | "city": "Shonai", 1010 | "state": "Yamagata Prefecture", 1011 | "country": "Japan", 1012 | "icao": "", 1013 | "direct_flights": "2", 1014 | "carriers": "2" 1015 | }, 1016 | "geometry": { 1017 | "type": "Point", 1018 | "coordinates": [ 1019 | 140.018, 1020 | 38.7061 1021 | ] 1022 | } 1023 | }, 1024 | { 1025 | "type": "Feature", 1026 | "properties": { 1027 | "code": "TAK", 1028 | "name": "Japan", 1029 | "city": "Takamatsu", 1030 | "state": "Kagawa Prefecture", 1031 | "country": "Japan", 1032 | "icao": "", 1033 | "direct_flights": "3", 1034 | "carriers": "3" 1035 | }, 1036 | "geometry": { 1037 | "type": "Point", 1038 | "coordinates": [ 1039 | 134.046, 1040 | 34.2557 1041 | ] 1042 | } 1043 | }, 1044 | { 1045 | "type": "Feature", 1046 | "properties": { 1047 | "code": "TKN", 1048 | "name": "Tokunoshima Airport", 1049 | "city": "Amagi-cho", 1050 | "state": "Kagoshima Prefecture", 1051 | "country": "Japan", 1052 | "icao": "", 1053 | "direct_flights": "1", 1054 | "carriers": "1" 1055 | }, 1056 | "geometry": { 1057 | "type": "Point", 1058 | "coordinates": [ 1059 | 128.882, 1060 | 27.834 1061 | ] 1062 | } 1063 | }, 1064 | { 1065 | "type": "Feature", 1066 | "properties": { 1067 | "code": "TKS", 1068 | "name": "Tokushima Airport", 1069 | "city": "Matsushige-cho", 1070 | "state": "Tokushima Prefecture", 1071 | "country": "Japan", 1072 | "icao": "", 1073 | "direct_flights": "2", 1074 | "carriers": "2" 1075 | }, 1076 | "geometry": { 1077 | "type": "Point", 1078 | "coordinates": [ 1079 | 134.603, 1080 | 34.1339 1081 | ] 1082 | } 1083 | }, 1084 | { 1085 | "type": "Feature", 1086 | "properties": { 1087 | "code": "TOY", 1088 | "name": "Toyama Airport", 1089 | "city": "Toyama-shi", 1090 | "state": "Toyama Prefecture", 1091 | "country": "Japan", 1092 | "icao": "", 1093 | "direct_flights": "6", 1094 | "carriers": "5" 1095 | }, 1096 | "geometry": { 1097 | "type": "Point", 1098 | "coordinates": [ 1099 | 137.187, 1100 | 36.6495 1101 | ] 1102 | } 1103 | }, 1104 | { 1105 | "type": "Feature", 1106 | "properties": { 1107 | "code": "TSJ", 1108 | "name": "Tsushima Airport", 1109 | "city": "Tsushima-shi", 1110 | "state": "Nagasaki Prefecture", 1111 | "country": "Japan", 1112 | "icao": "", 1113 | "direct_flights": "1", 1114 | "carriers": "1" 1115 | }, 1116 | "geometry": { 1117 | "type": "Point", 1118 | "coordinates": [ 1119 | 129.33, 1120 | 34.2856 1121 | ] 1122 | } 1123 | }, 1124 | { 1125 | "type": "Feature", 1126 | "properties": { 1127 | "code": "TTJ", 1128 | "name": "Tottori Airport", 1129 | "city": "Tottori-shi", 1130 | "state": "Tottori Prefecture", 1131 | "country": "Japan", 1132 | "icao": "", 1133 | "direct_flights": "1", 1134 | "carriers": "1" 1135 | }, 1136 | "geometry": { 1137 | "type": "Point", 1138 | "coordinates": [ 1139 | 134.166, 1140 | 35.5298 1141 | ] 1142 | } 1143 | }, 1144 | { 1145 | "type": "Feature", 1146 | "properties": { 1147 | "code": "UBJ", 1148 | "name": "Yamaguchi-Ube Airport", 1149 | "city": "Ube-shi", 1150 | "state": "Yamaguchi Prefecture", 1151 | "country": "Japan", 1152 | "icao": "", 1153 | "direct_flights": "1", 1154 | "carriers": "2" 1155 | }, 1156 | "geometry": { 1157 | "type": "Point", 1158 | "coordinates": [ 1159 | 131.276, 1160 | 33.931 1161 | ] 1162 | } 1163 | }, 1164 | { 1165 | "type": "Feature", 1166 | "properties": { 1167 | "code": "UEO", 1168 | "name": "Kumejima Airport", 1169 | "city": "Kumejima-cho", 1170 | "state": "Okinawa Prefecture", 1171 | "country": "Japan", 1172 | "icao": "", 1173 | "direct_flights": "1", 1174 | "carriers": "1" 1175 | }, 1176 | "geometry": { 1177 | "type": "Point", 1178 | "coordinates": [ 1179 | 126.716, 1180 | 26.3677 1181 | ] 1182 | } 1183 | }, 1184 | { 1185 | "type": "Feature", 1186 | "properties": { 1187 | "code": "UKB", 1188 | "name": "Kobe Airport", 1189 | "city": "Kobe-shi", 1190 | "state": "Hyogo Prefecture", 1191 | "country": "Japan", 1192 | "icao": "", 1193 | "direct_flights": "6", 1194 | "carriers": "4" 1195 | }, 1196 | "geometry": { 1197 | "type": "Point", 1198 | "coordinates": [ 1199 | 135.22, 1200 | 34.6356 1201 | ] 1202 | } 1203 | }, 1204 | { 1205 | "type": "Feature", 1206 | "properties": { 1207 | "code": "WKJ", 1208 | "name": "Wakkanai Airport", 1209 | "city": "Wakkanai-shi", 1210 | "state": "Hokkaido Prefecture", 1211 | "country": "Japan", 1212 | "icao": "", 1213 | "direct_flights": "5", 1214 | "carriers": "1" 1215 | }, 1216 | "geometry": { 1217 | "type": "Point", 1218 | "coordinates": [ 1219 | 141.801, 1220 | 45.4041 1221 | ] 1222 | } 1223 | }, 1224 | { 1225 | "type": "Feature", 1226 | "properties": { 1227 | "code": "YGJ", 1228 | "name": "Yonago Airport", 1229 | "city": "Sakaiminato-shi", 1230 | "state": "Tottori Prefecture", 1231 | "country": "Japan", 1232 | "icao": "", 1233 | "direct_flights": "3", 1234 | "carriers": "3" 1235 | }, 1236 | "geometry": { 1237 | "type": "Point", 1238 | "coordinates": [ 1239 | 133.237, 1240 | 35.4943 1241 | ] 1242 | } 1243 | } 1244 | ] 1245 | } 1246 | --------------------------------------------------------------------------------