├── .babelrc ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── charts └── .gitkeep ├── index.js ├── package-lock.json ├── package.json ├── preview-1.jpg ├── preview-2.jpg ├── public ├── GitHub-Mark-64px.png ├── IcoMoon-Free.ttf ├── Roboto-Light.ttf ├── Roboto-Regular.ttf ├── Roboto-Thin.ttf ├── RobotoMono-Bold.ttf ├── ais-target-selected.png ├── ais-target.png ├── index.html ├── path-marker.png ├── vessel-large.png ├── vessel-marker-round.png ├── vessel-medium.png ├── vessel-small.png └── world-base.geo.json ├── release.sh ├── s3-index.html ├── src ├── client │ ├── ais.js │ ├── api.js │ ├── app.js │ ├── data-connection.js │ ├── enums.js │ ├── fullscreen.js │ ├── instrument-config.js │ ├── map.js │ ├── provider-geolocation.js │ ├── provider-signalk.js │ ├── settings.js │ └── utils.js ├── server │ ├── chart-routes.js │ ├── server.js │ └── settings.js └── styles │ ├── _fonts.less │ ├── _reset.less │ └── app.less └── webpack.bundle.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const restrictedGlobals = require("eslint-restricted-globals") 2 | 3 | module.exports = { 4 | env: { 5 | browser: true, 6 | es6: true, 7 | node: true 8 | }, 9 | extends: [ 10 | "eslint:recommended", 11 | "plugin:react/recommended", 12 | ], 13 | parserOptions: { 14 | "ecmaVersion": 6, 15 | "sourceType": "module", 16 | "ecmaFeatures": { 17 | "jsx": true 18 | } 19 | }, 20 | plugins: ["prettier", "react", "node"], 21 | rules: { 22 | "array-callback-return": "error", 23 | curly: "error", 24 | eqeqeq: [ 25 | "error", 26 | "always", 27 | { 28 | null: "ignore", 29 | }, 30 | ], 31 | "for-direction": "error", 32 | "getter-return": "error", 33 | "guard-for-in": "error", 34 | "handle-callback-err": "error", 35 | "object-curly-spacing": ["error", "never"], 36 | "no-extra-bind": "error", 37 | "no-console": "off", 38 | "prefer-const": "error", 39 | "no-var": "error", 40 | "no-duplicate-imports": "error", 41 | "no-eval": "error", 42 | "no-extend-native": "error", 43 | "no-implied-eval": "error", 44 | "no-invalid-this": "error", 45 | "no-labels": "error", 46 | "no-path-concat": "error", 47 | "no-restricted-globals": ["error"].concat(restrictedGlobals), 48 | "no-unused-vars": ["error", { argsIgnorePattern: "^_" }], 49 | "no-useless-computed-key": "error", 50 | "prettier/prettier": ["error", {"jsxBracketSameLine": true}], 51 | quotes: ["error", "single", { avoidEscape: true }], 52 | "react/display-name": "off", 53 | "react/prop-types": "off", 54 | "unicode-bom": "error", 55 | "linebreak-style": [ 56 | "error", 57 | "unix" 58 | ] 59 | }, 60 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | public/bundle* 3 | .bundle.css 4 | npm-debug.* 5 | client-config.json 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | public/bundle.js.map 3 | .bundle.css 4 | npm-debug.* 5 | client-config.json 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "lts/*" 4 | 5 | jobs: 6 | include: 7 | - stage: test 8 | script: 9 | - npm run test 10 | - stage: bundle 11 | script: 12 | - npm run bundle 13 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-18 Mikko Vesikkala 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Tuktuk Chart plotter 3 | 4 | Chart plotter for [Signal K](https://github.com/SignalK/signalk-server-node) with the support for raster chart providers. 5 | 6 | Very much _WIP_. 7 | 8 | 9 | Tuktuk plotter with Finnish charts|Tuktuk plotter with NOAA charts 10 | 11 | # Usage 12 | 13 | Install "Tuktuk chart plotter" webapp from the Signal K Appstore together 14 | with [@signalk/charts-plugin](https://www.npmjs.com/package/@signalk/charts-plugin). 15 | 16 | Configure charts plugin to server some charts and open the Tuktuk chart plotter in 17 | `/tuktuk-chart-plotter/`. 18 | 19 | Some example MBTiles charts can be found from: 20 | - Finnish nautical charts: https://github.com/vokkim/rannikkokartat-mbtiles 21 | - NOAA charts: https://github.com/vokkim/noaa-nautical-charts 22 | 23 | # Developing 24 | 25 | Install dependencies: 26 | 27 | `npm install` 28 | 29 | Running tests (only eslint for now): 30 | 31 | `npm run test` 32 | 33 | Start development server: 34 | 35 | `npm run watch` 36 | 37 | Plotter accessible at http://localhost:4999/ 38 | To see actual data, you should have a [signalk-server-node](https://github.com/SignalK/signalk-server-node) 39 | running and maybe some [charts](https://github.com/vokkim/tuktuk-chart-plotter#charts). 40 | 41 | ## Code style 42 | 43 | Tuktuk uses uses [eslint](http://eslint.org/) and [Prettier](https://prettier.io/) 44 | to enforce and format code style. To auto-format the code run: 45 | 46 | `npm run lint-fix` 47 | 48 | # Local server 49 | 50 | **Tuktuk ships with a local server intended for development use only. 51 | For production use, please install Tuktuk as a Signal K Webapp** 52 | 53 | ## Environment variables 54 | 55 | - `PORT` = server port, default 4999 56 | - `CHARTS_PATH` = location for chart files (`.mbtiles`), default `charts/` 57 | - `CLIENT_CONFIG_FILE` = client config file, default `client-config.json` 58 | 59 | ## Client config 60 | 61 | - When the plotter is ran with a local server using `npm run start` or `npm run watch`, the browser will receive a configuration file defined by the `CLIENT_CONFIG_FILE` environment variable. 62 | 63 | - When the plotter is accessed through Signal K server plugin, the browser will use default Signal K configration defined in `public/index.html` 64 | 65 | Example config: 66 | ``` javascript 67 | { 68 | "data": [ 69 | { 70 | "type": "signalk", 71 | "address": "localhost:3000" 72 | } 73 | ], 74 | "course": "COG", 75 | "follow": true, 76 | "showInstruments": true, 77 | "zoom": 13, 78 | "charts": [ 79 | { 80 | "index": 0, 81 | "type": "tilelayer", 82 | "maxzoom": 15, 83 | "minzoom": 4, 84 | "name": "liikennevirasto_rannikkokartat_public_15_4", 85 | "description": "Lähde: Liikennevirasto. Ei navigointikäyttöön. Ei täytä virallisen merikartan vaatimuksia.", 86 | "tilemapUrl": "/charts/liikennevirasto_rannikkokartat_public_15_4/{z}/{x}/{y}", 87 | "bounds": [19.105224609375, 59.645540251443215, 27.88330078125, 65.84776766596988], 88 | "center": [24.805, 60.0888] 89 | } 90 | ] 91 | } 92 | ``` 93 | 94 | 95 | ## Data providers 96 | 97 | ### Signal K 98 | 99 | Chart plotter is designed to work with [Signal K](http://signalk.org/): 100 | 101 | - Install and run [signalk-server-node](https://github.com/SignalK/signalk-server-node) 102 | - Add `signalk` data provider to `client-config.json`: 103 | ``` javascript 104 | "data": [ 105 | { 106 | "type": "signalk", 107 | "address": "localhost:3000" 108 | } 109 | ] 110 | ... 111 | ``` 112 | 113 | ### Browser Geolocation API 114 | 115 | To use the [Geolocation API](https://developer.mozilla.org/en-US/docs/Web/API/Geolocation), add `geolocation` provider: 116 | ``` javascript 117 | "data": [ 118 | { 119 | "type": "geolocation" 120 | } 121 | ] 122 | ... 123 | ``` 124 | 125 | ### Local charts 126 | 127 | Put charts in [MBTiles](https://github.com/mapbox/mbtiles-spec) format to your `CHARTS_PATH`. 128 | Files must end with `.mbtiles` postfix. Charts found by the chart plotter are listed in `http://localhost:4999/charts/`. 129 | 130 | Local charts are configured in `client-config.json` by adding `local` chart provider: 131 | ``` javascript 132 | ... 133 | "charts": [ 134 | { 135 | "index": 0, 136 | "type": "local" 137 | } 138 | ] 139 | ... 140 | ``` 141 | 142 | ### Signal K charts 143 | 144 | Map tiles hosted by Signal K server are configured in `client-config.json` by adding `signalk` chart provider: 145 | ``` javascript 146 | ... 147 | "charts": [ 148 | { 149 | "index": 2, 150 | "type": "signalk", 151 | "address": ":3000" 152 | } 153 | ] 154 | ... 155 | ``` 156 | 157 | ### Online charts 158 | 159 | Other charts in `client-config.json` are of type `tilelayer`: 160 | ``` javascript 161 | "charts": [ 162 | { 163 | "index": 1, 164 | "type": "tilelayer", 165 | "maxzoom": 15, 166 | "minzoom": 1, 167 | "name": "OpenStreetMap", 168 | "description": "OSM charts.", 169 | "tilemapUrl": "http://a.tile.openstreetmap.org/{z}/{x}/{y}.png" 170 | } 171 | ] 172 | ``` 173 | 174 | # License 175 | 176 | MIT -------------------------------------------------------------------------------- /charts/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vokkim/tuktuk-chart-plotter/2981d9ad78118bb1c61b3ac0b724d7193a90a5ce/charts/.gitkeep -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('babel-register') 3 | require('./src/server/server.js') -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tuktuk-chart-plotter", 3 | "version": "0.0.21", 4 | "description": "Tuktuk chart plotter with SignalK and MBTiles support", 5 | "main": "index.js", 6 | "license": "MIT", 7 | "scripts": { 8 | "start": "node index.js", 9 | "lint": "eslint --ext=js src/", 10 | "lint-fix": "eslint --ext=js src/ --fix", 11 | "test": "npm run lint", 12 | "bundle": "npm run lint && npm run bundle:css && NODE_ENV=production npm run bundle:js", 13 | "bundle:js": "rm ./public/bundle.js || true && ./node_modules/.bin/webpack --hide-modules --bail -p --config=webpack.bundle.js", 14 | "bundle:css": "rm ./public/bundle.css || true && ./node_modules/.bin/lessc src/styles/app.less > .bundle.css && node_modules/postcss-cli/bin/postcss --use autoprefixer --replace .bundle.css && mv .bundle.css public/bundle.css", 15 | "watch:js": "./node_modules/.bin/webpack --hide-modules --watch --config=webpack.bundle.js", 16 | "watch:css": "npm run bundle:css && watch-run -p 'src/styles/*.less' 'npm run bundle:css'", 17 | "watch:server": "nodemon --ext js,css,json --watch src index.js", 18 | "watch": "npm run watch:js & npm run watch:css & npm run watch:server & wait", 19 | "prepublishOnly": "npm run bundle" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/vokkim/tuktuk-chart-plotter.git" 24 | }, 25 | "author": "Mikko Vesikkala", 26 | "keywords": [ 27 | "signalk", 28 | "mbtiles", 29 | "chart", 30 | "plotter", 31 | "marine", 32 | "signalk-webapp" 33 | ], 34 | "dependencies": {}, 35 | "devDependencies": { 36 | "@mapbox/mbtiles": "0.10.0", 37 | "@signalk/client": "0.2.1", 38 | "autoprefixer": "9.1.5", 39 | "babel-cli": "6.26.0", 40 | "babel-core": "6.26.3", 41 | "babel-loader": "7.1.4", 42 | "babel-plugin-react-transform": "3.0.0", 43 | "babel-preset-es2015": "6.24.1", 44 | "babel-preset-react": "6.24.1", 45 | "bacon.atom": "5.0.5", 46 | "baconjs": "2.0.9", 47 | "baret": "1.2.0", 48 | "classnames": "2.2.6", 49 | "compression": "1.7.3", 50 | "eslint": "^5.5.0", 51 | "eslint-plugin-es5": "1.3.1", 52 | "eslint-plugin-node": "^7.0.1", 53 | "eslint-plugin-prettier": "^2.6.2", 54 | "eslint-plugin-react": "7.11.1", 55 | "eslint-restricted-globals": "^0.1.1", 56 | "express": "4.16.3", 57 | "geolib": "2.0.24", 58 | "leaflet": "1.3.4", 59 | "leaflet-rotatedmarker": "0.2.0", 60 | "less": "3.8.1", 61 | "lodash": "4.17.10", 62 | "nodemon": "1.18.4", 63 | "numeral": "2.0.6", 64 | "partial.lenses": "14.6.0", 65 | "postcss-cli": "6.0.0", 66 | "prettier": "^1.14.2", 67 | "react": "16.4.2", 68 | "react-dom": "16.4.2", 69 | "react-drag-sortable": "1.0.6", 70 | "request": "2.88.0", 71 | "screenfull": "3.3.3", 72 | "store": "2.0.12", 73 | "watch-run": "1.2.5", 74 | "webpack": "4.17.2", 75 | "webpack-cli": "3.1.0", 76 | "whatwg-fetch": "2.0.4" 77 | }, 78 | "prettier": { 79 | "semi": false, 80 | "singleQuote": true, 81 | "printWidth": 120, 82 | "bracketSpacing": false 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /preview-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vokkim/tuktuk-chart-plotter/2981d9ad78118bb1c61b3ac0b724d7193a90a5ce/preview-1.jpg -------------------------------------------------------------------------------- /preview-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vokkim/tuktuk-chart-plotter/2981d9ad78118bb1c61b3ac0b724d7193a90a5ce/preview-2.jpg -------------------------------------------------------------------------------- /public/GitHub-Mark-64px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vokkim/tuktuk-chart-plotter/2981d9ad78118bb1c61b3ac0b724d7193a90a5ce/public/GitHub-Mark-64px.png -------------------------------------------------------------------------------- /public/IcoMoon-Free.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vokkim/tuktuk-chart-plotter/2981d9ad78118bb1c61b3ac0b724d7193a90a5ce/public/IcoMoon-Free.ttf -------------------------------------------------------------------------------- /public/Roboto-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vokkim/tuktuk-chart-plotter/2981d9ad78118bb1c61b3ac0b724d7193a90a5ce/public/Roboto-Light.ttf -------------------------------------------------------------------------------- /public/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vokkim/tuktuk-chart-plotter/2981d9ad78118bb1c61b3ac0b724d7193a90a5ce/public/Roboto-Regular.ttf -------------------------------------------------------------------------------- /public/Roboto-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vokkim/tuktuk-chart-plotter/2981d9ad78118bb1c61b3ac0b724d7193a90a5ce/public/Roboto-Thin.ttf -------------------------------------------------------------------------------- /public/RobotoMono-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vokkim/tuktuk-chart-plotter/2981d9ad78118bb1c61b3ac0b724d7193a90a5ce/public/RobotoMono-Bold.ttf -------------------------------------------------------------------------------- /public/ais-target-selected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vokkim/tuktuk-chart-plotter/2981d9ad78118bb1c61b3ac0b724d7193a90a5ce/public/ais-target-selected.png -------------------------------------------------------------------------------- /public/ais-target.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vokkim/tuktuk-chart-plotter/2981d9ad78118bb1c61b3ac0b724d7193a90a5ce/public/ais-target.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tuktuk Plotter 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 25 |
26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /public/path-marker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vokkim/tuktuk-chart-plotter/2981d9ad78118bb1c61b3ac0b724d7193a90a5ce/public/path-marker.png -------------------------------------------------------------------------------- /public/vessel-large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vokkim/tuktuk-chart-plotter/2981d9ad78118bb1c61b3ac0b724d7193a90a5ce/public/vessel-large.png -------------------------------------------------------------------------------- /public/vessel-marker-round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vokkim/tuktuk-chart-plotter/2981d9ad78118bb1c61b3ac0b724d7193a90a5ce/public/vessel-marker-round.png -------------------------------------------------------------------------------- /public/vessel-medium.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vokkim/tuktuk-chart-plotter/2981d9ad78118bb1c61b3ac0b724d7193a90a5ce/public/vessel-medium.png -------------------------------------------------------------------------------- /public/vessel-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vokkim/tuktuk-chart-plotter/2981d9ad78118bb1c61b3ac0b724d7193a90a5ce/public/vessel-small.png -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash -e 2 | # Inspired by https://github.com/baconjs/bacon.js/blob/master/release 3 | 4 | if [ -z $1 ]; then 5 | echo "usage: release " 6 | exit 1 7 | fi 8 | 9 | version=$1 10 | echo "Releasing with version $version" 11 | 12 | echo "Pulling from origin" 13 | git pull --ff-only --no-rebase origin 14 | 15 | echo "Building" 16 | npm install 17 | 18 | echo "Updating files" 19 | sed -i "" 's/\("version".*:.*\)".*"/\1"'$version'"/' package.json 20 | sed -i "" 's/\?v=[0-9]*\.[0-9]*\.[0-9]*/\?v='$version'/' *.html 21 | 22 | echo "Commit and tag" 23 | git add . 24 | git commit -m "release $version" 25 | git tag $version 26 | 27 | echo "Push to origin/master" 28 | git push 29 | git push --tags origin 30 | 31 | echo "Publish to npm" 32 | npm publish 33 | 34 | #echo "Publish to S3" 35 | #AWS_PROFILE=plotteri aws s3 cp --recursive public/ s3://plotteri.merikartat.space/public --region=eu-central-1 --acl public-read 36 | #AWS_PROFILE=plotteri aws s3 cp s3-index.html s3://plotteri.merikartat.space/index.html --region=eu-central-1 --acl public-read 37 | 38 | echo "DONE!" -------------------------------------------------------------------------------- /s3-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Plotteri 6 | 7 | 8 | 9 | 10 | 11 | 12 | 46 |
47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/client/ais.js: -------------------------------------------------------------------------------- 1 | import React from 'baret' 2 | import Bacon from 'baconjs' 3 | import Atom from 'bacon.atom' 4 | import * as L from 'partial.lenses' 5 | import classNames from 'classnames' 6 | import _ from 'lodash' 7 | import numeral from 'numeral' 8 | import * as Leaf from 'leaflet' 9 | // eslint-disable-next-line no-unused-vars 10 | import LeafletRotatedMarker from 'leaflet-rotatedmarker' 11 | import {toDegrees, toKnots} from './utils' 12 | 13 | const aisTargetMarker = Leaf.icon({ 14 | iconUrl: 'ais-target.png', 15 | iconSize: [40, 54], 16 | iconAnchor: [20, 23] 17 | }) 18 | 19 | const selectedAisTargetMarker = Leaf.icon({ 20 | iconUrl: 'ais-target-selected.png', 21 | iconSize: [40, 54], 22 | iconAnchor: [20, 23] 23 | }) 24 | 25 | class Ais extends React.Component { 26 | constructor() { 27 | super() 28 | this.aisSettings = Atom({selectedVessel: undefined, data: undefined}) 29 | } 30 | componentDidMount() { 31 | initAisData(this.props.connection.aisData, this.props.settings, this.aisSettings) 32 | } 33 | 34 | render() { 35 | const selectedVessel = this.aisSettings.map('.selectedVessel').skipDuplicates() 36 | return
{selectedVessel.map(vesselId => (vesselId ? this.renderContent(vesselId) : null))}
37 | } 38 | 39 | renderContent(vesselId) { 40 | const {aisSettings} = this 41 | const {settings} = this.props 42 | const {getVesselAisData} = this.props.connection 43 | const vesselData = getVesselAisData(vesselId) 44 | const classes = settings.map(v => classNames('ais-details', {'menu-visible': v.showMenu})) 45 | return
{vesselData.map(data => renderVesselData(data, aisSettings))}
46 | } 47 | } 48 | 49 | function renderVesselData(data, aisSettings) { 50 | function renderDataRow(label, value) { 51 | return ( 52 |
53 |
{label}
54 |
{value || 'N/A'}
55 |
56 | ) 57 | } 58 | function renderFormattedDataRow(label, selector, unit, numberFormat, valueFormatter) { 59 | const value = _.get(data, selector) 60 | const formattedValue = valueFormatter ? valueFormatter(value) : value 61 | const valueWithUnit = value !== undefined ? `${numeral(formattedValue).format(numberFormat)} ${unit}` : undefined 62 | return renderDataRow(label, valueWithUnit) 63 | } 64 | 65 | return ( 66 |
67 |
68 |
{_.get(data, 'name', 'Unknown')}
69 |
MMSI: {_.get(data, 'mmsi', 'Unknown')}
70 | 75 |
76 |
77 | {renderDataRow('Vessel type:', _.get(data, 'design.aisShipType.value.name'))} 78 | {renderDataRow('State:', _.get(data, 'navigation.state.value', 'Unknown'))} 79 |
80 | {renderFormattedDataRow('SOG:', 'navigation.speedOverGround.value', 'kn', '0.0', toKnots)} 81 | {renderFormattedDataRow('COG:', 'navigation.courseOverGroundTrue.value', '°', '0', toDegrees)} 82 |
83 | {renderFormattedDataRow('Length:', 'design.length.value.overall', 'm', '0.0')} 84 | {renderFormattedDataRow('Beam:', 'design.beam.value', 'm', '0.0')} 85 | {renderFormattedDataRow('Draft:', 'design.draft.value.maximum', 'm', '0.0')} 86 |
87 |
88 | ) 89 | } 90 | 91 | function initAisData(aisData, settings, aisSettings) { 92 | if (!window.map && window.map) { 93 | throw new Error('Unable to init AIS data, window.map does not exist') 94 | } 95 | const {map} = window 96 | let aisMarkers = {} 97 | 98 | const aisEnabled = settings.map(s => _.get(s, 'ais.enabled', false)).skipDuplicates() 99 | aisEnabled 100 | .not() 101 | .filter(_.identity) 102 | .onValue(() => { 103 | _.each(aisMarkers, marker => marker.remove()) // Remove all markers 104 | aisMarkers = {} 105 | }) 106 | 107 | Bacon.interval(60 * 1000, true) // Check for expired AIS targets every 60s 108 | .filter(aisEnabled) 109 | .onValue(() => { 110 | aisMarkers = removeOldVesselMarkers(aisMarkers) 111 | }) 112 | 113 | aisSettings 114 | .view(L.prop('selectedVessel')) 115 | .skipDuplicates() 116 | .filter(aisEnabled) 117 | .onValue(selectedVessel => { 118 | highlightSelectedVessel(aisMarkers, selectedVessel) 119 | }) 120 | 121 | aisData 122 | .filter(aisEnabled) 123 | .skipDuplicates() 124 | .onValue(vessels => { 125 | _.each(vessels, (data, vesselId) => { 126 | const position = _.get(data, 'navigation.position') 127 | const course = toDegrees(_.get(data, 'navigation.courseOverGroundTrue.value')) 128 | if (aisMarkers[vesselId]) { 129 | updateVesselMarker(aisMarkers[vesselId], {position, course}) 130 | } else if (position && course) { 131 | aisMarkers[vesselId] = addVesselMarker(map, vesselId, {position, course}, aisSettings) 132 | } 133 | if (aisMarkers[vesselId]) { 134 | setMarkerTooltip(aisMarkers[vesselId], data) 135 | } 136 | }) 137 | }) 138 | } 139 | 140 | function updateVesselMarker(marker, {position, course}) { 141 | course && marker.setRotationAngle(course) 142 | position && marker.setLatLng([position.value.latitude, position.value.longitude]) 143 | } 144 | 145 | function addVesselMarker(map, vesselId, {position, course}, aisSettings) { 146 | const latlng = [position.value.latitude, position.value.longitude] 147 | const vesselMarker = Leaf.marker(latlng, { 148 | icon: aisTargetMarker, 149 | draggable: false, 150 | zIndexOffset: 980, 151 | rotationOrigin: 'center center', 152 | rotationAngle: course 153 | }) 154 | vesselMarker.id = vesselId 155 | vesselMarker.on('click', () => { 156 | aisSettings.view(L.prop('selectedVessel')).set(vesselId) 157 | }) 158 | vesselMarker.addTo(map) 159 | return vesselMarker 160 | } 161 | 162 | function setMarkerTooltip(marker, data) { 163 | const name = _.get(data, 'name.value') || data.name || 'Unknown' 164 | const sog = toKnots(_.get(data, 'navigation.speedOverGround.value')) 165 | const course = toDegrees(_.get(data, 'navigation.courseOverGroundTrue.value')) 166 | const formattedSog = numeral(sog).format('0.0') 167 | const formattedCog = numeral(course).format('0') 168 | const tooltip = `
${name}
SOG: ${formattedSog} kn
COG: ${formattedCog}
` 169 | marker.bindTooltip(tooltip, {className: 'aisTooltip'}) 170 | marker._updatedAt = Date.now() 171 | } 172 | 173 | function removeOldVesselMarkers(currentMarkers) { 174 | const now = Date.now() 175 | const expired = _(currentMarkers) 176 | .toPairs() 177 | .filter(v => now - v[1]._updatedAt > 180 * 1000) // Remove markers if not updated in 3 minutes 178 | .value() 179 | _.each(expired, v => v[1].remove()) // Call Leaflet.Marker.remove() 180 | return _.omit(currentMarkers, _.map(expired, '0')) 181 | } 182 | 183 | function highlightSelectedVessel(markers, selectedVessel) { 184 | _.each(markers, marker => { 185 | if (marker.id === selectedVessel) { 186 | marker.setIcon(selectedAisTargetMarker) 187 | setTimeout(() => marker.closeTooltip(), 1) 188 | } else { 189 | marker.setIcon(aisTargetMarker) 190 | } 191 | }) 192 | } 193 | 194 | module.exports = Ais 195 | -------------------------------------------------------------------------------- /src/client/api.js: -------------------------------------------------------------------------------- 1 | import 'whatwg-fetch' 2 | import Bacon from 'baconjs' 3 | 4 | function checkStatus(response) { 5 | if (response.status >= 200 && response.status < 300) { 6 | return response 7 | } else { 8 | const error = new Error(response.statusText) 9 | error.response = response 10 | throw error 11 | } 12 | } 13 | 14 | function parseJSON(response) { 15 | return response.json() 16 | } 17 | 18 | function get({url}) { 19 | const request = fetch(url, {credentials: 'include'}) 20 | .then(checkStatus) 21 | .then(parseJSON) 22 | return Bacon.fromPromise(request) 23 | } 24 | 25 | module.exports = { 26 | get 27 | } 28 | -------------------------------------------------------------------------------- /src/client/app.js: -------------------------------------------------------------------------------- 1 | import React from 'baret' 2 | import * as L from 'partial.lenses' 3 | import Atom from 'bacon.atom' 4 | import {render} from 'react-dom' 5 | import numeral from 'numeral' 6 | import classNames from 'classnames' 7 | import DragSortableList from 'react-drag-sortable' 8 | import Bacon from 'baconjs' 9 | import _ from 'lodash' 10 | import { 11 | COG, 12 | HDG, 13 | MAX_ZOOM, 14 | MIN_ZOOM, 15 | EXTENSION_LINE_OFF, 16 | EXTENSION_LINE_2_MIN, 17 | EXTENSION_LINE_5_MIN, 18 | EXTENSION_LINE_10_MIN 19 | } from './enums' 20 | import Map from './map' 21 | import Ais from './ais' 22 | import Connection from './data-connection' 23 | import {toNauticalMiles} from './utils' 24 | import InstrumentConfig from './instrument-config' 25 | import fullscreen from './fullscreen' 26 | import {settings, clearSettingsFromLocalStorage} from './settings' 27 | numeral.nullFormat('N/A') 28 | fullscreen(settings) 29 | 30 | const drawObject = Atom({distance: 0, del: false}) 31 | const connection = Connection({providers: settings.get().data, settings}) 32 | 33 | const Controls = ({settings, connectionState}) => { 34 | return ( 35 |
36 |
37 | settings.view(L.prop('showMenu')).modify(v => !v)} 42 | /> 43 |
44 |
45 | {connectionState.map(state => { 46 | if (state === 'disconnected') { 47 | return
Signal K disconnected
48 | } else { 49 | return null 50 | } 51 | })} 52 |
53 |
54 | {} 55 | settings.view(L.prop('showInstruments')).modify(v => !v)} 60 | /> 61 | settings.view(L.prop('follow')).modify(v => !v)} 66 | /> 67 |
68 | settings.view(L.prop('zoom')).modify(zoom => Math.min(zoom + 1, MAX_ZOOM))} 73 | /> 74 | settings.view(L.prop('zoom')).modify(zoom => Math.max(zoom - 1, MIN_ZOOM))} 79 | /> 80 |
81 | settings.view(L.prop('fullscreen')).modify(v => !v)} 86 | /> 87 |
88 |
89 | ) 90 | } 91 | 92 | const Instruments = ({settings, data}) => { 93 | const classes = settings.map(v => 94 | classNames('right-bar-instruments', {visible: v.showInstruments, hidden: !v.showInstruments}) 95 | ) 96 | const renderSingleInstrument = key => { 97 | const config = InstrumentConfig[key] 98 | if (!config) { 99 | return null 100 | } 101 | return ( 102 | v[config.dataKey]) 106 | .skipDuplicates() 107 | .map(config.transformFn)} 108 | className={config.className} 109 | format={config.format} 110 | title={config.title} 111 | unit={config.unit} 112 | /> 113 | ) 114 | } 115 | return ( 116 |
117 |
118 | {settings 119 | .map('.instruments') 120 | .skipDuplicates() 121 | .map(instruments => _.map(instruments, renderSingleInstrument))} 122 |
123 |
124 | ) 125 | } 126 | 127 | const PathDrawControls = ({settings}) => { 128 | const distance = drawObject 129 | .view(L.prop('distance')) 130 | .map(toNauticalMiles) 131 | .map(v => numeral(v).format('0.0')) 132 | return ( 133 |
134 |
classNames('path-draw-controls', {enabled: v, disabled: !v}))}> 135 | 138 |
{distance} nm
139 |
140 | settings.view(L.prop('drawMode')).modify(v => !v)} 145 | /> 146 |
147 | ) 148 | } 149 | 150 | const Instrument = ({value, format = '0.00', className, title, unit}) => { 151 | return ( 152 |
153 |
154 |
{title}
155 |
{unit}
156 |
157 |
{value.map(v => numeral(v).format(format))}
158 |
159 | ) 160 | } 161 | 162 | const TopBarButton = ({enabled, className, iconClass, onClick}) => { 163 | return ( 164 | 169 | ) 170 | } 171 | 172 | const MenuCheckbox = ({checked, label, className, onClick}) => { 173 | const classname = checked.subscribe 174 | ? checked.map(v => (v ? 'icon-checkbox-checked' : 'icon-checkbox-unchecked')) 175 | : checked 176 | ? 'icon-checkbox-checked' 177 | : 'icon-checkbox-unchecked' 178 | return ( 179 | 183 | ) 184 | } 185 | 186 | const MenuSwitch = ({label, valueLabel, className, onClick}) => { 187 | return ( 188 | 192 | ) 193 | } 194 | 195 | const Menu = ({settings}) => { 196 | function toggleExtensionLine() { 197 | settings.view(L.prop('extensionLine')).modify(v => { 198 | const order = [EXTENSION_LINE_OFF, EXTENSION_LINE_2_MIN, EXTENSION_LINE_5_MIN, EXTENSION_LINE_10_MIN] 199 | return order[(order.indexOf(v) + 1) % 4] 200 | }) 201 | } 202 | 203 | return ( 204 |
classNames('left-bar-menu', {visible: v.showMenu, hidden: !v.showMenu}))}> 205 |
206 |
207 | 208 | 218 | settings 219 | .view( 220 | L.compose( 221 | L.prop('ais'), 222 | L.prop('enabled') 223 | ) 224 | ) 225 | .modify(v => !v) 226 | } 227 | /> 228 | settings.view(L.prop('course')).modify(v => (v === COG ? HDG : COG))} 233 | /> 234 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 254 |
255 |
256 | 260 |
261 |
262 |
263 | ) 264 | } 265 | 266 | const ChartRow = ({provider, onClick}) => { 267 | const {id, name, enabled, description, minzoom, maxzoom} = provider 268 | return ( 269 |
273 |
274 |

{name}

275 | {description &&

{description}

} 276 | {minzoom && 277 | maxzoom && ( 278 |

279 | Levels: {minzoom} - {maxzoom} 280 |

281 | )} 282 |
283 |
284 | 285 |
286 |
287 | ) 288 | } 289 | 290 | const ChartSettings = ({chartProviders}) => { 291 | const renderProviderRow = provider => { 292 | const onClick = () => 293 | chartProviders 294 | .view( 295 | L.compose( 296 | L.find(p => p.id === provider.id), 297 | L.prop('enabled') 298 | ) 299 | ) 300 | .modify(v => !v) 301 | return 302 | } 303 | 304 | return ( 305 |
306 | {chartProviders.map(providers => { 307 | if (_.isEmpty(providers)) { 308 | return
  • No charts
  • 309 | } 310 | return _.map(_.sortBy(providers, 'index'), renderProviderRow) 311 | })} 312 |
    313 | ) 314 | } 315 | 316 | class InstrumentSettings extends React.Component { 317 | constructor(props) { 318 | super(props) 319 | const allConfigs = _.keys(InstrumentConfig) 320 | const initialInstruments = props.instrumentSettings.get() 321 | const initialSortOrder = _.sortBy(allConfigs, key => { 322 | const i = _.indexOf(initialInstruments, key) 323 | return i === -1 ? Number.MAX_VALUE : i 324 | }) 325 | this._internalSort = Atom(initialSortOrder) 326 | this.unsub = this._internalSort.onValue(sortOrder => { 327 | this.props.instrumentSettings.modify(instruments => _.sortBy(instruments, key => _.indexOf(sortOrder, key))) 328 | }) 329 | } 330 | 331 | componentWillUnmount() { 332 | this.unsub && this.unsub() 333 | } 334 | 335 | render() { 336 | const {_internalSort} = this 337 | const {instrumentSettings} = this.props 338 | const instruments = instrumentSettings.map(instruments => { 339 | const sortOrder = _internalSort.get() 340 | return _.map(sortOrder, key => { 341 | const config = InstrumentConfig[key] 342 | const selected = _.includes(instruments, key) 343 | const content = ( 344 | 349 | instrumentSettings.modify(instruments => { 350 | if (selected) { 351 | return _.filter(instruments, k => k !== key) 352 | } else { 353 | return _.filter(sortOrder, k => k === key || _.includes(instruments, k)) 354 | } 355 | }) 356 | } 357 | /> 358 | ) 359 | return {content} 360 | }) 361 | }) 362 | 363 | function onSort(sortedElements) { 364 | const sortOrder = _(sortedElements) 365 | .sortBy(e => e.rank) 366 | .map(e => e.content.key) 367 | .value() 368 | _internalSort.set(sortOrder) 369 | } 370 | return ( 371 |
    372 | {instruments.map(list => ( 373 | // eslint-disable-next-line react/jsx-key 374 | 375 | ))} 376 |
    377 | ) 378 | } 379 | } 380 | 381 | class Accordion extends React.Component { 382 | constructor(props) { 383 | super(props) 384 | this.state = {open: props.openByDefault || false} 385 | } 386 | render() { 387 | const {open} = this.state 388 | const {header, children, className} = this.props 389 | return ( 390 |
    391 |
    392 | {header} 393 | 394 |
    395 | {open &&
    {children}
    } 396 |
    397 | ) 398 | } 399 | toggleOpen() { 400 | this.setState({open: !this.state.open}) 401 | } 402 | } 403 | 404 | const App = ( 405 |
    406 | 407 | 408 | 409 | {settings 410 | .view(L.prop('loadingChartProviders')) 411 | .skipDuplicates() 412 | .map(loading => { 413 | if (loading) { 414 | return ( 415 |
    416 |

    Loading ...

    417 |
    418 | ) 419 | } else { 420 | return ( 421 |
    422 | 423 | 424 |
    425 | ) 426 | } 427 | })} 428 |
    429 | ) 430 | 431 | render(App, document.getElementById('app')) 432 | -------------------------------------------------------------------------------- /src/client/data-connection.js: -------------------------------------------------------------------------------- 1 | import Bacon from 'baconjs' 2 | import SignalkProvider from './provider-signalk' 3 | import GeolocationProvider from './provider-geolocation' 4 | 5 | function connect({providers, settings}) { 6 | if (providers.length > 1) { 7 | throw 'Only 1 data provider supported for now' 8 | } 9 | if (providers.length === 0) { 10 | return { 11 | connectionState: Bacon.constant('disconnected'), 12 | selfData: Bacon.constant({}), 13 | aisData: Bacon.constant({}) 14 | } 15 | } 16 | const provider = providers[0] 17 | if (provider.type === 'signalk') { 18 | return SignalkProvider({address: provider.address, settings}) 19 | } else if (provider.type === 'geolocation') { 20 | return GeolocationProvider({settings}) 21 | } else { 22 | throw `Unsupported provider ${provider}` 23 | } 24 | } 25 | module.exports = connect 26 | -------------------------------------------------------------------------------- /src/client/enums.js: -------------------------------------------------------------------------------- 1 | const enums = { 2 | COG: 'COG', 3 | HDG: 'HDG', 4 | EXTENSION_LINE_OFF: 'Off', 5 | EXTENSION_LINE_2_MIN: '2 min', 6 | EXTENSION_LINE_5_MIN: '5 min', 7 | EXTENSION_LINE_10_MIN: '10 min', 8 | MAX_ZOOM: 16, 9 | MIN_ZOOM: 3, 10 | KNOTS_TO_MS: 0.514444, 11 | MS_TO_KNOTS: 1.94384, 12 | M_TO_NM: 0.000539957 13 | } 14 | 15 | module.exports = enums 16 | -------------------------------------------------------------------------------- /src/client/fullscreen.js: -------------------------------------------------------------------------------- 1 | import screenfull from 'screenfull' 2 | import * as L from 'partial.lenses' 3 | 4 | module.exports = function(settings) { 5 | if (!screenfull.enabled) { 6 | return 7 | } 8 | 9 | settings 10 | .map('.fullscreen') 11 | .skip(1) 12 | .skipDuplicates() 13 | .onValue(val => { 14 | if (val && !screenfull.isFullscreen) { 15 | screenfull.request() 16 | } 17 | if (!val && screenfull.isFullscreen) { 18 | screenfull.exit() 19 | } 20 | }) 21 | screenfull.onchange(() => { 22 | settings.view(L.prop('fullscreen')).set(screenfull.isFullscreen) 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /src/client/instrument-config.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import {toDegrees, toKnots} from './utils' 3 | 4 | const InstrumentConfigs = { 5 | sog: { 6 | dataKey: 'navigation.speedOverGround', 7 | transformFn: toKnots, 8 | className: 'sog', 9 | title: 'SOG', 10 | unit: 'kn' 11 | }, 12 | stw: { 13 | dataKey: 'navigation.speedThroughWater', 14 | transformFn: toKnots, 15 | className: 'stw', 16 | title: 'STW', 17 | unit: 'kn' 18 | }, 19 | heading: { 20 | dataKey: 'navigation.headingTrue', 21 | transformFn: toDegrees, 22 | className: 'heading', 23 | title: 'HDG', 24 | format: '00', 25 | unit: '°' 26 | }, 27 | cog: { 28 | dataKey: 'navigation.courseOverGroundTrue', 29 | transformFn: toDegrees, 30 | className: 'cog', 31 | title: 'COG', 32 | format: '00', 33 | unit: '°' 34 | }, 35 | dbt: { 36 | dataKey: 'environment.depth.belowTransducer', 37 | transformFn: _.identity, 38 | className: 'dbt', 39 | title: 'Depth', 40 | format: '0.0', 41 | unit: 'm' 42 | }, 43 | tws: { 44 | dataKey: 'environment.wind.speedTrue', 45 | transformFn: _.identity, 46 | className: 'tws', 47 | title: 'TWS', 48 | format: '0.0', 49 | unit: 'm' 50 | }, 51 | aws: { 52 | dataKey: 'environment.wind.speedApparent', 53 | transformFn: _.identity, 54 | className: 'aws', 55 | title: 'AWS', 56 | format: '0.0', 57 | unit: 'm' 58 | }, 59 | awaSog: { 60 | dataKey: 'environment.wind.angleApparent', 61 | transformFn: toDegrees, 62 | className: 'awa', 63 | title: 'AWA', 64 | format: '0', 65 | unit: '°' 66 | }, 67 | awaWater: { 68 | dataKey: 'environment.wind.angleTrueWater', 69 | transformFn: toDegrees, 70 | className: 'awa', 71 | title: 'AWA (STW)', 72 | format: '0', 73 | unit: '°' 74 | } 75 | } 76 | 77 | module.exports = InstrumentConfigs 78 | -------------------------------------------------------------------------------- /src/client/map.js: -------------------------------------------------------------------------------- 1 | import React from 'baret' 2 | import * as L from 'partial.lenses' 3 | import Bacon from 'baconjs' 4 | import classNames from 'classnames' 5 | import _ from 'lodash' 6 | import * as Leaf from 'leaflet' 7 | import {computeDestinationPoint} from 'geolib' 8 | // eslint-disable-next-line no-unused-vars 9 | import LeafletRotatedMarker from 'leaflet-rotatedmarker' 10 | import api from './api' 11 | import {toDegrees} from './utils' 12 | import {COG, MAX_ZOOM, MIN_ZOOM, EXTENSION_LINE_OFF} from './enums' 13 | 14 | class Map extends React.Component { 15 | componentDidMount() { 16 | initMap(this.props.connection, this.props.settings, this.props.drawObject) 17 | } 18 | render() { 19 | const {settings} = this.props 20 | const classes = settings.map(v => 21 | classNames('map-wrapper', {'instruments-visible': v.showInstruments, 'menu-visible': v.showMenu}) 22 | ) 23 | return ( 24 |
    25 |
    26 |
    27 | ) 28 | } 29 | } 30 | 31 | function initMap(connection, settings, drawObject) { 32 | console.log('Init map') 33 | const initialSettings = settings.get() 34 | const map = Leaf.map('map', { 35 | scrollWheelZoom: initialSettings.follow ? 'center' : true, 36 | zoom: initialSettings.zoom, 37 | zoomControl: false, 38 | minZoom: MIN_ZOOM, 39 | maxZoom: MAX_ZOOM, 40 | center: [0, 0], 41 | attributionControl: false 42 | }) 43 | window.map = map 44 | initialSettings.worldBaseChart && addBasemap(map) 45 | 46 | addCharts(map, initialSettings.chartProviders, settings.map('.chartProviders')) 47 | const vesselIcons = createVesselIcons(_.get(initialSettings.data, ['0', 'type']) === 'geolocation') 48 | 49 | const myVessel = Leaf.marker([0, 0], { 50 | icon: resolveIcon(vesselIcons, initialSettings.zoom), 51 | draggable: false, 52 | zIndexOffset: 990, 53 | rotationOrigin: 'center center', 54 | rotationAngle: 0 55 | }) 56 | myVessel.addTo(map) 57 | const pointerEnd = Leaf.circle([0, 0], {radius: 20, color: 'red', fillOpacity: 0}) 58 | pointerEnd.addTo(map) 59 | const pointer = Leaf.polyline([], {color: 'red'}) 60 | pointer.addTo(map) 61 | 62 | const vesselData = Bacon.combineTemplate({ 63 | vesselData: connection.selfData, 64 | settings 65 | }) 66 | vesselData.onValue(({vesselData, settings}) => { 67 | const position = vesselData['navigation.position'] 68 | if (position) { 69 | const newPos = [position.latitude, position.longitude] 70 | myVessel.setLatLng(newPos) 71 | } 72 | 73 | const course = 74 | settings.course === COG ? vesselData['navigation.courseOverGroundTrue'] : vesselData['navigation.headingTrue'] 75 | if (course) { 76 | myVessel.setRotationAngle(toDegrees(course)) 77 | } else { 78 | myVessel.setRotationAngle(0) 79 | } 80 | 81 | const speed = vesselData['navigation.speedOverGround'] 82 | const extensionLineCoordinates = calculateExtensionLine(position, course, speed, settings.extensionLine) 83 | if (extensionLineCoordinates) { 84 | pointer.setLatLngs([extensionLineCoordinates.start, extensionLineCoordinates.end]) 85 | pointerEnd.setLatLng(extensionLineCoordinates.end) 86 | } else { 87 | pointer.setLatLngs([[0, 0], [0, 0]]) 88 | pointerEnd.setLatLng([0, 0]) 89 | } 90 | if (settings.follow && (extensionLineCoordinates || position)) { 91 | const to = extensionLineCoordinates ? extensionLineCoordinates.middle : [position.latitude, position.longitude] 92 | if (map.getCenter().distanceTo(to) > 100) { 93 | map.panTo(to) 94 | } 95 | } 96 | }) 97 | 98 | handleDrawPath({map, settings, drawObject}) 99 | handleMapZoom() 100 | handleDragAndFollow() 101 | handleInstrumentsToggle() 102 | function handleMapZoom() { 103 | settings 104 | .map('.zoom') 105 | .skipDuplicates() 106 | .onValue(zoom => { 107 | map.setZoom(zoom) 108 | myVessel.setIcon(resolveIcon(vesselIcons, zoom)) 109 | if (zoom < 12) { 110 | pointer.setStyle({opacity: 0}) 111 | pointerEnd.setStyle({opacity: 0}) 112 | } else { 113 | pointer.setStyle({opacity: 1}) 114 | pointerEnd.setStyle({opacity: 1}) 115 | } 116 | }) 117 | 118 | const zoomEnd = Bacon.fromEvent(map, 'zoomend') 119 | zoomEnd.onValue(() => { 120 | settings.view(L.prop('zoom')).set(map.getZoom()) 121 | }) 122 | } 123 | 124 | function handleDragAndFollow() { 125 | const follow = settings.map('.follow').skipDuplicates() 126 | follow.onValue(f => { 127 | map.options.scrollWheelZoom = f ? 'center' : true 128 | }) 129 | 130 | const dragStart = Bacon.fromEvent(map, 'dragstart') 131 | //TODO: How to combine lenses and streams? 132 | dragStart.filter(follow).onValue(() => { 133 | settings.view(L.prop('follow')).modify(v => !v) 134 | }) 135 | } 136 | 137 | function handleInstrumentsToggle() { 138 | settings 139 | .map('.showInstruments') 140 | .changes() 141 | .merge(settings.map('.showMenu').changes()) 142 | .skipDuplicates() 143 | .delay(250) 144 | .onValue(() => { 145 | map.invalidateSize(true) 146 | }) 147 | } 148 | } 149 | 150 | function handleDrawPath({map, settings, drawObject}) { 151 | const pathMarker = Leaf.icon({ 152 | iconUrl: 'path-marker.png', 153 | iconSize: [20, 20], 154 | iconAnchor: [10, 10] 155 | }) 156 | let path = [] 157 | let lastMoveAt = undefined 158 | const del = drawObject.view(L.prop('del')) 159 | const pathPolyline = Leaf.polyline([], {color: '#3e3e86', weight: 5}) 160 | pathPolyline.addTo(map) 161 | 162 | Bacon.fromEvent(map, 'click') 163 | .filter(settings.map('.drawMode')) 164 | .filter(e => _.every(path, marker => e.latlng.distanceTo(marker._latlng) > 30)) 165 | .filter(() => lastMoveAt === undefined || Date.now() - lastMoveAt > 50) // Do not add new marker if one was just dragged 166 | .map(e => { 167 | const {latlng} = e 168 | return Leaf.marker(latlng, {icon: pathMarker, draggable: true, zIndexOffset: 900}) 169 | }) 170 | .onValue(marker => { 171 | marker.addTo(map) 172 | path.push(marker) 173 | marker.on('move', redrawPathAndChangeDistance) 174 | redrawPathAndChangeDistance() 175 | }) 176 | 177 | function redrawPathAndChangeDistance() { 178 | lastMoveAt = Date.now() 179 | const latlngs = _.map(path, marker => marker._latlng) 180 | pathPolyline.setLatLngs(latlngs) 181 | const distance = _.reduce( 182 | path, 183 | (sum, marker, i) => { 184 | if (i > 0) { 185 | return sum + path[i - 1]._latlng.distanceTo(marker._latlng) 186 | } else { 187 | return 0 188 | } 189 | }, 190 | 0 191 | ) 192 | drawObject.view(L.prop('distance')).set(distance) 193 | } 194 | 195 | del 196 | .filter(_.identity) 197 | .changes() 198 | .merge( 199 | settings 200 | .map('.drawMode') 201 | .skipDuplicates() 202 | .changes() 203 | ) 204 | .onValue(() => { 205 | _.each(path, marker => marker.remove()) 206 | path = [] 207 | redrawPathAndChangeDistance() 208 | drawObject.view(L.prop('del')).set(false) 209 | }) 210 | } 211 | 212 | function addCharts(map, providers, providersP) { 213 | // Initialize charts based on initial providers 214 | const mapLayers = _.map(providers, provider => { 215 | const {index, name, maxzoom, minzoom, tilemapUrl, enabled, type, center} = provider 216 | 217 | if (!_.includes(['tilelayer'], type)) { 218 | console.error(`Unsupported chart type ${type} for chart ${name}`) 219 | return 220 | } 221 | if (!tilemapUrl) { 222 | console.error(`Missing tilemapUrl for chart ${name}`) 223 | return 224 | } 225 | const pane = `chart-${index}` 226 | map.createPane(pane) 227 | const bounds = parseChartBounds(provider) 228 | // 'detectRetina' messes up Leaflet maxNativeZoom, fix with a hack: 229 | const maxNativeZoom = maxzoom ? maxzoom - (Leaf.Browser.retina ? 1 : 0) : undefined 230 | const minNativeZoom = minzoom ? minzoom + (Leaf.Browser.retina ? 1 : 0) : undefined 231 | const layer = Leaf.tileLayer(tilemapUrl, {detectRetina: true, bounds, maxNativeZoom, minNativeZoom, pane}) 232 | 233 | if (enabled) { 234 | layer.addTo(map) 235 | if (_.isArray(center) && center.length === 2) { 236 | map.panTo([center[1], center[0]]) 237 | } else if (bounds) { 238 | map.fitBounds(bounds) 239 | } 240 | } 241 | return {provider, layer} 242 | }) 243 | 244 | // Toggle chart layers on/off based on enabled providers 245 | providersP 246 | .skipDuplicates() 247 | .skip(1) 248 | .onValue(providers => { 249 | _.each(providers, ({enabled, id}) => { 250 | const mapLayer = _.find(mapLayers, ({provider}) => provider.id === id) 251 | if (enabled) { 252 | mapLayer.layer.addTo(map) 253 | } else { 254 | mapLayer.layer.removeFrom(map) 255 | } 256 | }) 257 | }) 258 | } 259 | 260 | function addBasemap(map) { 261 | map.createPane('basemap') 262 | const basemapStyle = { 263 | stroke: false, 264 | fill: true, 265 | fillColor: '#fafafa', 266 | fillOpacity: 1 267 | } 268 | const baseMap = api.get({url: 'world-base.geo.json'}) 269 | baseMap.onError(e => console.log('Unable to fetch base map', e)) 270 | baseMap.onValue(worldBaseGeoJSON => 271 | Leaf.geoJson(worldBaseGeoJSON, {clickable: false, style: basemapStyle, pane: 'basemap'}).addTo(map) 272 | ) 273 | } 274 | 275 | function calculateExtensionLine(position, course, speed, extensionLineSetting) { 276 | if (extensionLineSetting === EXTENSION_LINE_OFF) { 277 | return undefined 278 | } 279 | const time = 60 * parseInt(extensionLineSetting) 280 | if (position && position.latitude && position.longitude && course && speed > 0.5) { 281 | const distance = speed * time // Speed in m/s 282 | const start = [position.latitude, position.longitude] 283 | const destination = computeDestinationPoint( 284 | {lat: position.latitude, lon: position.longitude}, 285 | distance, 286 | toDegrees(course) 287 | ) 288 | const middle = computeDestinationPoint( 289 | {lat: position.latitude, lon: position.longitude}, 290 | distance / 2, 291 | toDegrees(course) 292 | ) 293 | return { 294 | start, 295 | middle: [middle.latitude, middle.longitude], 296 | end: [destination.latitude, destination.longitude] 297 | } 298 | } 299 | return undefined 300 | } 301 | 302 | function createVesselIcons(shouldUseRoundIcon) { 303 | if (shouldUseRoundIcon) { 304 | const icon = Leaf.icon({ 305 | iconUrl: 'vessel-marker-round.png', 306 | iconSize: [30, 30], 307 | iconAnchor: [15, 15] 308 | }) 309 | return {large: icon, medium: icon, small: icon} 310 | } 311 | const large = Leaf.icon({ 312 | iconUrl: 'vessel-large.png', 313 | iconSize: [20, 50], 314 | iconAnchor: [10, 25] 315 | }) 316 | 317 | const medium = Leaf.icon({ 318 | iconUrl: 'vessel-medium.png', 319 | iconSize: [16, 40], 320 | iconAnchor: [8, 20] 321 | }) 322 | 323 | const small = Leaf.icon({ 324 | iconUrl: 'vessel-small.png', 325 | iconSize: [12, 30], 326 | iconAnchor: [6, 15] 327 | }) 328 | return {large, medium, small} 329 | } 330 | 331 | function resolveIcon(icons, zoom) { 332 | if (zoom < 7) { 333 | return icons.small 334 | } else if (zoom < 12) { 335 | return icons.medium 336 | } else { 337 | return icons.large 338 | } 339 | } 340 | 341 | function parseChartBounds(provider) { 342 | if (!provider.bounds) { 343 | return undefined 344 | } 345 | if (!_.isArray(provider.bounds) || provider.bounds.length !== 4) { 346 | throw new Error('Unrecognized bounds format: ' + JSON.stringify(provider.bounds)) 347 | } 348 | 349 | const corner1 = Leaf.latLng(provider.bounds[1], provider.bounds[0]) 350 | const corner2 = Leaf.latLng(provider.bounds[3], provider.bounds[2]) 351 | const bounds = Leaf.latLngBounds(corner1, corner2) 352 | if (!bounds.isValid()) { 353 | throw new Error('Invalid bounds: ' + JSON.stringify(provider.bounds)) 354 | } 355 | return bounds 356 | } 357 | 358 | module.exports = Map 359 | -------------------------------------------------------------------------------- /src/client/provider-geolocation.js: -------------------------------------------------------------------------------- 1 | import Bacon from 'baconjs' 2 | import {toRadians} from './utils' 3 | 4 | function connect() { 5 | const rawStream = new Bacon.Bus() 6 | 7 | if (!navigator.geolocation || !navigator.geolocation.watchPosition) { 8 | throw 'Missing geolocation API' 9 | } 10 | const success = event => { 11 | const position = { 12 | path: 'navigation.position', 13 | latitude: event.coords.latitude, 14 | longitude: event.coords.longitude 15 | } 16 | const sog = { 17 | path: 'navigation.speedOverGround', 18 | value: event.coords.speed 19 | } 20 | const heading = { 21 | path: 'navigation.headingTrue', 22 | value: toRadians(event.coords.heading) 23 | } 24 | const vesselData = { 25 | 'navigation.position': position, 26 | 'navigation.speedOverGround': sog, 27 | 'navigation.headingTrue': heading 28 | } 29 | rawStream.push(vesselData) 30 | } 31 | const error = err => { 32 | console.log('Geolocation error', err) 33 | } 34 | 35 | const options = { 36 | enableHighAccuracy: true, 37 | maximumAge: 5000 38 | } 39 | 40 | navigator.geolocation.watchPosition(success, error, options) 41 | 42 | return { 43 | connectionState: Bacon.constant('connected'), 44 | selfData: rawStream, 45 | aisData: Bacon.constant({}) 46 | } 47 | } 48 | 49 | module.exports = connect 50 | -------------------------------------------------------------------------------- /src/client/provider-signalk.js: -------------------------------------------------------------------------------- 1 | import Bacon from 'baconjs' 2 | import _ from 'lodash' 3 | import api from './api' 4 | import SignalK from '@signalk/client' 5 | 6 | let selfId // TODO: Nasty, get rid of this mutate 7 | 8 | const isSelf = id => { 9 | return selfId && (selfId === id || selfId === `vessels.${id}`) 10 | } 11 | 12 | function connect({address, settings}) { 13 | const rawStream = new Bacon.Bus() 14 | const connectionStateBus = new Bacon.Bus() 15 | const connectionState = connectionStateBus.toProperty('connecting') 16 | 17 | const onMessage = msg => { 18 | if (!selfId && msg && msg.self && _.isString(msg.self)) { 19 | selfId = msg.self 20 | } 21 | rawStream.push(msg) 22 | } 23 | const onConnect = c => { 24 | connectionStateBus.push('connected') 25 | c.send({ 26 | context: 'vessels.self', 27 | subscribe: [ 28 | { 29 | path: '*', 30 | format: 'delta', 31 | policy: 'ideal', 32 | minPeriod: 3000 33 | } 34 | ] 35 | }) 36 | 37 | c.send({ 38 | context: 'vessels.*', 39 | subscribe: [{path: '*', period: 10000}] 40 | }) 41 | console.log(`SignalK connected to ${parseAddress(address)}`) 42 | } 43 | const onDisconnect = c => { 44 | c && c.close && c.close() 45 | rawStream.end() 46 | connectionStateBus.push('disconnected') 47 | console.log('SignalK disconnected') 48 | } 49 | const onError = e => { 50 | rawStream.push(new Bacon.Error(e)) 51 | onDisconnect() 52 | } 53 | 54 | const signalk = new SignalK.Client() 55 | signalk.connectDelta(parseAddress(address), onMessage, onConnect, onDisconnect, onError, onDisconnect, 'none') 56 | const selfStream = rawStream.filter(msg => isSelf(msg.context)) 57 | 58 | const updates = selfStream.map(msg => { 59 | return _(msg.updates) 60 | .map(u => 61 | _.map(u.values, v => { 62 | return { 63 | timestamp: u.timestamp, 64 | path: v.path, 65 | value: v.value 66 | } 67 | }) 68 | ) 69 | .flatten() 70 | .value() 71 | }) 72 | 73 | const selfData = updates 74 | .filter(values => !_.isEmpty(values)) 75 | .scan({}, (sum, values) => { 76 | const pairs = _.map(values, v => [v.path, v.value]) 77 | return _.assign({}, sum, _.fromPairs(pairs)) 78 | }) 79 | .debounceImmediate(1000) 80 | 81 | const {aisData, getVesselAisData} = parseAISData({selfStream, address, rawStream, settings}) 82 | 83 | return { 84 | connectionState: connectionState, 85 | rawStream, 86 | selfData, 87 | aisData, 88 | getVesselAisData 89 | } 90 | } 91 | 92 | function parseAISData({selfStream, address, rawStream, settings}) { 93 | const hasSelf = selfStream 94 | .take(1) 95 | .map(true) 96 | .toProperty(false) 97 | const aisEnabled = settings.map(s => _.get(s, 'ais.enabled', false)).skipDuplicates() 98 | //TODO: Subscribe / unsubscribe for AIS vessels 99 | const aisStream = rawStream 100 | .filter(aisEnabled) 101 | .map(singleDeltaMessageToAisData) 102 | .bufferWithTimeOrCount(1000, 100) 103 | .map(deltas => _.reduce(deltas, _.merge, {})) 104 | const fullAisData = Bacon.combineTemplate({ 105 | hasSelf, 106 | aisEnabled 107 | }) 108 | .flatMapLatest(({hasSelf, aisEnabled}) => { 109 | return hasSelf && aisEnabled ? getInitialAISData(address).concat(aisStream) : Bacon.once() 110 | }) 111 | .scan({delta: {}, full: {}}, (previous, data) => { 112 | const full = _.merge({}, previous.full, _.omitBy(data, (value, key) => isSelf(key))) 113 | const delta = _.pick(full, _.keys(data)) 114 | return {delta, full} 115 | }) 116 | 117 | const deltaAisData = fullAisData.map('.delta').filter(d => !_.isEmpty(d)) 118 | 119 | function getVesselAisData(vesselId) { 120 | return fullAisData 121 | .map(data => data.full[vesselId]) 122 | .filter(_.identity) 123 | .skipDuplicates(_.isEqual) 124 | } 125 | 126 | return { 127 | aisData: deltaAisData, 128 | getVesselAisData 129 | } 130 | } 131 | 132 | function singleDeltaMessageToAisData(msg) { 133 | if (!msg.context) { 134 | return {} // Not a proper SK delta message 135 | } 136 | const data = _.reduce( 137 | msg.updates, 138 | (sum, update) => { 139 | const {timestamp} = update 140 | _.each(update.values, value => { 141 | _.set(sum, value.path + '.value', value.value) 142 | _.set(sum, value.path + '.timestamp', timestamp) 143 | }) 144 | return sum 145 | }, 146 | {} 147 | ) 148 | return {[msg.context.substring(8)]: data} 149 | } 150 | 151 | function getInitialAISData(address) { 152 | const protocol = window.location.protocol 153 | const url = `${protocol}//${parseAddress(address)}/signalk/v1/api/` 154 | return api.get({url}).map('.vessels') 155 | } 156 | 157 | function parseAddress(address) { 158 | if (_.isEmpty(address)) { 159 | throw 'Empty SignalK address!' 160 | } 161 | if (_.isEmpty(address.split(':')[0])) { 162 | // Relative address such as ':80' 163 | return `${window.location.hostname}:${address.split(':')[1]}` 164 | } else { 165 | return address 166 | } 167 | } 168 | 169 | module.exports = connect 170 | -------------------------------------------------------------------------------- /src/client/settings.js: -------------------------------------------------------------------------------- 1 | import Atom from 'bacon.atom' 2 | import Bacon from 'baconjs' 3 | import Store from 'store' 4 | import _ from 'lodash' 5 | import api from './api' 6 | import * as L from 'partial.lenses' 7 | import InstrumentConfig from './instrument-config' 8 | import {COG, EXTENSION_LINE_5_MIN, MIN_ZOOM, MAX_ZOOM} from './enums' 9 | const LOCAL_STORAGE_KEY = 'plotter-settings' 10 | const defaultSettings = { 11 | zoom: 13, 12 | fullscreen: false, 13 | drawMode: false, 14 | course: COG, 15 | follow: true, 16 | showMenu: false, 17 | extensionLine: EXTENSION_LINE_5_MIN, 18 | showInstruments: true, 19 | ais: { 20 | enabled: false 21 | }, 22 | worldBaseChart: true, 23 | chartProviders: [], 24 | loadingChartProviders: true, 25 | data: [], 26 | instruments: _.keys(InstrumentConfig) 27 | } 28 | 29 | const fromLocalStorage = Store.get(LOCAL_STORAGE_KEY) || {} 30 | 31 | const charts = _.get(window.INITIAL_SETTINGS, 'charts', []) 32 | const settings = Atom(_.assign(defaultSettings, _.omit(window.INITIAL_SETTINGS, ['charts']) || {}, fromLocalStorage)) 33 | 34 | const chartProviders = Bacon.fromArray(charts) 35 | .flatMap(provider => { 36 | switch (provider.type) { 37 | case 'local': 38 | return fetchLocalCharts(provider, fromLocalStorage.hiddenChartProviders) 39 | case 'signalk': 40 | return fetchSignalKCharts(provider, fromLocalStorage.hiddenChartProviders) 41 | default: 42 | return Bacon.once(provider) 43 | } 44 | }) 45 | .fold([], _.concat) 46 | 47 | chartProviders.onValue(charts => { 48 | settings.view(L.prop('chartProviders')).set(charts) 49 | settings.view(L.prop('loadingChartProviders')).set(false) 50 | }) 51 | 52 | chartProviders.onError(e => { 53 | console.error('Error fetching chart providers') 54 | console.error(e) 55 | }) 56 | 57 | settings 58 | .map(v => { 59 | const hiddenChartProviders = v.chartProviders ? _.filter(v.chartProviders, p => !p.enabled) : [] 60 | return Object.assign({}, v, {hiddenChartProviders: _.map(hiddenChartProviders, 'id')}) 61 | }) 62 | .map(v => _.omit(v, ['chartProviders', 'drawMode', 'data', 'loadingChartProviders', 'zoom'])) 63 | .skipDuplicates((a, b) => JSON.stringify(a) === JSON.stringify(b)) 64 | .onValue(v => { 65 | Store.set(LOCAL_STORAGE_KEY, v) 66 | }) 67 | 68 | function fetchLocalCharts(provider, hiddenChartProviders) { 69 | const url = '/charts/' 70 | return api 71 | .get({url}) 72 | .map(_.values) 73 | .flatMap(charts => { 74 | return Bacon.fromArray( 75 | _.map(charts, chart => { 76 | const from = _.pick(chart, [ 77 | 'tilemapUrl', 78 | 'index', 79 | 'type', 80 | 'name', 81 | 'minzoom', 82 | 'maxzoom', 83 | 'center', 84 | 'description', 85 | 'format', 86 | 'bounds' 87 | ]) 88 | return _.merge( 89 | { 90 | id: chart.name, 91 | index: provider.index || 0, 92 | enabled: isChartHidden(hiddenChartProviders, chart.name) 93 | }, 94 | from 95 | ) 96 | }) 97 | ) 98 | }) 99 | } 100 | 101 | function fetchSignalKCharts(provider, hiddenChartProviders) { 102 | const address = parseChartProviderAddress(provider.address) 103 | const url = `${address}/signalk/v1/api/resources/charts` 104 | return api 105 | .get({url}) 106 | .map(_.values) 107 | .flatMap(charts => { 108 | return Bacon.fromArray( 109 | _.map(charts, chart => { 110 | const tilemapUrl = chart.tilemapUrl.startsWith('http') ? chart.tilemapUrl : address + chart.tilemapUrl 111 | const from = _.pick(chart, [ 112 | 'type', 113 | 'name', 114 | 'minzoom', 115 | 'maxzoom', 116 | 'center', 117 | 'description', 118 | 'format', 119 | 'bounds' 120 | ]) 121 | return _.merge( 122 | { 123 | id: chart.name, 124 | tilemapUrl, 125 | minzoom: MIN_ZOOM, 126 | maxzoom: MAX_ZOOM, 127 | index: provider.index || 0, 128 | enabled: !isChartHidden(hiddenChartProviders, chart.name) 129 | }, 130 | from 131 | ) 132 | }) 133 | ) 134 | }) 135 | } 136 | 137 | const isChartHidden = (hiddenChartProviders, requestedChartId) => { 138 | return !!_.find(hiddenChartProviders, id => id === requestedChartId) 139 | } 140 | 141 | function parseChartProviderAddress(address) { 142 | if (_.isEmpty(address)) { 143 | throw 'Empty chart provider address!' 144 | } 145 | if (_.isEmpty(address.split(':')[0])) { 146 | // Relative address such as ':80' 147 | return `${window.location.protocol}//${window.location.hostname}:${address.split(':')[1]}` 148 | } else { 149 | return address 150 | } 151 | } 152 | 153 | function clearSettingsFromLocalStorage() { 154 | Store.remove(LOCAL_STORAGE_KEY) 155 | } 156 | 157 | module.exports = { 158 | settings, 159 | clearSettingsFromLocalStorage 160 | } 161 | -------------------------------------------------------------------------------- /src/client/utils.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import {MS_TO_KNOTS, M_TO_NM} from './enums' 3 | 4 | function toDegrees(angle) { 5 | if (_.isFinite(angle)) { 6 | return angle * (180 / Math.PI) 7 | } else { 8 | return null 9 | } 10 | } 11 | 12 | function toRadians(angle) { 13 | if (_.isFinite(angle)) { 14 | return (angle * Math.PI) / 180 15 | } else { 16 | return null 17 | } 18 | } 19 | 20 | function toKnots(speed) { 21 | if (_.isFinite(speed)) { 22 | return speed * MS_TO_KNOTS 23 | } else { 24 | return null 25 | } 26 | } 27 | 28 | function toNauticalMiles(distance) { 29 | if (_.isFinite(distance)) { 30 | return distance * M_TO_NM 31 | } else { 32 | return null 33 | } 34 | } 35 | 36 | module.exports = { 37 | toDegrees, 38 | toRadians, 39 | toKnots, 40 | toNauticalMiles 41 | } 42 | -------------------------------------------------------------------------------- /src/server/chart-routes.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import _ from 'lodash' 3 | import Bacon from 'baconjs' 4 | import path from 'path' 5 | import settings from './settings' 6 | import MBTiles from '@mapbox/mbtiles' 7 | import url from 'url' 8 | 9 | const api = express.Router() 10 | 11 | let chartProviders = {} 12 | refreshChartProviders().onValue(providers => console.log(`Initial chart providers: ${_.keys(providers).length}`)) 13 | 14 | api.get('/charts/', (req, res) => { 15 | const providers = refreshChartProviders() 16 | providers.onError(err => { 17 | console.error('Error refreshing chart providers:', err) 18 | res.sendStatus(500) 19 | }) 20 | 21 | providers.onValue(providers => { 22 | const sanitized = _.map(_.values(providers), provider => { 23 | return _.merge(_.omit(provider, ['db']), { 24 | tilemapUrl: `/charts/${provider.name}/{z}/{x}/{y}`, 25 | type: 'tilelayer' 26 | }) 27 | }) 28 | res.send(sanitized) 29 | }) 30 | }) 31 | 32 | api.get('/charts/:map/:z/:x/:y', (req, res) => { 33 | const {map, z, x, y} = req.params 34 | const provider = chartProviders[map] 35 | if (!provider) { 36 | res.sendStatus(404) 37 | return 38 | } 39 | provider.db.getTile(z, x, y, (err, tile, headers) => { 40 | if (err && err.message && err.message === 'Tile does not exist') { 41 | res.sendStatus(404) 42 | } else if (err) { 43 | console.error(`Error fetching tile ${map}/${z}/${x}/${y}:`, err) 44 | res.sendStatus(500) 45 | } else { 46 | headers['Cache-Control'] = 'public, max-age=7776000' // 90 days 47 | res.writeHead(200, headers) 48 | res.end(tile) 49 | } 50 | }) 51 | }) 52 | 53 | function refreshChartProviders() { 54 | const providers = Bacon.fromNodeCallback(MBTiles.list, settings.chartsPath) 55 | .flatMap(files => Bacon.combineAsArray(_.map(files, chartFileToProvider))) 56 | .map(providers => { 57 | return _.reduce( 58 | providers, 59 | (sum, p) => { 60 | sum[p.name] = p 61 | return sum 62 | }, 63 | {} 64 | ) 65 | }) 66 | .doAction(providers => { 67 | chartProviders = providers 68 | }) 69 | 70 | return providers 71 | } 72 | 73 | function chartFileToProvider(uri) { 74 | const {pathname} = url.parse(uri) 75 | const name = sanitizeMapName(path.parse(pathname).name) 76 | const bus = new Bacon.Bus() // Must use Bus for now, since `new MBTiles` is not so easily Baconized 77 | new MBTiles(pathname, (err, db) => { 78 | if (err) { 79 | bus.push(new Bacon.Error(err)) 80 | } else { 81 | bus.push(db) 82 | } 83 | bus.end() 84 | }) 85 | return bus.flatMap(db => { 86 | return Bacon.fromNodeCallback(db, 'getInfo').map(metadata => { 87 | const fromMetadata = _.pick(metadata, [ 88 | 'bounds', 89 | 'minzoom', 90 | 'maxzoom', 91 | 'type', 92 | 'format', 93 | 'attribution', 94 | 'center', 95 | 'description', 96 | 'scheme' 97 | ]) 98 | return _.merge({name, file: pathname, db}, fromMetadata) 99 | }) 100 | }) 101 | } 102 | 103 | function sanitizeMapName(name) { 104 | return _.snakeCase(_.deburr(name)) 105 | } 106 | 107 | module.exports = api 108 | -------------------------------------------------------------------------------- /src/server/server.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import Bacon from 'baconjs' 3 | import fs from 'fs' 4 | import path from 'path' 5 | import settings from './settings' 6 | import chartRoutes from './chart-routes' 7 | 8 | const app = express() 9 | 10 | app.get('/', function(req, res) { 11 | const clientConfig = Bacon.fromNodeCallback(fs, 'readFile', settings.clientConfigFile) 12 | .flatMap(config => { 13 | try { 14 | return Bacon.once(JSON.parse(config)) 15 | } catch (e) { 16 | return Bacon.once(new Bacon.Error(e)) 17 | } 18 | }) 19 | .flatMapError(err => { 20 | if (err.code === 'ENOENT') { 21 | console.log('No client config file found') 22 | return Bacon.once({}) 23 | } else { 24 | console.error('Error loading client config file: ', err) 25 | return Bacon.once({}) 26 | } 27 | }) 28 | clientConfig.onValue(config => { 29 | res.send(createIndexHtml({config})) 30 | }) 31 | }) 32 | 33 | app.use('/', express.static(path.join(__dirname, '../../public'))) 34 | 35 | app.listen(settings.port, function() { 36 | console.log(`Listening ${settings.port}`) 37 | }) 38 | 39 | app.use(chartRoutes) 40 | 41 | function createIndexHtml({config}) { 42 | return ` 43 | 44 | 45 | 46 | Tuktuk Plotter 47 | 48 | 49 | 50 | 51 | 52 | 53 | 56 |
    57 | 58 | 59 | ` 60 | } 61 | -------------------------------------------------------------------------------- /src/server/settings.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import path from 'path' 3 | 4 | const environmentVariables = { 5 | port: process.env.PORT, 6 | production: process.env.NODE_ENV === 'production', 7 | chartsPath: process.env.CHARTS_PATH, 8 | clientConfigFile: process.env.CLIENT_CONFIG_FILE 9 | } 10 | 11 | const defaultSettings = { 12 | port: 4999, 13 | production: false, 14 | chartsPath: path.join(__dirname, '../../charts'), 15 | clientConfigFile: path.join(__dirname, '../../client-config.json') 16 | } 17 | 18 | const settings = _.merge(defaultSettings, environmentVariables) 19 | 20 | console.log(`settings: ${JSON.stringify(settings, null, 2)}`) 21 | 22 | module.exports = settings 23 | -------------------------------------------------------------------------------- /src/styles/_fonts.less: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'IcoMoon-Free'; 3 | src: url('IcoMoon-Free.ttf') format('truetype'); 4 | font-weight: normal; 5 | font-style: normal; 6 | } 7 | 8 | @font-face { 9 | font-family: 'Roboto Mono'; 10 | src: url('RobotoMono-Bold.ttf') format('truetype'); 11 | font-weight: 700; 12 | font-style: normal; 13 | } 14 | 15 | @font-face { 16 | font-family: 'Roboto'; 17 | src: url('Roboto-Regular.ttf') format('truetype'); 18 | font-weight: normal; 19 | font-style: normal; 20 | } 21 | 22 | @font-face { 23 | font-family: 'Roboto'; 24 | src: url('Roboto-Light.ttf') format('truetype'); 25 | font-weight: 300; 26 | font-style: normal; 27 | } 28 | 29 | @font-face { 30 | font-family: 'Roboto'; 31 | src: url('Roboto-Thin.ttf') format('truetype'); 32 | font-weight: 100; 33 | font-style: normal; 34 | } 35 | 36 | .icon-font-common() { 37 | /* use !important to prevent issues with browser extensions that change fonts */ 38 | font-family: 'IcoMoon-Free' !important; 39 | speak: none; 40 | font-style: normal; 41 | font-weight: normal; 42 | font-variant: normal; 43 | text-transform: none; 44 | line-height: 1; 45 | 46 | /* Enable Ligatures ================ */ 47 | letter-spacing: 0; 48 | -webkit-font-feature-settings: "liga"; 49 | -moz-font-feature-settings: "liga=1"; 50 | -moz-font-feature-settings: "liga"; 51 | -ms-font-feature-settings: "liga" 1; 52 | -o-font-feature-settings: "liga"; 53 | font-feature-settings: "liga"; 54 | 55 | /* Better Font Rendering =========== */ 56 | -webkit-font-smoothing: antialiased; 57 | -moz-osx-font-smoothing: grayscale; 58 | } 59 | 60 | [class^="icon-"], [class*=" icon-"] { 61 | .icon-font-common(); 62 | } 63 | 64 | .icon-home:before { 65 | content: "\e900"; 66 | } 67 | .icon-home2:before { 68 | content: "\e901"; 69 | } 70 | .icon-home3:before { 71 | content: "\e902"; 72 | } 73 | .icon-office:before { 74 | content: "\e903"; 75 | } 76 | .icon-newspaper:before { 77 | content: "\e904"; 78 | } 79 | .icon-pencil:before { 80 | content: "\e905"; 81 | } 82 | .icon-pencil2:before { 83 | content: "\e906"; 84 | } 85 | .icon-quill:before { 86 | content: "\e907"; 87 | } 88 | .icon-pen:before { 89 | content: "\e908"; 90 | } 91 | .icon-blog:before { 92 | content: "\e909"; 93 | } 94 | .icon-eyedropper:before { 95 | content: "\e90a"; 96 | } 97 | .icon-droplet:before { 98 | content: "\e90b"; 99 | } 100 | .icon-paint-format:before { 101 | content: "\e90c"; 102 | } 103 | .icon-image:before { 104 | content: "\e90d"; 105 | } 106 | .icon-images:before { 107 | content: "\e90e"; 108 | } 109 | .icon-camera:before { 110 | content: "\e90f"; 111 | } 112 | .icon-headphones:before { 113 | content: "\e910"; 114 | } 115 | .icon-music:before { 116 | content: "\e911"; 117 | } 118 | .icon-play:before { 119 | content: "\e912"; 120 | } 121 | .icon-film:before { 122 | content: "\e913"; 123 | } 124 | .icon-video-camera:before { 125 | content: "\e914"; 126 | } 127 | .icon-dice:before { 128 | content: "\e915"; 129 | } 130 | .icon-pacman:before { 131 | content: "\e916"; 132 | } 133 | .icon-spades:before { 134 | content: "\e917"; 135 | } 136 | .icon-clubs:before { 137 | content: "\e918"; 138 | } 139 | .icon-diamonds:before { 140 | content: "\e919"; 141 | } 142 | .icon-bullhorn:before { 143 | content: "\e91a"; 144 | } 145 | .icon-connection:before { 146 | content: "\e91b"; 147 | } 148 | .icon-podcast:before { 149 | content: "\e91c"; 150 | } 151 | .icon-feed:before { 152 | content: "\e91d"; 153 | } 154 | .icon-mic:before { 155 | content: "\e91e"; 156 | } 157 | .icon-book:before { 158 | content: "\e91f"; 159 | } 160 | .icon-books:before { 161 | content: "\e920"; 162 | } 163 | .icon-library:before { 164 | content: "\e921"; 165 | } 166 | .icon-file-text:before { 167 | content: "\e922"; 168 | } 169 | .icon-profile:before { 170 | content: "\e923"; 171 | } 172 | .icon-file-empty:before { 173 | content: "\e924"; 174 | } 175 | .icon-files-empty:before { 176 | content: "\e925"; 177 | } 178 | .icon-file-text2:before { 179 | content: "\e926"; 180 | } 181 | .icon-file-picture:before { 182 | content: "\e927"; 183 | } 184 | .icon-file-music:before { 185 | content: "\e928"; 186 | } 187 | .icon-file-play:before { 188 | content: "\e929"; 189 | } 190 | .icon-file-video:before { 191 | content: "\e92a"; 192 | } 193 | .icon-file-zip:before { 194 | content: "\e92b"; 195 | } 196 | .icon-copy:before { 197 | content: "\e92c"; 198 | } 199 | .icon-paste:before { 200 | content: "\e92d"; 201 | } 202 | .icon-stack:before { 203 | content: "\e92e"; 204 | } 205 | .icon-folder:before { 206 | content: "\e92f"; 207 | } 208 | .icon-folder-open:before { 209 | content: "\e930"; 210 | } 211 | .icon-folder-plus:before { 212 | content: "\e931"; 213 | } 214 | .icon-folder-minus:before { 215 | content: "\e932"; 216 | } 217 | .icon-folder-download:before { 218 | content: "\e933"; 219 | } 220 | .icon-folder-upload:before { 221 | content: "\e934"; 222 | } 223 | .icon-price-tag:before { 224 | content: "\e935"; 225 | } 226 | .icon-price-tags:before { 227 | content: "\e936"; 228 | } 229 | .icon-barcode:before { 230 | content: "\e937"; 231 | } 232 | .icon-qrcode:before { 233 | content: "\e938"; 234 | } 235 | .icon-ticket:before { 236 | content: "\e939"; 237 | } 238 | .icon-cart:before { 239 | content: "\e93a"; 240 | } 241 | .icon-coin-dollar:before { 242 | content: "\e93b"; 243 | } 244 | .icon-coin-euro:before { 245 | content: "\e93c"; 246 | } 247 | .icon-coin-pound:before { 248 | content: "\e93d"; 249 | } 250 | .icon-coin-yen:before { 251 | content: "\e93e"; 252 | } 253 | .icon-credit-card:before { 254 | content: "\e93f"; 255 | } 256 | .icon-calculator:before { 257 | content: "\e940"; 258 | } 259 | .icon-lifebuoy:before { 260 | content: "\e941"; 261 | } 262 | .icon-phone:before { 263 | content: "\e942"; 264 | } 265 | .icon-phone-hang-up:before { 266 | content: "\e943"; 267 | } 268 | .icon-address-book:before { 269 | content: "\e944"; 270 | } 271 | .icon-envelop:before { 272 | content: "\e945"; 273 | } 274 | .icon-pushpin:before { 275 | content: "\e946"; 276 | } 277 | .icon-location:before { 278 | content: "\e947"; 279 | } 280 | .icon-location2:before { 281 | content: "\e948"; 282 | } 283 | .icon-compass:before { 284 | content: "\e949"; 285 | } 286 | .icon-compass2:before { 287 | content: "\e94a"; 288 | } 289 | .icon-map:before { 290 | content: "\e94b"; 291 | } 292 | .icon-map2:before { 293 | content: "\e94c"; 294 | } 295 | .icon-history:before { 296 | content: "\e94d"; 297 | } 298 | .icon-clock:before { 299 | content: "\e94e"; 300 | } 301 | .icon-clock2:before { 302 | content: "\e94f"; 303 | } 304 | .icon-alarm:before { 305 | content: "\e950"; 306 | } 307 | .icon-bell:before { 308 | content: "\e951"; 309 | } 310 | .icon-stopwatch:before { 311 | content: "\e952"; 312 | } 313 | .icon-calendar:before { 314 | content: "\e953"; 315 | } 316 | .icon-printer:before { 317 | content: "\e954"; 318 | } 319 | .icon-keyboard:before { 320 | content: "\e955"; 321 | } 322 | .icon-display:before { 323 | content: "\e956"; 324 | } 325 | .icon-laptop:before { 326 | content: "\e957"; 327 | } 328 | .icon-mobile:before { 329 | content: "\e958"; 330 | } 331 | .icon-mobile2:before { 332 | content: "\e959"; 333 | } 334 | .icon-tablet:before { 335 | content: "\e95a"; 336 | } 337 | .icon-tv:before { 338 | content: "\e95b"; 339 | } 340 | .icon-drawer:before { 341 | content: "\e95c"; 342 | } 343 | .icon-drawer2:before { 344 | content: "\e95d"; 345 | } 346 | .icon-box-add:before { 347 | content: "\e95e"; 348 | } 349 | .icon-box-remove:before { 350 | content: "\e95f"; 351 | } 352 | .icon-download:before { 353 | content: "\e960"; 354 | } 355 | .icon-upload:before { 356 | content: "\e961"; 357 | } 358 | .icon-floppy-disk:before { 359 | content: "\e962"; 360 | } 361 | .icon-drive:before { 362 | content: "\e963"; 363 | } 364 | .icon-database:before { 365 | content: "\e964"; 366 | } 367 | .icon-undo:before { 368 | content: "\e965"; 369 | } 370 | .icon-redo:before { 371 | content: "\e966"; 372 | } 373 | .icon-undo2:before { 374 | content: "\e967"; 375 | } 376 | .icon-redo2:before { 377 | content: "\e968"; 378 | } 379 | .icon-forward:before { 380 | content: "\e969"; 381 | } 382 | .icon-reply:before { 383 | content: "\e96a"; 384 | } 385 | .icon-bubble:before { 386 | content: "\e96b"; 387 | } 388 | .icon-bubbles:before { 389 | content: "\e96c"; 390 | } 391 | .icon-bubbles2:before { 392 | content: "\e96d"; 393 | } 394 | .icon-bubble2:before { 395 | content: "\e96e"; 396 | } 397 | .icon-bubbles3:before { 398 | content: "\e96f"; 399 | } 400 | .icon-bubbles4:before { 401 | content: "\e970"; 402 | } 403 | .icon-user:before { 404 | content: "\e971"; 405 | } 406 | .icon-users:before { 407 | content: "\e972"; 408 | } 409 | .icon-user-plus:before { 410 | content: "\e973"; 411 | } 412 | .icon-user-minus:before { 413 | content: "\e974"; 414 | } 415 | .icon-user-check:before { 416 | content: "\e975"; 417 | } 418 | .icon-user-tie:before { 419 | content: "\e976"; 420 | } 421 | .icon-quotes-left:before { 422 | content: "\e977"; 423 | } 424 | .icon-quotes-right:before { 425 | content: "\e978"; 426 | } 427 | .icon-hour-glass:before { 428 | content: "\e979"; 429 | } 430 | .icon-spinner:before { 431 | content: "\e97a"; 432 | } 433 | .icon-spinner2:before { 434 | content: "\e97b"; 435 | } 436 | .icon-spinner3:before { 437 | content: "\e97c"; 438 | } 439 | .icon-spinner4:before { 440 | content: "\e97d"; 441 | } 442 | .icon-spinner5:before { 443 | content: "\e97e"; 444 | } 445 | .icon-spinner6:before { 446 | content: "\e97f"; 447 | } 448 | .icon-spinner7:before { 449 | content: "\e980"; 450 | } 451 | .icon-spinner8:before { 452 | content: "\e981"; 453 | } 454 | .icon-spinner9:before { 455 | content: "\e982"; 456 | } 457 | .icon-spinner10:before { 458 | content: "\e983"; 459 | } 460 | .icon-spinner11:before { 461 | content: "\e984"; 462 | } 463 | .icon-binoculars:before { 464 | content: "\e985"; 465 | } 466 | .icon-search:before { 467 | content: "\e986"; 468 | } 469 | .icon-zoom-in:before { 470 | content: "\e987"; 471 | } 472 | .icon-zoom-out:before { 473 | content: "\e988"; 474 | } 475 | .icon-enlarge:before { 476 | content: "\e989"; 477 | } 478 | .icon-shrink:before { 479 | content: "\e98a"; 480 | } 481 | .icon-enlarge2:before { 482 | content: "\e98b"; 483 | } 484 | .icon-shrink2:before { 485 | content: "\e98c"; 486 | } 487 | .icon-key:before { 488 | content: "\e98d"; 489 | } 490 | .icon-key2:before { 491 | content: "\e98e"; 492 | } 493 | .icon-lock:before { 494 | content: "\e98f"; 495 | } 496 | .icon-unlocked:before { 497 | content: "\e990"; 498 | } 499 | .icon-wrench:before { 500 | content: "\e991"; 501 | } 502 | .icon-equalizer:before { 503 | content: "\e992"; 504 | } 505 | .icon-equalizer2:before { 506 | content: "\e993"; 507 | } 508 | .icon-cog:before { 509 | content: "\e994"; 510 | } 511 | .icon-cogs:before { 512 | content: "\e995"; 513 | } 514 | .icon-hammer:before { 515 | content: "\e996"; 516 | } 517 | .icon-magic-wand:before { 518 | content: "\e997"; 519 | } 520 | .icon-aid-kit:before { 521 | content: "\e998"; 522 | } 523 | .icon-bug:before { 524 | content: "\e999"; 525 | } 526 | .icon-pie-chart:before { 527 | content: "\e99a"; 528 | } 529 | .icon-stats-dots:before { 530 | content: "\e99b"; 531 | } 532 | .icon-stats-bars:before { 533 | content: "\e99c"; 534 | } 535 | .icon-stats-bars2:before { 536 | content: "\e99d"; 537 | } 538 | .icon-trophy:before { 539 | content: "\e99e"; 540 | } 541 | .icon-gift:before { 542 | content: "\e99f"; 543 | } 544 | .icon-glass:before { 545 | content: "\e9a0"; 546 | } 547 | .icon-glass2:before { 548 | content: "\e9a1"; 549 | } 550 | .icon-mug:before { 551 | content: "\e9a2"; 552 | } 553 | .icon-spoon-knife:before { 554 | content: "\e9a3"; 555 | } 556 | .icon-leaf:before { 557 | content: "\e9a4"; 558 | } 559 | .icon-rocket:before { 560 | content: "\e9a5"; 561 | } 562 | .icon-meter:before { 563 | content: "\e9a6"; 564 | } 565 | .icon-meter2:before { 566 | content: "\e9a7"; 567 | } 568 | .icon-hammer2:before { 569 | content: "\e9a8"; 570 | } 571 | .icon-fire:before { 572 | content: "\e9a9"; 573 | } 574 | .icon-lab:before { 575 | content: "\e9aa"; 576 | } 577 | .icon-magnet:before { 578 | content: "\e9ab"; 579 | } 580 | .icon-bin:before { 581 | content: "\e9ac"; 582 | } 583 | .icon-bin2:before { 584 | content: "\e9ad"; 585 | } 586 | .icon-briefcase:before { 587 | content: "\e9ae"; 588 | } 589 | .icon-airplane:before { 590 | content: "\e9af"; 591 | } 592 | .icon-truck:before { 593 | content: "\e9b0"; 594 | } 595 | .icon-road:before { 596 | content: "\e9b1"; 597 | } 598 | .icon-accessibility:before { 599 | content: "\e9b2"; 600 | } 601 | .icon-target:before { 602 | content: "\e9b3"; 603 | } 604 | .icon-shield:before { 605 | content: "\e9b4"; 606 | } 607 | .icon-power:before { 608 | content: "\e9b5"; 609 | } 610 | .icon-switch:before { 611 | content: "\e9b6"; 612 | } 613 | .icon-power-cord:before { 614 | content: "\e9b7"; 615 | } 616 | .icon-clipboard:before { 617 | content: "\e9b8"; 618 | } 619 | .icon-list-numbered:before { 620 | content: "\e9b9"; 621 | } 622 | .icon-list:before { 623 | content: "\e9ba"; 624 | } 625 | .icon-list2:before { 626 | content: "\e9bb"; 627 | } 628 | .icon-tree:before { 629 | content: "\e9bc"; 630 | } 631 | .icon-menu:before { 632 | content: "\e9bd"; 633 | } 634 | .icon-menu2:before { 635 | content: "\e9be"; 636 | } 637 | .icon-menu3:before { 638 | content: "\e9bf"; 639 | } 640 | .icon-menu4:before { 641 | content: "\e9c0"; 642 | } 643 | .icon-cloud:before { 644 | content: "\e9c1"; 645 | } 646 | .icon-cloud-download:before { 647 | content: "\e9c2"; 648 | } 649 | .icon-cloud-upload:before { 650 | content: "\e9c3"; 651 | } 652 | .icon-cloud-check:before { 653 | content: "\e9c4"; 654 | } 655 | .icon-download2:before { 656 | content: "\e9c5"; 657 | } 658 | .icon-upload2:before { 659 | content: "\e9c6"; 660 | } 661 | .icon-download3:before { 662 | content: "\e9c7"; 663 | } 664 | .icon-upload3:before { 665 | content: "\e9c8"; 666 | } 667 | .icon-sphere:before { 668 | content: "\e9c9"; 669 | } 670 | .icon-earth:before { 671 | content: "\e9ca"; 672 | } 673 | .icon-link:before { 674 | content: "\e9cb"; 675 | } 676 | .icon-flag:before { 677 | content: "\e9cc"; 678 | } 679 | .icon-attachment:before { 680 | content: "\e9cd"; 681 | } 682 | .icon-eye:before { 683 | content: "\e9ce"; 684 | } 685 | .icon-eye-plus:before { 686 | content: "\e9cf"; 687 | } 688 | .icon-eye-minus:before { 689 | content: "\e9d0"; 690 | } 691 | .icon-eye-blocked:before { 692 | content: "\e9d1"; 693 | } 694 | .icon-bookmark:before { 695 | content: "\e9d2"; 696 | } 697 | .icon-bookmarks:before { 698 | content: "\e9d3"; 699 | } 700 | .icon-sun:before { 701 | content: "\e9d4"; 702 | } 703 | .icon-contrast:before { 704 | content: "\e9d5"; 705 | } 706 | .icon-brightness-contrast:before { 707 | content: "\e9d6"; 708 | } 709 | .icon-star-empty:before { 710 | content: "\e9d7"; 711 | } 712 | .icon-star-half:before { 713 | content: "\e9d8"; 714 | } 715 | .icon-star-full:before { 716 | content: "\e9d9"; 717 | } 718 | .icon-heart:before { 719 | content: "\e9da"; 720 | } 721 | .icon-heart-broken:before { 722 | content: "\e9db"; 723 | } 724 | .icon-man:before { 725 | content: "\e9dc"; 726 | } 727 | .icon-woman:before { 728 | content: "\e9dd"; 729 | } 730 | .icon-man-woman:before { 731 | content: "\e9de"; 732 | } 733 | .icon-happy:before { 734 | content: "\e9df"; 735 | } 736 | .icon-happy2:before { 737 | content: "\e9e0"; 738 | } 739 | .icon-smile:before { 740 | content: "\e9e1"; 741 | } 742 | .icon-smile2:before { 743 | content: "\e9e2"; 744 | } 745 | .icon-tongue:before { 746 | content: "\e9e3"; 747 | } 748 | .icon-tongue2:before { 749 | content: "\e9e4"; 750 | } 751 | .icon-sad:before { 752 | content: "\e9e5"; 753 | } 754 | .icon-sad2:before { 755 | content: "\e9e6"; 756 | } 757 | .icon-wink:before { 758 | content: "\e9e7"; 759 | } 760 | .icon-wink2:before { 761 | content: "\e9e8"; 762 | } 763 | .icon-grin:before { 764 | content: "\e9e9"; 765 | } 766 | .icon-grin2:before { 767 | content: "\e9ea"; 768 | } 769 | .icon-cool:before { 770 | content: "\e9eb"; 771 | } 772 | .icon-cool2:before { 773 | content: "\e9ec"; 774 | } 775 | .icon-angry:before { 776 | content: "\e9ed"; 777 | } 778 | .icon-angry2:before { 779 | content: "\e9ee"; 780 | } 781 | .icon-evil:before { 782 | content: "\e9ef"; 783 | } 784 | .icon-evil2:before { 785 | content: "\e9f0"; 786 | } 787 | .icon-shocked:before { 788 | content: "\e9f1"; 789 | } 790 | .icon-shocked2:before { 791 | content: "\e9f2"; 792 | } 793 | .icon-baffled:before { 794 | content: "\e9f3"; 795 | } 796 | .icon-baffled2:before { 797 | content: "\e9f4"; 798 | } 799 | .icon-confused:before { 800 | content: "\e9f5"; 801 | } 802 | .icon-confused2:before { 803 | content: "\e9f6"; 804 | } 805 | .icon-neutral:before { 806 | content: "\e9f7"; 807 | } 808 | .icon-neutral2:before { 809 | content: "\e9f8"; 810 | } 811 | .icon-hipster:before { 812 | content: "\e9f9"; 813 | } 814 | .icon-hipster2:before { 815 | content: "\e9fa"; 816 | } 817 | .icon-wondering:before { 818 | content: "\e9fb"; 819 | } 820 | .icon-wondering2:before { 821 | content: "\e9fc"; 822 | } 823 | .icon-sleepy:before { 824 | content: "\e9fd"; 825 | } 826 | .icon-sleepy2:before { 827 | content: "\e9fe"; 828 | } 829 | .icon-frustrated:before { 830 | content: "\e9ff"; 831 | } 832 | .icon-frustrated2:before { 833 | content: "\ea00"; 834 | } 835 | .icon-crying:before { 836 | content: "\ea01"; 837 | } 838 | .icon-crying2:before { 839 | content: "\ea02"; 840 | } 841 | .icon-point-up:before { 842 | content: "\ea03"; 843 | } 844 | .icon-point-right:before { 845 | content: "\ea04"; 846 | } 847 | .icon-point-down:before { 848 | content: "\ea05"; 849 | } 850 | .icon-point-left:before { 851 | content: "\ea06"; 852 | } 853 | .icon-warning:before { 854 | content: "\ea07"; 855 | } 856 | .icon-notification:before { 857 | content: "\ea08"; 858 | } 859 | .icon-question:before { 860 | content: "\ea09"; 861 | } 862 | .icon-plus:before { 863 | content: "\ea0a"; 864 | } 865 | .icon-minus:before { 866 | content: "\ea0b"; 867 | } 868 | .icon-info:before { 869 | content: "\ea0c"; 870 | } 871 | .icon-cancel-circle:before { 872 | content: "\ea0d"; 873 | } 874 | .icon-blocked:before { 875 | content: "\ea0e"; 876 | } 877 | .icon-cross:before { 878 | content: "\ea0f"; 879 | } 880 | .icon-checkmark:before { 881 | content: "\ea10"; 882 | } 883 | .icon-checkmark2:before { 884 | content: "\ea11"; 885 | } 886 | .icon-spell-check:before { 887 | content: "\ea12"; 888 | } 889 | .icon-enter:before { 890 | content: "\ea13"; 891 | } 892 | .icon-exit:before { 893 | content: "\ea14"; 894 | } 895 | .icon-play2:before { 896 | content: "\ea15"; 897 | } 898 | .icon-pause:before { 899 | content: "\ea16"; 900 | } 901 | .icon-stop:before { 902 | content: "\ea17"; 903 | } 904 | .icon-previous:before { 905 | content: "\ea18"; 906 | } 907 | .icon-next:before { 908 | content: "\ea19"; 909 | } 910 | .icon-backward:before { 911 | content: "\ea1a"; 912 | } 913 | .icon-forward2:before { 914 | content: "\ea1b"; 915 | } 916 | .icon-play3:before { 917 | content: "\ea1c"; 918 | } 919 | .icon-pause2:before { 920 | content: "\ea1d"; 921 | } 922 | .icon-stop2:before { 923 | content: "\ea1e"; 924 | } 925 | .icon-backward2:before { 926 | content: "\ea1f"; 927 | } 928 | .icon-forward3:before { 929 | content: "\ea20"; 930 | } 931 | .icon-first:before { 932 | content: "\ea21"; 933 | } 934 | .icon-last:before { 935 | content: "\ea22"; 936 | } 937 | .icon-previous2:before { 938 | content: "\ea23"; 939 | } 940 | .icon-next2:before { 941 | content: "\ea24"; 942 | } 943 | .icon-eject:before { 944 | content: "\ea25"; 945 | } 946 | .icon-volume-high:before { 947 | content: "\ea26"; 948 | } 949 | .icon-volume-medium:before { 950 | content: "\ea27"; 951 | } 952 | .icon-volume-low:before { 953 | content: "\ea28"; 954 | } 955 | .icon-volume-mute:before { 956 | content: "\ea29"; 957 | } 958 | .icon-volume-mute2:before { 959 | content: "\ea2a"; 960 | } 961 | .icon-volume-increase:before { 962 | content: "\ea2b"; 963 | } 964 | .icon-volume-decrease:before { 965 | content: "\ea2c"; 966 | } 967 | .icon-loop:before { 968 | content: "\ea2d"; 969 | } 970 | .icon-loop2:before { 971 | content: "\ea2e"; 972 | } 973 | .icon-infinite:before { 974 | content: "\ea2f"; 975 | } 976 | .icon-shuffle:before { 977 | content: "\ea30"; 978 | } 979 | .icon-arrow-up-left:before { 980 | content: "\ea31"; 981 | } 982 | .icon-arrow-up:before { 983 | content: "\ea32"; 984 | } 985 | .icon-arrow-up-right:before { 986 | content: "\ea33"; 987 | } 988 | .icon-arrow-right:before { 989 | content: "\ea34"; 990 | } 991 | .icon-arrow-down-right:before { 992 | content: "\ea35"; 993 | } 994 | .icon-arrow-down:before { 995 | content: "\ea36"; 996 | } 997 | .icon-arrow-down-left:before { 998 | content: "\ea37"; 999 | } 1000 | .icon-arrow-left:before { 1001 | content: "\ea38"; 1002 | } 1003 | .icon-arrow-up-left2:before { 1004 | content: "\ea39"; 1005 | } 1006 | .icon-arrow-up2:before { 1007 | content: "\ea3a"; 1008 | } 1009 | .icon-arrow-up-right2:before { 1010 | content: "\ea3b"; 1011 | } 1012 | .icon-arrow-right2:before { 1013 | content: "\ea3c"; 1014 | } 1015 | .icon-arrow-down-right2:before { 1016 | content: "\ea3d"; 1017 | } 1018 | .icon-arrow-down2:before { 1019 | content: "\ea3e"; 1020 | } 1021 | .icon-arrow-down-left2:before { 1022 | content: "\ea3f"; 1023 | } 1024 | .icon-arrow-left2:before { 1025 | content: "\ea40"; 1026 | } 1027 | .icon-circle-up:before { 1028 | content: "\ea41"; 1029 | } 1030 | .icon-circle-right:before { 1031 | content: "\ea42"; 1032 | } 1033 | .icon-circle-down:before { 1034 | content: "\ea43"; 1035 | } 1036 | .icon-circle-left:before { 1037 | content: "\ea44"; 1038 | } 1039 | .icon-tab:before { 1040 | content: "\ea45"; 1041 | } 1042 | .icon-move-up:before { 1043 | content: "\ea46"; 1044 | } 1045 | .icon-move-down:before { 1046 | content: "\ea47"; 1047 | } 1048 | .icon-sort-alpha-asc:before { 1049 | content: "\ea48"; 1050 | } 1051 | .icon-sort-alpha-desc:before { 1052 | content: "\ea49"; 1053 | } 1054 | .icon-sort-numeric-asc:before { 1055 | content: "\ea4a"; 1056 | } 1057 | .icon-sort-numberic-desc:before { 1058 | content: "\ea4b"; 1059 | } 1060 | .icon-sort-amount-asc:before { 1061 | content: "\ea4c"; 1062 | } 1063 | .icon-sort-amount-desc:before { 1064 | content: "\ea4d"; 1065 | } 1066 | .icon-command:before { 1067 | content: "\ea4e"; 1068 | } 1069 | .icon-shift:before { 1070 | content: "\ea4f"; 1071 | } 1072 | .icon-ctrl:before { 1073 | content: "\ea50"; 1074 | } 1075 | .icon-opt:before { 1076 | content: "\ea51"; 1077 | } 1078 | .icon-checkbox-checked:before { 1079 | content: "\ea52"; 1080 | } 1081 | .icon-checkbox-unchecked:before { 1082 | content: "\ea53"; 1083 | } 1084 | .icon-radio-checked:before { 1085 | content: "\ea54"; 1086 | } 1087 | .icon-radio-checked2:before { 1088 | content: "\ea55"; 1089 | } 1090 | .icon-radio-unchecked:before { 1091 | content: "\ea56"; 1092 | } 1093 | .icon-crop:before { 1094 | content: "\ea57"; 1095 | } 1096 | .icon-make-group:before { 1097 | content: "\ea58"; 1098 | } 1099 | .icon-ungroup:before { 1100 | content: "\ea59"; 1101 | } 1102 | .icon-scissors:before { 1103 | content: "\ea5a"; 1104 | } 1105 | .icon-filter:before { 1106 | content: "\ea5b"; 1107 | } 1108 | .icon-font:before { 1109 | content: "\ea5c"; 1110 | } 1111 | .icon-ligature:before { 1112 | content: "\ea5d"; 1113 | } 1114 | .icon-ligature2:before { 1115 | content: "\ea5e"; 1116 | } 1117 | .icon-text-height:before { 1118 | content: "\ea5f"; 1119 | } 1120 | .icon-text-width:before { 1121 | content: "\ea60"; 1122 | } 1123 | .icon-font-size:before { 1124 | content: "\ea61"; 1125 | } 1126 | .icon-bold:before { 1127 | content: "\ea62"; 1128 | } 1129 | .icon-underline:before { 1130 | content: "\ea63"; 1131 | } 1132 | .icon-italic:before { 1133 | content: "\ea64"; 1134 | } 1135 | .icon-strikethrough:before { 1136 | content: "\ea65"; 1137 | } 1138 | .icon-omega:before { 1139 | content: "\ea66"; 1140 | } 1141 | .icon-sigma:before { 1142 | content: "\ea67"; 1143 | } 1144 | .icon-page-break:before { 1145 | content: "\ea68"; 1146 | } 1147 | .icon-superscript:before { 1148 | content: "\ea69"; 1149 | } 1150 | .icon-subscript:before { 1151 | content: "\ea6a"; 1152 | } 1153 | .icon-superscript2:before { 1154 | content: "\ea6b"; 1155 | } 1156 | .icon-subscript2:before { 1157 | content: "\ea6c"; 1158 | } 1159 | .icon-text-color:before { 1160 | content: "\ea6d"; 1161 | } 1162 | .icon-pagebreak:before { 1163 | content: "\ea6e"; 1164 | } 1165 | .icon-clear-formatting:before { 1166 | content: "\ea6f"; 1167 | } 1168 | .icon-table:before { 1169 | content: "\ea70"; 1170 | } 1171 | .icon-table2:before { 1172 | content: "\ea71"; 1173 | } 1174 | .icon-insert-template:before { 1175 | content: "\ea72"; 1176 | } 1177 | .icon-pilcrow:before { 1178 | content: "\ea73"; 1179 | } 1180 | .icon-ltr:before { 1181 | content: "\ea74"; 1182 | } 1183 | .icon-rtl:before { 1184 | content: "\ea75"; 1185 | } 1186 | .icon-section:before { 1187 | content: "\ea76"; 1188 | } 1189 | .icon-paragraph-left:before { 1190 | content: "\ea77"; 1191 | } 1192 | .icon-paragraph-center:before { 1193 | content: "\ea78"; 1194 | } 1195 | .icon-paragraph-right:before { 1196 | content: "\ea79"; 1197 | } 1198 | .icon-paragraph-justify:before { 1199 | content: "\ea7a"; 1200 | } 1201 | .icon-indent-increase:before { 1202 | content: "\ea7b"; 1203 | } 1204 | .icon-indent-decrease:before { 1205 | content: "\ea7c"; 1206 | } 1207 | .icon-share:before { 1208 | content: "\ea7d"; 1209 | } 1210 | .icon-new-tab:before { 1211 | content: "\ea7e"; 1212 | } 1213 | .icon-embed:before { 1214 | content: "\ea7f"; 1215 | } 1216 | .icon-embed2:before { 1217 | content: "\ea80"; 1218 | } 1219 | .icon-terminal:before { 1220 | content: "\ea81"; 1221 | } 1222 | .icon-share2:before { 1223 | content: "\ea82"; 1224 | } 1225 | .icon-mail:before { 1226 | content: "\ea83"; 1227 | } 1228 | .icon-mail2:before { 1229 | content: "\ea84"; 1230 | } 1231 | .icon-mail3:before { 1232 | content: "\ea85"; 1233 | } 1234 | .icon-mail4:before { 1235 | content: "\ea86"; 1236 | } 1237 | .icon-amazon:before { 1238 | content: "\ea87"; 1239 | } 1240 | .icon-google:before { 1241 | content: "\ea88"; 1242 | } 1243 | .icon-google2:before { 1244 | content: "\ea89"; 1245 | } 1246 | .icon-google3:before { 1247 | content: "\ea8a"; 1248 | } 1249 | .icon-google-plus:before { 1250 | content: "\ea8b"; 1251 | } 1252 | .icon-google-plus2:before { 1253 | content: "\ea8c"; 1254 | } 1255 | .icon-google-plus3:before { 1256 | content: "\ea8d"; 1257 | } 1258 | .icon-hangouts:before { 1259 | content: "\ea8e"; 1260 | } 1261 | .icon-google-drive:before { 1262 | content: "\ea8f"; 1263 | } 1264 | .icon-facebook:before { 1265 | content: "\ea90"; 1266 | } 1267 | .icon-facebook2:before { 1268 | content: "\ea91"; 1269 | } 1270 | .icon-instagram:before { 1271 | content: "\ea92"; 1272 | } 1273 | .icon-whatsapp:before { 1274 | content: "\ea93"; 1275 | } 1276 | .icon-spotify:before { 1277 | content: "\ea94"; 1278 | } 1279 | .icon-telegram:before { 1280 | content: "\ea95"; 1281 | } 1282 | .icon-twitter:before { 1283 | content: "\ea96"; 1284 | } 1285 | .icon-vine:before { 1286 | content: "\ea97"; 1287 | } 1288 | .icon-vk:before { 1289 | content: "\ea98"; 1290 | } 1291 | .icon-renren:before { 1292 | content: "\ea99"; 1293 | } 1294 | .icon-sina-weibo:before { 1295 | content: "\ea9a"; 1296 | } 1297 | .icon-rss:before { 1298 | content: "\ea9b"; 1299 | } 1300 | .icon-rss2:before { 1301 | content: "\ea9c"; 1302 | } 1303 | .icon-youtube:before { 1304 | content: "\ea9d"; 1305 | } 1306 | .icon-youtube2:before { 1307 | content: "\ea9e"; 1308 | } 1309 | .icon-twitch:before { 1310 | content: "\ea9f"; 1311 | } 1312 | .icon-vimeo:before { 1313 | content: "\eaa0"; 1314 | } 1315 | .icon-vimeo2:before { 1316 | content: "\eaa1"; 1317 | } 1318 | .icon-lanyrd:before { 1319 | content: "\eaa2"; 1320 | } 1321 | .icon-flickr:before { 1322 | content: "\eaa3"; 1323 | } 1324 | .icon-flickr2:before { 1325 | content: "\eaa4"; 1326 | } 1327 | .icon-flickr3:before { 1328 | content: "\eaa5"; 1329 | } 1330 | .icon-flickr4:before { 1331 | content: "\eaa6"; 1332 | } 1333 | .icon-dribbble:before { 1334 | content: "\eaa7"; 1335 | } 1336 | .icon-behance:before { 1337 | content: "\eaa8"; 1338 | } 1339 | .icon-behance2:before { 1340 | content: "\eaa9"; 1341 | } 1342 | .icon-deviantart:before { 1343 | content: "\eaaa"; 1344 | } 1345 | .icon-500px:before { 1346 | content: "\eaab"; 1347 | } 1348 | .icon-steam:before { 1349 | content: "\eaac"; 1350 | } 1351 | .icon-steam2:before { 1352 | content: "\eaad"; 1353 | } 1354 | .icon-dropbox:before { 1355 | content: "\eaae"; 1356 | } 1357 | .icon-onedrive:before { 1358 | content: "\eaaf"; 1359 | } 1360 | .icon-github:before { 1361 | content: "\eab0"; 1362 | } 1363 | .icon-npm:before { 1364 | content: "\eab1"; 1365 | } 1366 | .icon-basecamp:before { 1367 | content: "\eab2"; 1368 | } 1369 | .icon-trello:before { 1370 | content: "\eab3"; 1371 | } 1372 | .icon-wordpress:before { 1373 | content: "\eab4"; 1374 | } 1375 | .icon-joomla:before { 1376 | content: "\eab5"; 1377 | } 1378 | .icon-ello:before { 1379 | content: "\eab6"; 1380 | } 1381 | .icon-blogger:before { 1382 | content: "\eab7"; 1383 | } 1384 | .icon-blogger2:before { 1385 | content: "\eab8"; 1386 | } 1387 | .icon-tumblr:before { 1388 | content: "\eab9"; 1389 | } 1390 | .icon-tumblr2:before { 1391 | content: "\eaba"; 1392 | } 1393 | .icon-yahoo:before { 1394 | content: "\eabb"; 1395 | } 1396 | .icon-yahoo2:before { 1397 | content: "\eabc"; 1398 | } 1399 | .icon-tux:before { 1400 | content: "\eabd"; 1401 | } 1402 | .icon-appleinc:before { 1403 | content: "\eabe"; 1404 | } 1405 | .icon-finder:before { 1406 | content: "\eabf"; 1407 | } 1408 | .icon-android:before { 1409 | content: "\eac0"; 1410 | } 1411 | .icon-windows:before { 1412 | content: "\eac1"; 1413 | } 1414 | .icon-windows8:before { 1415 | content: "\eac2"; 1416 | } 1417 | .icon-soundcloud:before { 1418 | content: "\eac3"; 1419 | } 1420 | .icon-soundcloud2:before { 1421 | content: "\eac4"; 1422 | } 1423 | .icon-skype:before { 1424 | content: "\eac5"; 1425 | } 1426 | .icon-reddit:before { 1427 | content: "\eac6"; 1428 | } 1429 | .icon-hackernews:before { 1430 | content: "\eac7"; 1431 | } 1432 | .icon-wikipedia:before { 1433 | content: "\eac8"; 1434 | } 1435 | .icon-linkedin:before { 1436 | content: "\eac9"; 1437 | } 1438 | .icon-linkedin2:before { 1439 | content: "\eaca"; 1440 | } 1441 | .icon-lastfm:before { 1442 | content: "\eacb"; 1443 | } 1444 | .icon-lastfm2:before { 1445 | content: "\eacc"; 1446 | } 1447 | .icon-delicious:before { 1448 | content: "\eacd"; 1449 | } 1450 | .icon-stumbleupon:before { 1451 | content: "\eace"; 1452 | } 1453 | .icon-stumbleupon2:before { 1454 | content: "\eacf"; 1455 | } 1456 | .icon-stackoverflow:before { 1457 | content: "\ead0"; 1458 | } 1459 | .icon-pinterest:before { 1460 | content: "\ead1"; 1461 | } 1462 | .icon-pinterest2:before { 1463 | content: "\ead2"; 1464 | } 1465 | .icon-xing:before { 1466 | content: "\ead3"; 1467 | } 1468 | .icon-xing2:before { 1469 | content: "\ead4"; 1470 | } 1471 | .icon-flattr:before { 1472 | content: "\ead5"; 1473 | } 1474 | .icon-foursquare:before { 1475 | content: "\ead6"; 1476 | } 1477 | .icon-yelp:before { 1478 | content: "\ead7"; 1479 | } 1480 | .icon-paypal:before { 1481 | content: "\ead8"; 1482 | } 1483 | .icon-chrome:before { 1484 | content: "\ead9"; 1485 | } 1486 | .icon-firefox:before { 1487 | content: "\eada"; 1488 | } 1489 | .icon-IE:before { 1490 | content: "\eadb"; 1491 | } 1492 | .icon-edge:before { 1493 | content: "\eadc"; 1494 | } 1495 | .icon-safari:before { 1496 | content: "\eadd"; 1497 | } 1498 | .icon-opera:before { 1499 | content: "\eade"; 1500 | } 1501 | .icon-file-pdf:before { 1502 | content: "\eadf"; 1503 | } 1504 | .icon-file-openoffice:before { 1505 | content: "\eae0"; 1506 | } 1507 | .icon-file-word:before { 1508 | content: "\eae1"; 1509 | } 1510 | .icon-file-excel:before { 1511 | content: "\eae2"; 1512 | } 1513 | .icon-libreoffice:before { 1514 | content: "\eae3"; 1515 | } 1516 | .icon-html-five:before { 1517 | content: "\eae4"; 1518 | } 1519 | .icon-html-five2:before { 1520 | content: "\eae5"; 1521 | } 1522 | .icon-css3:before { 1523 | content: "\eae6"; 1524 | } 1525 | .icon-git:before { 1526 | content: "\eae7"; 1527 | } 1528 | .icon-codepen:before { 1529 | content: "\eae8"; 1530 | } 1531 | .icon-svg:before { 1532 | content: "\eae9"; 1533 | } 1534 | .icon-IcoMoon:before { 1535 | content: "\eaea"; 1536 | } 1537 | .icon-uni21:before { 1538 | content: "\21"; 1539 | } 1540 | .icon-uni22:before { 1541 | content: "\22"; 1542 | } 1543 | .icon-uni23:before { 1544 | content: "\23"; 1545 | } 1546 | .icon-uni24:before { 1547 | content: "\24"; 1548 | } 1549 | .icon-uni25:before { 1550 | content: "\25"; 1551 | } 1552 | .icon-uni26:before { 1553 | content: "\26"; 1554 | } 1555 | .icon-uni27:before { 1556 | content: "\27"; 1557 | } 1558 | .icon-uni28:before { 1559 | content: "\28"; 1560 | } 1561 | .icon-uni29:before { 1562 | content: "\29"; 1563 | } 1564 | .icon-uni2A:before { 1565 | content: "\2a"; 1566 | } 1567 | .icon-uni2B:before { 1568 | content: "\2b"; 1569 | } 1570 | .icon-uni2C:before { 1571 | content: "\2c"; 1572 | } 1573 | .icon-uni2D:before { 1574 | content: "\2d"; 1575 | } 1576 | .icon-uni2E:before { 1577 | content: "\2e"; 1578 | } 1579 | .icon-uni2F:before { 1580 | content: "\2f"; 1581 | } 1582 | .icon-uni30:before { 1583 | content: "\30"; 1584 | } 1585 | .icon-uni31:before { 1586 | content: "\31"; 1587 | } 1588 | .icon-uni32:before { 1589 | content: "\32"; 1590 | } 1591 | .icon-uni33:before { 1592 | content: "\33"; 1593 | } 1594 | .icon-uni34:before { 1595 | content: "\34"; 1596 | } 1597 | .icon-uni35:before { 1598 | content: "\35"; 1599 | } 1600 | .icon-uni36:before { 1601 | content: "\36"; 1602 | } 1603 | .icon-uni37:before { 1604 | content: "\37"; 1605 | } 1606 | .icon-uni38:before { 1607 | content: "\38"; 1608 | } 1609 | .icon-uni39:before { 1610 | content: "\39"; 1611 | } 1612 | .icon-uni3A:before { 1613 | content: "\3a"; 1614 | } 1615 | .icon-uni3B:before { 1616 | content: "\3b"; 1617 | } 1618 | .icon-uni3C:before { 1619 | content: "\3c"; 1620 | } 1621 | .icon-uni3D:before { 1622 | content: "\3d"; 1623 | } 1624 | .icon-uni3E:before { 1625 | content: "\3e"; 1626 | } 1627 | .icon-uni3F:before { 1628 | content: "\3f"; 1629 | } 1630 | .icon-uni40:before { 1631 | content: "\40"; 1632 | } 1633 | .icon-uni41:before { 1634 | content: "\41"; 1635 | } 1636 | .icon-uni42:before { 1637 | content: "\42"; 1638 | } 1639 | .icon-uni43:before { 1640 | content: "\43"; 1641 | } 1642 | .icon-uni44:before { 1643 | content: "\44"; 1644 | } 1645 | .icon-uni45:before { 1646 | content: "\45"; 1647 | } 1648 | .icon-uni46:before { 1649 | content: "\46"; 1650 | } 1651 | .icon-uni47:before { 1652 | content: "\47"; 1653 | } 1654 | .icon-uni48:before { 1655 | content: "\48"; 1656 | } 1657 | .icon-uni49:before { 1658 | content: "\49"; 1659 | } 1660 | .icon-uni4A:before { 1661 | content: "\4a"; 1662 | } 1663 | .icon-uni4B:before { 1664 | content: "\4b"; 1665 | } 1666 | .icon-uni4C:before { 1667 | content: "\4c"; 1668 | } 1669 | .icon-uni4D:before { 1670 | content: "\4d"; 1671 | } 1672 | .icon-uni4E:before { 1673 | content: "\4e"; 1674 | } 1675 | .icon-uni4F:before { 1676 | content: "\4f"; 1677 | } 1678 | .icon-uni50:before { 1679 | content: "\50"; 1680 | } 1681 | .icon-uni51:before { 1682 | content: "\51"; 1683 | } 1684 | .icon-uni52:before { 1685 | content: "\52"; 1686 | } 1687 | .icon-uni53:before { 1688 | content: "\53"; 1689 | } 1690 | .icon-uni54:before { 1691 | content: "\54"; 1692 | } 1693 | .icon-uni55:before { 1694 | content: "\55"; 1695 | } 1696 | .icon-uni56:before { 1697 | content: "\56"; 1698 | } 1699 | .icon-uni57:before { 1700 | content: "\57"; 1701 | } 1702 | .icon-uni58:before { 1703 | content: "\58"; 1704 | } 1705 | .icon-uni59:before { 1706 | content: "\59"; 1707 | } 1708 | .icon-uni5A:before { 1709 | content: "\5a"; 1710 | } 1711 | .icon-uni5B:before { 1712 | content: "\5b"; 1713 | } 1714 | .icon-uni5C:before { 1715 | content: "\5c"; 1716 | } 1717 | .icon-uni5D:before { 1718 | content: "\5d"; 1719 | } 1720 | .icon-uni5E:before { 1721 | content: "\5e"; 1722 | } 1723 | .icon-uni5F:before { 1724 | content: "\5f"; 1725 | } 1726 | .icon-uni60:before { 1727 | content: "\60"; 1728 | } 1729 | .icon-uni61:before { 1730 | content: "\61"; 1731 | } 1732 | .icon-uni62:before { 1733 | content: "\62"; 1734 | } 1735 | .icon-uni63:before { 1736 | content: "\63"; 1737 | } 1738 | .icon-uni64:before { 1739 | content: "\64"; 1740 | } 1741 | .icon-uni65:before { 1742 | content: "\65"; 1743 | } 1744 | .icon-uni66:before { 1745 | content: "\66"; 1746 | } 1747 | .icon-uni67:before { 1748 | content: "\67"; 1749 | } 1750 | .icon-uni68:before { 1751 | content: "\68"; 1752 | } 1753 | .icon-uni69:before { 1754 | content: "\69"; 1755 | } 1756 | .icon-uni6A:before { 1757 | content: "\6a"; 1758 | } 1759 | .icon-uni6B:before { 1760 | content: "\6b"; 1761 | } 1762 | .icon-uni6C:before { 1763 | content: "\6c"; 1764 | } 1765 | .icon-uni6D:before { 1766 | content: "\6d"; 1767 | } 1768 | .icon-uni6E:before { 1769 | content: "\6e"; 1770 | } 1771 | .icon-uni6F:before { 1772 | content: "\6f"; 1773 | } 1774 | .icon-uni70:before { 1775 | content: "\70"; 1776 | } 1777 | .icon-uni71:before { 1778 | content: "\71"; 1779 | } 1780 | .icon-uni72:before { 1781 | content: "\72"; 1782 | } 1783 | .icon-uni73:before { 1784 | content: "\73"; 1785 | } 1786 | .icon-uni74:before { 1787 | content: "\74"; 1788 | } 1789 | .icon-uni75:before { 1790 | content: "\75"; 1791 | } 1792 | .icon-uni76:before { 1793 | content: "\76"; 1794 | } 1795 | .icon-uni77:before { 1796 | content: "\77"; 1797 | } 1798 | .icon-uni78:before { 1799 | content: "\78"; 1800 | } 1801 | .icon-uni79:before { 1802 | content: "\79"; 1803 | } 1804 | .icon-uni7A:before { 1805 | content: "\7a"; 1806 | } 1807 | .icon-uni7B:before { 1808 | content: "\7b"; 1809 | } 1810 | .icon-uni7C:before { 1811 | content: "\7c"; 1812 | } 1813 | .icon-uni7D:before { 1814 | content: "\7d"; 1815 | } 1816 | .icon-uni7E:before { 1817 | content: "\7e"; 1818 | } 1819 | .icon-uniA9:before { 1820 | content: "\a9"; 1821 | } 1822 | 1823 | -------------------------------------------------------------------------------- /src/styles/_reset.less: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, body, div, span, applet, object, iframe, 7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 8 | a, abbr, acronym, address, big, cite, code, 9 | del, dfn, em, img, ins, kbd, q, s, samp, 10 | small, strike, strong, sub, sup, tt, var, 11 | b, u, i, center, 12 | dl, dt, dd, ol, ul, li, 13 | fieldset, form, label, legend, 14 | table, caption, tbody, tfoot, thead, tr, th, td, 15 | article, aside, canvas, details, embed, 16 | figure, figcaption, footer, header, hgroup, 17 | menu, nav, output, ruby, section, summary, 18 | time, mark, audio, video { 19 | margin: 0; 20 | padding: 0; 21 | border: 0; 22 | font-size: 100%; 23 | font: inherit; 24 | vertical-align: baseline; 25 | } 26 | /* HTML5 display-role reset for older browsers */ 27 | article, aside, details, figcaption, figure, 28 | footer, header, hgroup, menu, nav, section { 29 | display: block; 30 | } 31 | body { 32 | line-height: 1; 33 | } 34 | ol, ul { 35 | list-style: none; 36 | } 37 | blockquote, q { 38 | quotes: none; 39 | } 40 | blockquote:before, blockquote:after, 41 | q:before, q:after { 42 | content: ''; 43 | content: none; 44 | } 45 | table { 46 | border-collapse: collapse; 47 | border-spacing: 0; 48 | } 49 | -------------------------------------------------------------------------------- /src/styles/app.less: -------------------------------------------------------------------------------- 1 | @import '_reset.less'; 2 | @import '_fonts.less'; 3 | @import (less) '../../node_modules/leaflet/dist/leaflet.css'; 4 | * { 5 | box-sizing: border-box; 6 | } 7 | button { 8 | cursor: pointer; 9 | } 10 | 11 | @tabletMaxWidth: 1025; 12 | @mobileMaxWidth: 550; 13 | @tabletMinWidth: (@mobileMaxWidth + 1); 14 | @desktopMinWidth: (@tabletMaxWidth + 1); 15 | @mobile: ~"(max-width: @{mobileMaxWidth}px)"; 16 | @tablet: ~"(min-width: @{tabletMinWidth}px) and (max-width: @{tabletMaxWidth}px)"; 17 | @mobile-and-tablet: ~"(max-width: @{tabletMaxWidth}px)"; 18 | @tablet-and-desktop: ~"(min-width: @{tabletMinWidth}px)"; 19 | @desktop: ~"(min-width: @{desktopMinWidth}px)"; 20 | @darkBg: #151515; 21 | @borderColor: lighten(@darkBg, 20%); 22 | @lightFontColor: #fafafa; 23 | @labelFontColor: darken(@lightFontColor, 20%); 24 | @desktopTopBarHeight: 70px; 25 | @mobileTopBarHeight: 60px; 26 | @rightBarWidth: 11em; 27 | @menuWidth: 22em; 28 | .body-font() { 29 | font-family: 'Roboto'; 30 | } 31 | .instruments-font() { 32 | font-family: 'Roboto'; 33 | font-weight: 700; 34 | } 35 | body { 36 | @media @desktop { 37 | font-size: 17px; 38 | } 39 | @media @tablet { 40 | font-size: 15px; 41 | } 42 | @media @mobile { 43 | font-size: 13px; 44 | } 45 | .body-font(); 46 | color: @lightFontColor; 47 | } 48 | p { 49 | margin: 10px 0; 50 | } 51 | :focus { 52 | outline: 0; 53 | /* or */ 54 | outline: none; 55 | } 56 | .button { 57 | border: none; 58 | background: lighten(@darkBg, 25%); 59 | padding: 15px; 60 | color: @lightFontColor; 61 | font-size: 16px; 62 | transition: 0.2s background; 63 | &:hover { 64 | background: lighten(@darkBg, 30%); 65 | } 66 | } 67 | .top-bar-controls { 68 | position: absolute; 69 | background: @darkBg; 70 | top: 0; 71 | left: 0; 72 | right: 0; 73 | display: flex; 74 | 75 | > div { 76 | height: 100%; 77 | } 78 | 79 | @media @tablet-and-desktop { 80 | height: @desktopTopBarHeight; 81 | .top-bar-controls-left { 82 | margin-right: 20px; 83 | } 84 | 85 | .follow { 86 | margin-right: 20px; 87 | } 88 | } 89 | @media @mobile { 90 | height: @mobileTopBarHeight; 91 | } 92 | 93 | .top-bar-controls-left { 94 | flex: 0 0 auto; 95 | } 96 | .top-bar-controls-center { 97 | flex: 1 1 auto; 98 | } 99 | .top-bar-controls-right { 100 | flex: 0 0 auto; 101 | display: flex; 102 | } 103 | } 104 | 105 | .left-bar-menu, .right-bar-instruments { 106 | position: absolute; 107 | @media @tablet-and-desktop { 108 | top: @desktopTopBarHeight; 109 | } 110 | 111 | @media @mobile { 112 | top: @mobileTopBarHeight; 113 | } 114 | 115 | background: @darkBg; 116 | z-index: 11111; 117 | overflow-x: hidden; 118 | overflow-y: auto; 119 | 120 | bottom: 0px; 121 | width: 0px; 122 | } 123 | 124 | .left-bar-menu { 125 | left: 0px; 126 | 127 | .wrapper { 128 | height: 100%; 129 | display: flex; 130 | flex-direction: column; 131 | } 132 | 133 | .settings { 134 | flex: 1 1 auto; 135 | } 136 | 137 | @media @tablet-and-desktop { 138 | transition: width 0.25s; 139 | .wrapper { 140 | width: @menuWidth; 141 | } 142 | &.visible { 143 | width: @menuWidth; 144 | } 145 | } 146 | @media @mobile { 147 | &.visible { 148 | left: 0; 149 | width: 100%; 150 | } 151 | } 152 | } 153 | .right-bar-instruments { 154 | right: 0px; 155 | 156 | @media @tablet-and-desktop { 157 | transition: width 0.25s; 158 | .wrapper { 159 | width: @rightBarWidth; 160 | overflow: hidden; 161 | } 162 | &.visible { 163 | width: @rightBarWidth; 164 | } 165 | } 166 | @media @mobile { 167 | &.visible { 168 | left: 0; 169 | width: 100%; 170 | } 171 | .wrapper { 172 | display: flex; 173 | flex-wrap: wrap; 174 | } 175 | } 176 | } 177 | .instrument { 178 | padding: 10px 20px; 179 | min-height: 100px; 180 | 181 | @media @mobile { 182 | width: 50%; 183 | } 184 | .top-row { 185 | display: flex; 186 | } 187 | .title, .unit { 188 | flex: 1 1 auto; 189 | font-size: 110%; 190 | color: @labelFontColor; 191 | text-align: right; 192 | } 193 | .value { 194 | .instruments-font(); 195 | margin: 10px 0; 196 | font-size: 350%; 197 | color: @lightFontColor; 198 | text-align: right; 199 | } 200 | } 201 | 202 | .leaflet-container { 203 | background-color:#c5e8ff; 204 | } 205 | 206 | .map-wrapper { 207 | position: absolute; 208 | @media @tablet-and-desktop { 209 | top: @desktopTopBarHeight; 210 | } 211 | 212 | @media @mobile { 213 | top: @mobileTopBarHeight; 214 | } 215 | bottom: 0; 216 | left: 0; 217 | right: 0; 218 | transition: right 0.25s; 219 | 220 | @media @tablet-and-desktop { 221 | &.instruments-visible { 222 | right: @rightBarWidth; 223 | } 224 | &.menu-visible { 225 | left: @menuWidth; 226 | } 227 | } 228 | } 229 | #map { 230 | position: absolute; 231 | top: 0; 232 | bottom: 0; 233 | left: 0; 234 | right: 0; 235 | } 236 | .navigation { 237 | position: absolute; 238 | top: 50px; 239 | left: 0; 240 | right: 0; 241 | } 242 | .leaflet-pane.leaflet-basemap-pane { 243 | z-index: 100; 244 | } 245 | .generate-panes(@counter) when (@counter >= 0) { 246 | .generate-panes((@counter - 1)); 247 | .leaflet-pane.leaflet-chart-@{counter}-pane { 248 | z-index: (399 - @counter); 249 | } 250 | } 251 | .generate-panes(10); 252 | .zoom-buttons { 253 | display: flex; 254 | height: 100%; 255 | @media @mobile { 256 | position: absolute; 257 | top: @mobileTopBarHeight; 258 | z-index: 11111; 259 | height: 2 * @mobileTopBarHeight; 260 | right: 0; 261 | flex-direction: column; 262 | margin-top: 10px; 263 | } 264 | } 265 | .top-bar-button { 266 | background-color: lighten(@darkBg, 5%); 267 | display: block; 268 | height: 100%; 269 | border: 0; 270 | color: #fafafa; 271 | 272 | @media @tablet-and-desktop { 273 | width: @desktopTopBarHeight * 1.1; 274 | } 275 | 276 | @media @mobile { 277 | width: @mobileTopBarHeight; 278 | } 279 | cursor: pointer; 280 | border-left: 2px solid lighten(@darkBg, 25%); 281 | border-right: 2px solid lighten(@darkBg, 25%); 282 | font-size: 200%; 283 | &.enabled { 284 | text-shadow: 0px 0px 30px #fff679; 285 | color: #cbfff8; 286 | } 287 | &:active { 288 | background-color: lighten(@darkBg, 10%); 289 | font-size: 150%; 290 | } 291 | } 292 | 293 | .top-bar-button.fullscreen { 294 | @media @mobile { 295 | display: none; 296 | } 297 | margin-left: 20px; 298 | .icon-fullscreen { 299 | .icon-font-common(); 300 | } 301 | &.disabled .icon-fullscreen:before { 302 | content: "\e989"; 303 | } 304 | &.enabled .icon-fullscreen:before { 305 | content: "\e98c"; 306 | } 307 | &.enabled { 308 | text-shadow: none; 309 | color: #fafafa; 310 | } 311 | } 312 | .path-draw-controls-wrapper { 313 | display: flex; 314 | height: 100%; 315 | } 316 | .path-draw-controls { 317 | @drawControlBackground: #303052; 318 | 319 | align-items: center; 320 | color: #fafafa; 321 | display: flex; 322 | transition: 0.25s max-width; 323 | background: @drawControlBackground; 324 | overflow: hidden; 325 | 326 | button { 327 | display: block; 328 | padding: 20px; 329 | height: 100%; 330 | border: 0; 331 | color: #fafafa; 332 | background: @drawControlBackground; 333 | font-size: 170%; 334 | &:active { 335 | background-color: lighten(@drawControlBackground, 10%); 336 | font-size: 150%; 337 | } 338 | } 339 | .distance { 340 | font-size: 150%; 341 | text-align: right; 342 | margin-right: 10px; 343 | white-space: nowrap; 344 | @media @tablet-and-desktop { 345 | min-width: 140px; 346 | padding: 20px; 347 | } 348 | @media @mobile { 349 | min-width: 90px; 350 | padding: 20px 5px; 351 | } 352 | } 353 | &.enabled { 354 | max-width: 230px; 355 | } 356 | &.disabled { 357 | max-width: 0; 358 | } 359 | } 360 | 361 | .reset-settings { 362 | margin: 10px; 363 | } 364 | 365 | .aisTooltip { 366 | border-radius: 0; 367 | background-color: black; 368 | border: none; 369 | padding: 10px; 370 | color: darken(@lightFontColor, 20%); 371 | .body-font(); 372 | font-size: 16px; 373 | .name { 374 | font-weight: 500; 375 | color: @lightFontColor; 376 | } 377 | } 378 | 379 | .accordion { 380 | border-top: 1px solid @borderColor; 381 | border-bottom: 1px solid @borderColor; 382 | width: 100%; 383 | &.open .accordion-header i { 384 | transform: rotate(180deg); 385 | } 386 | .accordion-header { 387 | padding: 20px; 388 | display: flex; 389 | font-size: 18px; 390 | cursor: pointer; 391 | background-color: lighten(@darkBg, 5%); 392 | > *:first-child { 393 | flex: 1 1 auto; 394 | } 395 | > i { 396 | text-align: center; 397 | flex: 0 0 20px; 398 | display: block; 399 | transition: transform 0.1s; 400 | } 401 | } 402 | .accordion-content { 403 | padding: 10px 0; 404 | } 405 | } 406 | 407 | .menu-checkbox, .menu-switch { 408 | background: none; 409 | border: none; 410 | padding: 10px 20px; 411 | margin: 5px 0; 412 | display: flex; 413 | color: @lightFontColor; 414 | width: 100%; 415 | text-align: left; 416 | cursor: pointer; 417 | font-size: 18px; 418 | > span:first-child { 419 | flex: 1 1 auto; 420 | color: @labelFontColor; 421 | } 422 | > i { 423 | text-align: right; 424 | flex: 0 0 40px; 425 | } 426 | > span:last-child { 427 | flex: 0 0 auto; 428 | text-align: right; 429 | background: lighten(@darkBg, 10%); 430 | margin: -10px; 431 | padding: 10px; 432 | min-width: 100px; 433 | } 434 | } 435 | 436 | .github-link { 437 | display: flex; 438 | background: lighten(@darkBg, 70%); 439 | align-items: center; 440 | img { 441 | height: 45px; 442 | margin: 10px 10px 10px 20px; 443 | } 444 | a { 445 | padding: 20px 10px; 446 | color: #5151b3; 447 | text-decoration: none; 448 | } 449 | } 450 | 451 | .charts-provider { 452 | cursor: pointer; 453 | margin: 20px 0; 454 | display: flex; 455 | 456 | div:first-child { 457 | flex: 1 1 auto; 458 | padding-left: 20px; 459 | overflow: hidden; 460 | margin-right: 10px; 461 | } 462 | 463 | div:last-child { 464 | flex: 0 0 auto; 465 | padding-right: 20px; 466 | } 467 | 468 | i { 469 | display: block; 470 | padding: 10px 0; 471 | } 472 | 473 | p { 474 | overflow-wrap: break-word; 475 | } 476 | 477 | .name { 478 | overflow: hidden; 479 | white-space: nowrap; 480 | text-overflow: ellipsis; 481 | } 482 | .description, .levels { 483 | color: @labelFontColor; 484 | } 485 | } 486 | 487 | .charts-loading { 488 | background: lighten(@darkBg, 20%); 489 | text-align: center; 490 | h2 { 491 | color: lighten(@darkBg, 50%); 492 | font-size: 500%; 493 | margin-top: 30%; 494 | } 495 | } 496 | 497 | .instrument-settings { 498 | .placeholder { 499 | opacity: 0.3; 500 | } 501 | } 502 | 503 | .connection-state.disconnected { 504 | padding: 20px 40px; 505 | background: rgba(220, 0, 10, 0.5); 506 | text-align: center; 507 | position: absolute; 508 | } 509 | 510 | .ais-details { 511 | border: 2px solid @borderColor; 512 | z-index: 111111; 513 | position: absolute; 514 | min-width: 250px; 515 | background-color: @darkBg; 516 | left: 0; 517 | transition: left 0.25s; 518 | margin-left: 5px; 519 | top: @desktopTopBarHeight; 520 | margin-top: 5px; 521 | 522 | &.menu-visible { 523 | left: @menuWidth; 524 | } 525 | } 526 | .ais-details-name { 527 | margin-right: 40px; 528 | } 529 | .ais-details-mmsi { 530 | margin-top: 5px; 531 | font-size: 80%; 532 | color: @labelFontColor; 533 | } 534 | .ais-details-header { 535 | padding: 6px 10px; 536 | background-color: lighten(@darkBg, 5%); 537 | border-bottom: 2px solid @borderColor; 538 | } 539 | .ais-details-data { 540 | padding: 10px; 541 | } 542 | .ais-details-row { 543 | display: flex; 544 | margin: 3px 0; 545 | } 546 | .ais-details-label { 547 | flex: 1 0 auto; 548 | color: @labelFontColor; 549 | } 550 | .ais-details-value { 551 | flex: 1 1 auto; 552 | min-width: 50px; 553 | text-align: right; 554 | margin-left: 20px; 555 | max-width: 170px; 556 | color: @lightFontColor; 557 | text-transform: capitalize; 558 | } 559 | .ais-details-separator { 560 | margin: 5px 0; 561 | border-top: 2px solid @borderColor; 562 | } 563 | .ais-details-close { 564 | position: absolute; 565 | top: 0; 566 | right: 0; 567 | } 568 | 569 | @media @tablet { 570 | .ais-details { 571 | width: 240px; 572 | } 573 | .ais-details-close { 574 | padding: 14px; 575 | } 576 | } 577 | 578 | @media @mobile { 579 | .ais-details { 580 | margin-top: 0; 581 | width: 220px; 582 | &.menu-visible { 583 | display: none; 584 | } 585 | } 586 | .ais-details-close { 587 | padding: 11px; 588 | } 589 | } -------------------------------------------------------------------------------- /webpack.bundle.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require("webpack") 3 | 4 | const production = process.env.NODE_ENV === 'production' 5 | 6 | console.log('Webpack production mode: ', production) 7 | 8 | module.exports = { 9 | bail: true, 10 | mode: production ? 'production' : 'development', 11 | entry: path.resolve(__dirname, 'src/client/app.js'), 12 | resolve: { 13 | modules: ["node_modules"], 14 | }, 15 | module: { 16 | rules: [{ 17 | test: /.js$/, 18 | loader: 'babel-loader', 19 | include: path.resolve(__dirname, "src") 20 | }] 21 | }, 22 | plugins: production ? [new webpack.optimize.ModuleConcatenationPlugin()] : [], 23 | devtool: production ? 'none' : 'eval-source-map', 24 | output: { 25 | path: path.resolve(__dirname, 'public/'), 26 | filename: 'bundle.js' 27 | }, 28 | externals: ['mdns'] 29 | } 30 | --------------------------------------------------------------------------------