├── .eslintrc ├── .gitignore ├── LICENSE ├── README.md ├── docs ├── index.html ├── rain-layer.css ├── rain-layer.json └── screenshot1.jpg ├── package.json ├── rollup.config.mjs └── src ├── index.js ├── scales.json └── sources.json /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "mourner", 4 | "plugin:import/recommended" 5 | ], 6 | "parserOptions": { 7 | "sourceType": "module" 8 | }, 9 | "plugins": [ 10 | "import", 11 | "jsdoc" 12 | ], 13 | "rules": { 14 | // temporarily disabled due to https://github.com/babel/babel-eslint/issues/485 15 | "no-use-before-define": "off", 16 | 17 | // no-duplicate-imports doesn't play well with Flow 18 | // https://github.com/babel/eslint-plugin-babel/issues/59 19 | "no-duplicate-imports": "off", 20 | "import/no-duplicates": "error", 21 | 22 | // temporarily disabled for easier upgrading of dependencies 23 | "implicit-arrow-linebreak": "off", 24 | "arrow-parens": "off", 25 | "arrow-body-style": "off", 26 | "no-confusing-arrow": "off", 27 | "no-control-regex": "off", 28 | "no-invalid-this": "off", 29 | "no-buffer-constructor": "off", 30 | 31 | "array-bracket-spacing": "off", 32 | "consistent-return": "off", 33 | "global-require": "off", 34 | "import/no-commonjs": "error", 35 | "key-spacing": "off", 36 | "no-eq-null": "off", 37 | "no-lonely-if": "off", 38 | "no-new": "off", 39 | "no-unused-vars": ["error", {"argsIgnorePattern": "^_$"}], 40 | "no-warning-comments": "error", 41 | "object-curly-spacing": ["error", "never"], 42 | "prefer-arrow-callback": "error", 43 | "prefer-const": ["error", {"destructuring": "all"}], 44 | "prefer-template": "error", 45 | "quotes": "off", 46 | "space-before-function-paren": "off", 47 | "template-curly-spacing": "error", 48 | "no-useless-escape": "off", 49 | "indent": ["error", 4, { 50 | "flatTernaryExpressions": true, 51 | "CallExpression": { 52 | "arguments": "off" 53 | }, 54 | "FunctionDeclaration": { 55 | "parameters": "off" 56 | }, 57 | "FunctionExpression": { 58 | "parameters": "off" 59 | } 60 | }], 61 | "no-multiple-empty-lines": [ "error", { 62 | "max": 1 63 | }], 64 | "jsdoc/check-param-names": "warn", 65 | "jsdoc/require-param": "warn", 66 | "jsdoc/require-param-description": "warn", 67 | "jsdoc/require-param-name": "warn", 68 | "jsdoc/require-returns": "warn", 69 | "jsdoc/require-returns-description": "warn" 70 | }, 71 | "settings": { 72 | "jsdoc":{ 73 | "ignorePrivate": true 74 | } 75 | }, 76 | "ignorePatterns": [], 77 | "globals": { 78 | "performance": true 79 | }, 80 | "env": { 81 | "es6": true, 82 | "browser": true 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | /package-lock.json 4 | .DS_Store 5 | .eslintcache 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021-2024 Akihiko Kusanagi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mapbox GL JS Rain Layer 2 | 3 | *An animated rain layer for [Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js)* 4 | 5 | ![Screenshot](https://nagix.github.io/mapbox-gl-rain-layer/screenshot1.jpg) 6 | 7 | See a [Live Demo](https://nagix.github.io/mapbox-gl-rain-layer). 8 | 9 | The rain animation is up to date according to the current radar data from data sources. In addition to the density of raindrops, the colors of semi-transparent boxes indicate the intensity of rainfall. 10 | 11 | Version 0.7 requires Mapbox GL JS 0.54.0 or later, and only works with the Mercator projection. This component works on [browsers that support ES6](https://caniuse.com/es6). It supports the Mapbox Standard style but only works at zoom level 6 or above. 12 | 13 | ## Installation 14 | 15 | You can download the latest version of Mapbox GL JS Rain Layer from the [GitHub releases](https://github.com/nagix/mapbox-gl-rain-layer/releases/latest). 16 | 17 | To install via npm: 18 | 19 | ```bash 20 | npm install mapbox-gl-rain-layer --save 21 | ``` 22 | 23 | To use CDN: 24 | 25 | ```html 26 | 27 | ``` 28 | 29 | ## Usage 30 | 31 | Mapbox GL JS Rain Layer can be used with ES6 modules, plain JavaScript and module loaders. 32 | 33 | Mapbox GL JS Rain Layer requires [Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js). Include Mapbox GL JS and Mapbox GL JS Rain Layer to your page, then you can use the `RainLayer` class, which can be added to your map as a layer. 34 | 35 | ```js 36 | const rainLayer = new RainLayer({ 37 | id: 'rain', 38 | source: 'rainviewer', 39 | scale: 'noaa' 40 | }); 41 | map.addLayer(rainLayer); 42 | 43 | // You can get the HTML text for the legend 44 | const legendHTML = rainLayer.getLegendHTML(); 45 | 46 | // You can receive radar data refresh events 47 | // data.timestamp - Unix timestamp in seconds (UTC) when the data was generated 48 | rainLayer.on('refresh', data => { 49 | console.log(data.timestamp); 50 | }); 51 | ``` 52 | 53 | ### Usage in ES6 as module 54 | 55 | Import the module as `RainLayer`, and use it in the same way as described above. 56 | 57 | ```js 58 | import RainLayer from 'mapbox-gl-rain-layer'; 59 | ``` 60 | 61 | ## Samples 62 | 63 | You can find an interactive demo at [nagix.github.io/mapbox-gl-rain-layer](https://nagix.github.io/mapbox-gl-rain-layer). 64 | 65 | ## API 66 | 67 | ### Constructor Options 68 | 69 | `RainLayer` supports the following constructor options. 70 | 71 | | Name | Type | Default | Description 72 | | ---- | ---- | ------- | ----------- 73 | | **`options.id`** | `string` | | A unique identifier that you define. 74 | | **`options.maxzoom`** | `number` | | The maximum zoom level for the layer. At zoom levels equal to or greater than the maxzoom, the layer will be hidden. The value can be any number between `0` and `24` (inclusive). If no maxzoom is provided, the layer will be visible at all zoom levels for which there are tiles available. 75 | | **`options.meshOpacity`** | `number` | `0.1` | The opacity of mesh boxes. The value can be any number between `0` and `1` (inclusive). 76 | | **`options.minzoom`** | `number` | | The minimum zoom level for the layer. At zoom levels less than the minzoom, the layer will be hidden. The value can be any number between `0` and `24` (inclusive). If no minzoom is provided, the layer will be visible at all zoom levels for which there are tiles available. 77 | | **`options.rainColor`** | `string` | `'#ccf'` | The color of raindrops. Colors are strings in a variety of permitted formats: HTML-style hex values, RGB, RGBA, HSL and HSLA. Predefined HTML colors names, like `yellow` and `blue`, are also permitted. The default is light blue. 78 | | **`options.slot`** | `string` | | (Optional) The identifier of a slot layer that will be used to position this layer. 79 | | **`options.snowColor`** | `string` | `'#fff'` | The color of snowflakes. Colors are strings in a variety of permitted formats: HTML-style hex values, RGB, RGBA, HSL and HSLA. Predefined HTML colors names, like `yellow` and `blue`, are also permitted. The default is white. 80 | | **`options.repaint`** | `boolean` | `true` | If true, rendering is automatically triggered for every frame. If false, `Map#triggerRepaint()` needs to be called explicitly. 81 | | **`options.scale`** | `string` | `'noaa'` | The type of the color scale for the radar/precipitation data. Currently, only `'noaa'` is supported. See [Radar Images: Reflectivity](https://www.weather.gov/jetstream/refl) by National Weather Service for details. 82 | | **`options.source`** | `string` | `'rainviewer'` | The data source for the layer. Currently, only `'rainviewer'` is supported. 83 | 84 | ### Instance Members 85 | 86 | #### **`getLegendHTML()`** 87 | 88 | Returns the HTML text for the legend. 89 | 90 | ##### Returns 91 | 92 | `string`: The HTML text for the legend. 93 | 94 | #### **`off(type, listener)`** 95 | 96 | Removes an event listener previously added with `RainLayer#on`. 97 | 98 | ##### Parameters 99 | 100 | **`type`** (string) The event type previously used to install the listener. 101 | 102 | **`listener`** (function) The function previously installed as a listener. 103 | 104 | ##### Returns 105 | 106 | `RainLayer`: `this` 107 | 108 | #### **`on(type, listener)`** 109 | 110 | Adds a listener for events of a specified type. 111 | 112 | ##### Parameters 113 | 114 | **`type`** (`string`) The event type to listen for. 115 | 116 | **`listener`** (`function`) The function to be called when the event is fired. 117 | 118 | ##### Returns 119 | 120 | `RainLayer`: `this` 121 | 122 | #### **`once(type, listener)`** 123 | 124 | Adds a listener that will be called only once to a specified event type. 125 | 126 | ##### Parameters 127 | 128 | **`type`** (`string`) The event type to add a listener for. 129 | 130 | **`listener`** (`function`) The function to be called when the event is fired. 131 | 132 | ##### Returns 133 | 134 | `RainLayer`: `this` 135 | 136 | #### **`setMeshOpacity(opacity)`** 137 | 138 | Sets the opacity of mesh boxes. 139 | 140 | ##### Parameters 141 | 142 | **`opacity`** (`number`) The opacity of mesh boxes. The value can be any number between `0` and `1` (inclusive). 143 | 144 | ##### Returns 145 | 146 | `RainLayer`: `this` 147 | 148 | #### **`setRainColor(color)`** 149 | 150 | Sets the color of raindrops. 151 | 152 | ##### Parameters 153 | 154 | **`color`** (`string`) The color of raindrops. Colors are strings in a variety of permitted formats: HTML-style hex values, RGB, RGBA, HSL and HSLA. Predefined HTML colors names, like `yellow` and `blue`, are also permitted. 155 | 156 | ##### Returns 157 | 158 | `RainLayer`: `this` 159 | 160 | #### **`setSnowColor(color)`** 161 | 162 | Sets the color of snowflakes. 163 | 164 | ##### Parameters 165 | 166 | **`color`** (`string`) The color of snowflakes. Colors are strings in a variety of permitted formats: HTML-style hex values, RGB, RGBA, HSL and HSLA. Predefined HTML colors names, like `yellow` and `blue`, are also permitted. 167 | 168 | ##### Returns 169 | 170 | `RainLayer`: `this` 171 | 172 | ### Events 173 | 174 | #### **`refresh`** 175 | 176 | Fired when the radar data is refreshed. 177 | 178 | ##### Properties 179 | 180 | **`timestamp`** (`number`): Unix timestamp in seconds (UTC) when the data was generated 181 | 182 | ## Building 183 | 184 | You first need to install node dependencies (requires [Node.js](https://nodejs.org/)): 185 | 186 | ```bash 187 | npm install 188 | ``` 189 | 190 | The following commands will then be available from the repository root: 191 | 192 | ```bash 193 | npm run build # build dist files 194 | npm run lint # perform code linting 195 | ``` 196 | 197 | ## About Data 198 | 199 | The data for this visualization are sourced from [RainViewer](https://www.rainviewer.com), which also gathers data from [different data sources](https://www.rainviewer.com/sources.html). 200 | 201 | ## License 202 | 203 | Mapbox GL JS Rain Layer is available under the [MIT license](https://opensource.org/licenses/MIT). 204 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 11 | 12 | Mapbox GL JS Rain Layer Demo 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
3D Terrain
42 |
Streets
43 |
Outdoors
44 |
Light
45 |
Dark
46 |
Satellite
47 |
Satellite Streets
48 |
49 |
50 |
51 |
52 |
53 |
Rain Color
54 |
Snow Color
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
Mesh Opacity: 0.1
64 |
65 | 66 |
67 |
68 |
69 |
70 |
71 |

About Mapbox GL JS Rain Layer

72 |

This data visualization was produced by Akihiko Kusanagi. The data for this visualization are sourced from RainViewer, which also gathers data from different data sources.

73 |

Source code is available at GitHub repository.

74 |
75 |
76 |
77 |
78 | 313 | 314 | 315 | -------------------------------------------------------------------------------- /docs/rain-layer.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 0; 3 | padding: 0; 4 | width: 100%; 5 | height: 100%; 6 | font: 12px/20px 'Helvetica Neue', Arial, Helvetica, sans-serif; 7 | } 8 | #map { 9 | position: absolute; 10 | top: 0; 11 | bottom: 0; 12 | width: 100%; 13 | height: 100%; 14 | } 15 | #title { 16 | position: absolute; 17 | width: 300px; 18 | top: 10px; 19 | left: 10px; 20 | background-color: #fff; 21 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); 22 | border-radius: 3px; 23 | padding: 10px; 24 | white-space: nowrap; 25 | z-index: 1; 26 | } 27 | #time { 28 | font-size: 16px; 29 | font-weight: bold; 30 | } 31 | #last-updated { 32 | color: #999; 33 | } 34 | #map-styles-bg { 35 | position: absolute; 36 | top: 0; 37 | right: 0; 38 | width: 100%; 39 | height: 100%; 40 | background-color: rgba(0, 0, 0, 0.5);; 41 | display: none; 42 | z-index: 99; 43 | } 44 | #map-styles { 45 | position: absolute; 46 | top: 24px; 47 | right: 50px; 48 | background-color: #fff; 49 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); 50 | border-radius: 4px; 51 | } 52 | #map-styles div { 53 | font: 14px/26px 'Helvetica Neue', Arial, Helvetica, sans-serif; 54 | padding: 6px 10px; 55 | background-color: transparent; 56 | cursor: pointer; 57 | } 58 | #map-styles div+div { 59 | border-top: 1px solid #ddd; 60 | } 61 | #map-styles div:hover { 62 | background-color: rgba(0, 0, 0, .05); 63 | } 64 | #map-styles div:first-child { 65 | border-radius: 4px 4px 0 0; 66 | } 67 | #map-styles div:last-child { 68 | border-radius: 0 0 4px 4px; 69 | } 70 | #map-styles span { 71 | display: inline-block; 72 | width: 16px; 73 | } 74 | #map-styles div.active span::after { 75 | content: "\2714"; 76 | } 77 | #rain-bg { 78 | position: absolute; 79 | top: 0; 80 | right: 0; 81 | width: 100%; 82 | height: 100%; 83 | background-color: transparent;; 84 | display: none; 85 | z-index: 99; 86 | } 87 | #rain { 88 | position: absolute; 89 | top: 44px; 90 | right: 50px; 91 | padding: 10px; 92 | background-color: #fff; 93 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); 94 | border-radius: 4px; 95 | } 96 | #rain hr { 97 | margin: 10px 0; 98 | } 99 | #color-select { 100 | display: table; 101 | width: 100%; 102 | height: 30px; 103 | box-shadow: 0 0 0 1px #ccc; 104 | border-radius: 4px; 105 | margin-bottom: 10px; 106 | } 107 | #color-select>div { 108 | display: table-cell; 109 | text-align: center; 110 | vertical-align: middle; 111 | cursor: pointer;; 112 | } 113 | #color-select>div.active { 114 | background-color: #ddd; 115 | } 116 | #rain-color-picker, #snow-color-picker { 117 | display: none; 118 | } 119 | #rain-color-picker.active, #snow-color-picker.active { 120 | display: block; 121 | } 122 | #slider { 123 | width: 100%; 124 | cursor: grab; 125 | } 126 | #slider:active { 127 | cursor: grabbing; 128 | } 129 | #info-bg { 130 | position: absolute; 131 | top: 0; 132 | right: 0; 133 | width: 100%; 134 | height: 100%; 135 | background-color: rgba(0, 0, 0, 0.5);; 136 | display: none; 137 | z-index: 99; 138 | } 139 | #info { 140 | position: absolute; 141 | top: 110px; 142 | right: 50px; 143 | width: 250px; 144 | padding: 10px; 145 | background-color: #fff; 146 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); 147 | border-radius: 4px; 148 | } 149 | #legend { 150 | position: absolute; 151 | bottom: 40px; 152 | left: 10px; 153 | padding: 10px; 154 | background-color: #fff; 155 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); 156 | border-radius: 4px; 157 | z-index: 1; 158 | } 159 | .mapboxgl-ctrl button.mapboxgl-ctrl-map .mapboxgl-ctrl-icon { 160 | background: no-repeat center/22px url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 576 512'%3E%3Cpath fill='%23333' d='M0 117.66v346.32c0 11.32 11.43 19.06 21.94 14.86L160 416V32L20.12 87.95A32.006 32.006 0 0 0 0 117.66zM192 416l192 64V96L192 32v384zM554.06 33.16L416 96v384l139.88-55.95A31.996 31.996 0 0 0 576 394.34V48.02c0-11.32-11.43-19.06-21.94-14.86z'%3E%3C/path%3E%3C/svg%3E"); 161 | } 162 | .mapboxgl-ctrl button.mapboxgl-ctrl-rain .mapboxgl-ctrl-icon { 163 | background: no-repeat center/22px url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath fill='%23333' d='M183.9 370.1c-7.6-4.4-17.4-1.8-21.8 6l-64 112c-4.4 7.7-1.7 17.5 6 21.8 2.5 1.4 5.2 2.1 7.9 2.1 5.5 0 10.9-2.9 13.9-8.1l64-112c4.4-7.6 1.7-17.4-6-21.8zm96 0c-7.6-4.4-17.4-1.8-21.8 6l-64 112c-4.4 7.7-1.7 17.5 6 21.8 2.5 1.4 5.2 2.1 7.9 2.1 5.5 0 10.9-2.9 13.9-8.1l64-112c4.4-7.6 1.7-17.4-6-21.8zm-192 0c-7.6-4.4-17.4-1.8-21.8 6l-64 112c-4.4 7.7-1.7 17.5 6 21.8 2.5 1.4 5.2 2.1 7.9 2.1 5.5 0 10.9-2.9 13.9-8.1l64-112c4.4-7.6 1.7-17.4-6-21.8zm384 0c-7.6-4.4-17.4-1.8-21.8 6l-64 112c-4.4 7.7-1.7 17.5 6 21.8 2.5 1.4 5.2 2.1 7.9 2.1 5.5 0 10.9-2.9 13.9-8.1l64-112c4.4-7.6 1.7-17.4-6-21.8zm-96 0c-7.6-4.4-17.4-1.8-21.8 6l-64 112c-4.4 7.7-1.7 17.5 6 21.8 2.5 1.4 5.2 2.1 7.9 2.1 5.5 0 10.9-2.9 13.9-8.1l64-112c4.4-7.6 1.7-17.4-6-21.8zM416 128c-.6 0-1.1.2-1.6.2 1.1-5.2 1.6-10.6 1.6-16.2 0-44.2-35.8-80-80-80-24.6 0-46.3 11.3-61 28.8C256.4 24.8 219.3 0 176 0 114.2 0 64 50.1 64 112c0 7.3.8 14.3 2.1 21.2C27.8 145.8 0 181.5 0 224c0 53 43 96 96 96h320c53 0 96-43 96-96s-43-96-96-96z'%3E%3C/path%3E%3C/svg%3E"); 164 | } 165 | .mapboxgl-ctrl button.mapboxgl-ctrl-info .mapboxgl-ctrl-icon { 166 | background: no-repeat center/22px url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath fill='%23333' d='M256 8C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm0 110c23.196 0 42 18.804 42 42s-18.804 42-42 42-42-18.804-42-42 18.804-42 42-42zm56 254c0 6.627-5.373 12-12 12h-88c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h12v-64h-12c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h64c6.627 0 12 5.373 12 12v100h12c6.627 0 12 5.373 12 12v24z'%3E%3C/path%3E%3C/svg%3E"); 167 | } 168 | -------------------------------------------------------------------------------- /docs/screenshot1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nagix/mapbox-gl-rain-layer/81053c6e92298ded4c7e442a5e332d8385361cf4/docs/screenshot1.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mapbox-gl-rain-layer", 3 | "version": "0.7.0", 4 | "description": "Mapbox GL Rain Layer", 5 | "main": "dist/mapbox-gl-rain-layer.js", 6 | "module": "dist/mapbox-gl-rain-layer.esm.js", 7 | "dependencies": { 8 | "three": "^0.162.0" 9 | }, 10 | "devDependencies": { 11 | "@rollup/plugin-commonjs": "^28.0.1", 12 | "@rollup/plugin-json": "^6.1.0", 13 | "@rollup/plugin-node-resolve": "^15.3.0", 14 | "@rollup/plugin-strip": "^3.0.2", 15 | "@rollup/plugin-terser": "^0.4.1", 16 | "eslint": "^8.57.1", 17 | "eslint-config-mourner": "^3.0.0", 18 | "eslint-plugin-import": "^2.31.0", 19 | "eslint-plugin-jsdoc": "^50.4.3", 20 | "mapbox-gl": "^3.7.0", 21 | "rollup": "^4.24.0" 22 | }, 23 | "peerDependencies": { 24 | "mapbox-gl": ">=0.54.0" 25 | }, 26 | "scripts": { 27 | "build": "rollup -c", 28 | "lint": "eslint --cache --ignore-path .gitignore src" 29 | }, 30 | "files": [ 31 | "dist/mapbox-gl-rain-layer.*", 32 | "src/" 33 | ], 34 | "repository": { 35 | "type": "git", 36 | "url": "git+https://github.com/nagix/mapbox-gl-rain-layer.git" 37 | }, 38 | "author": "Akihiko Kusanagi", 39 | "license": "MIT", 40 | "bugs": { 41 | "url": "https://github.com/nagix/mapbox-gl-rain-layer/issues" 42 | }, 43 | "homepage": "https://nagix.github.io/mapbox-gl-rain-layer", 44 | "jsdelivr": "dist/mapbox-gl-rain-layer.min.js", 45 | "unpkg": "dist/mapbox-gl-rain-layer.min.js" 46 | } 47 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | import json from '@rollup/plugin-json'; 5 | import terser from '@rollup/plugin-terser'; 6 | import strip from '@rollup/plugin-strip'; 7 | 8 | const pkg = JSON.parse(fs.readFileSync('package.json')); 9 | const banner = `/*! 10 | * mapbox-gl-rain-layer v${pkg.version} 11 | * ${pkg.homepage} 12 | * (c) 2021-${new Date().getFullYear()} ${pkg.author} 13 | * Released under the ${pkg.license} license 14 | */`; 15 | 16 | export default [{ 17 | input: 'src/index.js', 18 | output: { 19 | name: 'RainLayer', 20 | file: `dist/${pkg.name}.js`, 21 | format: 'umd', 22 | indent: false, 23 | sourcemap: true, 24 | banner, 25 | globals: { 26 | 'mapbox-gl': 'mapboxgl' 27 | } 28 | }, 29 | external: ['mapbox-gl'], 30 | plugins: [ 31 | resolve({ 32 | browser: true, 33 | preferBuiltins: false 34 | }), 35 | commonjs(), 36 | json() 37 | ] 38 | }, { 39 | input: 'src/index.js', 40 | output: { 41 | name: 'RainLayer', 42 | file: `dist/${pkg.name}.min.js`, 43 | format: 'umd', 44 | indent: false, 45 | sourcemap: true, 46 | banner, 47 | globals: { 48 | 'mapbox-gl': 'mapboxgl' 49 | } 50 | }, 51 | external: ['mapbox-gl'], 52 | plugins: [ 53 | resolve({ 54 | browser: true, 55 | preferBuiltins: false 56 | }), 57 | commonjs(), 58 | json(), 59 | terser({ 60 | compress: { 61 | pure_getters: true 62 | } 63 | }), 64 | strip({ 65 | sourceMap: true 66 | }) 67 | ] 68 | }, { 69 | input: 'src/index.js', 70 | output: { 71 | file: pkg.module, 72 | format: 'esm', 73 | indent: false, 74 | banner 75 | }, 76 | external: ['mapbox-gl'], 77 | plugins: [ 78 | resolve({ 79 | browser: true, 80 | preferBuiltins: false 81 | }), 82 | commonjs(), 83 | json() 84 | ] 85 | }]; 86 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import {Evented, MercatorCoordinate} from 'mapbox-gl'; 2 | import {AmbientLight, BoxGeometry, BufferAttribute, Camera, Color, DirectionalLight, DoubleSide, Group, InstancedBufferGeometry, InstancedMesh, InstancedBufferAttribute, Matrix4, Mesh, MeshLambertMaterial, RawShaderMaterial, Scene, Vector4, WebGLRenderer} from 'three'; 3 | import scales from './scales.json'; 4 | import sources from './sources.json'; 5 | 6 | const RESOLUTION_X = 64; 7 | const RESOLUTION_Y = 64; 8 | 9 | const boxGeometry = new BoxGeometry(1, -1, 1); 10 | boxGeometry.translate(0.5, 0.5, 0.5); 11 | 12 | const rainVertexBuffer = new Float32Array([ 13 | // Front 14 | -0.002, 0.002, 0.01, 15 | 0.002, 0.002, 0.01, 16 | -0.002, 0.002, -0.01, 17 | 0.002, 0.002, -0.01, 18 | // Left 19 | -0.002, -0.002, 0.01, 20 | -0.002, 0.002, 0.01, 21 | -0.002, -0.002, -0.01, 22 | -0.002, 0.002, -0.01, 23 | // Top 24 | -0.002, 0.002, 0.01, 25 | 0.002, 0.002, 0.01, 26 | -0.002, -0.002, 0.01, 27 | 0.002, -0.002, 0.01 28 | ]); 29 | 30 | const rainIndices = new Uint16Array([ 31 | 0, 1, 2, 32 | 2, 1, 3, 33 | 4, 5, 6, 34 | 6, 5, 7, 35 | 8, 9, 10, 36 | 10, 9, 11 37 | ]); 38 | 39 | const snowVertexBuffer = new Float32Array([ 40 | // Front 41 | -0.004, 0.004, 0.001, 42 | 0.004, 0.004, 0.001, 43 | -0.004, 0.004, -0.001, 44 | 0.004, 0.004, -0.001, 45 | // Left 46 | -0.004, -0.004, 0.001, 47 | -0.004, 0.004, 0.001, 48 | -0.004, -0.004, -0.001, 49 | -0.004, 0.004, -0.001, 50 | // Top 51 | -0.004, 0.004, 0.001, 52 | 0.004, 0.004, 0.001, 53 | -0.004, -0.004, 0.001, 54 | 0.004, -0.004, 0.001 55 | ]); 56 | 57 | const snowIndices = new Uint16Array([ 58 | 0, 1, 2, 59 | 2, 1, 3, 60 | 4, 5, 6, 61 | 6, 5, 7, 62 | 8, 9, 10, 63 | 10, 9, 11 64 | ]); 65 | 66 | const rainVertexShader = ` 67 | precision highp float; 68 | uniform mat4 modelViewMatrix; 69 | uniform mat4 projectionMatrix; 70 | uniform float time; 71 | uniform float scale; 72 | attribute vec3 position; 73 | attribute vec3 offset; 74 | 75 | void main(void) { 76 | vec3 translate = vec3(position.x * scale + offset.x, position.y * scale + offset.y, position.z + mod(offset.z - time + 1.0, 1.0)); 77 | gl_Position = projectionMatrix * modelViewMatrix * vec4(translate, 1.0); 78 | } 79 | `; 80 | 81 | const rainFragmentShader = ` 82 | precision highp float; 83 | uniform vec4 color; 84 | 85 | void main(void) { 86 | gl_FragColor = color; 87 | } 88 | `; 89 | 90 | function valueOrDefault(value, defaultValue) { 91 | return value === undefined ? defaultValue : value; 92 | } 93 | 94 | function resolve(object, key) { 95 | let first = key.split(/\.|(?=\[)/)[0]; 96 | const rest = key.slice(first.length).replace(/^\./, ''); 97 | 98 | if (Array.isArray(object) && first.match(/^\[-?\d+\]$/)) { 99 | first = +first.slice(1, -1); 100 | if (first < 0) { 101 | first += object.length; 102 | } 103 | } 104 | if (first in object && rest) { 105 | return resolve(object[first], rest); 106 | } 107 | return object[first]; 108 | } 109 | 110 | function format(text, dict) { 111 | const matches = text.match(/\$\{[^}]+\}/g); 112 | 113 | if (matches) { 114 | for (const match of matches) { 115 | text = text.replace(match, resolve(dict, match.slice(2, -1))); 116 | } 117 | } 118 | return text; 119 | } 120 | 121 | function getMercatorBounds(canonical) { 122 | const {x, y, z} = canonical; 123 | const n = Math.pow(2, z); 124 | const lng1 = x / n * 360 - 180; 125 | const lat1 = Math.atan(Math.sinh(Math.PI * (1 - 2 * y / n))) * 180 / Math.PI; 126 | const lng2 = (x + 1) / n * 360 - 180; 127 | const lat2 = Math.atan(Math.sinh(Math.PI * (1 - 2 * (y + 1) / n))) * 180 / Math.PI; 128 | const coord1 = MercatorCoordinate.fromLngLat([lng1, lat1]); 129 | const coord2 = MercatorCoordinate.fromLngLat([lng2, lat2]); 130 | 131 | return {x: coord1.x, y: coord1.y, dx: coord2.x - coord1.x, dy: coord2.y - coord1.y}; 132 | } 133 | 134 | function createBoxMesh(z, mercatorBounds, dbz, scaleColors, material) { 135 | const factor = 1 / Math.pow(2, (z - 1) / 3); 136 | const resolutionX = Math.floor(RESOLUTION_X * factor); 137 | const resolutionY = Math.floor(RESOLUTION_Y * factor); 138 | const threshold = scaleColors[0][0]; 139 | const instances = []; 140 | 141 | for (let y = 0; y < resolutionY; y++) { 142 | for (let x = 0; x < resolutionX; x++) { 143 | const level = dbz[Math.floor((y + 0.5) / resolutionY * 256) * 256 + Math.floor((x + 0.5) / resolutionX * 256)] & 127; 144 | if (level >= threshold) { 145 | for (let p = 1; p < scaleColors.length; p++) { 146 | if (level < scaleColors[p][0]) { 147 | instances.push({x, y, p}); 148 | break; 149 | } 150 | } 151 | } 152 | } 153 | } 154 | if (instances.length === 0) { 155 | return; 156 | } 157 | 158 | const mesh = new InstancedMesh(boxGeometry, material, instances.length); 159 | for (let i = 0; i < instances.length; i++) { 160 | const {x, y, p} = instances[i]; 161 | const matrix = new Matrix4() 162 | .makeScale(1 / resolutionX, 1 / resolutionY, 1) 163 | .setPosition(x / resolutionX, y / resolutionY, 0); 164 | mesh.setMatrixAt(i, matrix); 165 | mesh.setColorAt(i, scaleColors[p][1]); 166 | } 167 | mesh.position.x = mercatorBounds.x; 168 | mesh.position.y = mercatorBounds.y; 169 | mesh.scale.x = mercatorBounds.dx; 170 | mesh.scale.y = mercatorBounds.dy; 171 | mesh.scale.z = Math.pow(2, z < 10 ? 10 - z : z < 14 ? 0 : (14 - z) * 0.8) * 0.0002; 172 | mesh.updateMatrix(); 173 | mesh.matrixAutoUpdate = false; 174 | mesh.renderOrder = 1; 175 | return mesh; 176 | } 177 | 178 | function createRainMesh(z, mercatorBounds, dbz, scaleColors, material, snow) { 179 | const factor = 1 / Math.pow(2, (z - 1) / 3); 180 | const resolutionX = Math.floor(RESOLUTION_X * factor); 181 | const resolutionY = Math.floor(RESOLUTION_Y * factor); 182 | const threshold = scaleColors[0][0]; 183 | const instances = []; 184 | 185 | for (let y = 0; y < resolutionY; y++) { 186 | for (let x = 0; x < resolutionX; x++) { 187 | const level = dbz[Math.floor((y + 0.5) / resolutionY * 256) * 256 + Math.floor((x + 0.5) / resolutionX * 256)]; 188 | if (!snow === !(level & 128) && (level & 127) >= threshold) { 189 | for (let i = 0; i < Math.pow(2, ((level & 127) - threshold) / 10) * Math.max(1, z - 14); i++) { 190 | instances.push({x, y}); 191 | } 192 | } 193 | } 194 | } 195 | if (instances.length === 0) { 196 | return; 197 | } 198 | 199 | const instancedBufferGeometry = new InstancedBufferGeometry(); 200 | 201 | const positions = new BufferAttribute(snow ? snowVertexBuffer : rainVertexBuffer, 3); 202 | instancedBufferGeometry.setAttribute('position', positions); 203 | 204 | instancedBufferGeometry.setIndex(new BufferAttribute(snow ? snowIndices : rainIndices, 1)); 205 | 206 | const rainOffsetBuffer = new Float32Array(instances.length * 3); 207 | const offsets = new InstancedBufferAttribute(rainOffsetBuffer, 3); 208 | for (let i = 0; i < instances.length; i++) { 209 | const {x, y} = instances[i]; 210 | offsets.setXYZ( 211 | i, 212 | (x + Math.random()) / resolutionX, 213 | (y + Math.random()) / resolutionY, 214 | Math.random() 215 | ); 216 | } 217 | instancedBufferGeometry.setAttribute('offset', offsets); 218 | 219 | const mesh = new Mesh(instancedBufferGeometry, material); 220 | mesh.position.x = mercatorBounds.x; 221 | mesh.position.y = mercatorBounds.y; 222 | mesh.scale.x = mercatorBounds.dx; 223 | mesh.scale.y = mercatorBounds.dy; 224 | mesh.scale.z = Math.pow(2, z < 10 ? 10 - z : z < 14 ? 0 : (14 - z) * 0.8) * 0.0002; 225 | mesh.updateMatrix(); 226 | mesh.matrixAutoUpdate = false; 227 | mesh.frustumCulled = false; 228 | return mesh; 229 | } 230 | 231 | function disposeMesh(mesh) { 232 | if (mesh.geometry instanceof InstancedBufferGeometry) { 233 | mesh.geometry.dispose(); 234 | } 235 | if (mesh instanceof InstancedMesh) { 236 | mesh.dispose(); 237 | } 238 | } 239 | 240 | function loadTile(tile, callback) { 241 | this.constructor.prototype.loadTile.call(this, tile, err => { 242 | const {x, y, z} = tile.tileID.canonical; 243 | const position = `${z}/${x}/${y}`; 244 | const texture = tile.texture; 245 | const layer = this._parentLayer; 246 | const tileDict = this._tileDict; 247 | 248 | if (texture && layer && !tileDict[position]) { 249 | const gl = this.map.painter.context.gl; 250 | const fb = gl.createFramebuffer(); 251 | const [width, height] = texture.size; 252 | const pixels = new Uint8Array(width * height * 4); 253 | const dbz = tile._dbz = new Uint8Array(width * height); 254 | const mercatorBounds = tile._mercatorBounds = getMercatorBounds(tile.tileID.canonical); 255 | 256 | gl.bindFramebuffer(gl.FRAMEBUFFER, fb); 257 | gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture.texture, 0); 258 | gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels); 259 | gl.bindFramebuffer(gl.FRAMEBUFFER, null); 260 | gl.deleteFramebuffer(fb); 261 | 262 | if (layer._colors) { 263 | // Index scale will be supported in the next minor version 264 | const colors = layer._colors.map(color => parseInt(color.replace('#', '0x'), 16)); 265 | for (let i = 0; i < dbz.length; i++) { 266 | const color = ((pixels[i * 4] * 256) + pixels[i * 4 + 1]) * 256 + pixels[i * 4 + 2]; 267 | for (let j = 0; j < colors.length; j++) { 268 | if (color === color[j]) { 269 | dbz[i] = j; 270 | break; 271 | } 272 | } 273 | } 274 | } else { 275 | for (let i = 0; i < dbz.length; i++) { 276 | dbz[i] = pixels[i * 4]; 277 | } 278 | } 279 | 280 | const group = layer._zoomGroups[z - 1]; 281 | const boxMesh = createBoxMesh(z, mercatorBounds, dbz, layer._scaleColors, layer._meshMaterial); 282 | if (boxMesh) { 283 | tile._boxMesh = boxMesh; 284 | group.add(boxMesh); 285 | } 286 | const rainMesh = createRainMesh(z, mercatorBounds, dbz, layer._scaleColors, layer._rainMaterial); 287 | if (rainMesh) { 288 | tile._rainMesh = rainMesh; 289 | group.add(rainMesh); 290 | } 291 | const snowMesh = createRainMesh(z, mercatorBounds, dbz, layer._scaleColors, layer._snowMaterial, true); 292 | if (snowMesh) { 293 | tile._snowMesh = snowMesh; 294 | group.add(snowMesh); 295 | } 296 | 297 | tileDict[position] = tile; 298 | } 299 | 300 | callback(err); 301 | }); 302 | } 303 | 304 | function unloadTile(tile, callback) { 305 | this.constructor.prototype.unloadTile.call(this, tile, err => { 306 | const {x, y, z} = tile.tileID.canonical; 307 | const position = `${z}/${x}/${y}`; 308 | const boxMesh = tile._boxMesh; 309 | const rainMesh = tile._rainMesh; 310 | const snowMesh = tile._snowMesh; 311 | 312 | if (boxMesh) { 313 | boxMesh.parent.remove(boxMesh); 314 | disposeMesh(boxMesh); 315 | delete tile._boxMesh; 316 | } 317 | 318 | if (rainMesh) { 319 | rainMesh.parent.remove(rainMesh); 320 | disposeMesh(rainMesh); 321 | delete tile._rainMesh; 322 | } 323 | 324 | if (snowMesh) { 325 | snowMesh.parent.remove(snowMesh); 326 | disposeMesh(snowMesh); 327 | delete tile._snowMesh; 328 | } 329 | 330 | delete this._tileDict[position]; 331 | 332 | if (callback) { 333 | callback(err); 334 | } 335 | }); 336 | } 337 | 338 | export default class RainLayer extends Evented { 339 | 340 | constructor(options) { 341 | super(); 342 | 343 | this.id = options.id; 344 | this.type = 'custom'; 345 | this.renderingMode = '3d'; 346 | this.slot = options.slot; 347 | this.minzoom = options.minzoom; 348 | this.maxzoom = options.maxzoom; 349 | this.source = options.source || 'rainviewer'; 350 | this.scale = options.scale || 'noaa'; 351 | this.rainColor = options.rainColor || '#ccf'; 352 | this.snowColor = options.snowColor || '#fff'; 353 | this.meshOpacity = valueOrDefault(options.meshOpacity, 0.1); 354 | this.repaint = valueOrDefault(options.repaint, true); 355 | this._interval = sources[this.source].interval; 356 | this._colors = sources[this.source].colors; 357 | this._onZoom = this._onZoom.bind(this); 358 | } 359 | 360 | onAdd(map, gl) { 361 | this._parseColor = map.painter.context.clearColor.default.constructor.parse; 362 | 363 | this._scene = new Scene(); 364 | this._camera = new Camera(); 365 | 366 | this._directionalLight = new DirectionalLight(0xffffff); 367 | this._directionalLight.position.set(0, -70, 100).normalize(); 368 | this._scene.add(this._directionalLight); 369 | 370 | this._ambientLight = new AmbientLight(0xffffff, .4); 371 | this._scene.add(this._ambientLight); 372 | 373 | this._meshMaterial = new MeshLambertMaterial({ 374 | opacity: this.meshOpacity, 375 | transparent: this.meshOpacity < 1, 376 | visible: this.meshOpacity > 0 377 | }); 378 | 379 | let c = this._parseColor(this.rainColor); 380 | this._rainMaterial = new RawShaderMaterial({ 381 | uniforms: { 382 | time: {type: 'f', value: 0.0}, 383 | scale: {type: 'f', value: 1.0}, 384 | color: {type: 'v4', value: new Vector4(c.r, c.g, c.b, c.a)} 385 | }, 386 | vertexShader: rainVertexShader, 387 | fragmentShader: rainFragmentShader, 388 | transparent: c.a < 1, 389 | side: DoubleSide 390 | }); 391 | 392 | c = this._parseColor(this.snowColor); 393 | this._snowMaterial = new RawShaderMaterial({ 394 | uniforms: { 395 | time: {type: 'f', value: 0.0}, 396 | scale: {type: 'f', value: 1.0}, 397 | color: {type: 'v4', value: new Vector4(c.r, c.g, c.b, c.a)} 398 | }, 399 | vertexShader: rainVertexShader, 400 | fragmentShader: rainFragmentShader, 401 | transparent: c.a < 1, 402 | side: DoubleSide 403 | }); 404 | 405 | this._baseZoom = Math.round(map.getZoom()); 406 | this._zoomGroups = []; 407 | for (let i = 0; i <= 24; i++) { 408 | this._zoomGroups[i] = new Group(); 409 | this._zoomGroups[i].visible = i === this._baseZoom; 410 | this._scene.add(this._zoomGroups[i]); 411 | } 412 | 413 | const {scale, align} = scales[this.scale]; 414 | this._scaleColors = scale.map(({value, color}, index, array) => { 415 | if (align === 'center') { 416 | const nextValue = index < array.length - 1 ? array[index + 1].value : Infinity; 417 | value = (value + nextValue) / 2; 418 | } 419 | return [value + 32, new Color(color)]; 420 | }); 421 | 422 | this._map = map; 423 | this._map.setLayerZoomRange(this.id, this.minzoom, this.maxzoom); 424 | this._map.on('zoom', this._onZoom); 425 | 426 | this._renderer = new WebGLRenderer({ 427 | canvas: map.getCanvas(), 428 | context: gl, 429 | antialias: true 430 | }); 431 | 432 | this._renderer.autoClear = false; 433 | 434 | this._refreshSource(); 435 | this._timer = setInterval(this._refreshSource.bind(this), this._interval); 436 | } 437 | 438 | onRemove() { 439 | delete this._parseColor; 440 | 441 | this._scene.remove(this._directionalLight); 442 | this._directionalLight.dispose(); 443 | delete this._directionalLight; 444 | 445 | this._scene.remove(this._ambientLight); 446 | this._ambientLight.dispose(); 447 | delete this._ambientLight; 448 | 449 | this._meshMaterial.dispose(); 450 | delete this._meshMaterial; 451 | 452 | this._rainMaterial.dispose(); 453 | delete this._rainMaterial; 454 | 455 | delete this._baseZoom; 456 | for (let i = 0; i <= 24; i++) { 457 | this._scene.remove(this._zoomGroups[i]); 458 | } 459 | delete this._zoomGroups; 460 | 461 | delete this._scaleColors; 462 | 463 | delete this._camera; 464 | delete this._scene; 465 | 466 | this._renderer.dispose(); 467 | delete this._renderer; 468 | 469 | this._map.off('zoom', this._onZoom); 470 | this._removeSource(); 471 | delete this._map; 472 | 473 | clearInterval(this._timer); 474 | delete this._timer; 475 | } 476 | 477 | render(gl, matrix) { 478 | const zoom = this._map.getZoom(); 479 | 480 | this._rainMaterial.uniforms.time.value = performance.now() * 0.0006; 481 | this._rainMaterial.uniforms.scale.value = Math.pow(2, this._baseZoom - zoom - (zoom >= 10.5 ? 1 : 0)); 482 | this._snowMaterial.uniforms.time.value = performance.now() * 0.00015; 483 | this._snowMaterial.uniforms.scale.value = Math.pow(2, this._baseZoom - zoom - (zoom >= 10.5 ? 1 : 0)); 484 | this._camera.projectionMatrix = new Matrix4().fromArray(matrix); 485 | this._renderer.resetState(); 486 | this._renderer.render(this._scene, this._camera); 487 | if (this.repaint) { 488 | this._map.triggerRepaint(); 489 | } 490 | } 491 | 492 | _onZoom() { 493 | this._baseZoom = Math.round(this._map.getZoom()); 494 | for (let i = 0; i <= 24; i++) { 495 | this._zoomGroups[i].visible = i === this._baseZoom; 496 | } 497 | } 498 | 499 | _refreshSource() { 500 | const sourceId = this.source; 501 | const {tiles, tileSize, minzoom, maxzoom, attribution, catalog, timestamp} = sources[sourceId]; 502 | 503 | fetch(catalog).then(response => response.json()).then(data => { 504 | const map = this._map; 505 | 506 | this._removeSource(); 507 | 508 | map.addSource(sourceId, { 509 | type: 'raster', 510 | tiles: tiles.map(tile => format(tile, data)), 511 | tileSize, 512 | minzoom, 513 | maxzoom, 514 | attribution 515 | }); 516 | 517 | const source = map.getSource(sourceId); 518 | 519 | source._parentLayer = this; 520 | source._tileDict = {}; 521 | source.loadTile = loadTile; 522 | source.unloadTile = unloadTile; 523 | 524 | map.addLayer({ 525 | id: sourceId, 526 | type: 'raster', 527 | source: sourceId, 528 | paint: {'raster-opacity': 0} 529 | }, this.id); 530 | 531 | this.fire({type: 'refresh', timestamp: +format(timestamp, data)}); 532 | }); 533 | } 534 | 535 | _removeSource() { 536 | const sourceId = this.source; 537 | const map = this._map; 538 | const source = map.getSource(sourceId); 539 | 540 | if (source) { 541 | map.removeLayer(sourceId); 542 | map.removeSource(sourceId); 543 | delete source._parentLayer; 544 | } 545 | } 546 | 547 | setRainColor(rainColor) { 548 | this.rainColor = rainColor || '#ccf'; 549 | if (this._parseColor && this._rainMaterial) { 550 | const {r, g, b, a} = this._parseColor(this.rainColor); 551 | 552 | this._rainMaterial.uniforms.color.value = new Vector4(r, g, b, a); 553 | this._rainMaterial.transparent = a < 1; 554 | } 555 | return this; 556 | } 557 | 558 | setSnowColor(snowColor) { 559 | this.snowColor = snowColor || '#fff'; 560 | if (this._parseColor && this._snowMaterial) { 561 | const {r, g, b, a} = this._parseColor(this.snowColor); 562 | 563 | this._snowMaterial.uniforms.color.value = new Vector4(r, g, b, a); 564 | this._snowMaterial.transparent = a < 1; 565 | } 566 | return this; 567 | } 568 | 569 | setMeshOpacity(meshOpacity) { 570 | this.meshOpacity = valueOrDefault(meshOpacity, 0.1); 571 | if (this._meshMaterial) { 572 | this._meshMaterial.opacity = meshOpacity; 573 | this._meshMaterial.transparent = meshOpacity < 1; 574 | this._meshMaterial.visible = meshOpacity > 0; 575 | } 576 | return this; 577 | } 578 | 579 | getLegendHTML() { 580 | return [ 581 | '
dBZ
', 582 | ...scales[this.scale].scale.slice(1).reverse().map(item => ` 583 |
584 |
585 |
${item.value}
586 |
587 | `), 588 | '
' 589 | ].join(''); 590 | } 591 | 592 | } 593 | -------------------------------------------------------------------------------- /src/scales.json: -------------------------------------------------------------------------------- 1 | { 2 | "noaa": { 3 | "scale": [ 4 | { 5 | "value": 0, 6 | "color": "#000000" 7 | }, 8 | { 9 | "value": 5, 10 | "color": "#69E5E4" 11 | }, 12 | { 13 | "value": 10, 14 | "color": "#4599E9" 15 | }, 16 | { 17 | "value": 15, 18 | "color": "#0F02E7" 19 | }, 20 | { 21 | "value": 20, 22 | "color": "#72F44A" 23 | }, 24 | { 25 | "value": 25, 26 | "color": "#59C239" 27 | }, 28 | { 29 | "value": 30, 30 | "color": "#3E8B27" 31 | }, 32 | { 33 | "value": 35, 34 | "color": "#F8F551" 35 | }, 36 | { 37 | "value": 40, 38 | "color": "#DDBC3F" 39 | }, 40 | { 41 | "value": 45, 42 | "color": "#EA9736" 43 | }, 44 | { 45 | "value": 50, 46 | "color": "#E33323" 47 | }, 48 | { 49 | "value": 55, 50 | "color": "#BF281C" 51 | }, 52 | { 53 | "value": 60, 54 | "color": "#A72318" 55 | }, 56 | { 57 | "value": 65, 58 | "color": "#E131F0" 59 | }, 60 | { 61 | "value": 70, 62 | "color": "#8D54BF" 63 | }, 64 | { 65 | "value": 75, 66 | "color": "#FEFEFE" 67 | } 68 | ], 69 | "align": "center" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/sources.json: -------------------------------------------------------------------------------- 1 | { 2 | "jma": { 3 | "type": "raster", 4 | "tiles": [ 5 | "https://www.jma.go.jp/bosai/jmatile/data/nowc/${[0].basetime}/none/${[0].validtime}/surf/hrpns/{z}/{x}/{y}.png" 6 | ], 7 | "tileSize": 256, 8 | "minzoom": 0, 9 | "maxzoom": 10, 10 | "attribution": "© Japan Meteorological Agency", 11 | "catalog": "https://www.jma.go.jp/bosai/jmatile/data/nowc/targetTimes_N1.json", 12 | "interval": 300000, 13 | "colors": [ 14 | "#000000", 15 | "#F2F2FF", 16 | "#A0D2FF", 17 | "#218CFF", 18 | "#0041FF", 19 | "#FAF500", 20 | "#FF9900", 21 | "#FF2800", 22 | "#B40068" 23 | ], 24 | "timestamp": "${[0].basetime}" 25 | }, 26 | "rainviewer": { 27 | "type": "raster", 28 | "tiles": [ 29 | "${host}${radar.past[-1].path}/256/{z}/{x}/{y}/0/0_1.png" 30 | ], 31 | "tileSize": 256, 32 | "minzoom": 1, 33 | "maxzoom": 20, 34 | "attribution": "© RainViewer", 35 | "catalog": "https://api.rainviewer.com/public/weather-maps.json", 36 | "interval": 600000, 37 | "timestamp": "${radar.past[-1].time}" 38 | } 39 | } 40 | --------------------------------------------------------------------------------