├── .eslintignore ├── .eslintrc ├── .gitattributes ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── LICENSE ├── README.md ├── cors └── index.mjs ├── datagenerators ├── chunk.mjs ├── https.mjs ├── output │ ├── regionalcities.json │ └── stations.json ├── regionalcities-raw.json ├── regionalcities.mjs ├── stations-states.mjs └── stations.mjs ├── gulp ├── publish-frontend.mjs └── update-vendor.mjs ├── gulpfile.mjs ├── index.mjs ├── package-lock.json ├── package.json ├── server ├── fonts │ ├── ReadMe.txt │ ├── Star3000 Extended.ttf │ ├── Star3000 Large.ttf │ ├── Star3000 Small.ttf │ └── Star3000.ttf ├── images │ ├── backgrounds │ │ ├── 1-wide.png │ │ └── 1.png │ ├── gimp │ │ └── Ws3kp.xcf │ ├── logos │ │ └── logo192.png │ ├── nav │ │ ├── ic_fullscreen_exit_white_24dp_2x.png │ │ ├── ic_fullscreen_white_24dp_2x.png │ │ ├── ic_gps_fixed_black_18dp_1x.png │ │ ├── ic_gps_fixed_white_18dp_1x.png │ │ ├── ic_menu_white_24dp_2x.png │ │ ├── ic_pause_white_24dp_2x.png │ │ ├── ic_play_arrow_white_24dp_2x.png │ │ ├── ic_refresh_white_24dp_2x.png │ │ ├── ic_skip_next_white_24dp_2x.png │ │ ├── ic_skip_previous_white_24dp_2x.png │ │ ├── ic_volume_off_white_24dp_2x.png │ │ └── ic_volume_on_white_24dp_2x.png │ └── social │ │ └── 1200x600.png ├── manifest.json ├── robots.txt ├── scripts │ ├── custom.sample.js │ ├── data │ │ ├── regionalcities.js │ │ └── stations.js │ ├── index.mjs │ ├── modules │ │ ├── almanac.mjs │ │ ├── autocomplete.mjs │ │ ├── currentweather.mjs │ │ ├── currentweatherscroll.mjs │ │ ├── extendedforecast.mjs │ │ ├── hazards.mjs │ │ ├── latestobservations.mjs │ │ ├── localforecast.mjs │ │ ├── navigation.mjs │ │ ├── progress.mjs │ │ ├── radar-utils.mjs │ │ ├── regionalforecast.mjs │ │ ├── settings.mjs │ │ ├── share.mjs │ │ ├── status.mjs │ │ ├── utils │ │ │ ├── calc.mjs │ │ │ ├── cors.mjs │ │ │ ├── elem.mjs │ │ │ ├── fetch.mjs │ │ │ ├── nosleep.mjs │ │ │ ├── setting.mjs │ │ │ ├── string.mjs │ │ │ ├── units.mjs │ │ │ └── weather.mjs │ │ └── weatherdisplay.mjs │ └── vendor │ │ └── auto │ │ ├── luxon.js.map │ │ ├── luxon.mjs │ │ ├── nosleep.js │ │ ├── suncalc.js │ │ └── swiped-events.js └── styles │ ├── main.css │ ├── main.css.map │ └── scss │ ├── _almanac.scss │ ├── _current-weather.scss │ ├── _extended-forecast.scss │ ├── _hazards.scss │ ├── _latest-observations.scss │ ├── _local-forecast.scss │ ├── _page.scss │ ├── _progress.scss │ ├── _regional-forecast.scss │ ├── _weather-display.scss │ ├── main.scss │ └── shared │ ├── _colors.scss │ ├── _margins.scss │ └── _utils.scss ├── views ├── index.ejs └── partials │ ├── almanac.ejs │ ├── current-weather.ejs │ ├── extended-forecast.ejs │ ├── hazards.ejs │ ├── header.ejs │ ├── latest-observations.ejs │ ├── local-forecast.ejs │ ├── progress.ejs │ ├── regional-forecast.ejs │ ├── scroll.ejs │ └── travel.ejs └── ws3kp.code-workspace /.eslintignore: -------------------------------------------------------------------------------- 1 | *.min.* 2 | server/scripts/vendor/* -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "airbnb-base" 9 | ], 10 | "globals": { 11 | "RegionalCities": "readonly", 12 | "StationInfo": "readonly", 13 | "SunCalc": "readonly", 14 | "NoSleep": "readonly" 15 | }, 16 | "parserOptions": { 17 | "ecmaVersion": "latest", 18 | "sourceType": "module" 19 | }, 20 | "plugins": [], 21 | "rules": { 22 | "indent": [ 23 | "error", 24 | "tab", 25 | { 26 | "SwitchCase": 1 27 | } 28 | ], 29 | "no-tabs": 0, 30 | "no-console": 0, 31 | "max-len": 0, 32 | "no-use-before-define": [ 33 | "error", 34 | { 35 | "variables": false 36 | } 37 | ], 38 | "no-param-reassign": [ 39 | "error", 40 | { 41 | "props": false 42 | } 43 | ], 44 | "no-mixed-operators": [ 45 | "error", 46 | { 47 | "groups": [ 48 | [ 49 | "&", 50 | "|", 51 | "^", 52 | "~", 53 | "<<", 54 | ">>", 55 | ">>>" 56 | ], 57 | [ 58 | "==", 59 | "!=", 60 | "===", 61 | "!==", 62 | ">", 63 | ">=", 64 | "<", 65 | "<=" 66 | ], 67 | [ 68 | "&&", 69 | "||" 70 | ], 71 | [ 72 | "in", 73 | "instanceof" 74 | ] 75 | ], 76 | "allowSamePrecedence": true 77 | } 78 | ], 79 | "import/extensions": [ 80 | "error", 81 | { 82 | "mjs": "always", 83 | "json": "always" 84 | } 85 | ] 86 | }, 87 | "ignorePatterns": [ 88 | "*.min.js" 89 | ] 90 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.js text eol=lf 2 | *.mjs text eol=lf 3 | *.ejs text eol=lf 4 | *.html text eol=lf 5 | *.json text eol=lf 6 | *.php text eol=lf -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: ['https://buymeacoffee.com/temp.exp'] 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | Please do not report issues with api.weather.gov being down. It's a new service and not considered fully operational yet. Before reporting an issue or requesting a feature please consider that this is not intended to be a perfect recreation of the WeatherStar 3000, it's a best effort that fits within what's available from the API and within a web browser. 11 | 12 | Please include: 13 | * Web browser and OS 14 | * Location for which you are viewing a forecast 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | Before requesting a feature please consider that this is not intended to be a perfect recreation of the WeatherStar 3000, it's a best effort that fits within what's available from the API and within a web browser. 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | server/scripts/custom.js 3 | 4 | #dist folder 5 | dist/* 6 | !dist/readme.txt 7 | 8 | #environment variables 9 | .env -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Frontend", 9 | "request": "launch", 10 | "type": "chrome", 11 | "url": "http://localhost:8083", 12 | "webRoot": "${workspaceFolder}/server", 13 | "skipFiles": [ 14 | "/**", 15 | "**/*.min.js", 16 | "**/vendor/**" 17 | ], 18 | }, 19 | { 20 | "name": "Data:stations", 21 | "program": "${workspaceFolder}/datagenerators/stations.js", 22 | "request": "launch", 23 | "skipFiles": [ 24 | "/**" 25 | ], 26 | "type": "node" 27 | }, 28 | { 29 | "name": "Data:regionalcities", 30 | "program": "${workspaceFolder}/datagenerators/regionalcities.js", 31 | "request": "launch", 32 | "skipFiles": [ 33 | "/**" 34 | ], 35 | "type": "node" 36 | }, 37 | { 38 | "type": "node", 39 | "request": "launch", 40 | "name": "Server", 41 | "skipFiles": [ 42 | "/**", 43 | ], 44 | "program": "${workspaceFolder}/index.mjs", 45 | }, 46 | { 47 | "type": "node", 48 | "request": "launch", 49 | "name": "Server-dist", 50 | "skipFiles": [ 51 | "/**", 52 | ], 53 | "program": "${workspaceFolder}/index.mjs", 54 | "env": { 55 | "DIST": "1" 56 | } 57 | }, 58 | ], 59 | "compounds": [ 60 | { 61 | "name": "Compound", 62 | "configurations": [ 63 | "Frontend", 64 | "Server" 65 | ] 66 | } 67 | ] 68 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "liveSassCompile.settings.formats": [ 3 | { 4 | "format": "compressed", 5 | "extensionName": ".css", 6 | "savePath": "/server/styles", 7 | } 8 | ], 9 | "search.exclude": { 10 | "**/node_modules": true, 11 | "**/bower_components": true, 12 | "**/*.code-search": true, 13 | "**/compiled.css": true, 14 | "**/*.min.js": true, 15 | }, 16 | "editor.formatOnSave": true, 17 | "editor.codeActionsOnSave": { 18 | "source.fixAll.eslint": "explicit" 19 | } 20 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "lint", 7 | "problemMatcher": [ 8 | "$eslint-stylish" 9 | ], 10 | "label": "npm: lint", 11 | "detail": "eslint ./server/scripts/**" 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Matt Walsh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WeatherStar 3000+ 2 | 3 | A live version of this project is available at https://weatherstar3000.netbymatt.com 4 | 5 | ## About 6 | 7 | This project aims to bring back the feel of the 80's with a weather forecast that has the look and feel of The Weather Channel at that time but available in a modern way. This is by no means intended to be a perfect emulation of the WeatherStar 3000, the hardware that produced those wonderful blue and white text you saw during the local forecast on The Weather Channel. Instead, this project intends to create a simple to use interface with minimal configuration fuss. Some changes have been made to the screens available because either more or less forecast information is available today than was in the 80's. Most of these changes are captured in sections below. 8 | 9 | ## Acknowledgements 10 | 11 | This project is based on a sister project [Weatherstar 4000+](https://github.com/netbymatt/ws4kp) 12 | 13 | * [Mike Battaglia](https://github.com/vbguyny/ws4kp) for the original WeatherStar4000+ code upon which this is loosely based. 14 | * The team at [TWCClassics](https://twcclassics.com/) for several resources. 15 | * A [font](https://twcclassics.com/downloads.html) set used on the original WeatherStar 3000 16 | * [Icon](https://twcclassics.com/downloads.html) sets 17 | * Countless photos and videos of WeatherStar 3000 forecasts used as references. 18 | 19 | ## Run Your WeatherStar3000 20 | There are a lot of CORS considerations and issues with api.weather.gov that are easiest to deal with by running a local server to see this in action (or use the live link above). You'll need Node.js >18.0 to run the local server. 21 | 22 | To run via Node locally: 23 | ``` 24 | git clone https://github.com/netbymatt/ws3kp.git 25 | cd ws4kp 26 | npm i 27 | node index.js 28 | ``` 29 | ## Sharing a permalink (bookmarking) 30 | Selected displays, the forecast city and widescreen setting are sticky from one session to the next. However if you would like to share your exact configuration or bookmark it click the "Copy Permalink" (or get "Get Parmalink") near the bottom of the page. A URL will be copied to your clipboard with all of you selected displays and location (or copy it from the page if your browser doesn't support clipboard transfers directly). You can then share this link or add it to your bookmarks. 31 | 32 | ## Kiosk mode 33 | Kiosk mode can be activated by a checkbox on the page. Note that there is no way out of kiosk mode (except refresh or closing the browser), and the play/pause and other controls will not be available. This is deliberate as a browser's kiosk mode it intended not to be exited or significantly modified. 34 | 35 | It's also possible to enter kiosk mode using a permalink. First generate a [Permalink](#sharing-a-permalink-bookmarking), then to the end of it add `&kiosk=true`. Opening this link will load all of the selected displays included in the Permalink, enter kiosk mode immediately upon loading and start playing the forecast. 36 | 37 | ## Customization 38 | A hook is provided as `/server/scripts/custom.js` to allow customizations to your own fork of this project, without accidentally pushing your customizations back upstream to the git repository. An sample file is provided at `/server/scripts/custom.sample.js` and should be renamed to `custom.js` activate it. 39 | 40 | ## Serving static files 41 | The app can be served as a static set of files on any web server. Run the provided gulp task to create a set of static distribution files: 42 | ``` 43 | npm run buildDist 44 | ``` 45 | The resulting files will be in the /dist folder in the root of the project. These can then be uploaded to a web server for hosting, no server-side scripting is required. 46 | 47 | ## Issue reporting and feature requests 48 | 49 | Please do not report issues with api.weather.gov being down. It's a new service and not considered fully operational yet. Before reporting an issue or requesting a feature please consider that this is not intended to be a perfect recreation of the WeatherStar 3000, it's a best effort that fits within what's available from the API and within a web browser. 50 | 51 | ## Related Projects 52 | 53 | Too retro? Try the [Weatherstar 4000+](https://github.com/netbymatt/ws3kp) 54 | 55 | ## Disclaimer 56 | 57 | This web site should NOT be used in life threatening weather situations, or be relied on to inform the public of such situations. The Internet is an unreliable network subject to server and network outages and by nature is not suitable for such mission critical use. If you require such access to NWS data, please consider one of their subscription services. The authors of this web site shall not be held liable in the event of injury, death or property damage that occur as a result of disregarding this warning. 58 | 59 | The WeatherSTAR 3000 unit and technology is owned by The Weather Channel. This web site is a free, non-profit work by fans. All of the back ground graphics of this web site were created from scratch. The fonts were originally created by Nick Smith (http://twcclassics.com/downloads/fonts.html). 60 | 61 | -------------------------------------------------------------------------------- /cors/index.mjs: -------------------------------------------------------------------------------- 1 | // pass through api requests 2 | 3 | // http(s) modules 4 | import https from 'https'; 5 | 6 | // url parsing 7 | import queryString from 'querystring'; 8 | 9 | // return an express router 10 | const cors = (req, res) => { 11 | // add out-going headers 12 | const headers = {}; 13 | headers['user-agent'] = '(WeatherStar 4000+, ws4000@netbymatt.com)'; 14 | headers.accept = req.headers.accept; 15 | 16 | // get query paramaters if the exist 17 | const queryParams = Object.keys(req.query).reduce((acc, key) => { 18 | // skip the paramater 'u' 19 | if (key === 'u') return acc; 20 | // add the paramter to the resulting object 21 | acc[key] = req.query[key]; 22 | return acc; 23 | }, {}); 24 | let query = queryString.encode(queryParams); 25 | if (query.length > 0) query = `?${query}`; 26 | 27 | // get the page 28 | https.get(`https://api.weather.gov${req.path}${query}`, { 29 | headers, 30 | }, (getRes) => { 31 | // pull some info 32 | const { statusCode } = getRes; 33 | // pass the status code through 34 | res.status(statusCode); 35 | 36 | // set headers 37 | res.header('content-type', getRes.headers['content-type']); 38 | // pipe to response 39 | getRes.pipe(res); 40 | }).on('error', (e) => { 41 | console.error(e); 42 | }); 43 | }; 44 | 45 | export default cors; 46 | -------------------------------------------------------------------------------- /datagenerators/chunk.mjs: -------------------------------------------------------------------------------- 1 | // turn a long array into a set of smaller chunks 2 | 3 | const chunk = (data, chunkSize = 10) => { 4 | const chunks = []; 5 | 6 | let thisChunk = []; 7 | 8 | data.forEach((d) => { 9 | if (thisChunk.length >= chunkSize) { 10 | chunks.push(thisChunk); 11 | thisChunk = []; 12 | } 13 | thisChunk.push(d); 14 | }); 15 | 16 | // final chunk 17 | if (thisChunk.length > 0) chunks.push(thisChunk); 18 | 19 | return chunks; 20 | }; 21 | 22 | export default chunk; 23 | -------------------------------------------------------------------------------- /datagenerators/https.mjs: -------------------------------------------------------------------------------- 1 | // async https wrapper 2 | import https from 'https'; 3 | 4 | const get = (url) => new Promise((resolve, reject) => { 5 | const headers = {}; 6 | headers['user-agent'] = '(WeatherStar 4000+ data generator, ws4000@netbymatt.com)'; 7 | 8 | https.get(url, { 9 | headers, 10 | }, (res) => { 11 | if (res.statusCode === 200) { 12 | const buffers = []; 13 | res.on('data', (data) => buffers.push(data)); 14 | res.on('end', () => resolve(Buffer.concat(buffers).toString())); 15 | } else { 16 | console.log(res); 17 | reject(new Error(`Unable to get: ${url}`)); 18 | } 19 | }).on('error', (e) => { 20 | console.log(e); 21 | reject(e); 22 | }); 23 | }); 24 | 25 | export default get; 26 | -------------------------------------------------------------------------------- /datagenerators/regionalcities-raw.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "city": "Atlanta", 4 | "lat": 33.749, 5 | "lon": -84.388 6 | }, 7 | { 8 | "city": "Boston", 9 | "lat": 42.3584, 10 | "lon": -71.0598 11 | }, 12 | { 13 | "city": "Chicago", 14 | "lat": 41.9796, 15 | "lon": -87.9045 16 | }, 17 | { 18 | "city": "Cleveland", 19 | "lat": 41.4995, 20 | "lon": -81.6954 21 | }, 22 | { 23 | "city": "Dallas", 24 | "lat": 32.8959, 25 | "lon": -97.0372 26 | }, 27 | { 28 | "city": "Denver", 29 | "lat": 39.7391, 30 | "lon": -104.9847 31 | }, 32 | { 33 | "city": "Detroit", 34 | "lat": 42.3314, 35 | "lon": -83.0457 36 | }, 37 | { 38 | "city": "Hartford", 39 | "lat": 41.7637, 40 | "lon": -72.6851 41 | }, 42 | { 43 | "city": "Houston", 44 | "lat": 29.7633, 45 | "lon": -95.3633 46 | }, 47 | { 48 | "city": "Indianapolis", 49 | "lat": 39.7684, 50 | "lon": -86.158 51 | }, 52 | { 53 | "city": "Los Angeles", 54 | "lat": 34.0522, 55 | "lon": -118.2437 56 | }, 57 | { 58 | "city": "Miami", 59 | "lat": 25.7743, 60 | "lon": -80.1937 61 | }, 62 | { 63 | "city": "Minneapolis", 64 | "lat": 44.98, 65 | "lon": -93.2638 66 | }, 67 | { 68 | "city": "New York", 69 | "lat": 40.78, 70 | "lon": -73.88 71 | }, 72 | { 73 | "city": "Norfolk", 74 | "lat": 36.8468, 75 | "lon": -76.2852 76 | }, 77 | { 78 | "city": "Orlando", 79 | "lat": 28.5383, 80 | "lon": -81.3792 81 | }, 82 | { 83 | "city": "Philadelphia", 84 | "lat": 39.9523, 85 | "lon": -75.1638 86 | }, 87 | { 88 | "city": "Pittsburgh", 89 | "lat": 40.4406, 90 | "lon": -79.9959 91 | }, 92 | { 93 | "city": "St. Louis", 94 | "lat": 38.6273, 95 | "lon": -90.1979 96 | }, 97 | { 98 | "city": "San Francisco", 99 | "lat": 37.6148, 100 | "lon": -122.3918 101 | }, 102 | { 103 | "city": "Seattle", 104 | "lat": 47.6062, 105 | "lon": -122.3321 106 | }, 107 | { 108 | "city": "Syracuse", 109 | "lat": 43.0481, 110 | "lon": -76.1474 111 | }, 112 | { 113 | "city": "Tampa", 114 | "lat": 27.9756, 115 | "lon": -82.5329 116 | }, 117 | { 118 | "city": "Washington DC", 119 | "lat": 38.8951, 120 | "lon": -77.0364 121 | }, 122 | { 123 | "city": "Albany", 124 | "lat": 42.6526, 125 | "lon": -73.7562 126 | }, 127 | { 128 | "city": "Albuquerque", 129 | "lat": 35.0845, 130 | "lon": -106.6511 131 | }, 132 | { 133 | "city": "Amarillo", 134 | "lat": 35.222, 135 | "lon": -101.8313 136 | }, 137 | { 138 | "city": "Anchorage", 139 | "lat": 61.2181, 140 | "lon": -149.9003 141 | }, 142 | { 143 | "city": "Austin", 144 | "lat": 30.2671, 145 | "lon": -97.7431 146 | }, 147 | { 148 | "city": "Baker", 149 | "lat": 44.7502, 150 | "lon": -117.6677 151 | }, 152 | { 153 | "city": "Baltimore", 154 | "lat": 39.2904, 155 | "lon": -76.6122 156 | }, 157 | { 158 | "city": "Bangor", 159 | "lat": 44.8012, 160 | "lon": -68.7778 161 | }, 162 | { 163 | "city": "Birmingham", 164 | "lat": 33.5207, 165 | "lon": -86.8025 166 | }, 167 | { 168 | "city": "Bismarck", 169 | "lat": 46.8083, 170 | "lon": -100.7837 171 | }, 172 | { 173 | "city": "Boise", 174 | "lat": 43.6135, 175 | "lon": -116.2034 176 | }, 177 | { 178 | "city": "Buffalo", 179 | "lat": 42.8864, 180 | "lon": -78.8784 181 | }, 182 | { 183 | "city": "Carlsbad", 184 | "lat": 32.4207, 185 | "lon": -104.2288 186 | }, 187 | { 188 | "city": "Charleston", 189 | "lat": 32.7766, 190 | "lon": -79.9309 191 | }, 192 | { 193 | "city": "Charleston", 194 | "lat": 38.3498, 195 | "lon": -81.6326 196 | }, 197 | { 198 | "city": "Charlotte", 199 | "lat": 35.2271, 200 | "lon": -80.8431 201 | }, 202 | { 203 | "city": "Cheyenne", 204 | "lat": 41.14, 205 | "lon": -104.8202 206 | }, 207 | { 208 | "city": "Cincinnati", 209 | "lat": 39.162, 210 | "lon": -84.4569 211 | }, 212 | { 213 | "city": "Columbia", 214 | "lat": 34.0007, 215 | "lon": -81.0348 216 | }, 217 | { 218 | "city": "Columbus", 219 | "lat": 39.9612, 220 | "lon": -82.9988 221 | }, 222 | { 223 | "city": "Des Moines", 224 | "lat": 41.6005, 225 | "lon": -93.6091 226 | }, 227 | { 228 | "city": "Dubuque", 229 | "lat": 42.5006, 230 | "lon": -90.6646 231 | }, 232 | { 233 | "city": "Duluth", 234 | "lat": 46.7833, 235 | "lon": -92.1066 236 | }, 237 | { 238 | "city": "Eastport", 239 | "lat": 44.9062, 240 | "lon": -66.99 241 | }, 242 | { 243 | "city": "El Centro", 244 | "lat": 32.792, 245 | "lon": -115.563 246 | }, 247 | { 248 | "city": "El Paso", 249 | "lat": 31.7587, 250 | "lon": -106.4869 251 | }, 252 | { 253 | "city": "Eugene", 254 | "lat": 44.0521, 255 | "lon": -123.0867 256 | }, 257 | { 258 | "city": "Fargo", 259 | "lat": 46.8772, 260 | "lon": -96.7898 261 | }, 262 | { 263 | "city": "Flagstaff", 264 | "lat": 35.1981, 265 | "lon": -111.6513 266 | }, 267 | { 268 | "city": "Fresno", 269 | "lat": 36.7477, 270 | "lon": -119.7724 271 | }, 272 | { 273 | "city": "Grand Junction", 274 | "lat": 39.0639, 275 | "lon": -108.5506 276 | }, 277 | { 278 | "city": "Grand Rapids", 279 | "lat": 42.9634, 280 | "lon": -85.6681 281 | }, 282 | { 283 | "city": "Havre", 284 | "lat": 48.55, 285 | "lon": -109.6841 286 | }, 287 | { 288 | "city": "Helena", 289 | "lat": 46.5927, 290 | "lon": -112.0361 291 | }, 292 | { 293 | "city": "Honolulu", 294 | "lat": 21.3069, 295 | "lon": -157.8583 296 | }, 297 | { 298 | "city": "Hot Springs", 299 | "lat": 34.5037, 300 | "lon": -93.0552 301 | }, 302 | { 303 | "city": "Idaho Falls", 304 | "lat": 43.4666, 305 | "lon": -112.0341 306 | }, 307 | { 308 | "city": "Jackson", 309 | "lat": 32.2988, 310 | "lon": -90.1848 311 | }, 312 | { 313 | "city": "Jacksonville", 314 | "lat": 30.3322, 315 | "lon": -81.6556 316 | }, 317 | { 318 | "city": "Juneau", 319 | "lat": 58.3019, 320 | "lon": -134.4197 321 | }, 322 | { 323 | "city": "Kansas City", 324 | "lat": 39.1142, 325 | "lon": -94.6275 326 | }, 327 | { 328 | "city": "Key West", 329 | "lat": 24.5557, 330 | "lon": -81.7826 331 | }, 332 | { 333 | "city": "Klamath Falls", 334 | "lat": 42.2249, 335 | "lon": -121.7817 336 | }, 337 | { 338 | "city": "Knoxville", 339 | "lat": 35.9606, 340 | "lon": -83.9207 341 | }, 342 | { 343 | "city": "Las Vegas", 344 | "lat": 36.175, 345 | "lon": -115.1372 346 | }, 347 | { 348 | "city": "Lewiston", 349 | "lat": 46.4165, 350 | "lon": -117.0177 351 | }, 352 | { 353 | "city": "Lincoln", 354 | "lat": 40.8, 355 | "lon": -96.667 356 | }, 357 | { 358 | "city": "Long Beach", 359 | "lat": 33.767, 360 | "lon": -118.1892 361 | }, 362 | { 363 | "city": "Louisville", 364 | "lat": 38.2542, 365 | "lon": -85.7594 366 | }, 367 | { 368 | "city": "Manchester", 369 | "lat": 42.9956, 370 | "lon": -71.4548 371 | }, 372 | { 373 | "city": "Memphis", 374 | "lat": 35.1495, 375 | "lon": -90.049 376 | }, 377 | { 378 | "city": "Milwaukee", 379 | "lat": 43.0389, 380 | "lon": -87.9065 381 | }, 382 | { 383 | "city": "Mobile", 384 | "lat": 30.6944, 385 | "lon": -88.043 386 | }, 387 | { 388 | "city": "Montgomery", 389 | "lat": 32.3668, 390 | "lon": -86.3 391 | }, 392 | { 393 | "city": "Montpelier", 394 | "lat": 44.2601, 395 | "lon": -72.5754 396 | }, 397 | { 398 | "city": "Nashville", 399 | "lat": 36.1659, 400 | "lon": -86.7844 401 | }, 402 | { 403 | "city": "Newark", 404 | "lat": 40.7357, 405 | "lon": -74.1724 406 | }, 407 | { 408 | "city": "New Haven", 409 | "lat": 41.3081, 410 | "lon": -72.9282 411 | }, 412 | { 413 | "city": "New Orleans", 414 | "lat": 29.9546, 415 | "lon": -90.0751 416 | }, 417 | { 418 | "city": "Nome", 419 | "lat": 64.5011, 420 | "lon": -165.4064 421 | }, 422 | { 423 | "city": "Oklahoma City", 424 | "lat": 35.4676, 425 | "lon": -97.5164 426 | }, 427 | { 428 | "city": "Omaha", 429 | "lat": 41.2586, 430 | "lon": -95.9378 431 | }, 432 | { 433 | "city": "Phoenix", 434 | "lat": 33.4484, 435 | "lon": -112.074 436 | }, 437 | { 438 | "city": "Pierre", 439 | "lat": 44.3683, 440 | "lon": -100.351 441 | }, 442 | { 443 | "city": "Portland", 444 | "lat": 43.6615, 445 | "lon": -70.2553 446 | }, 447 | { 448 | "city": "Portland", 449 | "lat": 45.5234, 450 | "lon": -122.6762 451 | }, 452 | { 453 | "city": "Providence", 454 | "lat": 41.824, 455 | "lon": -71.4128 456 | }, 457 | { 458 | "city": "Raleigh", 459 | "lat": 35.7721, 460 | "lon": -78.6386 461 | }, 462 | { 463 | "city": "Reno", 464 | "lat": 39.4986, 465 | "lon": -119.7681 466 | }, 467 | { 468 | "city": "Richfield", 469 | "lat": 38.7725, 470 | "lon": -112.0841 471 | }, 472 | { 473 | "city": "Richmond", 474 | "lat": 37.5538, 475 | "lon": -77.4603 476 | }, 477 | { 478 | "city": "Roanoke", 479 | "lat": 37.271, 480 | "lon": -79.9414 481 | }, 482 | { 483 | "city": "Sacramento", 484 | "lat": 38.5816, 485 | "lon": -121.4944 486 | }, 487 | { 488 | "city": "Salt Lake City", 489 | "lat": 40.7608, 490 | "lon": -111.891 491 | }, 492 | { 493 | "city": "San Antonio", 494 | "lat": 29.4241, 495 | "lon": -98.4936 496 | }, 497 | { 498 | "city": "San Diego", 499 | "lat": 32.7153, 500 | "lon": -117.1573 501 | }, 502 | { 503 | "city": "San Jose", 504 | "lat": 37.3394, 505 | "lon": -121.895 506 | }, 507 | { 508 | "city": "Santa Fe", 509 | "lat": 35.687, 510 | "lon": -105.9378 511 | }, 512 | { 513 | "city": "Savannah", 514 | "lat": 32.0835, 515 | "lon": -81.0998 516 | }, 517 | { 518 | "city": "Shreveport", 519 | "lat": 32.5251, 520 | "lon": -93.7502 521 | }, 522 | { 523 | "city": "Sioux Falls", 524 | "lat": 43.55, 525 | "lon": -96.7003 526 | }, 527 | { 528 | "city": "Sitka", 529 | "lat": 57.0531, 530 | "lon": -135.33 531 | }, 532 | { 533 | "city": "Spokane", 534 | "lat": 47.6597, 535 | "lon": -117.4291 536 | }, 537 | { 538 | "city": "Springfield", 539 | "lat": 39.8017, 540 | "lon": -89.6437 541 | }, 542 | { 543 | "city": "Springfield", 544 | "lat": 42.1015, 545 | "lon": -72.5898 546 | }, 547 | { 548 | "city": "Springfield", 549 | "lat": 37.2153, 550 | "lon": -93.2982 551 | }, 552 | { 553 | "city": "Toledo", 554 | "lat": 41.6639, 555 | "lon": -83.5552 556 | }, 557 | { 558 | "city": "Tulsa", 559 | "lat": 36.154, 560 | "lon": -95.9928 561 | }, 562 | { 563 | "city": "Virginia Beach", 564 | "lat": 36.8529, 565 | "lon": -75.978 566 | }, 567 | { 568 | "city": "Wichita", 569 | "lat": 37.6922, 570 | "lon": -97.3375 571 | }, 572 | { 573 | "city": "Wilmington", 574 | "lat": 34.2257, 575 | "lon": -77.9447 576 | }, 577 | { 578 | "city": "Tucson", 579 | "lat": 32.2216, 580 | "lon": -110.9698 581 | } 582 | ] -------------------------------------------------------------------------------- /datagenerators/regionalcities.mjs: -------------------------------------------------------------------------------- 1 | // look up points for each regional city 2 | import fs from 'fs/promises'; 3 | 4 | import chunk from './chunk.mjs'; 5 | import https from './https.mjs'; 6 | 7 | // source data 8 | const regionalCities = JSON.parse(await fs.readFile('./datagenerators/regionalcities-raw.json')); 9 | 10 | const result = []; 11 | const dataChunks = chunk(regionalCities, 5); 12 | 13 | // for loop intentional for use of await 14 | // this keeps the api from getting overwhelmed 15 | for (let i = 0; i < dataChunks.length; i += 1) { 16 | const cityChunk = dataChunks[i]; 17 | 18 | // eslint-disable-next-line no-await-in-loop 19 | const chunkResult = await Promise.all(cityChunk.map(async (city) => { 20 | try { 21 | const data = await https(`https://api.weather.gov/points/${city.lat},${city.lon}`); 22 | const point = JSON.parse(data); 23 | return { 24 | ...city, 25 | point: { 26 | x: point.properties.gridX, 27 | y: point.properties.gridY, 28 | wfo: point.properties.gridId, 29 | }, 30 | }; 31 | } catch (e) { 32 | console.error(e); 33 | return city; 34 | } 35 | })); 36 | 37 | result.push(...chunkResult); 38 | } 39 | 40 | await fs.writeFile('./datagenerators/output/regionalcities.json', JSON.stringify(result, null, ' ')); 41 | -------------------------------------------------------------------------------- /datagenerators/stations-states.mjs: -------------------------------------------------------------------------------- 1 | export default [ 2 | 'AZ', 3 | 'AL', 4 | 'AK', 5 | 'AR', 6 | 'CA', 7 | 'CO', 8 | 'CT', 9 | 'DE', 10 | 'FL', 11 | 'GA', 12 | 'HI', 13 | 'ID', 14 | 'IL', 15 | 'IN', 16 | 'IA', 17 | 'KS', 18 | 'KY', 19 | 'LA', 20 | 'ME', 21 | 'MD', 22 | 'MA', 23 | 'MI', 24 | 'MN', 25 | 'MS', 26 | 'MO', 27 | 'MT', 28 | 'NE', 29 | 'NV', 30 | 'NH', 31 | 'NJ', 32 | 'NM', 33 | 'NY', 34 | 'NC', 35 | 'ND', 36 | 'OH', 37 | 'OK', 38 | 'OR', 39 | 'PA', 40 | 'RI', 41 | 'SC', 42 | 'SD', 43 | 'TN', 44 | 'TX', 45 | 'UT', 46 | 'VT', 47 | 'VA', 48 | 'WA', 49 | 'WV', 50 | 'WI', 51 | 'WY', 52 | 'PR', 53 | ]; 54 | -------------------------------------------------------------------------------- /datagenerators/stations.mjs: -------------------------------------------------------------------------------- 1 | // list all stations in a single file 2 | // only find stations with 4 letter codes 3 | 4 | import { writeFileSync } from 'fs'; 5 | import https from './https.mjs'; 6 | import states from './stations-states.mjs'; 7 | import chunk from './chunk.mjs'; 8 | 9 | // skip stations starting with these letters 10 | const skipStations = ['U', 'C', 'H', 'W', 'Y', 'T', 'S', 'M', 'O', 'L', 'A', 'F', 'B', 'N', 'V', 'R', 'D', 'E', 'I', 'G', 'J']; 11 | 12 | // chunk the list of states 13 | const chunkStates = chunk(states, 1); 14 | 15 | // store output 16 | const output = {}; 17 | 18 | // process all chunks 19 | for (let i = 0; i < chunkStates.length; i += 1) { 20 | const stateChunk = chunkStates[i]; 21 | // loop through states 22 | 23 | // eslint-disable-next-line no-await-in-loop 24 | await Promise.allSettled(stateChunk.map(async (state) => { 25 | try { 26 | let stations; 27 | let next = `https://api.weather.gov/stations?state=${state}`; 28 | let round = 0; 29 | do { 30 | console.log(`Getting: ${state}-${round}`); 31 | // get list and parse the JSON 32 | // eslint-disable-next-line no-await-in-loop 33 | const stationsRaw = await https(next); 34 | stations = JSON.parse(stationsRaw); 35 | // filter stations for 4 letter identifiers 36 | const stationsFiltered4 = stations.features.filter((station) => station.properties.stationIdentifier.match(/^[A-Z]{4}$/)); 37 | // filter against starting letter 38 | const stationsFiltered = stationsFiltered4.filter((station) => !skipStations.includes(station.properties.stationIdentifier.slice(0, 1))); 39 | // add each resulting station to the output 40 | stationsFiltered.forEach((station) => { 41 | const id = station.properties.stationIdentifier; 42 | if (output[id]) { 43 | console.log(`Duplicate station: ${state}-${id}`); 44 | return; 45 | } 46 | output[id] = { 47 | id, 48 | city: station.properties.name, 49 | state, 50 | lat: station.geometry.coordinates[1], 51 | lon: station.geometry.coordinates[0], 52 | }; 53 | }); 54 | next = stations?.pagination?.next; 55 | round += 1; 56 | // write the output 57 | writeFileSync('./datagenerators/output/stations.json', JSON.stringify(output, null, 2)); 58 | } 59 | while (next && stations.features.length > 0); 60 | console.log(`Complete: ${state}`); 61 | return true; 62 | } catch (e) { 63 | console.error(`Unable to get state: ${state}`); 64 | return false; 65 | } 66 | })); 67 | } 68 | -------------------------------------------------------------------------------- /gulp/publish-frontend.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import 'dotenv/config'; 3 | import { 4 | src, dest, series, parallel, 5 | } from 'gulp'; 6 | import concat from 'gulp-concat'; 7 | import terser from 'gulp-terser'; 8 | import ejs from 'gulp-ejs'; 9 | import rename from 'gulp-rename'; 10 | import htmlmin from 'gulp-html-minifier-terser'; 11 | import { deleteAsync } from 'del'; 12 | import s3Upload from 'gulp-s3-uploader'; 13 | import webpack from 'webpack-stream'; 14 | import TerserPlugin from 'terser-webpack-plugin'; 15 | import { readFile } from 'fs/promises'; 16 | 17 | // get cloudfront 18 | import { CloudFrontClient, CreateInvalidationCommand } from '@aws-sdk/client-cloudfront'; 19 | 20 | const clean = () => deleteAsync(['./dist**']); 21 | 22 | const cloudfront = new CloudFrontClient({ region: process.env.CLOUDFRONT_REGION }); 23 | 24 | const RESOURCES_PATH = './dist/resources'; 25 | 26 | const jsSourcesData = [ 27 | 'server/scripts/data/regionalcities.js', 28 | 'server/scripts/data/stations.js', 29 | ]; 30 | 31 | const webpackOptions = { 32 | mode: 'production', 33 | // mode: 'development', 34 | // devtool: 'source-map', 35 | output: { 36 | filename: 'ws.min.js', 37 | }, 38 | resolve: { 39 | roots: ['./'], 40 | }, 41 | optimization: { 42 | minimize: true, 43 | minimizer: [ 44 | new TerserPlugin({ 45 | extractComments: false, 46 | terserOptions: { 47 | // sourceMap: true, 48 | format: { 49 | comments: false, 50 | }, 51 | }, 52 | }), 53 | ], 54 | }, 55 | }; 56 | 57 | const compressJsData = () => src(jsSourcesData) 58 | .pipe(concat('data.min.js')) 59 | .pipe(terser()) 60 | .pipe(dest(RESOURCES_PATH)); 61 | 62 | const jsVendorSources = [ 63 | 'server/scripts/vendor/auto/nosleep.js', 64 | 'server/scripts/vendor/auto/swiped-events.js', 65 | 'server/scripts/vendor/auto/suncalc.js', 66 | ]; 67 | 68 | const compressJsVendor = () => src(jsVendorSources) 69 | .pipe(concat('vendor.min.js')) 70 | .pipe(terser()) 71 | .pipe(dest(RESOURCES_PATH)); 72 | 73 | const mjsSources = [ 74 | 'server/scripts/modules/currentweatherscroll.mjs', 75 | 'server/scripts/modules/hazards.mjs', 76 | 'server/scripts/modules/currentweather.mjs', 77 | 'server/scripts/modules/almanac.mjs', 78 | 'server/scripts/modules/extendedforecast.mjs', 79 | 'server/scripts/modules/latestobservations.mjs', 80 | 'server/scripts/modules/localforecast.mjs', 81 | 'server/scripts/modules/regionalforecast.mjs', 82 | 'server/scripts/modules/progress.mjs', 83 | 'server/scripts/index.mjs', 84 | ]; 85 | 86 | const buildJs = () => src(mjsSources) 87 | .pipe(webpack(webpackOptions)) 88 | .pipe(dest(RESOURCES_PATH)); 89 | 90 | const cssSources = [ 91 | 'server/styles/main.css', 92 | ]; 93 | const copyCss = () => src(cssSources) 94 | .pipe(concat('ws.min.css')) 95 | .pipe(dest(RESOURCES_PATH)); 96 | 97 | const htmlSources = [ 98 | 'views/*.ejs', 99 | ]; 100 | const compressHtml = async () => { 101 | const packageJson = await readFile('package.json'); 102 | const { version } = JSON.parse(packageJson); 103 | 104 | return src(htmlSources) 105 | .pipe(ejs({ 106 | production: version, 107 | version, 108 | })) 109 | .pipe(rename({ extname: '.html' })) 110 | .pipe(htmlmin({ collapseWhitespace: true })) 111 | .pipe(dest('./dist')); 112 | }; 113 | 114 | const otherFiles = [ 115 | 'server/robots.txt', 116 | 'server/manifest.json', 117 | ]; 118 | const copyOtherFiles = () => src(otherFiles, { base: 'server/' }) 119 | .pipe(dest('./dist')); 120 | 121 | const s3 = s3Upload({ 122 | useIAM: true, 123 | region: process.env.S3_REGION, 124 | }); 125 | const uploadSources = [ 126 | 'dist/**', 127 | '!dist/**/*.map', 128 | ]; 129 | const upload = () => src(uploadSources, { base: './dist' }) 130 | .pipe(s3({ 131 | Bucket: process.env.BUCKET, 132 | StorageClass: 'STANDARD', 133 | maps: { 134 | CacheControl: (keyname) => { 135 | if (keyname.indexOf('index.html') > -1) return 'max-age=300'; // 10 minutes 136 | return 'max-age=2592000'; // 1 month 137 | }, 138 | }, 139 | })); 140 | 141 | const imageSources = [ 142 | 'server/fonts/**', 143 | 'server/images/**', 144 | '!server/images/gimp/**', 145 | ]; 146 | const uploadImages = () => src(imageSources, { base: './server', encoding: false }) 147 | .pipe( 148 | s3({ 149 | Bucket: process.env.BUCKET, 150 | StorageClass: 'STANDARD', 151 | maps: { 152 | CacheControl: () => 'max-age=2592000', // 1 month 153 | }, 154 | }), 155 | ); 156 | 157 | const invalidate = () => cloudfront.send(new CreateInvalidationCommand({ 158 | DistributionId: process.env.DISTRIBUTION_ID, 159 | InvalidationBatch: { 160 | CallerReference: (new Date()).toLocaleString(), 161 | Paths: { 162 | Quantity: 1, 163 | Items: ['/*'], 164 | }, 165 | }, 166 | })); 167 | 168 | const buildDist = series(clean, parallel(buildJs, compressJsData, compressJsVendor, copyCss, compressHtml, copyOtherFiles)); 169 | 170 | // upload_images could be in parallel with upload, but _images logs a lot and has little changes 171 | // by running upload last the majority of the changes will be at the bottom of the log for easy viewing 172 | const publishFrontend = series(buildDist, uploadImages, upload, invalidate); 173 | 174 | export default publishFrontend; 175 | 176 | export { 177 | buildDist, 178 | invalidate, 179 | }; 180 | -------------------------------------------------------------------------------- /gulp/update-vendor.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import { src, series, dest } from 'gulp'; 3 | import { deleteAsync } from 'del'; 4 | import rename from 'gulp-rename'; 5 | 6 | const clean = () => deleteAsync(['./server/scripts/vendor/auto/**']); 7 | 8 | const vendorFiles = [ 9 | './node_modules/luxon/build/es6/luxon.js', 10 | './node_modules/luxon/build/es6/luxon.js.map', 11 | './node_modules/nosleep.js/dist/NoSleep.js', 12 | './node_modules/suncalc/suncalc.js', 13 | './node_modules/swiped-events/src/swiped-events.js', 14 | ]; 15 | 16 | const copy = () => src(vendorFiles) 17 | .pipe(rename((path) => { 18 | path.dirname = path.dirname.toLowerCase(); 19 | path.basename = path.basename.toLowerCase(); 20 | path.extname = path.extname.toLowerCase(); 21 | if (path.basename === 'luxon') path.extname = '.mjs'; 22 | })) 23 | .pipe(dest('./server/scripts/vendor/auto')); 24 | 25 | const updateVendor = series(clean, copy); 26 | 27 | export default updateVendor; 28 | -------------------------------------------------------------------------------- /gulpfile.mjs: -------------------------------------------------------------------------------- 1 | import updateVendor from './gulp/update-vendor.mjs'; 2 | import publishFrontend, { buildDist, invalidate } from './gulp/publish-frontend.mjs'; 3 | 4 | export { 5 | updateVendor, 6 | publishFrontend, 7 | buildDist, 8 | invalidate, 9 | }; 10 | -------------------------------------------------------------------------------- /index.mjs: -------------------------------------------------------------------------------- 1 | // express 2 | 3 | import express from 'express'; 4 | import fs from 'fs'; 5 | import corsPassThru from './cors/index.mjs'; 6 | 7 | const app = express(); 8 | const port = process.env.WS3KP_PORT ?? 8083; 9 | 10 | // template engine 11 | app.set('view engine', 'ejs'); 12 | 13 | // cors pass-thru to api.weather.gov 14 | app.get('/stations/*station', corsPassThru); 15 | 16 | // version 17 | const { version } = JSON.parse(fs.readFileSync('package.json')); 18 | 19 | const index = (req, res) => { 20 | res.render('index', { 21 | production: false, 22 | version, 23 | }); 24 | }; 25 | 26 | const geoip = (req, res) => { 27 | res.set({ 28 | 'x-geoip-city': 'Orlando', 29 | 'x-geoip-country': 'US', 30 | 'x-geoip-country-name': 'United States', 31 | 'x-geoip-country-region': 'FL', 32 | 'x-geoip-country-region-name': 'Florida', 33 | 'x-geoip-latitude': '28.52135', 34 | 'x-geoip-longitude': '-81.41079', 35 | 'x-geoip-postal-code': '32789', 36 | 'x-geoip-time-zone': 'America/New_York', 37 | 'content-type': 'application/json', 38 | }); 39 | res.json({}); 40 | }; 41 | 42 | // debugging 43 | if (process.env?.DIST === '1') { 44 | // distribution 45 | app.use('/images', express.static('./server/images')); 46 | app.use('/fonts', express.static('./server/fonts')); 47 | app.use('/scripts', express.static('./server/scripts')); 48 | app.use('/', express.static('./dist')); 49 | app.use('/geoip', geoip); 50 | } else { 51 | // debugging 52 | app.get('/index.html', index); 53 | app.get('/', index); 54 | app.get('*name', express.static('./server')); 55 | app.use('/geoip', geoip); 56 | } 57 | 58 | const server = app.listen(port, () => { 59 | console.log(`Server listening on port ${port}`); 60 | }); 61 | 62 | // graceful shutdown 63 | process.on('SIGINT', () => { 64 | server.close(() => { 65 | console.log('Server closed'); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ws4kp", 3 | "version": "1.0.4", 4 | "description": "Welcome to the WeatherStar 4000+ project page!", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "build:css": "sass --style=compressed ./server/styles/scss/main.scss ./server/styles/main.css", 10 | "lint": "eslint ./server/scripts/**/*.mjs", 11 | "lint:fix": "eslint --fix ./server/scripts/**/*.mjs" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/netbymatt/ws4kp.git" 16 | }, 17 | "author": "Matt Walsh", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/netbymatt/ws4kp/issues" 21 | }, 22 | "homepage": "https://github.com/netbymatt/ws4kp#readme", 23 | "devDependencies": { 24 | "@aws-sdk/client-cloudfront": "^3.609.0", 25 | "del": "^8.0.0", 26 | "eslint": "^8.2.0", 27 | "eslint-config-airbnb-base": "^15.0.0", 28 | "eslint-plugin-import": "^2.10.0", 29 | "gulp": "^5.0.0", 30 | "gulp-awspublish": "^8.0.0", 31 | "gulp-concat": "^2.6.1", 32 | "gulp-ejs": "^5.1.0", 33 | "gulp-html-minifier-terser": "^7.1.0", 34 | "gulp-rename": "^2.0.0", 35 | "gulp-s3-uploader": "^1.0.0", 36 | "gulp-sass": "^6.0.1", 37 | "gulp-terser": "^2.0.0", 38 | "luxon": "^3.0.0", 39 | "nosleep.js": "^0.12.0", 40 | "sass": "^1.54.0", 41 | "suncalc": "^1.8.0", 42 | "swiped-events": "^1.1.4", 43 | "terser-webpack-plugin": "^5.3.6", 44 | "webpack-stream": "^7.0.0" 45 | }, 46 | "dependencies": { 47 | "dotenv": "^16.5.0", 48 | "ejs": "^3.1.5", 49 | "express": "^5.0.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /server/fonts/ReadMe.txt: -------------------------------------------------------------------------------- 1 | --Star 3000-- 2 | 3 | Star3000.ttf - Standard text style for most screens (and Travel Cities title header) 4 | Star3000 Small.ttf - Time/Date and some page headers 5 | Star3000 Large.ttf - Travel Cities Forecast (Forecast portion only) 6 | Star3000 Extra Large.ttf - Only used on some advertiser text 7 | Star3000 Extended.ttf - Only used on some advertiser text 8 | "Heavy" style is an emboldened version of the standard font (used on some STARs) 9 | 10 | Star3000 Outline.ttf - A contrast border (stroke) that surrounds the Star3000.ttf base font. When used, must be as a text layer undeneath the base font (and is usually black in color). 11 | Star3000 Small Outline.ttf - A contrast border (stroke) that surrounds the Star3000 Small.ttf base font. When used, must be as a text layer undeneath the base font (and is usually black in color). 12 | Star3000 Large Outline.ttf - A contrast border (stroke) that surrounds the Star3000 Large.ttf base font. When used, must be as a text layer undeneath the base font (and is usually black in color). 13 | 14 | ***Outlines for other font styles are not currently available. 15 | 16 | -------------------------------------------------------------------------------- /server/fonts/Star3000 Extended.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbymatt/ws3kp/ac4322c10fb94095445b9c709ec89be91a6ccff7/server/fonts/Star3000 Extended.ttf -------------------------------------------------------------------------------- /server/fonts/Star3000 Large.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbymatt/ws3kp/ac4322c10fb94095445b9c709ec89be91a6ccff7/server/fonts/Star3000 Large.ttf -------------------------------------------------------------------------------- /server/fonts/Star3000 Small.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbymatt/ws3kp/ac4322c10fb94095445b9c709ec89be91a6ccff7/server/fonts/Star3000 Small.ttf -------------------------------------------------------------------------------- /server/fonts/Star3000.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbymatt/ws3kp/ac4322c10fb94095445b9c709ec89be91a6ccff7/server/fonts/Star3000.ttf -------------------------------------------------------------------------------- /server/images/backgrounds/1-wide.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbymatt/ws3kp/ac4322c10fb94095445b9c709ec89be91a6ccff7/server/images/backgrounds/1-wide.png -------------------------------------------------------------------------------- /server/images/backgrounds/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbymatt/ws3kp/ac4322c10fb94095445b9c709ec89be91a6ccff7/server/images/backgrounds/1.png -------------------------------------------------------------------------------- /server/images/gimp/Ws3kp.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbymatt/ws3kp/ac4322c10fb94095445b9c709ec89be91a6ccff7/server/images/gimp/Ws3kp.xcf -------------------------------------------------------------------------------- /server/images/logos/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbymatt/ws3kp/ac4322c10fb94095445b9c709ec89be91a6ccff7/server/images/logos/logo192.png -------------------------------------------------------------------------------- /server/images/nav/ic_fullscreen_exit_white_24dp_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbymatt/ws3kp/ac4322c10fb94095445b9c709ec89be91a6ccff7/server/images/nav/ic_fullscreen_exit_white_24dp_2x.png -------------------------------------------------------------------------------- /server/images/nav/ic_fullscreen_white_24dp_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbymatt/ws3kp/ac4322c10fb94095445b9c709ec89be91a6ccff7/server/images/nav/ic_fullscreen_white_24dp_2x.png -------------------------------------------------------------------------------- /server/images/nav/ic_gps_fixed_black_18dp_1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbymatt/ws3kp/ac4322c10fb94095445b9c709ec89be91a6ccff7/server/images/nav/ic_gps_fixed_black_18dp_1x.png -------------------------------------------------------------------------------- /server/images/nav/ic_gps_fixed_white_18dp_1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbymatt/ws3kp/ac4322c10fb94095445b9c709ec89be91a6ccff7/server/images/nav/ic_gps_fixed_white_18dp_1x.png -------------------------------------------------------------------------------- /server/images/nav/ic_menu_white_24dp_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbymatt/ws3kp/ac4322c10fb94095445b9c709ec89be91a6ccff7/server/images/nav/ic_menu_white_24dp_2x.png -------------------------------------------------------------------------------- /server/images/nav/ic_pause_white_24dp_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbymatt/ws3kp/ac4322c10fb94095445b9c709ec89be91a6ccff7/server/images/nav/ic_pause_white_24dp_2x.png -------------------------------------------------------------------------------- /server/images/nav/ic_play_arrow_white_24dp_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbymatt/ws3kp/ac4322c10fb94095445b9c709ec89be91a6ccff7/server/images/nav/ic_play_arrow_white_24dp_2x.png -------------------------------------------------------------------------------- /server/images/nav/ic_refresh_white_24dp_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbymatt/ws3kp/ac4322c10fb94095445b9c709ec89be91a6ccff7/server/images/nav/ic_refresh_white_24dp_2x.png -------------------------------------------------------------------------------- /server/images/nav/ic_skip_next_white_24dp_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbymatt/ws3kp/ac4322c10fb94095445b9c709ec89be91a6ccff7/server/images/nav/ic_skip_next_white_24dp_2x.png -------------------------------------------------------------------------------- /server/images/nav/ic_skip_previous_white_24dp_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbymatt/ws3kp/ac4322c10fb94095445b9c709ec89be91a6ccff7/server/images/nav/ic_skip_previous_white_24dp_2x.png -------------------------------------------------------------------------------- /server/images/nav/ic_volume_off_white_24dp_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbymatt/ws3kp/ac4322c10fb94095445b9c709ec89be91a6ccff7/server/images/nav/ic_volume_off_white_24dp_2x.png -------------------------------------------------------------------------------- /server/images/nav/ic_volume_on_white_24dp_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbymatt/ws3kp/ac4322c10fb94095445b9c709ec89be91a6ccff7/server/images/nav/ic_volume_on_white_24dp_2x.png -------------------------------------------------------------------------------- /server/images/social/1200x600.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbymatt/ws3kp/ac4322c10fb94095445b9c709ec89be91a6ccff7/server/images/social/1200x600.png -------------------------------------------------------------------------------- /server/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "WeatherStar 3000+", 3 | "icons": [ 4 | { 5 | "src": "/images/logos/logo192.png", 6 | "sizes": "192x192", 7 | "type": "images/png" 8 | } 9 | ], 10 | "start_url": "/", 11 | "display": "standalone" 12 | } -------------------------------------------------------------------------------- /server/robots.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbymatt/ws3kp/ac4322c10fb94095445b9c709ec89be91a6ccff7/server/robots.txt -------------------------------------------------------------------------------- /server/scripts/custom.sample.js: -------------------------------------------------------------------------------- 1 | // this file is loaded by the main html page (when renamed to custom.js) 2 | // it is intended to allow for customizations that do not get published back to the git repo 3 | // for example, changing the logo 4 | 5 | // start running after all content is loaded 6 | document.addEventListener('DOMContentLoaded', () => { 7 | // get all of the logo images 8 | const logos = document.querySelectorAll('.logo img'); 9 | // loop through each logo 10 | logos.forEach((elem) => { 11 | // change the source 12 | elem.src = 'my-custom-logo.gif'; 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /server/scripts/index.mjs: -------------------------------------------------------------------------------- 1 | import { json } from './modules/utils/fetch.mjs'; 2 | import noSleep from './modules/utils/nosleep.mjs'; 3 | import { 4 | message as navMessage, isPlaying, resize, resetStatuses, latLonReceived, 5 | } from './modules/navigation.mjs'; 6 | import progress from './modules/progress.mjs'; 7 | import { round2 } from './modules/utils/units.mjs'; 8 | import { parseQueryString } from './modules/share.mjs'; 9 | import settings from './modules/settings.mjs'; 10 | import AutoComplete from './modules/autocomplete.mjs'; 11 | 12 | document.addEventListener('DOMContentLoaded', () => { 13 | init(); 14 | getCustomCode(); 15 | }); 16 | 17 | const categories = [ 18 | 'Land Features', 19 | 'Bay', 'Channel', 'Cove', 'Dam', 'Delta', 'Gulf', 'Lagoon', 'Lake', 'Ocean', 'Reef', 'Reservoir', 'Sea', 'Sound', 'Strait', 'Waterfall', 'Wharf', // Water Features 20 | 'Amusement Park', 'Historical Monument', 'Landmark', 'Tourist Attraction', 'Zoo', // POI/Arts and Entertainment 21 | 'College', // POI/Education 22 | 'Beach', 'Campground', 'Golf Course', 'Harbor', 'Nature Reserve', 'Other Parks and Outdoors', 'Park', 'Racetrack', 23 | 'Scenic Overlook', 'Ski Resort', 'Sports Center', 'Sports Field', 'Wildlife Reserve', // POI/Parks and Outdoors 24 | 'Airport', 'Ferry', 'Marina', 'Pier', 'Port', 'Resort', // POI/Travel 25 | 'Postal', 'Populated Place', 26 | ]; 27 | const category = categories.join(','); 28 | const TXT_ADDRESS_SELECTOR = '#txtAddress'; 29 | const TOGGLE_FULL_SCREEN_SELECTOR = '#ToggleFullScreen'; 30 | const BNT_GET_GPS_SELECTOR = '#btnGetGps'; 31 | 32 | const init = () => { 33 | document.querySelector(TXT_ADDRESS_SELECTOR).addEventListener('focus', (e) => { 34 | e.target.select(); 35 | }); 36 | 37 | document.querySelector('#NavigateMenu').addEventListener('click', btnNavigateMenuClick); 38 | document.querySelector('#NavigateRefresh').addEventListener('click', btnNavigateRefreshClick); 39 | document.querySelector('#NavigateNext').addEventListener('click', btnNavigateNextClick); 40 | document.querySelector('#NavigatePrevious').addEventListener('click', btnNavigatePreviousClick); 41 | document.querySelector('#NavigatePlay').addEventListener('click', btnNavigatePlayClick); 42 | document.querySelector(TOGGLE_FULL_SCREEN_SELECTOR).addEventListener('click', btnFullScreenClick); 43 | const btnGetGps = document.querySelector(BNT_GET_GPS_SELECTOR); 44 | btnGetGps.addEventListener('click', btnGetGpsClick); 45 | if (!navigator.geolocation) btnGetGps.style.display = 'none'; 46 | 47 | document.querySelector('#divTwc').addEventListener('mousemove', () => { 48 | if (document.fullscreenElement) updateFullScreenNavigate(); 49 | }); 50 | // local change detection when exiting full screen via ESC key (or other non button click methods) 51 | window.addEventListener('resize', fullScreenResizeCheck); 52 | fullScreenResizeCheck.wasFull = false; 53 | 54 | document.querySelector('#btnGetLatLng').addEventListener('click', () => autoComplete.directFormSubmit()); 55 | 56 | document.addEventListener('keydown', documentKeydown); 57 | document.addEventListener('touchmove', (e) => { if (document.fullscreenElement) e.preventDefault(); }); 58 | 59 | const autoComplete = new AutoComplete(document.querySelector(TXT_ADDRESS_SELECTOR), { 60 | serviceUrl: 'https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/suggest', 61 | deferRequestBy: 300, 62 | paramName: 'text', 63 | params: { 64 | f: 'json', 65 | countryCode: 'USA', // 'USA,PRI,VIR,GUM,ASM', 66 | category, 67 | maxSuggestions: 10, 68 | }, 69 | dataType: 'json', 70 | transformResult: (response) => ({ 71 | suggestions: response.suggestions.map((i) => ({ 72 | value: i.text, 73 | data: i.magicKey, 74 | })), 75 | }), 76 | minChars: 3, 77 | showNoSuggestionNotice: true, 78 | noSuggestionNotice: 'No results found. Please try a different search string.', 79 | onSelect(suggestion) { autocompleteOnSelect(suggestion); }, 80 | width: 490, 81 | }); 82 | window.autoComplete = autoComplete; 83 | 84 | // attempt to parse the url parameters 85 | const parsedParameters = parseQueryString(); 86 | 87 | const loadFromParsed = parsedParameters.latLonQuery && parsedParameters.latLon; 88 | 89 | // Auto load the parsed parameters and fall back to the previous query 90 | const query = parsedParameters.latLonQuery ?? localStorage.getItem('latLonQuery'); 91 | const latLon = parsedParameters.latLon ?? localStorage.getItem('latLon'); 92 | const fromGPS = localStorage.getItem('latLonFromGPS') && !loadFromParsed; 93 | if (query && latLon && !fromGPS) { 94 | const txtAddress = document.querySelector(TXT_ADDRESS_SELECTOR); 95 | txtAddress.value = query; 96 | loadData(JSON.parse(latLon)); 97 | } 98 | if (fromGPS) { 99 | btnGetGpsClick(); 100 | } 101 | 102 | // if kiosk mode was set via the query string, also play immediately 103 | settings.kiosk.value = parsedParameters['settings-kiosk-checkbox'] === 'true'; 104 | const play = parsedParameters['settings-kiosk-checkbox'] ?? localStorage.getItem('play'); 105 | if (play === null || play === 'true') postMessage('navButton', 'play'); 106 | 107 | document.querySelector('#btnClearQuery').addEventListener('click', () => { 108 | document.querySelector('#spanCity').innerHTML = ''; 109 | document.querySelector('#spanState').innerHTML = ''; 110 | document.querySelector('#spanStationId').innerHTML = ''; 111 | document.querySelector('#spanRadarId').innerHTML = ''; 112 | document.querySelector('#spanZoneId').innerHTML = ''; 113 | 114 | document.querySelector('#chkAutoRefresh').checked = true; 115 | localStorage.removeItem('autoRefresh'); 116 | 117 | localStorage.removeItem('play'); 118 | postMessage('navButton', 'play'); 119 | 120 | localStorage.removeItem('latLonQuery'); 121 | localStorage.removeItem('latLon'); 122 | localStorage.removeItem('latLonFromGPS'); 123 | document.querySelector(BNT_GET_GPS_SELECTOR).classList.remove('active'); 124 | }); 125 | 126 | // swipe functionality 127 | document.querySelector('#container').addEventListener('swiped-left', () => swipeCallBack('left')); 128 | document.querySelector('#container').addEventListener('swiped-right', () => swipeCallBack('right')); 129 | }; 130 | 131 | const autocompleteOnSelect = async (suggestion) => { 132 | const data = await json('https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/find', { 133 | data: { 134 | text: suggestion.value, 135 | magicKey: suggestion.data, 136 | f: 'json', 137 | }, 138 | }); 139 | 140 | const loc = data.locations[0]; 141 | if (loc) { 142 | localStorage.removeItem('latLonFromGPS'); 143 | document.querySelector(BNT_GET_GPS_SELECTOR).classList.remove('active'); 144 | doRedirectToGeometry(loc.feature.geometry); 145 | } else { 146 | console.error('An unexpected error occurred. Please try a different search string.'); 147 | } 148 | }; 149 | 150 | const doRedirectToGeometry = (geom, haveDataCallback) => { 151 | const latLon = { lat: round2(geom.y, 4), lon: round2(geom.x, 4) }; 152 | // Save the query 153 | localStorage.setItem('latLonQuery', document.querySelector(TXT_ADDRESS_SELECTOR).value); 154 | localStorage.setItem('latLon', JSON.stringify(latLon)); 155 | 156 | // get the data 157 | loadData(latLon, haveDataCallback); 158 | }; 159 | 160 | const btnFullScreenClick = () => { 161 | if (document.fullscreenElement) { 162 | exitFullscreen(); 163 | } else { 164 | enterFullScreen(); 165 | } 166 | 167 | if (isPlaying()) { 168 | noSleep(true); 169 | } else { 170 | noSleep(false); 171 | } 172 | 173 | updateFullScreenNavigate(); 174 | 175 | return false; 176 | }; 177 | 178 | const enterFullScreen = () => { 179 | const element = document.querySelector('#divTwc'); 180 | 181 | // Supports most browsers and their versions. 182 | const requestMethod = element.requestFullScreen || element.webkitRequestFullScreen 183 | || element.mozRequestFullScreen || element.msRequestFullscreen; 184 | 185 | if (requestMethod) { 186 | // Native full screen. 187 | requestMethod.call(element, { navigationUI: 'hide' }); 188 | } else { 189 | // iOS doesn't support FullScreen API. 190 | window.scrollTo(0, 0); 191 | } 192 | resize(); 193 | updateFullScreenNavigate(); 194 | 195 | // change hover text and image 196 | const img = document.querySelector(TOGGLE_FULL_SCREEN_SELECTOR); 197 | img.src = 'images/nav/ic_fullscreen_exit_white_24dp_2x.png'; 198 | img.title = 'Exit fullscreen'; 199 | }; 200 | 201 | const exitFullscreen = () => { 202 | // exit full-screen 203 | 204 | if (document.exitFullscreen) { 205 | // Chrome 71 broke this if the user pressed F11 to enter full screen mode. 206 | document.exitFullscreen(); 207 | } else if (document.webkitExitFullscreen) { 208 | document.webkitExitFullscreen(); 209 | } else if (document.mozCancelFullScreen) { 210 | document.mozCancelFullScreen(); 211 | } else if (document.msExitFullscreen) { 212 | document.msExitFullscreen(); 213 | } 214 | resize(); 215 | exitFullScreenVisibilityChanges(); 216 | }; 217 | 218 | const exitFullScreenVisibilityChanges = () => { 219 | // change hover text and image 220 | const img = document.querySelector(TOGGLE_FULL_SCREEN_SELECTOR); 221 | img.src = 'images/nav/ic_fullscreen_white_24dp_2x.png'; 222 | img.title = 'Enter fullscreen'; 223 | document.querySelector('#divTwc').classList.remove('no-cursor'); 224 | const divTwcBottom = document.querySelector('#divTwcBottom'); 225 | divTwcBottom.classList.remove('hidden'); 226 | divTwcBottom.classList.add('visible'); 227 | }; 228 | 229 | const btnNavigateMenuClick = () => { 230 | postMessage('navButton', 'menu'); 231 | return false; 232 | }; 233 | 234 | const loadData = (_latLon, haveDataCallback) => { 235 | // if latlon is provided store it locally 236 | if (_latLon) loadData.latLon = _latLon; 237 | // get the data 238 | const { latLon } = loadData; 239 | // if there's no data stop 240 | if (!latLon) return; 241 | 242 | document.querySelector(TXT_ADDRESS_SELECTOR).blur(); 243 | latLonReceived(latLon, haveDataCallback); 244 | }; 245 | 246 | const swipeCallBack = (direction) => { 247 | switch (direction) { 248 | case 'left': 249 | btnNavigateNextClick(); 250 | break; 251 | 252 | case 'right': 253 | default: 254 | btnNavigatePreviousClick(); 255 | break; 256 | } 257 | }; 258 | 259 | const btnNavigateRefreshClick = () => { 260 | resetStatuses(); 261 | loadData(); 262 | 263 | return false; 264 | }; 265 | 266 | const btnNavigateNextClick = () => { 267 | postMessage('navButton', 'next'); 268 | 269 | return false; 270 | }; 271 | 272 | const btnNavigatePreviousClick = () => { 273 | postMessage('navButton', 'previous'); 274 | 275 | return false; 276 | }; 277 | 278 | let navigateFadeIntervalId = null; 279 | 280 | const updateFullScreenNavigate = () => { 281 | document.activeElement.blur(); 282 | const divTwcBottom = document.querySelector('#divTwcBottom'); 283 | divTwcBottom.classList.remove('hidden'); 284 | divTwcBottom.classList.add('visible'); 285 | document.querySelector('#divTwc').classList.remove('no-cursor'); 286 | 287 | if (navigateFadeIntervalId) { 288 | clearTimeout(navigateFadeIntervalId); 289 | navigateFadeIntervalId = null; 290 | } 291 | 292 | navigateFadeIntervalId = setTimeout(() => { 293 | if (document.fullscreenElement) { 294 | divTwcBottom.classList.remove('visible'); 295 | divTwcBottom.classList.add('hidden'); 296 | document.querySelector('#divTwc').classList.add('no-cursor'); 297 | } 298 | }, 2000); 299 | }; 300 | 301 | const documentKeydown = (e) => { 302 | const { key } = e; 303 | 304 | if (document.fullscreenElement || document.activeElement === document.body) { 305 | switch (key) { 306 | case ' ': // Space 307 | // don't scroll 308 | e.preventDefault(); 309 | btnNavigatePlayClick(); 310 | return false; 311 | 312 | case 'ArrowRight': 313 | case 'PageDown': 314 | // don't scroll 315 | e.preventDefault(); 316 | btnNavigateNextClick(); 317 | return false; 318 | 319 | case 'ArrowLeft': 320 | case 'PageUp': 321 | // don't scroll 322 | e.preventDefault(); 323 | btnNavigatePreviousClick(); 324 | return false; 325 | 326 | case 'ArrowUp': // Home 327 | e.preventDefault(); 328 | btnNavigateMenuClick(); 329 | return false; 330 | 331 | case '0': // "O" Restart 332 | btnNavigateRefreshClick(); 333 | return false; 334 | 335 | case 'F': 336 | case 'f': 337 | btnFullScreenClick(); 338 | return false; 339 | 340 | default: 341 | } 342 | } 343 | return false; 344 | }; 345 | 346 | const btnNavigatePlayClick = () => { 347 | postMessage('navButton', 'playToggle'); 348 | 349 | return false; 350 | }; 351 | 352 | // post a message to the iframe 353 | const postMessage = (type, myMessage = {}) => { 354 | navMessage({ type, message: myMessage }); 355 | }; 356 | 357 | const getPosition = async () => new Promise((resolve) => { 358 | navigator.geolocation.getCurrentPosition(resolve); 359 | }); 360 | 361 | const btnGetGpsClick = async () => { 362 | if (!navigator.geolocation) return; 363 | const btn = document.querySelector(BNT_GET_GPS_SELECTOR); 364 | 365 | // toggle first 366 | if (btn.classList.contains('active')) { 367 | btn.classList.remove('active'); 368 | localStorage.removeItem('latLonFromGPS'); 369 | return; 370 | } 371 | 372 | // set gps active 373 | btn.classList.add('active'); 374 | 375 | // get position 376 | const position = await getPosition(); 377 | const { latitude, longitude } = position.coords; 378 | 379 | getForecastFromLatLon(latitude, longitude, true); 380 | }; 381 | 382 | const getForecastFromLatLon = (latitude, longitude, fromGps = false) => { 383 | const txtAddress = document.querySelector(TXT_ADDRESS_SELECTOR); 384 | txtAddress.value = `${round2(latitude, 4)}, ${round2(longitude, 4)}`; 385 | 386 | doRedirectToGeometry({ y: latitude, x: longitude }, (point) => { 387 | const location = point.properties.relativeLocation.properties; 388 | // Save the query 389 | const query = `${location.city}, ${location.state}`; 390 | localStorage.setItem('latLon', JSON.stringify({ lat: latitude, lon: longitude })); 391 | localStorage.setItem('latLonQuery', query); 392 | localStorage.setItem('latLonFromGPS', fromGps); 393 | txtAddress.value = `${location.city}, ${location.state}`; 394 | }); 395 | }; 396 | 397 | // check for change in full screen triggered by browser and run local functions 398 | const fullScreenResizeCheck = () => { 399 | if (fullScreenResizeCheck.wasFull && !document.fullscreenElement) { 400 | // leaving full screen 401 | exitFullScreenVisibilityChanges(); 402 | } 403 | if (!fullScreenResizeCheck.wasFull && document.fullscreenElement) { 404 | // entering full screen 405 | // can't do much here because a UI interaction is required to change the full screen div element 406 | } 407 | 408 | // store state of fullscreen element for next change detection 409 | fullScreenResizeCheck.wasFull = !!document.fullscreenElement; 410 | }; 411 | 412 | const getCustomCode = async () => { 413 | const url = `scripts/custom.js?_=${progress.getVersion()}`; 414 | // fetch the custom file and see if it returns a 200 status 415 | const response = await fetch(url, { method: 'HEAD' }); 416 | if (response.ok) { 417 | // add the script element to the page 418 | const customElem = document.createElement('script'); 419 | customElem.src = url; 420 | customElem.type = 'text/javascript'; 421 | document.body.append(customElem); 422 | } 423 | }; 424 | 425 | // expose functions for external use 426 | window.getForecastFromLatLon = getForecastFromLatLon; 427 | -------------------------------------------------------------------------------- /server/scripts/modules/almanac.mjs: -------------------------------------------------------------------------------- 1 | // display sun and moon data 2 | import { DateTime } from '../vendor/auto/luxon.mjs'; 3 | import STATUS from './status.mjs'; 4 | import WeatherDisplay from './weatherdisplay.mjs'; 5 | import { registerDisplay, timeZone } from './navigation.mjs'; 6 | 7 | class Almanac extends WeatherDisplay { 8 | constructor(navId, elemId) { 9 | super(navId, elemId, 'Almanac', true); 10 | 11 | this.timing.totalScreens = 1; 12 | } 13 | 14 | async getData(weatherParameters, refresh) { 15 | const superResponse = super.getData(weatherParameters, refresh); 16 | 17 | // get sun/moon data 18 | const { sun, moon } = this.calcSunMoonData(this.weatherParameters); 19 | 20 | // store the data 21 | this.data = { 22 | sun, 23 | moon, 24 | }; 25 | // share data 26 | this.getDataCallback(); 27 | 28 | if (!superResponse) return; 29 | 30 | // update status 31 | this.setStatus(STATUS.loaded); 32 | } 33 | 34 | calcSunMoonData(weatherParameters) { 35 | const sun = [ 36 | SunCalc.getTimes(new Date(), weatherParameters.latitude, weatherParameters.longitude), 37 | SunCalc.getTimes(DateTime.local().plus({ days: 1 }).toJSDate(), weatherParameters.latitude, weatherParameters.longitude), 38 | ]; 39 | 40 | // brute force the moon phases by scanning the next 30 days 41 | const moon = []; 42 | // start with yesterday 43 | let moonDate = DateTime.local().minus({ days: 1 }); 44 | let { phase } = SunCalc.getMoonIllumination(moonDate.toJSDate()); 45 | let iterations = 0; 46 | do { 47 | // get yesterday's moon info 48 | const lastPhase = phase; 49 | // calculate new values 50 | moonDate = moonDate.plus({ days: 1 }); 51 | phase = SunCalc.getMoonIllumination(moonDate.toJSDate()).phase; 52 | // check for 4 cases 53 | if (lastPhase < 0.25 && phase >= 0.25) moon.push(this.getMoonTransition(0.25, 'First', moonDate)); 54 | if (lastPhase < 0.50 && phase >= 0.50) moon.push(this.getMoonTransition(0.50, 'Full', moonDate)); 55 | if (lastPhase < 0.75 && phase >= 0.75) moon.push(this.getMoonTransition(0.75, 'Last', moonDate)); 56 | if (lastPhase > phase) moon.push(this.getMoonTransition(0.00, 'New', moonDate)); 57 | 58 | // stop after 30 days or 4 moon phases 59 | iterations += 1; 60 | } while (iterations <= 30 && moon.length < 4); 61 | 62 | return { 63 | sun, 64 | moon, 65 | }; 66 | } 67 | 68 | // get moon transition from one phase to the next by drilling down by hours, minutes and seconds 69 | getMoonTransition(threshold, phaseName, start, iteration = 0) { 70 | let moonDate = start; 71 | let { phase } = SunCalc.getMoonIllumination(moonDate.toJSDate()); 72 | let iterations = 0; 73 | const step = { 74 | hours: iteration === 0 ? -1 : 0, 75 | minutes: iteration === 1 ? 1 : 0, 76 | seconds: iteration === 2 ? -1 : 0, 77 | milliseconds: iteration === 3 ? 1 : 0, 78 | }; 79 | 80 | // increasing test 81 | let test = (lastPhase, testPhase) => lastPhase < threshold && testPhase >= threshold; 82 | // decreasing test 83 | if (iteration % 2 === 0) test = (lastPhase, testPhase) => lastPhase > threshold && testPhase <= threshold; 84 | 85 | do { 86 | // store last phase 87 | const lastPhase = phase; 88 | // calculate new phase after step 89 | moonDate = moonDate.plus(step); 90 | phase = SunCalc.getMoonIllumination(moonDate.toJSDate()).phase; 91 | // wrap phases > 0.9 to -0.1 for ease of detection 92 | if (phase > 0.9) phase -= 1.0; 93 | // compare 94 | if (test(lastPhase, phase)) { 95 | // last iteration is three, return value 96 | if (iteration >= 3) break; 97 | // iterate recursively 98 | return this.getMoonTransition(threshold, phaseName, moonDate, iteration + 1); 99 | } 100 | iterations += 1; 101 | } while (iterations < 1000); 102 | 103 | return { phase: phaseName, date: moonDate }; 104 | } 105 | 106 | async drawCanvas() { 107 | super.drawCanvas(); 108 | const info = this.data; 109 | const Today = DateTime.local(); 110 | const Tomorrow = Today.plus({ days: 1 }); 111 | 112 | // sun and moon data 113 | this.elem.querySelector('.day-1').innerHTML = Today.toLocaleString({ weekday: 'long' }); 114 | this.elem.querySelector('.day-2').innerHTML = Tomorrow.toLocaleString({ weekday: 'long' }); 115 | this.elem.querySelector('.rise-1').innerHTML = DateTime.fromJSDate(info.sun[0].sunrise).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase(); 116 | this.elem.querySelector('.rise-2').innerHTML = DateTime.fromJSDate(info.sun[1].sunrise).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase(); 117 | this.elem.querySelector('.set-1').innerHTML = DateTime.fromJSDate(info.sun[0].sunset).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase(); 118 | this.elem.querySelector('.set-2').innerHTML = DateTime.fromJSDate(info.sun[1].sunset).setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase(); 119 | 120 | const days = info.moon.map((MoonPhase) => { 121 | const fill = {}; 122 | 123 | const date = MoonPhase.date.toLocaleString({ month: 'short', day: 'numeric' }); 124 | 125 | fill.date = date; 126 | fill.type = MoonPhase.phase; 127 | 128 | return this.fillTemplate('times', fill); 129 | }); 130 | 131 | const daysContainer = this.elem.querySelector('.moon'); 132 | daysContainer.innerHTML = ''; 133 | daysContainer.append(...days); 134 | 135 | this.finishDraw(); 136 | } 137 | 138 | // make sun and moon data available outside this class 139 | // promise allows for data to be requested before it is available 140 | async getSun() { 141 | return new Promise((resolve) => { 142 | if (this.data) resolve(this.data); 143 | // data not available, put it into the data callback queue 144 | this.getDataCallbacks.push(resolve); 145 | }); 146 | } 147 | } 148 | 149 | // register display 150 | const display = new Almanac(6, 'almanac'); 151 | registerDisplay(display); 152 | 153 | export default display.getSun.bind(display); 154 | -------------------------------------------------------------------------------- /server/scripts/modules/autocomplete.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable default-case */ 2 | import { json } from './utils/fetch.mjs'; 3 | 4 | const KEYS = { 5 | ESC: 27, 6 | TAB: 9, 7 | RETURN: 13, 8 | LEFT: 37, 9 | UP: 38, 10 | RIGHT: 39, 11 | DOWN: 40, 12 | ENTER: 13, 13 | }; 14 | 15 | const DEFAULT_OPTIONS = { 16 | autoSelectFirst: false, 17 | serviceUrl: null, 18 | lookup: null, 19 | onSelect: () => { }, 20 | onHint: null, 21 | width: 'auto', 22 | minChars: 3, 23 | maxHeight: 300, 24 | deferRequestBy: 0, 25 | params: {}, 26 | delimiter: null, 27 | zIndex: 9999, 28 | type: 'GET', 29 | noCache: false, 30 | preserveInput: false, 31 | containerClass: 'autocomplete-suggestions', 32 | tabDisabled: false, 33 | dataType: 'text', 34 | currentRequest: null, 35 | triggerSelectOnValidInput: true, 36 | preventBadQueries: true, 37 | paramName: 'query', 38 | transformResult: (a) => a, 39 | showNoSuggestionNotice: false, 40 | noSuggestionNotice: 'No results', 41 | orientation: 'bottom', 42 | forceFixPosition: false, 43 | }; 44 | 45 | const escapeRegExChars = (string) => string.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&'); 46 | 47 | const formatResult = (suggestion, search) => { 48 | // Do not replace anything if the current value is empty 49 | if (!search) { 50 | return suggestion; 51 | } 52 | 53 | const pattern = `(${escapeRegExChars(search)})`; 54 | 55 | return suggestion 56 | .replace(new RegExp(pattern, 'gi'), '$1') 57 | .replace(/&/g, '&') 58 | .replace(//g, '>') 60 | .replace(/"/g, '"') 61 | .replace(/<(\/?strong)>/g, '<$1>'); 62 | }; 63 | 64 | class AutoComplete { 65 | constructor(elem, options) { 66 | this.options = { ...DEFAULT_OPTIONS, ...options }; 67 | this.elem = elem; 68 | this.selectedItem = -1; 69 | this.onChangeTimeout = null; 70 | this.currentValue = ''; 71 | this.suggestions = []; 72 | this.cachedResponses = {}; 73 | 74 | // create and add the results container 75 | const results = document.createElement('div'); 76 | results.style.display = 'none'; 77 | results.classList.add(this.options.containerClass); 78 | results.style.width = (typeof this.options.width === 'string') ? this.options.width : `${this.options.width}px`; 79 | results.style.zIndex = this.options.zIndex; 80 | results.style.maxHeight = `${this.options.maxHeight}px`; 81 | results.style.overflowX = 'hidden'; 82 | results.addEventListener('mouseover', (e) => this.mouseOver(e)); 83 | results.addEventListener('mouseout', (e) => this.mouseOut(e)); 84 | results.addEventListener('click', (e) => this.click(e)); 85 | 86 | this.results = results; 87 | this.elem.after(results); 88 | 89 | // add handlers for typing text and submitting the form 90 | this.elem.addEventListener('keyup', (e) => this.keyUp(e)); 91 | this.elem.closest('form')?.addEventListener('submit', (e) => this.directFormSubmit(e)); 92 | this.elem.addEventListener('click', () => this.deselectAll()); 93 | 94 | // clicking outside the suggestion box requires a bit of work to determine if suggestions should be hidden 95 | document.addEventListener('click', (e) => this.checkOutsideClick(e)); 96 | } 97 | 98 | mouseOver(e) { 99 | // suggestion line 100 | if (e.target?.classList?.contains('suggestion')) { 101 | e.target.classList.add('selected'); 102 | this.selectedItem = parseInt(e.target.dataset.item, 10); 103 | } 104 | } 105 | 106 | mouseOut(e) { 107 | // suggestion line 108 | if (e.target?.classList?.contains('suggestion')) { 109 | e.target.classList.remove('selected'); 110 | this.selectedItem = -1; 111 | } 112 | } 113 | 114 | click(e) { 115 | // suggestion line 116 | if (e.target?.classList?.contains('suggestion')) { 117 | // get the entire suggestion 118 | const suggestion = this.suggestions[parseInt(e.target.dataset.item, 10)]; 119 | this.options.onSelect(suggestion); 120 | this.elem.value = suggestion.value; 121 | this.hideSuggestions(); 122 | } 123 | } 124 | 125 | hideSuggestions() { 126 | this.results.style.display = 'none'; 127 | } 128 | 129 | showSuggestions() { 130 | this.results.style.removeProperty('display'); 131 | } 132 | 133 | clearSuggestions() { 134 | this.results.innerHTML = ''; 135 | } 136 | 137 | keyUp(e) { 138 | // reset the change timeout 139 | clearTimeout(this.onChangeTimeout); 140 | 141 | // up/down direction 142 | switch (e.which) { 143 | case KEYS.ESC: 144 | this.hideSuggestions(); 145 | return; 146 | case KEYS.UP: 147 | case KEYS.DOWN: 148 | // move up or down the selection list 149 | this.keySelect(e.which); 150 | return; 151 | case KEYS.ENTER: 152 | // if the text entry field is active call direct form submit 153 | // if there is a suggestion highlighted call the click function on that element 154 | if (this.getSelected() !== undefined) { 155 | this.click({ target: this.results.querySelector('.suggestion.selected') }); 156 | return; 157 | } 158 | if (document.activeElement.id === this.elem.id) { 159 | // call the direct submit routine 160 | this.directFormSubmit(); 161 | } 162 | return; 163 | } 164 | 165 | if (this.currentValue !== this.elem.value) { 166 | if (this.options.deferRequestBy > 0) { 167 | // defer lookup during rapid key presses 168 | this.onChangeTimeout = setTimeout(() => { 169 | this.onValueChange(); 170 | }, this.options.deferRequestBy); 171 | } 172 | } 173 | } 174 | 175 | setValue(newValue) { 176 | this.currentValue = newValue; 177 | this.elem.value = newValue; 178 | } 179 | 180 | onValueChange() { 181 | clearTimeout(this.onValueChange); 182 | 183 | // confirm value actually changed 184 | if (this.currentValue === this.elem.value) return; 185 | // store new value 186 | this.currentValue = this.elem.value; 187 | 188 | // clear the selected index 189 | this.selectedItem = -1; 190 | this.results.querySelectorAll('div').forEach((elem) => elem.classList.remove('selected')); 191 | 192 | // if less than minimum don't query api 193 | if (this.currentValue.length < this.options.minChars) { 194 | this.hideSuggestions(); 195 | return; 196 | } 197 | 198 | this.getSuggestions(this.currentValue); 199 | } 200 | 201 | async getSuggestions(search, skipHtml = false) { 202 | // assemble options 203 | const searchOptions = { ...this.options.params }; 204 | searchOptions[this.options.paramName] = search; 205 | 206 | // build search url 207 | const url = new URL(this.options.serviceUrl); 208 | Object.entries(searchOptions).forEach(([key, value]) => { 209 | url.searchParams.append(key, value); 210 | }); 211 | 212 | let result = this.cachedResponses[search]; 213 | if (!result) { 214 | // make the request 215 | const resultRaw = await json(url); 216 | 217 | // use the provided parser 218 | result = this.options.transformResult(resultRaw); 219 | } 220 | 221 | // store suggestions 222 | this.cachedResponses[search] = result; 223 | this.suggestions = result.suggestions; 224 | 225 | if (skipHtml) return; 226 | 227 | // populate the suggestion area 228 | this.populateSuggestions(); 229 | } 230 | 231 | populateSuggestions() { 232 | if (this.suggestions.length === 0) { 233 | if (this.options.showNoSuggestionNotice) { 234 | this.noSuggestionNotice(); 235 | } else { 236 | this.hideSuggestions(); 237 | } 238 | return; 239 | } 240 | 241 | // build the list 242 | const suggestionElems = this.suggestions.map((suggested, idx) => { 243 | const elem = document.createElement('div'); 244 | elem.classList.add('suggestion'); 245 | elem.dataset.item = idx; 246 | elem.innerHTML = (formatResult(suggested.value, this.currentValue)); 247 | return elem.outerHTML; 248 | }); 249 | 250 | this.results.innerHTML = suggestionElems.join(''); 251 | this.showSuggestions(); 252 | } 253 | 254 | noSuggestionNotice() { 255 | this.results.innerHTML = `
${this.options.noSuggestionNotice}
`; 256 | this.showSuggestions(); 257 | } 258 | 259 | // the submit button has been pressed and we'll just use the first suggestion found 260 | async directFormSubmit() { 261 | // check for minimum length 262 | if (this.currentValue.length < this.options.minChars) return; 263 | await this.getSuggestions(this.elem.value, true); 264 | const suggestion = this.suggestions?.[0]; 265 | if (suggestion) { 266 | this.options.onSelect(suggestion); 267 | this.elem.value = suggestion.value; 268 | this.hideSuggestions(); 269 | } 270 | } 271 | 272 | // return the index of the selected item in suggestions 273 | getSelected() { 274 | const index = this.results.querySelector('.selected')?.dataset?.item; 275 | if (index !== undefined) return parseInt(index, 10); 276 | return index; 277 | } 278 | 279 | // move the selection highlight up or down 280 | keySelect(key) { 281 | // if the suggestions are hidden do nothing 282 | if (this.results.style.display === 'none') return; 283 | // if there are no suggestions do nothing 284 | if (this.suggestions.length <= 0) return; 285 | 286 | // get the currently selected index (or default to off the top of the list) 287 | let index = this.getSelected(); 288 | 289 | // adjust the index per the key 290 | // and include defaults in case no index is selected 291 | switch (key) { 292 | case KEYS.UP: 293 | index = (index ?? 0) - 1; 294 | break; 295 | case KEYS.DOWN: 296 | index = (index ?? -1) + 1; 297 | break; 298 | } 299 | 300 | // wrap the index (and account for negative) 301 | index = ((index % this.suggestions.length) + this.suggestions.length) % this.suggestions.length; 302 | 303 | // set this index 304 | this.deselectAll(); 305 | this.mouseOver({ 306 | target: this.results.querySelectorAll('.suggestion')[index], 307 | }); 308 | } 309 | 310 | deselectAll() { 311 | // clear other selected indexes 312 | [...this.results.querySelectorAll('.suggestion.selected')].forEach((elem) => elem.classList.remove('selected')); 313 | this.selectedItem = 0; 314 | } 315 | 316 | // if a click is detected on the page, generally we hide the suggestions, unless the click was within the autocomplete elements 317 | checkOutsideClick(e) { 318 | if (e.target.id === 'txtAddress') return; 319 | if (e.target?.parentNode?.classList.contains(this.options.containerClass)) return; 320 | this.hideSuggestions(); 321 | } 322 | } 323 | 324 | export default AutoComplete; 325 | -------------------------------------------------------------------------------- /server/scripts/modules/currentweather.mjs: -------------------------------------------------------------------------------- 1 | // current weather conditions display 2 | import STATUS from './status.mjs'; 3 | import { json } from './utils/fetch.mjs'; 4 | import { directionToNSEW } from './utils/calc.mjs'; 5 | import { locationCleanup } from './utils/string.mjs'; 6 | import WeatherDisplay from './weatherdisplay.mjs'; 7 | import { registerDisplay } from './navigation.mjs'; 8 | import { 9 | celsiusToFahrenheit, kphToMph, pascalToInHg, metersToFeet, kilometersToMiles, 10 | } from './utils/units.mjs'; 11 | 12 | // some stations prefixed do not provide all the necessary data 13 | const skipStations = ['U', 'C', 'H', 'W', 'Y', 'T', 'S', 'M', 'O', 'L', 'A', 'F', 'B', 'N', 'V', 'R', 'D', 'E', 'I', 'G', 'J']; 14 | 15 | class CurrentWeather extends WeatherDisplay { 16 | constructor(navId, elemId) { 17 | super(navId, elemId, 'Current Conditions', true); 18 | } 19 | 20 | async getData(weatherParameters, refresh) { 21 | // always load the data for use in the lower scroll 22 | const superResult = super.getData(weatherParameters, refresh); 23 | // note: current weather does not use old data on a silent refresh 24 | // this is deliberate because it can pull data from more than one station in sequence 25 | 26 | // filter for 4-letter observation stations, only those contain sky conditions and thus an icon 27 | const filteredStations = this.weatherParameters.stations.filter((station) => station?.properties?.stationIdentifier?.length === 4 && !skipStations.includes(station.properties.stationIdentifier.slice(0, 1))); 28 | 29 | // Load the observations 30 | let observations; 31 | let station; 32 | 33 | // station number counter 34 | let stationNum = 0; 35 | while (!observations && stationNum < filteredStations.length) { 36 | // get the station 37 | station = filteredStations[stationNum]; 38 | stationNum += 1; 39 | try { 40 | // station observations 41 | // eslint-disable-next-line no-await-in-loop 42 | observations = await json(`${station.id}/observations`, { 43 | cors: true, 44 | data: { 45 | limit: 2, 46 | }, 47 | retryCount: 3, 48 | stillWaiting: () => this.stillWaiting(), 49 | }); 50 | 51 | // test data quality 52 | if (observations.features[0].properties.temperature.value === null 53 | || observations.features[0].properties.windSpeed.value === null 54 | || observations.features[0].properties.textDescription === null 55 | || observations.features[0].properties.textDescription === '' 56 | || observations.features[0].properties.barometricPressure.value === null) { 57 | observations = undefined; 58 | throw new Error(`Unable to get observations: ${station.properties.stationIdentifier}, trying next station`); 59 | } 60 | } catch (error) { 61 | console.error(error); 62 | } 63 | } 64 | // test for data received 65 | if (!observations) { 66 | console.error('All current weather stations exhausted'); 67 | if (this.isEnabled) this.setStatus(STATUS.failed); 68 | // send failed to subscribers 69 | this.getDataCallback(undefined); 70 | return; 71 | } 72 | 73 | // we only get here if there was no error above 74 | this.data = parseData({ ...observations, station }); 75 | this.getDataCallback(); 76 | 77 | // stop here if we're disabled 78 | if (!superResult) return; 79 | 80 | this.setStatus(STATUS.loaded); 81 | } 82 | 83 | async drawCanvas() { 84 | super.drawCanvas(); 85 | 86 | let condition = this.data.observations.textDescription; 87 | if (condition.length > 15) { 88 | condition = shortConditions(condition); 89 | } 90 | 91 | const fill = { 92 | temp: this.data.Temperature + String.fromCharCode(176), 93 | condition, 94 | wind: this.data.WindDirection.padEnd(3, '') + this.data.WindSpeed.toString().padStart(3, ' '), 95 | location: locationCleanup(this.data.station.properties.name).substr(0, 20), 96 | humidity: `${this.data.Humidity}%`, 97 | dewpoint: this.data.DewPoint + String.fromCharCode(176), 98 | ceiling: (this.data.Ceiling === 0 ? 'Unlimited' : this.data.Ceiling + this.data.CeilingUnit), 99 | visibility: this.data.Visibility + this.data.VisibilityUnit, 100 | pressure: `${this.data.Pressure} ${this.data.PressureDirection}`, 101 | }; 102 | 103 | if (this.data.WindGust) fill['wind-gusts'] = `Gusts to ${this.data.WindGust}`; 104 | 105 | if (this.data.observations.heatIndex.value && this.data.HeatIndex !== this.data.Temperature) { 106 | fill['heat-index-label'] = 'Heat Index:'; 107 | fill['heat-index'] = this.data.HeatIndex + String.fromCharCode(176); 108 | } else if (this.data.observations.windChill.value && this.data.WindChill !== '' && this.data.WindChill < this.data.Temperature) { 109 | fill['heat-index-label'] = 'Wind Chill:'; 110 | fill['heat-index'] = this.data.WindChill + String.fromCharCode(176); 111 | } 112 | 113 | const area = this.elem.querySelector('.main'); 114 | 115 | area.innerHTML = ''; 116 | area.append(this.fillTemplate('weather', fill)); 117 | 118 | this.finishDraw(); 119 | } 120 | 121 | // make data available outside this class 122 | // promise allows for data to be requested before it is available 123 | async getCurrentWeather(stillWaiting) { 124 | if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting); 125 | return new Promise((resolve) => { 126 | if (this.data) resolve(this.data); 127 | // data not available, put it into the data callback queue 128 | this.getDataCallbacks.push(() => resolve(this.data)); 129 | }); 130 | } 131 | } 132 | 133 | const shortConditions = (_condition) => { 134 | let condition = _condition; 135 | condition = condition.replace(/Light/g, 'L'); 136 | condition = condition.replace(/Heavy/g, 'H'); 137 | condition = condition.replace(/Partly/g, 'P'); 138 | condition = condition.replace(/Mostly/g, 'M'); 139 | condition = condition.replace(/Few/g, 'F'); 140 | condition = condition.replace(/Thunderstorm/g, 'T\'storm'); 141 | condition = condition.replace(/ in /g, ''); 142 | condition = condition.replace(/Vicinity/g, ''); 143 | condition = condition.replace(/ and /g, ' '); 144 | condition = condition.replace(/Freezing Rain/g, 'Frz Rn'); 145 | condition = condition.replace(/Freezing/g, 'Frz'); 146 | condition = condition.replace(/Unknown Precip/g, ''); 147 | condition = condition.replace(/L Snow Fog/g, 'L Snw/Fog'); 148 | condition = condition.replace(/ with /g, '/'); 149 | return condition; 150 | }; 151 | 152 | // format the received data 153 | const parseData = (data) => { 154 | const observations = data.features[0].properties; 155 | // values from api are provided in metric 156 | data.observations = observations; 157 | data.Temperature = Math.round(observations.temperature.value); 158 | data.TemperatureUnit = 'C'; 159 | data.DewPoint = Math.round(observations.dewpoint.value); 160 | data.Ceiling = Math.round(observations.cloudLayers[0]?.base?.value ?? 0); 161 | data.CeilingUnit = 'm.'; 162 | data.Visibility = Math.round(observations.visibility.value / 1000); 163 | data.VisibilityUnit = ' km.'; 164 | data.WindSpeed = Math.round(observations.windSpeed.value); 165 | data.WindDirection = directionToNSEW(observations.windDirection.value); 166 | data.Pressure = Math.round(observations.barometricPressure.value); 167 | data.HeatIndex = Math.round(observations.heatIndex.value); 168 | data.WindChill = Math.round(observations.windChill.value); 169 | data.WindGust = Math.round(observations.windGust.value); 170 | data.WindUnit = 'KPH'; 171 | data.Humidity = Math.round(observations.relativeHumidity.value); 172 | data.PressureDirection = ''; 173 | data.TextConditions = observations.textDescription; 174 | 175 | // difference since last measurement (pascals, looking for difference of more than 150) 176 | const pressureDiff = (observations.barometricPressure.value - data.features[1].properties.barometricPressure.value); 177 | if (pressureDiff > 150) data.PressureDirection = 'R'; 178 | if (pressureDiff < -150) data.PressureDirection = 'F'; 179 | 180 | // convert to us units 181 | data.Temperature = celsiusToFahrenheit(data.Temperature); 182 | data.TemperatureUnit = 'F'; 183 | data.DewPoint = celsiusToFahrenheit(data.DewPoint); 184 | data.Ceiling = Math.round(metersToFeet(data.Ceiling) / 100) * 100; 185 | data.CeilingUnit = 'ft.'; 186 | data.Visibility = kilometersToMiles(observations.visibility.value / 1000); 187 | data.VisibilityUnit = ' mi.'; 188 | data.WindSpeed = kphToMph(data.WindSpeed); 189 | data.WindUnit = 'MPH'; 190 | data.Pressure = pascalToInHg(data.Pressure).toFixed(2); 191 | data.HeatIndex = celsiusToFahrenheit(data.HeatIndex); 192 | data.WindChill = celsiusToFahrenheit(data.WindChill); 193 | data.WindGust = kphToMph(data.WindGust); 194 | return data; 195 | }; 196 | 197 | const display = new CurrentWeather(1, 'current-weather'); 198 | registerDisplay(display); 199 | 200 | export default display.getCurrentWeather.bind(display); 201 | -------------------------------------------------------------------------------- /server/scripts/modules/currentweatherscroll.mjs: -------------------------------------------------------------------------------- 1 | import { locationCleanup } from './utils/string.mjs'; 2 | import { elemForEach } from './utils/elem.mjs'; 3 | import getCurrentWeather from './currentweather.mjs'; 4 | import { currentDisplay } from './navigation.mjs'; 5 | 6 | // constants 7 | const degree = String.fromCharCode(176); 8 | const SCROLL_SPEED = 75; // pixels/second 9 | const DEFAULT_UPDATE = 8; // 0.5s ticks 10 | 11 | // local variables 12 | let interval; 13 | let screenIndex = 0; 14 | let sinceLastUpdate = 0; 15 | let nextUpdate = DEFAULT_UPDATE; 16 | 17 | // start drawing conditions 18 | // reset starts from the first item in the text scroll list 19 | const start = () => { 20 | // store see if the context is new 21 | 22 | // set up the interval if needed 23 | if (!interval) { 24 | interval = setInterval(incrementInterval, 500); 25 | } 26 | 27 | // draw the data 28 | drawScreen(); 29 | }; 30 | 31 | const stop = (reset) => { 32 | if (reset) screenIndex = 0; 33 | clearInterval(interval); 34 | interval = null; 35 | }; 36 | 37 | // increment interval, roll over 38 | // forcing is used when drawScreen receives an invalid screen and needs to request the next one in line 39 | const incrementInterval = (force) => { 40 | if (!force) { 41 | // test for elapsed time (0.5s ticks); 42 | sinceLastUpdate += 1; 43 | if (sinceLastUpdate < nextUpdate) return; 44 | } 45 | // reset flags 46 | sinceLastUpdate = 0; 47 | nextUpdate = DEFAULT_UPDATE; 48 | 49 | // test current screen 50 | const display = currentDisplay(); 51 | if (!display?.okToDrawCurrentConditions) { 52 | stop(display?.elemId === 'progress'); 53 | return; 54 | } 55 | screenIndex = (screenIndex + 1) % (lastScreen); 56 | // draw new text 57 | drawScreen(); 58 | }; 59 | 60 | const drawScreen = async () => { 61 | // get the conditions 62 | const data = await getCurrentWeather(); 63 | 64 | // nothing to do if there's no data yet 65 | if (!data) return; 66 | 67 | const thisScreen = screens[screenIndex](data); 68 | if (typeof thisScreen === 'string') { 69 | // only a string 70 | drawCondition(thisScreen); 71 | } else if (typeof thisScreen === 'object') { 72 | // an object was provided with additional parameters 73 | switch (thisScreen.type) { 74 | case 'scroll': 75 | drawScrollCondition(thisScreen); 76 | break; 77 | default: drawCondition(thisScreen); 78 | } 79 | } else { 80 | // can't identify screen, get another one 81 | incrementInterval(true); 82 | } 83 | }; 84 | 85 | // the "screens" are stored in an array for easy addition and removal 86 | const screens = [ 87 | // station name 88 | (data) => `Conditions at ${locationCleanup(data.station.properties.name).substr(0, 20)}`, 89 | 90 | // temperature 91 | (data) => { 92 | let text = `Temp: ${data.Temperature}${degree}${data.TemperatureUnit}`; 93 | if (data.observations.heatIndex.value) { 94 | text += ` Heat Index: ${data.HeatIndex}${degree}${data.TemperatureUnit}`; 95 | } else if (data.observations.windChill.value) { 96 | text += ` Wind Chill: ${data.WindChill}${degree}${data.TemperatureUnit}`; 97 | } 98 | return text; 99 | }, 100 | 101 | // humidity 102 | (data) => `Humidity: ${data.Humidity}% Dewpoint: ${data.DewPoint}${degree}${data.TemperatureUnit}`, 103 | 104 | // barometric pressure 105 | (data) => `Barometric Pressure: ${data.Pressure} ${data.PressureDirection}`, 106 | 107 | // wind 108 | (data) => { 109 | let text = data.WindSpeed > 0 110 | ? `Wind: ${data.WindDirection} ${data.WindSpeed} ${data.WindUnit}` 111 | : 'Wind: Calm'; 112 | 113 | if (data.WindGust > 0) { 114 | text += ` Gusts to ${data.WindGust}`; 115 | } 116 | return text; 117 | }, 118 | 119 | // visibility 120 | (data) => { 121 | const distance = `${data.Ceiling} ${data.CeilingUnit}`; 122 | return `Visib: ${data.Visibility} ${data.VisibilityUnit} Ceiling: ${data.Ceiling === 0 ? 'Unlimited' : distance}`; 123 | }, 124 | ]; 125 | 126 | // internal draw function with preset parameters 127 | const drawCondition = (text) => { 128 | // update all html scroll elements 129 | elemForEach('.weather-display .scroll .fixed', (elem) => { 130 | elem.innerHTML = text; 131 | }); 132 | }; 133 | document.addEventListener('DOMContentLoaded', () => { 134 | start(); 135 | }); 136 | 137 | // store the original number of screens 138 | const originalScreens = screens.length; 139 | let lastScreen = originalScreens; 140 | 141 | // reset the number of screens 142 | const reset = () => { 143 | lastScreen = originalScreens; 144 | }; 145 | 146 | // add screen 147 | const addScreen = (screen) => { 148 | screens.push(screen); 149 | lastScreen += 1; 150 | }; 151 | 152 | const drawScrollCondition = (screen) => { 153 | // create the scroll element 154 | const scrollElement = document.createElement('div'); 155 | scrollElement.classList.add('scroll-area'); 156 | scrollElement.innerHTML = screen.text; 157 | // add it to the page to get the width 158 | document.querySelector('.weather-display .scroll .fixed').innerHTML = scrollElement.outerHTML; 159 | // grab the width 160 | const { scrollWidth, clientWidth } = document.querySelector('.weather-display .scroll .fixed .scroll-area'); 161 | 162 | // calculate the scroll distance and set a minimum scroll 163 | const scrollDistance = Math.max(scrollWidth - clientWidth, 0); 164 | // calculate the scroll time 165 | const scrollTime = scrollDistance / SCROLL_SPEED; 166 | // calculate a new minimum on-screen time +1.0s at start and end 167 | nextUpdate = Math.round(Math.ceil(scrollTime / 0.5) + 4); 168 | 169 | // update the element transition and set initial left position 170 | scrollElement.style.left = '0px'; 171 | scrollElement.style.transition = `left linear ${scrollTime.toFixed(1)}s`; 172 | elemForEach('.weather-display .scroll .fixed', (elem) => { 173 | elem.innerHTML = ''; 174 | elem.append(scrollElement.cloneNode(true)); 175 | }); 176 | // start the scroll after a short delay 177 | setTimeout(() => { 178 | // change the left position to trigger the scroll 179 | elemForEach('.weather-display .scroll .fixed .scroll-area', (elem) => { 180 | elem.style.left = `-${scrollDistance.toFixed(0)}px`; 181 | }); 182 | }, 1000); 183 | }; 184 | 185 | window.CurrentWeatherScroll = { 186 | start, 187 | stop, 188 | addScreen, 189 | reset, 190 | }; 191 | -------------------------------------------------------------------------------- /server/scripts/modules/extendedforecast.mjs: -------------------------------------------------------------------------------- 1 | // display extended forecast graphically 2 | // technically uses the same data as the local forecast, we'll let the browser do the caching of that 3 | 4 | import STATUS from './status.mjs'; 5 | import { json } from './utils/fetch.mjs'; 6 | import { DateTime } from '../vendor/auto/luxon.mjs'; 7 | import WeatherDisplay from './weatherdisplay.mjs'; 8 | import { registerDisplay } from './navigation.mjs'; 9 | 10 | class ExtendedForecast extends WeatherDisplay { 11 | constructor(navId, elemId) { 12 | super(navId, elemId, 'Extended Forecast', true); 13 | 14 | // set timings 15 | this.timing.totalScreens = 2; 16 | } 17 | 18 | async getData(weatherParameters, refresh) { 19 | if (!super.getData(weatherParameters, refresh)) return; 20 | 21 | // request us or si units 22 | try { 23 | this.data = await json(this.weatherParameters.forecast, { 24 | data: { 25 | units: 'us', 26 | }, 27 | retryCount: 3, 28 | stillWaiting: () => this.stillWaiting(), 29 | }); 30 | } catch (error) { 31 | console.error('Unable to get extended forecast'); 32 | console.error(error.status, error.responseJSON); 33 | // if there's no previous data, fail 34 | if (!this.data) { 35 | this.setStatus(STATUS.failed); 36 | return; 37 | } 38 | } 39 | // we only get here if there was no error above 40 | this.screenIndex = 0; 41 | this.setStatus(STATUS.loaded); 42 | } 43 | 44 | async drawCanvas() { 45 | super.drawCanvas(); 46 | 47 | // determine bounds 48 | // grab the first three or second set of three array elements 49 | const forecast = parse(this.data.properties.periods).slice(0 + 3 * this.screenIndex, 3 + this.screenIndex * 3); 50 | 51 | // create each day template 52 | const days = forecast.map((Day) => { 53 | const fill = { 54 | condition: Day.text, 55 | date: Day.dayName, 56 | }; 57 | 58 | const { low } = Day; 59 | if (low !== undefined) { 60 | fill['value-lo'] = Math.round(low); 61 | } 62 | const { high } = Day; 63 | fill['value-hi'] = Math.round(high); 64 | 65 | // return the filled template 66 | return this.fillTemplate('day', fill); 67 | }); 68 | 69 | // empty and update the container 70 | const dayContainer = this.elem.querySelector('.day-container'); 71 | dayContainer.innerHTML = ''; 72 | dayContainer.append(...days); 73 | this.finishDraw(); 74 | } 75 | } 76 | 77 | // the api provides the forecast in 12 hour increments, flatten to day increments with high and low temperatures 78 | const parse = (fullForecast) => { 79 | // create a list of days starting with today 80 | const Days = [0, 1, 2, 3, 4, 5, 6]; 81 | 82 | const dates = Days.map((shift) => { 83 | const date = DateTime.local().startOf('day').plus({ days: shift }); 84 | return date.toLocaleString({ weekday: 'long' }); 85 | }); 86 | 87 | // track the destination forecast index 88 | let destIndex = 0; 89 | const forecast = []; 90 | fullForecast.forEach((period) => { 91 | // create the destination object if necessary 92 | if (!forecast[destIndex]) { 93 | forecast.push({ 94 | dayName: '', low: undefined, high: undefined, text: undefined, 95 | }); 96 | } 97 | // get the object to modify/populate 98 | const fDay = forecast[destIndex]; 99 | // high temperature will always be last in the source array so it will overwrite the low values assigned below 100 | fDay.text = shortenExtendedForecastText(period.shortForecast); 101 | fDay.dayName = dates[destIndex]; 102 | 103 | if (period.isDaytime) { 104 | // day time is the high temperature 105 | fDay.high = period.temperature; 106 | destIndex += 1; 107 | } else { 108 | // low temperature 109 | fDay.low = period.temperature; 110 | } 111 | }); 112 | 113 | return forecast; 114 | }; 115 | 116 | const shortenExtendedForecastText = (long) => { 117 | const regexList = [ 118 | [/ and /gi, ' '], 119 | [/slight /gi, ''], 120 | [/chance /gi, ''], 121 | [/very /gi, ''], 122 | [/patchy /gi, ''], 123 | [/areas /gi, ''], 124 | [/dense /gi, ''], 125 | [/Thunderstorm/g, 'T\'Storm'], 126 | ]; 127 | // run all regexes 128 | const short = regexList.reduce((working, [regex, replace]) => working.replace(regex, replace), long); 129 | 130 | let conditions = short.split(' '); 131 | if (short.indexOf('then') !== -1) { 132 | conditions = short.split(' then '); 133 | conditions = conditions[1].split(' '); 134 | } 135 | 136 | let short1 = conditions[0].substr(0, 10); 137 | let short2 = ''; 138 | if (conditions[1]) { 139 | if (short1.endsWith('.')) { 140 | short1 = short1.replace(/\./, ''); 141 | } else { 142 | short2 = conditions[1].substr(0, 10); 143 | } 144 | 145 | if (short2 === 'Blowing') { 146 | short2 = ''; 147 | } 148 | } 149 | let result = short1; 150 | if (short2 !== '') { 151 | result += ` ${short2}`; 152 | } 153 | 154 | return result; 155 | }; 156 | 157 | // register display 158 | registerDisplay(new ExtendedForecast(5, 'extended-forecast')); 159 | -------------------------------------------------------------------------------- /server/scripts/modules/hazards.mjs: -------------------------------------------------------------------------------- 1 | // hourly forecast list 2 | 3 | import STATUS from './status.mjs'; 4 | import { json } from './utils/fetch.mjs'; 5 | import WeatherDisplay from './weatherdisplay.mjs'; 6 | import { registerDisplay } from './navigation.mjs'; 7 | 8 | const hazardLevels = { 9 | Extreme: 10, 10 | Severe: 5, 11 | }; 12 | 13 | class Hazards extends WeatherDisplay { 14 | constructor(navId, elemId, defaultActive) { 15 | // special height and width for scrolling 16 | super(navId, elemId, 'Hazards', defaultActive); 17 | this.showOnProgress = false; 18 | 19 | // 0 screens skips this during "play" 20 | this.timing.totalScreens = 0; 21 | } 22 | 23 | async getData(weatherParameters, refresh) { 24 | // super checks for enabled 25 | const superResult = super.getData(weatherParameters, refresh); 26 | // hazards performs a silent refresh, but does not fall back to a previous fetch if no data is available 27 | // this is intentional to ensure the latest alerts only are displayed. 28 | 29 | const alert = this.checkbox.querySelector('.alert'); 30 | alert.classList.remove('show'); 31 | 32 | try { 33 | // get the forecast 34 | const url = new URL('https://api.weather.gov/alerts/active'); 35 | url.searchParams.append('point', `${this.weatherParameters.latitude},${this.weatherParameters.longitude}`); 36 | const alerts = await json(url, { retryCount: 3, stillWaiting: () => this.stillWaiting() }); 37 | const allUnsortedAlerts = alerts.features ?? []; 38 | const unsortedAlerts = allUnsortedAlerts.slice(0, 5); 39 | const sortedAlerts = unsortedAlerts.sort((a, b) => (hazardLevels[b.properties.severity] ?? 0) - (hazardLevels[a.properties.severity] ?? 0)); 40 | const filteredAlerts = sortedAlerts.filter((hazard) => hazard.properties.severity !== 'Unknown'); 41 | this.data = filteredAlerts; 42 | 43 | // show alert indicator 44 | if (this.data.length > 0) alert.classList.add('show'); 45 | } catch (error) { 46 | console.error('Get hourly forecast failed'); 47 | console.error(error.status, error.responseJSON); 48 | if (this.isEnabled) this.setStatus(STATUS.failed); 49 | // return undefined to other subscribers 50 | this.getDataCallback(undefined); 51 | return; 52 | } 53 | 54 | this.getDataCallback(); 55 | 56 | if (!superResult) { 57 | this.setStatus(STATUS.loaded); 58 | return; 59 | } 60 | this.drawLongCanvas(); 61 | } 62 | 63 | async drawLongCanvas() { 64 | // get the list element and populate 65 | const list = this.elem.querySelector('.hazard-lines'); 66 | list.innerHTML = ''; 67 | 68 | const lines = this.data.map((data) => { 69 | const fillValues = {}; 70 | // text 71 | fillValues['hazard-text'] = `${data.properties.event}

${data.properties.description.replaceAll('\n\n', '

').replaceAll('\n', ' ')}`; 72 | 73 | return this.fillTemplate('hazard', fillValues); 74 | }); 75 | 76 | list.append(...lines); 77 | 78 | // no alerts, skip this display by setting timing to zero 79 | if (lines.length === 0) { 80 | this.setStatus(STATUS.loaded); 81 | this.timing.totalScreens = 0; 82 | this.setStatus(STATUS.loaded); 83 | return; 84 | } 85 | 86 | // update timing 87 | // set up the timing 88 | this.timing.baseDelay = 20; 89 | // 24 hours = 6 pages 90 | const pages = Math.max(Math.ceil(list.scrollHeight / 400) - 3, 1); 91 | const timingStep = 400; 92 | this.timing.delay = [150 + timingStep]; 93 | // add additional pages 94 | for (let i = 0; i < pages; i += 1) this.timing.delay.push(timingStep); 95 | // add the final 3 second delay 96 | this.timing.delay.push(250); 97 | this.calcNavTiming(); 98 | this.setStatus(STATUS.loaded); 99 | } 100 | 101 | drawCanvas() { 102 | super.drawCanvas(); 103 | this.finishDraw(); 104 | } 105 | 106 | showCanvas() { 107 | // special to hourly to draw the remainder of the canvas 108 | this.drawCanvas(); 109 | super.showCanvas(); 110 | } 111 | 112 | // screen index change callback just runs the base count callback 113 | screenIndexChange() { 114 | this.baseCountChange(this.navBaseCount); 115 | } 116 | 117 | // base count change callback 118 | baseCountChange(count) { 119 | // calculate scroll offset and don't go past end 120 | let offsetY = Math.min(this.elem.querySelector('.hazard-lines').getBoundingClientRect().height - 390, (count - 150)); 121 | 122 | // don't let offset go negative 123 | if (offsetY < 0) offsetY = 0; 124 | 125 | // copy the scrolled portion of the canvas 126 | this.elem.querySelector('.main').scrollTo(0, offsetY); 127 | } 128 | 129 | // make data available outside this class 130 | // promise allows for data to be requested before it is available 131 | async getCurrentData(stillWaiting) { 132 | if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting); 133 | return new Promise((resolve) => { 134 | if (this.data) resolve(this.data); 135 | // data not available, put it into the data callback queue 136 | this.getDataCallbacks.push(() => resolve(this.data)); 137 | }); 138 | } 139 | } 140 | 141 | // register display 142 | registerDisplay(new Hazards(0, 'hazards', true)); 143 | -------------------------------------------------------------------------------- /server/scripts/modules/latestobservations.mjs: -------------------------------------------------------------------------------- 1 | // current weather conditions display 2 | import { distance as calcDistance, directionToNSEW } from './utils/calc.mjs'; 3 | import { json } from './utils/fetch.mjs'; 4 | import STATUS from './status.mjs'; 5 | import { locationCleanup, shortenCurrentConditions } from './utils/string.mjs'; 6 | import { celsiusToFahrenheit, kphToMph } from './utils/units.mjs'; 7 | import WeatherDisplay from './weatherdisplay.mjs'; 8 | import { registerDisplay } from './navigation.mjs'; 9 | 10 | class LatestObservations extends WeatherDisplay { 11 | constructor(navId, elemId) { 12 | super(navId, elemId, 'Latest Observations', true); 13 | 14 | // constants 15 | this.MaximumRegionalStations = 7; 16 | } 17 | 18 | async getData(weatherParameters, refresh) { 19 | if (!super.getData(weatherParameters, refresh)) return; 20 | // latest observations does a silent refresh but will not fall back to previously fetched data 21 | // this is intentional because up to 30 stations are available to pull data from 22 | 23 | // calculate distance to each station 24 | const stationsByDistance = Object.keys(StationInfo).map((key) => { 25 | const station = StationInfo[key]; 26 | const distance = calcDistance(station.lat, station.lon, this.weatherParameters.latitude, this.weatherParameters.longitude); 27 | return { ...station, distance }; 28 | }); 29 | 30 | // sort the stations by distance 31 | const sortedStations = stationsByDistance.sort((a, b) => a.distance - b.distance); 32 | // try up to 30 regional stations 33 | const regionalStations = sortedStations.slice(0, 30); 34 | 35 | // get data for regional stations 36 | // get first 7 stations 37 | const actualConditions = []; 38 | let lastStation = Math.min(regionalStations.length, 7); 39 | let firstStation = 0; 40 | while (actualConditions.length < 7 && (lastStation) <= regionalStations.length) { 41 | // eslint-disable-next-line no-await-in-loop 42 | const someStations = await getStations(regionalStations.slice(firstStation, lastStation)); 43 | 44 | actualConditions.push(...someStations); 45 | // update counters 46 | firstStation += lastStation; 47 | lastStation = Math.min(regionalStations.length + 1, firstStation + 7 - actualConditions.length); 48 | } 49 | 50 | // cut down to the maximum of 7 51 | this.data = actualConditions.slice(0, this.MaximumRegionalStations); 52 | 53 | // test for at least one station 54 | if (this.data.length === 0) { 55 | this.setStatus(STATUS.noData); 56 | return; 57 | } 58 | this.setStatus(STATUS.loaded); 59 | } 60 | 61 | async drawCanvas() { 62 | super.drawCanvas(); 63 | const conditions = this.data; 64 | 65 | // sort array by station name 66 | const sortedConditions = conditions.sort((a, b) => ((a.Name < b.Name) ? -1 : 1)); 67 | 68 | this.elem.querySelector('.column-headers .temp.english').classList.add('show'); 69 | this.elem.querySelector('.column-headers .temp.metric').classList.remove('show'); 70 | 71 | const lines = sortedConditions.map((condition) => { 72 | const windDirection = directionToNSEW(condition.windDirection.value); 73 | 74 | const Temperature = Math.round(celsiusToFahrenheit(condition.temperature.value)); 75 | const WindSpeed = Math.round(kphToMph(condition.windSpeed.value)); 76 | 77 | const fill = { 78 | location: locationCleanup(condition.city).substr(0, 14), 79 | temp: Temperature, 80 | weather: shortenCurrentConditions(condition.textDescription).substr(0, 9), 81 | }; 82 | 83 | if (WindSpeed > 0) { 84 | fill.wind = windDirection + (Array(6 - windDirection.length - WindSpeed.toString().length).join(' ')) + WindSpeed.toString(); 85 | } else if (WindSpeed === 'NA') { 86 | fill.wind = 'NA'; 87 | } else { 88 | fill.wind = 'Calm'; 89 | } 90 | 91 | return this.fillTemplate('observation-row', fill); 92 | }); 93 | 94 | const linesContainer = this.elem.querySelector('.observation-lines'); 95 | linesContainer.innerHTML = ''; 96 | linesContainer.append(...lines); 97 | 98 | this.finishDraw(); 99 | } 100 | } 101 | const getStations = async (stations) => { 102 | const stationData = await Promise.all(stations.map(async (station) => { 103 | try { 104 | const data = await json(`https://api.weather.gov/stations/${station.id}/observations/latest`, { retryCount: 1, stillWaiting: () => this.stillWaiting() }); 105 | // test for temperature, weather and wind values present 106 | if (data.properties.temperature.value === null 107 | || data.properties.textDescription === '' 108 | || data.properties.windSpeed.value === null) return false; 109 | // format the return values 110 | return { 111 | ...data.properties, 112 | StationId: station.id, 113 | city: station.city, 114 | }; 115 | } catch (error) { 116 | console.log(`Unable to get latest observations for ${station.id}`); 117 | return false; 118 | } 119 | })); 120 | // filter false (no data or other error) 121 | return stationData.filter((d) => d); 122 | }; 123 | // register display 124 | registerDisplay(new LatestObservations(2, 'latest-observations')); 125 | -------------------------------------------------------------------------------- /server/scripts/modules/localforecast.mjs: -------------------------------------------------------------------------------- 1 | // display text based local forecast 2 | 3 | import STATUS from './status.mjs'; 4 | import { json } from './utils/fetch.mjs'; 5 | import WeatherDisplay from './weatherdisplay.mjs'; 6 | import { registerDisplay } from './navigation.mjs'; 7 | 8 | class LocalForecast extends WeatherDisplay { 9 | constructor(navId, elemId) { 10 | super(navId, elemId, 'Local Forecast', true); 11 | 12 | // set timings 13 | this.timing.baseDelay = 5000; 14 | } 15 | 16 | async getData(weatherParameters, refresh) { 17 | if (!super.getData(weatherParameters, refresh)) return; 18 | 19 | // get title 20 | if (!this.originalTitle) { 21 | this.originalTitle = this.elem.querySelector('.header .title.single').textContent; 22 | } 23 | 24 | // get raw data 25 | const rawData = await this.getRawData(this.weatherParameters); 26 | // check for data, or if there's old data available 27 | if (!rawData && !this.data) { 28 | // fail for no old or new data 29 | this.setStatus(STATUS.failed); 30 | return; 31 | } 32 | // store the data 33 | this.data = rawData || this.data; 34 | // parse raw data 35 | const conditions = parse(this.data); 36 | 37 | // read each text 38 | this.screenTexts = conditions.map((condition) => { 39 | // process the text 40 | let text = `${condition.DayName.toUpperCase()}...`; 41 | const conditionText = condition.Text; 42 | text += conditionText.toUpperCase().replace('...', ' '); 43 | 44 | return text; 45 | }); 46 | 47 | // fill the forecast texts 48 | const templates = this.screenTexts.map((text) => this.fillTemplate('forecast', { text })); 49 | const forecastsElem = this.elem.querySelector('.forecasts'); 50 | forecastsElem.innerHTML = ''; 51 | forecastsElem.append(...templates); 52 | 53 | // increase each forecast height to a multiple of container height 54 | this.pageHeight = forecastsElem.parentNode.offsetHeight; 55 | templates.forEach((forecast) => { 56 | const newHeight = Math.ceil(forecast.scrollHeight / this.pageHeight) * this.pageHeight; 57 | forecast.style.height = `${newHeight}px`; 58 | }); 59 | 60 | // update the title 61 | this.elem.querySelector('.header .title.single').textContent = `${this.originalTitle} -- Zone ${weatherParameters.zoneId}`; 62 | 63 | this.timing.totalScreens = forecastsElem.scrollHeight / this.pageHeight; 64 | this.calcNavTiming(); 65 | this.setStatus(STATUS.loaded); 66 | } 67 | 68 | // get the unformatted data (also used by extended forecast) 69 | async getRawData(weatherParameters) { 70 | // request us or si units 71 | try { 72 | return await json(weatherParameters.forecast, { 73 | data: { 74 | units: 'us', 75 | }, 76 | retryCount: 3, 77 | stillWaiting: () => this.stillWaiting(), 78 | }); 79 | } catch (error) { 80 | console.error(`GetWeatherForecast failed: ${weatherParameters.forecast}`); 81 | console.error(error.status, error.responseJSON); 82 | return false; 83 | } 84 | } 85 | 86 | async drawCanvas() { 87 | super.drawCanvas(); 88 | 89 | // update the title 90 | const titleElem = this.elem.querySelector('.header .title.single'); 91 | if (this.screenIndex === 0 && this.weatherParameters.zoneId) { 92 | titleElem.textContent = `${this.originalTitle} -- Zone ${this.weatherParameters.zoneId}`; 93 | } else { 94 | titleElem.textContent = 'Nat\'l Weather Service Forecast'; 95 | } 96 | 97 | const top = -this.screenIndex * this.pageHeight; 98 | this.elem.querySelector('.forecasts').style.top = `${top}px`; 99 | 100 | this.finishDraw(); 101 | } 102 | } 103 | 104 | // format the forecast 105 | // only use the first 6 lines 106 | const parse = (forecast) => forecast.properties.periods.slice(0, 6).map((text) => ({ 107 | // format day and text 108 | DayName: text.name.toUpperCase(), 109 | Text: text.detailedForecast, 110 | })); 111 | // register display 112 | registerDisplay(new LocalForecast(4, 'local-forecast')); 113 | -------------------------------------------------------------------------------- /server/scripts/modules/navigation.mjs: -------------------------------------------------------------------------------- 1 | // navigation handles progress, next/previous and initial load messages from the parent frame 2 | import noSleep from './utils/nosleep.mjs'; 3 | import STATUS from './status.mjs'; 4 | import { wrap } from './utils/calc.mjs'; 5 | import { json } from './utils/fetch.mjs'; 6 | import { getPoint } from './utils/weather.mjs'; 7 | import settings from './settings.mjs'; 8 | 9 | document.addEventListener('DOMContentLoaded', () => { 10 | init(); 11 | }); 12 | 13 | const displays = []; 14 | let playing = false; 15 | let progress; 16 | const weatherParameters = {}; 17 | 18 | const init = async () => { 19 | // set up resize handler 20 | window.addEventListener('resize', resize); 21 | resize(); 22 | 23 | generateCheckboxes(); 24 | }; 25 | 26 | const message = (data) => { 27 | // dispatch event 28 | if (!data.type) return false; 29 | if (data.type === 'navButton') return handleNavButton(data.message); 30 | return console.error(`Unknown event ${data.type}`); 31 | }; 32 | 33 | const getWeather = async (latLon, haveDataCallback) => { 34 | // get initial weather data 35 | const point = await getPoint(latLon.lat, latLon.lon); 36 | 37 | if (typeof haveDataCallback === 'function') haveDataCallback(point); 38 | 39 | // get stations 40 | const stations = await json(point.properties.observationStations); 41 | 42 | const StationId = stations.features[0].properties.stationIdentifier; 43 | 44 | let { city } = point.properties.relativeLocation.properties; 45 | const { state } = point.properties.relativeLocation.properties; 46 | 47 | if (StationId in StationInfo) { 48 | city = StationInfo[StationId].city; 49 | [city] = city.split('/'); 50 | city = city.replace(/\s+$/, ''); 51 | } 52 | 53 | // populate the weather parameters 54 | weatherParameters.latitude = latLon.lat; 55 | weatherParameters.longitude = latLon.lon; 56 | weatherParameters.zoneId = point.properties.forecastZone.substr(-6); 57 | weatherParameters.radarId = point.properties.radarStation.substr(-3); 58 | weatherParameters.stationId = StationId; 59 | weatherParameters.weatherOffice = point.properties.cwa; 60 | weatherParameters.city = city; 61 | weatherParameters.state = state; 62 | weatherParameters.timeZone = point.properties.timeZone; 63 | weatherParameters.forecast = point.properties.forecast; 64 | weatherParameters.forecastGridData = point.properties.forecastGridData; 65 | weatherParameters.stations = stations.features; 66 | 67 | // update the main process for display purposes 68 | populateWeatherParameters(weatherParameters); 69 | 70 | // draw the progress canvas and hide others 71 | hideAllCanvases(); 72 | document.querySelector('#loading').style.display = 'none'; 73 | if (progress) { 74 | await progress.drawCanvas(); 75 | progress.showCanvas(); 76 | } 77 | 78 | // call for new data on each display 79 | displays.forEach((display) => display.getData(weatherParameters)); 80 | }; 81 | 82 | // receive a status update from a module {id, value} 83 | const updateStatus = (value) => { 84 | if (value.id < 0) return; 85 | if (!progress) return; 86 | progress.drawCanvas(displays, countLoadedDisplays()); 87 | 88 | // first display is hazards and it must load before evaluating the first display 89 | if (displays[0].status === STATUS.loading) return; 90 | 91 | // calculate first enabled display 92 | const firstDisplayIndex = displays.findIndex((display) => display.enabled && display.timing.totalScreens > 0); 93 | 94 | // value.id = 0 is hazards, if they fail to load hot-wire a new value.id to the current display to see if it needs to be loaded 95 | // typically this plays out as current conditions loads, then hazards fails. 96 | if (value.id === 0 && (value.status === STATUS.failed || value.status === STATUS.retrying)) { 97 | value.id = firstDisplayIndex; 98 | value.status = displays[firstDisplayIndex].status; 99 | } 100 | 101 | // if hazards data arrives after the firstDisplayIndex loads, then we need to hot wire this to the first display 102 | if (value.id === 0 && value.status === STATUS.loaded && displays[0].timing.totalScreens === 0) { 103 | value.id = firstDisplayIndex; 104 | value.status = displays[firstDisplayIndex].status; 105 | } 106 | 107 | // if this is the first display and we're playing, load it up so it starts playing 108 | if (isPlaying() && value.id === firstDisplayIndex && value.status === STATUS.loaded) { 109 | navTo(msg.command.firstFrame); 110 | } 111 | }; 112 | 113 | // note: a display that is "still waiting"/"retrying" is considered loaded intentionally 114 | // the weather.gov api has long load times for some products when you are the first 115 | // requester for the product after the cache expires 116 | const countLoadedDisplays = () => displays.reduce((acc, display) => { 117 | if (display.status !== STATUS.loading) return acc + 1; 118 | return acc; 119 | }, 0); 120 | 121 | const hideAllCanvases = () => { 122 | displays.forEach((display) => display.hideCanvas()); 123 | }; 124 | 125 | // is playing interface 126 | const isPlaying = () => playing; 127 | 128 | // navigation message constants 129 | const msg = { 130 | response: { // display to navigation 131 | previous: Symbol('previous'), // already at first frame, calling function should switch to previous canvas 132 | inProgress: Symbol('inProgress'), // have data to display, calling function should do nothing 133 | next: Symbol('next'), // end of frames reached, calling function should switch to next canvas 134 | }, 135 | command: { // navigation to display 136 | firstFrame: Symbol('firstFrame'), 137 | previousFrame: Symbol('previousFrame'), 138 | nextFrame: Symbol('nextFrame'), 139 | lastFrame: Symbol('lastFrame'), // used when navigating backwards from the begining of the next canvas 140 | }, 141 | }; 142 | 143 | // receive navigation messages from displays 144 | const displayNavMessage = (myMessage) => { 145 | if (myMessage.type === msg.response.previous) loadDisplay(-1); 146 | if (myMessage.type === msg.response.next) loadDisplay(1); 147 | }; 148 | 149 | // navigate to next or previous 150 | const navTo = (direction) => { 151 | // test for a current display 152 | const current = currentDisplay(); 153 | progress.hideCanvas(); 154 | if (!current) { 155 | // special case for no active displays (typically on progress screen) 156 | // find the first ready display 157 | let firstDisplay; 158 | let displayCount = 0; 159 | do { 160 | if (displays[displayCount].status === STATUS.loaded && displays[displayCount].timing.totalScreens > 0) firstDisplay = displays[displayCount]; 161 | displayCount += 1; 162 | } while (!firstDisplay && displayCount < displays.length); 163 | 164 | if (!firstDisplay) return; 165 | 166 | firstDisplay.navNext(msg.command.firstFrame); 167 | firstDisplay.showCanvas(); 168 | return; 169 | } 170 | if (direction === msg.command.nextFrame) currentDisplay().navNext(); 171 | if (direction === msg.command.previousFrame) currentDisplay().navPrev(); 172 | }; 173 | 174 | // find the next or previous available display 175 | const loadDisplay = (direction) => { 176 | const totalDisplays = displays.length; 177 | const curIdx = currentDisplayIndex(); 178 | let idx; 179 | for (let i = 0; i < totalDisplays; i += 1) { 180 | // convert form simple 0-10 to start at current display index +/-1 and wrap 181 | idx = wrap(curIdx + (i + 1) * direction, totalDisplays); 182 | if (displays[idx].status === STATUS.loaded && displays[idx].timing.totalScreens > 0) break; 183 | } 184 | const newDisplay = displays[idx]; 185 | // hide all displays 186 | hideAllCanvases(); 187 | // show the new display and navigate to an appropriate display 188 | if (direction < 0) newDisplay.showCanvas(msg.command.lastFrame); 189 | if (direction > 0) newDisplay.showCanvas(msg.command.firstFrame); 190 | }; 191 | 192 | // get the current display index or value 193 | const currentDisplayIndex = () => displays.findIndex((display) => display.active); 194 | const currentDisplay = () => displays[currentDisplayIndex()]; 195 | 196 | const setPlaying = (newValue) => { 197 | playing = newValue; 198 | const playButton = document.querySelector('#NavigatePlay'); 199 | localStorage.setItem('play', playing); 200 | 201 | if (playing) { 202 | noSleep(true); 203 | playButton.title = 'Pause'; 204 | playButton.src = 'images/nav/ic_pause_white_24dp_2x.png'; 205 | } else { 206 | noSleep(false); 207 | playButton.title = 'Play'; 208 | playButton.src = 'images/nav/ic_play_arrow_white_24dp_2x.png'; 209 | } 210 | // if we're playing and on the progress screen jump to the next screen 211 | if (!progress) return; 212 | if (playing && !currentDisplay()) navTo(msg.command.firstFrame); 213 | }; 214 | 215 | // handle all navigation buttons 216 | const handleNavButton = (button) => { 217 | switch (button) { 218 | case 'play': 219 | setPlaying(true); 220 | break; 221 | case 'playToggle': 222 | setPlaying(!playing); 223 | break; 224 | case 'stop': 225 | setPlaying(false); 226 | break; 227 | case 'next': 228 | setPlaying(false); 229 | navTo(msg.command.nextFrame); 230 | break; 231 | case 'previous': 232 | setPlaying(false); 233 | navTo(msg.command.previousFrame); 234 | break; 235 | case 'menu': 236 | setPlaying(false); 237 | progress.showCanvas(); 238 | hideAllCanvases(); 239 | break; 240 | default: 241 | console.error(`Unknown navButton ${button}`); 242 | } 243 | }; 244 | 245 | // return the specificed display 246 | const getDisplay = (index) => displays[index]; 247 | 248 | // resize the container on a page resize 249 | const resize = () => { 250 | const targetWidth = settings.wide.value ? 640 + 107 + 107 : 640; 251 | const widthZoomPercent = (document.querySelector('#divTwcBottom').getBoundingClientRect().width) / targetWidth; 252 | const heightZoomPercent = (window.innerHeight) / 480; 253 | 254 | const scale = Math.min(widthZoomPercent, heightZoomPercent); 255 | if (scale < 1.0 || document.fullscreenElement || settings.kiosk) { 256 | document.querySelector('#container').style.transform = `scale(${scale})`; 257 | } else { 258 | document.querySelector('#container').style.transform = 'unset'; 259 | } 260 | }; 261 | 262 | // reset all statuses to loading on all displays, used to keep the progress bar accurate during refresh 263 | const resetStatuses = () => { 264 | displays.forEach((display) => { display.status = STATUS.loading; }); 265 | }; 266 | 267 | // allow displays to register themselves 268 | const registerDisplay = (display) => { 269 | displays[display.navId] = display; 270 | 271 | // generate checkboxes 272 | generateCheckboxes(); 273 | }; 274 | 275 | const generateCheckboxes = () => { 276 | const availableDisplays = document.querySelector('#enabledDisplays'); 277 | 278 | if (!availableDisplays) return; 279 | // generate checkboxes 280 | const checkboxes = displays.map((d) => d.generateCheckbox(d.defaultEnabled)).filter((d) => d); 281 | 282 | // write to page 283 | availableDisplays.innerHTML = ''; 284 | availableDisplays.append(...checkboxes); 285 | }; 286 | 287 | // special registration method for progress display 288 | const registerProgress = (_progress) => { 289 | progress = _progress; 290 | }; 291 | 292 | const populateWeatherParameters = (params) => { 293 | document.querySelector('#spanCity').innerHTML = `${params.city}, `; 294 | document.querySelector('#spanState').innerHTML = params.state; 295 | document.querySelector('#spanStationId').innerHTML = params.stationId; 296 | document.querySelector('#spanRadarId').innerHTML = params.radarId; 297 | document.querySelector('#spanZoneId').innerHTML = params.zoneId; 298 | }; 299 | 300 | const latLonReceived = (data, haveDataCallback) => { 301 | getWeather(data, haveDataCallback); 302 | }; 303 | 304 | const timeZone = () => weatherParameters.timeZone; 305 | 306 | export { 307 | updateStatus, 308 | displayNavMessage, 309 | resetStatuses, 310 | isPlaying, 311 | resize, 312 | registerDisplay, 313 | registerProgress, 314 | currentDisplay, 315 | getDisplay, 316 | msg, 317 | message, 318 | latLonReceived, 319 | timeZone, 320 | }; 321 | -------------------------------------------------------------------------------- /server/scripts/modules/progress.mjs: -------------------------------------------------------------------------------- 1 | // regional forecast and observations 2 | import STATUS, { calcStatusClass, statusClasses } from './status.mjs'; 3 | import WeatherDisplay from './weatherdisplay.mjs'; 4 | import { 5 | registerProgress, message, getDisplay, msg, 6 | } from './navigation.mjs'; 7 | 8 | class Progress extends WeatherDisplay { 9 | constructor(navId, elemId) { 10 | super(navId, elemId, '', false); 11 | 12 | // disable any navigation timing 13 | this.timing = false; 14 | 15 | // setup event listener for dom-required initialization 16 | document.addEventListener('DOMContentLoaded', () => { 17 | this.version = document.querySelector('#version').innerHTML; 18 | this.elem.querySelector('.container').addEventListener('click', this.lineClick.bind(this)); 19 | }); 20 | 21 | this.okToDrawCurrentConditions = false; 22 | } 23 | 24 | async drawCanvas(displays, loadedCount) { 25 | if (!this.elem) return; 26 | super.drawCanvas(); 27 | 28 | // get the progress bar cover (makes percentage) 29 | if (!this.progressCover) this.progressCover = this.elem.querySelector('.scroll .cover'); 30 | 31 | // if no displays provided just draw the backgrounds (above) 32 | if (!displays) return; 33 | const lines = displays.map((display, index) => { 34 | if (display.showOnProgress === false) return false; 35 | const fill = { 36 | name: display.name, 37 | }; 38 | 39 | const statusClass = calcStatusClass(display.status); 40 | 41 | // make the line 42 | const line = this.fillTemplate('item', fill); 43 | // because of timing, this might get called before the template is loaded 44 | if (!line) return false; 45 | 46 | // update the status 47 | const links = line.querySelector('.links'); 48 | links.classList.remove(...statusClasses); 49 | links.classList.add(statusClass); 50 | links.dataset.index = index; 51 | return line; 52 | }).filter((d) => d); 53 | 54 | // get the container and update 55 | const container = this.elem.querySelector('.container'); 56 | container.innerHTML = ''; 57 | container.append(...lines); 58 | 59 | this.finishDraw(); 60 | 61 | // calculate loaded percent 62 | const loadedPercent = (loadedCount / displays.length); 63 | 64 | this.progressCover.style.width = `${(1.0 - loadedPercent) * 100}%`; 65 | if (loadedPercent < 1.0) { 66 | // show the progress bar and set width 67 | this.progressCover.parentNode.classList.add('show'); 68 | } else { 69 | // hide the progressbar after 1 second (lines up with with width transition animation) 70 | setTimeout(() => this.progressCover.parentNode.classList.remove('show'), 1000); 71 | } 72 | } 73 | 74 | lineClick(e) { 75 | // get index 76 | const indexRaw = e.target?.parentNode?.dataset?.index; 77 | if (indexRaw === undefined) return; 78 | const index = +indexRaw; 79 | 80 | // stop playing 81 | message('navButton'); 82 | // use the y value to determine an index 83 | const display = getDisplay(index); 84 | if (display && display.status === STATUS.loaded) { 85 | display.showCanvas(msg.command.firstFrame); 86 | this.elem.classList.remove('show'); 87 | } 88 | } 89 | 90 | getVersion() { 91 | return this.version; 92 | } 93 | } 94 | 95 | // register our own display 96 | const progress = new Progress(-1, 'progress'); 97 | registerProgress(progress); 98 | 99 | export default progress; 100 | -------------------------------------------------------------------------------- /server/scripts/modules/radar-utils.mjs: -------------------------------------------------------------------------------- 1 | const getXYFromLatitudeLongitudeMap = (pos, offsetX, offsetY) => { 2 | let y = 0; 3 | let x = 0; 4 | const imgHeight = 3200; 5 | const imgWidth = 5100; 6 | 7 | y = (51.75 - pos.latitude) * 55.2; 8 | // center map 9 | y -= offsetY; 10 | 11 | // Do not allow the map to exceed the max/min coordinates. 12 | if (y > (imgHeight - (offsetY * 2))) { 13 | y = imgHeight - (offsetY * 2); 14 | } else if (y < 0) { 15 | y = 0; 16 | } 17 | 18 | x = ((-130.37 - pos.longitude) * 41.775) * -1; 19 | // center map 20 | x -= offsetX; 21 | 22 | // Do not allow the map to exceed the max/min coordinates. 23 | if (x > (imgWidth - (offsetX * 2))) { 24 | x = imgWidth - (offsetX * 2); 25 | } else if (x < 0) { 26 | x = 0; 27 | } 28 | 29 | return { x: x * 2, y: y * 2 }; 30 | }; 31 | 32 | const getXYFromLatitudeLongitudeDoppler = (pos, offsetX, offsetY) => { 33 | let y = 0; 34 | let x = 0; 35 | const imgHeight = 6000; 36 | const imgWidth = 2800; 37 | 38 | y = (51 - pos.latitude) * 61.4481; 39 | // center map 40 | y -= offsetY; 41 | 42 | // Do not allow the map to exceed the max/min coordinates. 43 | if (y > (imgHeight - (offsetY * 2))) { 44 | y = imgHeight - (offsetY * 2); 45 | } else if (y < 0) { 46 | y = 0; 47 | } 48 | 49 | x = ((-129.138 - pos.longitude) * 42.1768) * -1; 50 | // center map 51 | x -= offsetX; 52 | 53 | // Do not allow the map to exceed the max/min coordinates. 54 | if (x > (imgWidth - (offsetX * 2))) { 55 | x = imgWidth - (offsetX * 2); 56 | } else if (x < 0) { 57 | x = 0; 58 | } 59 | 60 | return { x: x * 2, y: y * 2 }; 61 | }; 62 | 63 | const removeDopplerRadarImageNoise = (RadarContext) => { 64 | const RadarImageData = RadarContext.getImageData(0, 0, RadarContext.canvas.width, RadarContext.canvas.height); 65 | 66 | // examine every pixel, 67 | // change any old rgb to the new-rgb 68 | for (let i = 0; i < RadarImageData.data.length; i += 4) { 69 | // i + 0 = red 70 | // i + 1 = green 71 | // i + 2 = blue 72 | // i + 3 = alpha (0 = transparent, 255 = opaque) 73 | let R = RadarImageData.data[i]; 74 | let G = RadarImageData.data[i + 1]; 75 | let B = RadarImageData.data[i + 2]; 76 | let A = RadarImageData.data[i + 3]; 77 | 78 | // is this pixel the old rgb? 79 | if ((R === 0 && G === 0 && B === 0) 80 | || (R === 0 && G === 236 && B === 236) 81 | || (R === 1 && G === 160 && B === 246) 82 | || (R === 0 && G === 0 && B === 246)) { 83 | // change to your new rgb 84 | 85 | // Transparent 86 | R = 0; 87 | G = 0; 88 | B = 0; 89 | A = 0; 90 | } else if ((R === 0 && G === 255 && B === 0)) { 91 | // Light Green 1 92 | R = 49; 93 | G = 210; 94 | B = 22; 95 | A = 255; 96 | } else if ((R === 0 && G === 200 && B === 0)) { 97 | // Light Green 2 98 | R = 0; 99 | G = 142; 100 | B = 0; 101 | A = 255; 102 | } else if ((R === 0 && G === 144 && B === 0)) { 103 | // Dark Green 1 104 | R = 20; 105 | G = 90; 106 | B = 15; 107 | A = 255; 108 | } else if ((R === 255 && G === 255 && B === 0)) { 109 | // Dark Green 2 110 | R = 10; 111 | G = 40; 112 | B = 10; 113 | A = 255; 114 | } else if ((R === 231 && G === 192 && B === 0)) { 115 | // Yellow 116 | R = 196; 117 | G = 179; 118 | B = 70; 119 | A = 255; 120 | } else if ((R === 255 && G === 144 && B === 0)) { 121 | // Orange 122 | R = 190; 123 | G = 72; 124 | B = 19; 125 | A = 255; 126 | } else if ((R === 214 && G === 0 && B === 0) 127 | || (R === 255 && G === 0 && B === 0)) { 128 | // Red 129 | R = 171; 130 | G = 14; 131 | B = 14; 132 | A = 255; 133 | } else if ((R === 192 && G === 0 && B === 0) 134 | || (R === 255 && G === 0 && B === 255)) { 135 | // Brown 136 | R = 115; 137 | G = 31; 138 | B = 4; 139 | A = 255; 140 | } 141 | 142 | RadarImageData.data[i] = R; 143 | RadarImageData.data[i + 1] = G; 144 | RadarImageData.data[i + 2] = B; 145 | RadarImageData.data[i + 3] = A; 146 | } 147 | 148 | RadarContext.putImageData(RadarImageData, 0, 0); 149 | }; 150 | 151 | const mergeDopplerRadarImage = (mapContext, radarContext) => { 152 | const mapImageData = mapContext.getImageData(0, 0, mapContext.canvas.width, mapContext.canvas.height); 153 | const radarImageData = radarContext.getImageData(0, 0, radarContext.canvas.width, radarContext.canvas.height); 154 | 155 | // examine every pixel, 156 | // change any old rgb to the new-rgb 157 | for (let i = 0; i < radarImageData.data.length; i += 4) { 158 | // i + 0 = red 159 | // i + 1 = green 160 | // i + 2 = blue 161 | // i + 3 = alpha (0 = transparent, 255 = opaque) 162 | 163 | // is this pixel the old rgb? 164 | if ((mapImageData.data[i] < 116 && mapImageData.data[i + 1] < 116 && mapImageData.data[i + 2] < 116)) { 165 | // change to your new rgb 166 | 167 | // Transparent 168 | radarImageData.data[i] = 0; 169 | radarImageData.data[i + 1] = 0; 170 | radarImageData.data[i + 2] = 0; 171 | radarImageData.data[i + 3] = 0; 172 | } 173 | } 174 | 175 | radarContext.putImageData(radarImageData, 0, 0); 176 | 177 | mapContext.drawImage(radarContext.canvas, 0, 0); 178 | }; 179 | 180 | export { 181 | getXYFromLatitudeLongitudeDoppler, 182 | getXYFromLatitudeLongitudeMap, 183 | removeDopplerRadarImageNoise, 184 | mergeDopplerRadarImage, 185 | }; 186 | -------------------------------------------------------------------------------- /server/scripts/modules/regionalforecast.mjs: -------------------------------------------------------------------------------- 1 | // regional forecast 2 | 3 | import STATUS from './status.mjs'; 4 | import { distance as calcDistance } from './utils/calc.mjs'; 5 | import { json } from './utils/fetch.mjs'; 6 | import WeatherDisplay from './weatherdisplay.mjs'; 7 | import { registerDisplay } from './navigation.mjs'; 8 | import { getPoint } from './utils/weather.mjs'; 9 | import { locationCleanup, shortenCurrentConditions } from './utils/string.mjs'; 10 | 11 | class RegionalForecast extends WeatherDisplay { 12 | constructor(navId, elemId) { 13 | super(navId, elemId, 'Regional Forecast', true); 14 | 15 | // timings 16 | this.timing.totalScreens = 1; 17 | } 18 | 19 | async getData(weatherParameters, refresh) { 20 | if (!super.getData(weatherParameters, refresh)) return; 21 | // regional forecast implements a silent reload 22 | // but it will not fall back to previously loaded data if data can not be loaded 23 | // there are enough other cities available to populate the map sufficiently even if some do not load 24 | 25 | // calculate distance to each city 26 | const regionalCitiesDistances = RegionalCities.map((city) => ({ 27 | ...city, 28 | distance: calcDistance(city.lon, city.lat, this.weatherParameters.longitude, this.weatherParameters.latitude), 29 | })); 30 | 31 | // sort the regional cities by distance 32 | const regionalCitiesSorted = regionalCitiesDistances.toSorted((a, b) => a.distance - b.distance); 33 | 34 | // get regional forecasts and observations (the two are intertwined due to the design of api.weather.gov) 35 | const regionalDataAll = await Promise.all(regionalCitiesSorted.slice(0, 9).map(async (city) => { 36 | try { 37 | const point = city?.point ?? (await getAndFormatPoint(city.lat, city.lon)); 38 | if (!point) throw new Error('No pre-loaded point'); 39 | 40 | const forecast = await json(`https://api.weather.gov/gridpoints/${point.wfo}/${point.x},${point.y}/forecast`); 41 | 42 | // we need a high and low temperature 43 | // high temperature is the first period with isDaytime = true 44 | // low temperature is the next adjacent period 45 | const firstDaytime = forecast.properties.periods.findIndex((period) => period.isDaytime); 46 | 47 | return buildForecast(city, forecast.properties.periods[firstDaytime], forecast.properties.periods[firstDaytime + 1]); 48 | } catch (error) { 49 | console.log(`No regional forecast data for '${city.name ?? city.city}'`); 50 | console.log(error); 51 | return false; 52 | } 53 | })); 54 | 55 | // filter out any false (unavailable data) 56 | const regionalData = regionalDataAll.filter((data) => data); 57 | 58 | // test for data present 59 | if (regionalData.length === 0) { 60 | this.setStatus(STATUS.noData); 61 | return; 62 | } 63 | 64 | // return the weather data and offsets 65 | this.data = regionalData; 66 | this.setStatus(STATUS.loaded); 67 | } 68 | 69 | async drawCanvas() { 70 | super.drawCanvas(); 71 | const conditions = this.data; 72 | 73 | // sort array by station name 74 | const forecasts = conditions.sort((a, b) => ((a.Name < b.Name) ? -1 : 1)); 75 | 76 | const lines = forecasts.slice(0, 7).map((forecast) => { 77 | const fill = { 78 | location: locationCleanup(forecast.name).substr(0, 14), 79 | 'temp-low': forecast.low, 80 | 'temp-high': forecast.high, 81 | weather: shortenCurrentConditions(forecast.weather).substr(0, 9), 82 | }; 83 | 84 | return this.fillTemplate('forecast-row', fill); 85 | }); 86 | 87 | const linesContainer = this.elem.querySelector('.forecast-lines'); 88 | linesContainer.innerHTML = ''; 89 | linesContainer.append(...lines); 90 | 91 | this.finishDraw(); 92 | } 93 | } 94 | 95 | const getAndFormatPoint = async (lat, lon) => { 96 | const point = await getPoint(lat, lon); 97 | return { 98 | x: point.properties.gridX, 99 | y: point.properties.gridY, 100 | wfo: point.properties.gridId, 101 | }; 102 | }; 103 | 104 | const buildForecast = (city, high, low) => ({ 105 | high: high?.temperature ?? 0, 106 | low: low?.temperature ?? 0, 107 | name: locationCleanup(city.city).substr(0, 14), 108 | weather: shortenCurrentConditions(high.shortForecast), 109 | }); 110 | 111 | // register display 112 | registerDisplay(new RegionalForecast(3, 'regional-forecast')); 113 | -------------------------------------------------------------------------------- /server/scripts/modules/settings.mjs: -------------------------------------------------------------------------------- 1 | import Setting from './utils/setting.mjs'; 2 | 3 | document.addEventListener('DOMContentLoaded', () => { 4 | init(); 5 | }); 6 | 7 | // default speed 8 | const settings = { speed: { value: 1.0 } }; 9 | 10 | const init = () => { 11 | // create settings see setting.mjs for defaults 12 | settings.wide = new Setting('wide', { 13 | name: 'Widescreen', 14 | defaultValue: false, 15 | changeAction: wideScreenChange, 16 | sticky: true, 17 | }); 18 | settings.kiosk = new Setting('kiosk', { 19 | name: 'Kiosk', 20 | defaultValue: false, 21 | changeAction: kioskChange, 22 | sticky: false, 23 | }); 24 | settings.speed = new Setting('speed', { 25 | name: 'Speed', 26 | type: 'select', 27 | defaultValue: 1.0, 28 | values: [ 29 | [0.5, 'Very Fast'], 30 | [0.75, 'Fast'], 31 | [1.0, 'Normal'], 32 | [1.25, 'Slow'], 33 | [1.5, 'Very Slow'], 34 | ], 35 | }); 36 | settings.refreshTime = new Setting('refreshTime', { 37 | type: 'select', 38 | defaultValue: 600_000, 39 | sticky: false, 40 | values: [ 41 | [30_000, 'TESTING'], 42 | [300_000, '5 minutes'], 43 | [600_000, '10 minutes'], 44 | [900_000, '15 minutes'], 45 | [1_800_000, '30 minutes'], 46 | ], 47 | visible: false, 48 | }); 49 | 50 | // generate html objects 51 | const settingHtml = Object.values(settings).map((d) => d.generate()); 52 | 53 | // write to page 54 | const settingsSection = document.querySelector('#settings'); 55 | settingsSection.innerHTML = ''; 56 | settingsSection.append(...settingHtml); 57 | }; 58 | 59 | const wideScreenChange = (value) => { 60 | const container = document.querySelector('#divTwc'); 61 | if (value) { 62 | container.classList.add('wide'); 63 | } else { 64 | container.classList.remove('wide'); 65 | } 66 | }; 67 | 68 | const kioskChange = (value) => { 69 | const body = document.querySelector('body'); 70 | if (value) { 71 | body.classList.add('kiosk'); 72 | window.dispatchEvent(new Event('resize')); 73 | } else { 74 | body.classList.remove('kiosk'); 75 | } 76 | }; 77 | 78 | export default settings; 79 | -------------------------------------------------------------------------------- /server/scripts/modules/share.mjs: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', () => init()); 2 | 3 | // shorthand mappings for frequently used values 4 | const specialMappings = { 5 | kiosk: 'settings-kiosk-checkbox', 6 | }; 7 | 8 | const init = () => { 9 | // add action to existing link 10 | const shareLink = document.querySelector('#share-link'); 11 | shareLink.addEventListener('click', createLink); 12 | 13 | // if navigator.clipboard does not exist, change text 14 | if (!navigator?.clipboard) { 15 | shareLink.textContent = 'Get Permalink'; 16 | } 17 | }; 18 | 19 | const createLink = async (e) => { 20 | // cancel default event (click on hyperlink) 21 | e.preventDefault(); 22 | // get all checkboxes on page 23 | const checkboxes = document.querySelectorAll('input[type=checkbox]'); 24 | 25 | // list to receive checkbox statuses 26 | const queryStringElements = {}; 27 | 28 | [...checkboxes].forEach((elem) => { 29 | if (elem?.id) { 30 | queryStringElements[elem.id] = elem?.checked ?? false; 31 | } 32 | }); 33 | 34 | // get all select boxes 35 | const selects = document.querySelectorAll('select'); 36 | [...selects].forEach((elem) => { 37 | if (elem?.id) { 38 | queryStringElements[elem.id] = elem?.value ?? 0; 39 | } 40 | }); 41 | 42 | // add the location string 43 | queryStringElements.latLonQuery = localStorage.getItem('latLonQuery'); 44 | queryStringElements.latLon = localStorage.getItem('latLon'); 45 | 46 | const queryString = (new URLSearchParams(queryStringElements)).toString(); 47 | 48 | const url = new URL(`?${queryString}`, document.location.href); 49 | 50 | // send to proper function based on availability of clipboard 51 | if (navigator?.clipboard) { 52 | copyToClipboard(url); 53 | } else { 54 | writeLinkToPage(url); 55 | } 56 | }; 57 | 58 | const copyToClipboard = async (url) => { 59 | try { 60 | // write to clipboard 61 | await navigator.clipboard.writeText(url.toString()); 62 | // alert user 63 | const confirmSpan = document.querySelector('#share-link-copied'); 64 | confirmSpan.style.display = 'inline'; 65 | 66 | // hide confirm text after 5 seconds 67 | setTimeout(() => { 68 | confirmSpan.style.display = 'none'; 69 | }, 5000); 70 | } catch (error) { 71 | console.error(error); 72 | } 73 | }; 74 | 75 | const writeLinkToPage = (url) => { 76 | // get elements 77 | const shareLinkInstructions = document.querySelector('#share-link-instructions'); 78 | const shareLinkUrl = shareLinkInstructions.querySelector('#share-link-url'); 79 | // populate url and display 80 | shareLinkUrl.value = url; 81 | shareLinkInstructions.style.display = 'inline'; 82 | // highlight for convenience 83 | shareLinkUrl.focus(); 84 | shareLinkUrl.select(); 85 | }; 86 | 87 | const parseQueryString = () => { 88 | // return memoized result 89 | if (parseQueryString.params) return parseQueryString.params; 90 | const urlSearchParams = new URLSearchParams(window.location.search); 91 | 92 | // turn into an array of key-value pairs 93 | const paramsArray = [...urlSearchParams]; 94 | 95 | // add additional expanded keys 96 | paramsArray.forEach((paramPair) => { 97 | const expandedKey = specialMappings[paramPair[0]]; 98 | if (expandedKey) { 99 | paramsArray.push([expandedKey, paramPair[1]]); 100 | } 101 | }); 102 | 103 | // memoize result 104 | parseQueryString.params = Object.fromEntries(paramsArray); 105 | 106 | return parseQueryString.params; 107 | }; 108 | 109 | export { 110 | createLink, 111 | parseQueryString, 112 | }; 113 | -------------------------------------------------------------------------------- /server/scripts/modules/status.mjs: -------------------------------------------------------------------------------- 1 | const STATUS = { 2 | loading: Symbol('loading'), 3 | loaded: Symbol('loaded'), 4 | failed: Symbol('failed'), 5 | noData: Symbol('noData'), 6 | disabled: Symbol('disabled'), 7 | retrying: Symbol('retrying'), 8 | }; 9 | 10 | const calcStatusClass = (statusCode) => { 11 | switch (statusCode) { 12 | case STATUS.loading: 13 | return 'loading'; 14 | case STATUS.loaded: 15 | return 'press-here'; 16 | case STATUS.failed: 17 | return 'failed'; 18 | case STATUS.noData: 19 | return 'no-data'; 20 | case STATUS.disabled: 21 | return 'disabled'; 22 | case STATUS.retrying: 23 | return 'retrying'; 24 | default: 25 | return ''; 26 | } 27 | }; 28 | 29 | const statusClasses = ['loading', 'press-here', 'failed', 'no-data', 'disabled', 'retrying']; 30 | 31 | export default STATUS; 32 | export { 33 | calcStatusClass, 34 | statusClasses, 35 | }; 36 | -------------------------------------------------------------------------------- /server/scripts/modules/utils/calc.mjs: -------------------------------------------------------------------------------- 1 | // wind direction 2 | const directionToNSEW = (Direction) => { 3 | const val = Math.floor((Direction / 22.5) + 0.5); 4 | const arr = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']; 5 | return arr[(val % 16)]; 6 | }; 7 | 8 | const distance = (x1, y1, x2, y2) => Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); 9 | 10 | // wrap a number to 0-m 11 | const wrap = (x, m) => ((x % m) + m) % m; 12 | 13 | export { 14 | directionToNSEW, 15 | distance, 16 | wrap, 17 | }; 18 | -------------------------------------------------------------------------------- /server/scripts/modules/utils/cors.mjs: -------------------------------------------------------------------------------- 1 | // rewrite some urls for local server 2 | const rewriteUrl = (_url) => { 3 | let url = _url; 4 | url = url.replace('https://api.weather.gov/', `${window.location.protocol}//${window.location.host}/`); 5 | url = url.replace('https://www.cpc.ncep.noaa.gov/', `${window.location.protocol}//${window.location.host}/`); 6 | return url; 7 | }; 8 | 9 | export { 10 | // eslint-disable-next-line import/prefer-default-export 11 | rewriteUrl, 12 | }; 13 | -------------------------------------------------------------------------------- /server/scripts/modules/utils/elem.mjs: -------------------------------------------------------------------------------- 1 | const elemForEach = (selector, callback) => { 2 | [...document.querySelectorAll(selector)].forEach(callback); 3 | }; 4 | 5 | export { 6 | // eslint-disable-next-line import/prefer-default-export 7 | elemForEach, 8 | }; 9 | -------------------------------------------------------------------------------- /server/scripts/modules/utils/fetch.mjs: -------------------------------------------------------------------------------- 1 | import { rewriteUrl } from './cors.mjs'; 2 | 3 | const json = (url, params) => fetchAsync(url, 'json', params); 4 | const text = (url, params) => fetchAsync(url, 'text', params); 5 | const blob = (url, params) => fetchAsync(url, 'blob', params); 6 | 7 | const fetchAsync = async (_url, responseType, _params = {}) => { 8 | // combine default and provided parameters 9 | const params = { 10 | method: 'GET', 11 | mode: 'cors', 12 | type: 'GET', 13 | retryCount: 0, 14 | ..._params, 15 | }; 16 | // store original number of retries 17 | params.originalRetries = params.retryCount; 18 | 19 | // build a url, including the rewrite for cors if necessary 20 | let corsUrl = _url; 21 | if (params.cors === true) corsUrl = rewriteUrl(_url); 22 | const url = new URL(corsUrl, `${window.location.origin}/`); 23 | // match the security protocol when not on localhost 24 | // url.protocol = window.location.hostname === 'localhost' ? url.protocol : window.location.protocol; 25 | // add parameters if necessary 26 | if (params.data) { 27 | Object.keys(params.data).forEach((key) => { 28 | // get the value 29 | const value = params.data[key]; 30 | // add to the url 31 | url.searchParams.append(key, value); 32 | }); 33 | } 34 | 35 | // make the request 36 | const response = await doFetch(url, params); 37 | 38 | // check for ok response 39 | if (!response.ok) throw new Error(`Fetch error ${response.status} ${response.statusText} while fetching ${response.url}`); 40 | // return the requested response 41 | switch (responseType) { 42 | case 'json': 43 | return response.json(); 44 | case 'text': 45 | return response.text(); 46 | case 'blob': 47 | return response.blob(); 48 | default: 49 | return response; 50 | } 51 | }; 52 | 53 | // fetch with retry and back-off 54 | const doFetch = (url, params) => new Promise((resolve, reject) => { 55 | fetch(url, params).then((response) => { 56 | if (params.retryCount > 0) { 57 | // 500 status codes should be retried after a short backoff 58 | if (response.status >= 500 && response.status <= 599 && params.retryCount > 0) { 59 | // call the "still waiting" function 60 | if (typeof params.stillWaiting === 'function' && params.retryCount === params.originalRetries) { 61 | params.stillWaiting(); 62 | } 63 | // decrement and retry 64 | const newParams = { 65 | ...params, 66 | retryCount: params.retryCount - 1, 67 | }; 68 | return resolve(delay(retryDelay(params.originalRetries - newParams.retryCount), doFetch, url, newParams)); 69 | } 70 | // not 500 status 71 | return resolve(response); 72 | } 73 | // out of retries 74 | return resolve(response); 75 | }) 76 | .catch(reject); 77 | }); 78 | 79 | const delay = (time, func, ...args) => new Promise((resolve) => { 80 | setTimeout(() => { 81 | resolve(func(...args)); 82 | }, time); 83 | }); 84 | 85 | const retryDelay = (retryNumber) => { 86 | switch (retryNumber) { 87 | case 1: return 1000; 88 | case 2: return 2000; 89 | case 3: return 5000; 90 | case 4: return 10_000; 91 | default: return 30_000; 92 | } 93 | }; 94 | 95 | export { 96 | json, 97 | text, 98 | blob, 99 | }; 100 | -------------------------------------------------------------------------------- /server/scripts/modules/utils/nosleep.mjs: -------------------------------------------------------------------------------- 1 | // track state of nosleep locally to avoid a null case error 2 | // when nosleep.disable is called without first calling .enable 3 | 4 | let wakeLock = false; 5 | 6 | const noSleep = (enable = false) => { 7 | // get a nosleep controller 8 | if (!noSleep.controller) noSleep.controller = new NoSleep(); 9 | // don't call anything if the states match 10 | if (wakeLock === enable) return false; 11 | // store the value 12 | wakeLock = enable; 13 | // call the function 14 | if (enable) return noSleep.controller.enable(); 15 | return noSleep.controller.disable(); 16 | }; 17 | 18 | export default noSleep; 19 | -------------------------------------------------------------------------------- /server/scripts/modules/utils/setting.mjs: -------------------------------------------------------------------------------- 1 | import { parseQueryString } from '../share.mjs'; 2 | 3 | const SETTINGS_KEY = 'Settings'; 4 | 5 | const DEFAULTS = { 6 | shortName: undefined, 7 | name: undefined, 8 | type: 'checkbox', 9 | defaultValue: undefined, 10 | changeAction: () => { }, 11 | sticky: true, 12 | values: [], 13 | visible: true, 14 | }; 15 | 16 | class Setting { 17 | constructor(shortName, _options) { 18 | if (shortName === undefined) { 19 | throw new Error('No name provided for setting'); 20 | } 21 | // merge options with defaults 22 | const options = { ...DEFAULTS, ...(_options ?? {}) }; 23 | 24 | // store values and combine with defaults 25 | this.shortName = shortName; 26 | this.name = options.name ?? shortName; 27 | this.defaultValue = options.defaultValue; 28 | this.myValue = this.defaultValue; 29 | this.type = options?.type; 30 | this.sticky = options.sticky; 31 | this.values = options.values; 32 | this.visible = options.visible; 33 | this.changeAction = options.changeAction; 34 | 35 | // get value from url 36 | const urlValue = parseQueryString()?.[`settings-${shortName}-${this.type}`]; 37 | let urlState; 38 | if (this.type === 'checkbox' && urlValue !== undefined) { 39 | urlState = urlValue === 'true'; 40 | } 41 | if (this.type === 'select' && urlValue !== undefined) { 42 | urlState = parseFloat(urlValue); 43 | } 44 | if (this.type === 'select' && urlValue !== undefined && Number.isNaN(urlState)) { 45 | // couldn't parse as a float, store as a string 46 | urlState = urlValue; 47 | } 48 | 49 | // get existing value if present 50 | const storedValue = urlState ?? this.getFromLocalStorage(); 51 | if ((this.sticky || urlValue !== undefined) && storedValue !== null) { 52 | this.myValue = storedValue; 53 | } 54 | 55 | // call the change function on startup 56 | switch (this.type) { 57 | case 'select': 58 | this.selectChange({ target: { value: this.myValue } }); 59 | break; 60 | case 'checkbox': 61 | default: 62 | this.checkboxChange({ target: { checked: this.myValue } }); 63 | } 64 | } 65 | 66 | generateSelect() { 67 | // create a radio button set in the selected displays area 68 | const label = document.createElement('label'); 69 | label.for = `settings-${this.shortName}-select`; 70 | label.id = `settings-${this.shortName}-label`; 71 | 72 | const span = document.createElement('span'); 73 | span.innerHTML = `${this.name} `; 74 | label.append(span); 75 | 76 | const select = document.createElement('select'); 77 | select.id = `settings-${this.shortName}-select`; 78 | select.name = `settings-${this.shortName}-select`; 79 | select.addEventListener('change', (e) => this.selectChange(e)); 80 | 81 | this.values.forEach(([value, text]) => { 82 | const option = document.createElement('option'); 83 | if (typeof value === 'number') { 84 | option.value = value.toFixed(2); 85 | } else { 86 | option.value = value; 87 | } 88 | 89 | option.innerHTML = text; 90 | select.append(option); 91 | }); 92 | label.append(select); 93 | 94 | this.element = label; 95 | 96 | // set the initial value 97 | this.selectHighlight(this.myValue); 98 | 99 | return label; 100 | } 101 | 102 | generateCheckbox() { 103 | // create a checkbox in the selected displays area 104 | const label = document.createElement('label'); 105 | label.for = `settings-${this.shortName}-checkbox`; 106 | label.id = `settings-${this.shortName}-label`; 107 | const checkbox = document.createElement('input'); 108 | checkbox.type = 'checkbox'; 109 | checkbox.value = true; 110 | checkbox.id = `settings-${this.shortName}-checkbox`; 111 | checkbox.name = `settings-${this.shortName}-checkbox`; 112 | checkbox.checked = this.myValue; 113 | checkbox.addEventListener('change', (e) => this.checkboxChange(e)); 114 | const span = document.createElement('span'); 115 | span.innerHTML = this.name; 116 | 117 | label.append(checkbox, span); 118 | 119 | this.element = label; 120 | 121 | return label; 122 | } 123 | 124 | checkboxChange(e) { 125 | // update the state 126 | this.myValue = e.target.checked; 127 | this.storeToLocalStorage(this.myValue); 128 | 129 | // call change action 130 | this.changeAction(this.myValue); 131 | } 132 | 133 | selectChange(e) { 134 | // update the value 135 | this.myValue = parseFloat(e.target.value); 136 | if (Number.isNaN(this.myValue)) { 137 | // was a string, store as such 138 | this.myValue = e.target.value; 139 | } 140 | this.storeToLocalStorage(this.myValue); 141 | 142 | // call the change action 143 | this.changeAction(this.myValue); 144 | } 145 | 146 | storeToLocalStorage(value) { 147 | if (!this.sticky) return; 148 | const allSettingsString = localStorage?.getItem(SETTINGS_KEY) ?? '{}'; 149 | const allSettings = JSON.parse(allSettingsString); 150 | allSettings[this.shortName] = value; 151 | localStorage?.setItem(SETTINGS_KEY, JSON.stringify(allSettings)); 152 | } 153 | 154 | getFromLocalStorage() { 155 | const allSettings = localStorage?.getItem(SETTINGS_KEY); 156 | try { 157 | if (allSettings) { 158 | const storedValue = JSON.parse(allSettings)?.[this.shortName]; 159 | if (storedValue !== undefined) { 160 | switch (this.type) { 161 | case 'boolean': 162 | case 'checkbox': 163 | return storedValue; 164 | case 'select': 165 | return storedValue; 166 | default: 167 | return null; 168 | } 169 | } 170 | } 171 | } catch { 172 | return null; 173 | } 174 | return null; 175 | } 176 | 177 | get value() { 178 | return this.myValue; 179 | } 180 | 181 | set value(newValue) { 182 | // update the state 183 | this.myValue = newValue; 184 | switch (this.type) { 185 | case 'select': 186 | this.selectHighlight(newValue); 187 | break; 188 | case 'boolean': 189 | break; 190 | case 'checkbox': 191 | default: 192 | this.element.checked = newValue; 193 | } 194 | this.storeToLocalStorage(this.myValue); 195 | 196 | // call change action 197 | this.changeAction(this.myValue); 198 | } 199 | 200 | selectHighlight(newValue) { 201 | // set the dropdown to the provided value 202 | this?.element?.querySelectorAll('option')?.forEach?.((elem) => { 203 | elem.selected = (newValue?.toFixed?.(2) === elem.value) || (newValue === elem.value); 204 | }); 205 | } 206 | 207 | generate() { 208 | // don't generate a control for not visible items 209 | if (!this.visible) return ''; 210 | // call the appropriate control generator 211 | switch (this.type) { 212 | case 'select': 213 | return this.generateSelect(); 214 | case 'checkbox': 215 | default: 216 | return this.generateCheckbox(); 217 | } 218 | } 219 | } 220 | 221 | export default Setting; 222 | -------------------------------------------------------------------------------- /server/scripts/modules/utils/string.mjs: -------------------------------------------------------------------------------- 1 | const locationCleanup = (input) => { 2 | // regexes to run 3 | const regexes = [ 4 | // "Chicago / West Chicago", removes before slash 5 | /^[ A-Za-z]+ \/ /, 6 | // "Chicago/Waukegan" removes before slash 7 | /^[ A-Za-z]+\//, 8 | // "Chicago, Chicago O'hare" removes before comma 9 | /^[ A-Za-z]+, /, 10 | ]; 11 | 12 | // run all regexes 13 | return regexes.reduce((value, regex) => value.replace(regex, ''), input); 14 | }; 15 | 16 | const shortenCurrentConditions = (_condition) => { 17 | let condition = _condition; 18 | condition = condition.replace(/Light/, 'L'); 19 | condition = condition.replace(/Heavy/, 'H'); 20 | condition = condition.replace(/Partly/, 'P'); 21 | condition = condition.replace(/Mostly/, 'M'); 22 | condition = condition.replace(/Few/, 'F'); 23 | condition = condition.replace(/Thunderstorm/, 'T\'storm'); 24 | condition = condition.replace(/ in /, ''); 25 | condition = condition.replace(/Vicinity/, ''); 26 | condition = condition.replace(/ and /, ' '); 27 | condition = condition.replace(/Freezing Rain/, 'Frz Rn'); 28 | condition = condition.replace(/Freezing/, 'Frz'); 29 | condition = condition.replace(/Unknown Precip/, ''); 30 | condition = condition.replace(/L Snow Fog/, 'L Snw/Fog'); 31 | condition = condition.replace(/ with /, '/'); 32 | return condition; 33 | }; 34 | 35 | export { 36 | locationCleanup, 37 | shortenCurrentConditions, 38 | }; 39 | -------------------------------------------------------------------------------- /server/scripts/modules/utils/units.mjs: -------------------------------------------------------------------------------- 1 | // get the settings for units 2 | import settings from '../settings.mjs'; 3 | // *********************************** unit conversions *********************** 4 | 5 | // round 2 provided for lat/lon formatting 6 | const round2 = (value, decimals) => Math.trunc(value * 10 ** decimals) / 10 ** decimals; 7 | 8 | const kphToMph = (Kph) => Math.round(Kph / 1.609_34); 9 | const celsiusToFahrenheit = (Celsius) => Math.round((Celsius * 9) / 5 + 32); 10 | const fahrenheitToCelsius = (Fahrenheit) => Math.round((Fahrenheit - 32) * 5 / 9); 11 | const kilometersToMiles = (Kilometers) => Math.round(Kilometers / 1.609_34); 12 | const metersToFeet = (Meters) => Math.round(Meters / 0.3048); 13 | const pascalToInHg = (Pascal) => round2(Pascal * 0.000_295_3, 2); 14 | 15 | // each module/page/slide creates it's own unit converter as needed by providing the base units available 16 | // the factory function then returns an appropriate converter or pass-thru function for use on the page 17 | 18 | const windSpeed = (defaultUnit = 'si') => { 19 | // default to passthru 20 | let converter = (passthru) => Math.round(passthru); 21 | // change the converter if there is a mismatch 22 | if (defaultUnit !== settings.units.value) { 23 | converter = kphToMph; 24 | } 25 | // append units 26 | if (settings.units.value === 'si') { 27 | converter.units = 'kph'; 28 | } else { 29 | converter.units = 'MPH'; 30 | } 31 | return converter; 32 | }; 33 | 34 | const temperature = (defaultUnit = 'si') => { 35 | // default to passthru 36 | let converter = (passthru) => Math.round(passthru); 37 | // change the converter if there is a mismatch 38 | if (defaultUnit !== settings.units.value) { 39 | if (defaultUnit === 'us') { 40 | converter = fahrenheitToCelsius; 41 | } else { 42 | converter = celsiusToFahrenheit; 43 | } 44 | } 45 | // append units 46 | if (settings.units.value === 'si') { 47 | converter.units = 'C'; 48 | } else { 49 | converter.units = 'F'; 50 | } 51 | return converter; 52 | }; 53 | 54 | const distanceMeters = (defaultUnit = 'si') => { 55 | // default to passthru 56 | let converter = (passthru) => Math.round(passthru); 57 | // change the converter if there is a mismatch 58 | if (defaultUnit !== settings.units.value) { 59 | // rounded to the nearest 100 (ceiling) 60 | converter = (value) => Math.round(metersToFeet(value) / 100) * 100; 61 | } 62 | // append units 63 | if (settings.units.value === 'si') { 64 | converter.units = 'm.'; 65 | } else { 66 | converter.units = 'ft.'; 67 | } 68 | return converter; 69 | }; 70 | 71 | const distanceKilometers = (defaultUnit = 'si') => { 72 | // default to passthru 73 | let converter = (passthru) => Math.round(passthru / 1000); 74 | // change the converter if there is a mismatch 75 | if (defaultUnit !== settings.units.value) { 76 | converter = (value) => Math.round(kilometersToMiles(value) / 1000); 77 | } 78 | // append units 79 | if (settings.units.value === 'si') { 80 | converter.units = ' km.'; 81 | } else { 82 | converter.units = ' mi.'; 83 | } 84 | return converter; 85 | }; 86 | 87 | const pressure = (defaultUnit = 'si') => { 88 | // default to passthru (millibar) 89 | let converter = (passthru) => Math.round(passthru / 100); 90 | // change the converter if there is a mismatch 91 | if (defaultUnit !== settings.units.value) { 92 | converter = (value) => pascalToInHg(value).toFixed(2); 93 | } 94 | // append units 95 | if (settings.units.value === 'si') { 96 | converter.units = ' mbar'; 97 | } else { 98 | converter.units = ' in.hg'; 99 | } 100 | return converter; 101 | }; 102 | 103 | export { 104 | // unit conversions 105 | windSpeed, 106 | temperature, 107 | distanceMeters, 108 | distanceKilometers, 109 | pressure, 110 | 111 | celsiusToFahrenheit, 112 | kphToMph, 113 | pascalToInHg, 114 | metersToFeet, 115 | kilometersToMiles, 116 | 117 | // formatter 118 | round2, 119 | }; 120 | -------------------------------------------------------------------------------- /server/scripts/modules/utils/weather.mjs: -------------------------------------------------------------------------------- 1 | import { json } from './fetch.mjs'; 2 | 3 | const getPoint = async (lat, lon) => { 4 | try { 5 | return await json(`https://api.weather.gov/points/${lat},${lon}`); 6 | } catch (error) { 7 | console.log(`Unable to get point ${lat}, ${lon}`); 8 | console.error(error); 9 | return false; 10 | } 11 | }; 12 | 13 | export { 14 | // eslint-disable-next-line import/prefer-default-export 15 | getPoint, 16 | }; 17 | -------------------------------------------------------------------------------- /server/scripts/vendor/auto/suncalc.js: -------------------------------------------------------------------------------- 1 | /* 2 | (c) 2011-2015, Vladimir Agafonkin 3 | SunCalc is a JavaScript library for calculating sun/moon position and light phases. 4 | https://github.com/mourner/suncalc 5 | */ 6 | 7 | (function () { 'use strict'; 8 | 9 | // shortcuts for easier to read formulas 10 | 11 | var PI = Math.PI, 12 | sin = Math.sin, 13 | cos = Math.cos, 14 | tan = Math.tan, 15 | asin = Math.asin, 16 | atan = Math.atan2, 17 | acos = Math.acos, 18 | rad = PI / 180; 19 | 20 | // sun calculations are based on http://aa.quae.nl/en/reken/zonpositie.html formulas 21 | 22 | 23 | // date/time constants and conversions 24 | 25 | var dayMs = 1000 * 60 * 60 * 24, 26 | J1970 = 2440588, 27 | J2000 = 2451545; 28 | 29 | function toJulian(date) { return date.valueOf() / dayMs - 0.5 + J1970; } 30 | function fromJulian(j) { return new Date((j + 0.5 - J1970) * dayMs); } 31 | function toDays(date) { return toJulian(date) - J2000; } 32 | 33 | 34 | // general calculations for position 35 | 36 | var e = rad * 23.4397; // obliquity of the Earth 37 | 38 | function rightAscension(l, b) { return atan(sin(l) * cos(e) - tan(b) * sin(e), cos(l)); } 39 | function declination(l, b) { return asin(sin(b) * cos(e) + cos(b) * sin(e) * sin(l)); } 40 | 41 | function azimuth(H, phi, dec) { return atan(sin(H), cos(H) * sin(phi) - tan(dec) * cos(phi)); } 42 | function altitude(H, phi, dec) { return asin(sin(phi) * sin(dec) + cos(phi) * cos(dec) * cos(H)); } 43 | 44 | function siderealTime(d, lw) { return rad * (280.16 + 360.9856235 * d) - lw; } 45 | 46 | function astroRefraction(h) { 47 | if (h < 0) // the following formula works for positive altitudes only. 48 | h = 0; // if h = -0.08901179 a div/0 would occur. 49 | 50 | // formula 16.4 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998. 51 | // 1.02 / tan(h + 10.26 / (h + 5.10)) h in degrees, result in arc minutes -> converted to rad: 52 | return 0.0002967 / Math.tan(h + 0.00312536 / (h + 0.08901179)); 53 | } 54 | 55 | // general sun calculations 56 | 57 | function solarMeanAnomaly(d) { return rad * (357.5291 + 0.98560028 * d); } 58 | 59 | function eclipticLongitude(M) { 60 | 61 | var C = rad * (1.9148 * sin(M) + 0.02 * sin(2 * M) + 0.0003 * sin(3 * M)), // equation of center 62 | P = rad * 102.9372; // perihelion of the Earth 63 | 64 | return M + C + P + PI; 65 | } 66 | 67 | function sunCoords(d) { 68 | 69 | var M = solarMeanAnomaly(d), 70 | L = eclipticLongitude(M); 71 | 72 | return { 73 | dec: declination(L, 0), 74 | ra: rightAscension(L, 0) 75 | }; 76 | } 77 | 78 | 79 | var SunCalc = {}; 80 | 81 | 82 | // calculates sun position for a given date and latitude/longitude 83 | 84 | SunCalc.getPosition = function (date, lat, lng) { 85 | 86 | var lw = rad * -lng, 87 | phi = rad * lat, 88 | d = toDays(date), 89 | 90 | c = sunCoords(d), 91 | H = siderealTime(d, lw) - c.ra; 92 | 93 | return { 94 | azimuth: azimuth(H, phi, c.dec), 95 | altitude: altitude(H, phi, c.dec) 96 | }; 97 | }; 98 | 99 | 100 | // sun times configuration (angle, morning name, evening name) 101 | 102 | var times = SunCalc.times = [ 103 | [-0.833, 'sunrise', 'sunset' ], 104 | [ -0.3, 'sunriseEnd', 'sunsetStart' ], 105 | [ -6, 'dawn', 'dusk' ], 106 | [ -12, 'nauticalDawn', 'nauticalDusk'], 107 | [ -18, 'nightEnd', 'night' ], 108 | [ 6, 'goldenHourEnd', 'goldenHour' ] 109 | ]; 110 | 111 | // adds a custom time to the times config 112 | 113 | SunCalc.addTime = function (angle, riseName, setName) { 114 | times.push([angle, riseName, setName]); 115 | }; 116 | 117 | 118 | // calculations for sun times 119 | 120 | var J0 = 0.0009; 121 | 122 | function julianCycle(d, lw) { return Math.round(d - J0 - lw / (2 * PI)); } 123 | 124 | function approxTransit(Ht, lw, n) { return J0 + (Ht + lw) / (2 * PI) + n; } 125 | function solarTransitJ(ds, M, L) { return J2000 + ds + 0.0053 * sin(M) - 0.0069 * sin(2 * L); } 126 | 127 | function hourAngle(h, phi, d) { return acos((sin(h) - sin(phi) * sin(d)) / (cos(phi) * cos(d))); } 128 | function observerAngle(height) { return -2.076 * Math.sqrt(height) / 60; } 129 | 130 | // returns set time for the given sun altitude 131 | function getSetJ(h, lw, phi, dec, n, M, L) { 132 | 133 | var w = hourAngle(h, phi, dec), 134 | a = approxTransit(w, lw, n); 135 | return solarTransitJ(a, M, L); 136 | } 137 | 138 | 139 | // calculates sun times for a given date, latitude/longitude, and, optionally, 140 | // the observer height (in meters) relative to the horizon 141 | 142 | SunCalc.getTimes = function (date, lat, lng, height) { 143 | 144 | height = height || 0; 145 | 146 | var lw = rad * -lng, 147 | phi = rad * lat, 148 | 149 | dh = observerAngle(height), 150 | 151 | d = toDays(date), 152 | n = julianCycle(d, lw), 153 | ds = approxTransit(0, lw, n), 154 | 155 | M = solarMeanAnomaly(ds), 156 | L = eclipticLongitude(M), 157 | dec = declination(L, 0), 158 | 159 | Jnoon = solarTransitJ(ds, M, L), 160 | 161 | i, len, time, h0, Jset, Jrise; 162 | 163 | 164 | var result = { 165 | solarNoon: fromJulian(Jnoon), 166 | nadir: fromJulian(Jnoon - 0.5) 167 | }; 168 | 169 | for (i = 0, len = times.length; i < len; i += 1) { 170 | time = times[i]; 171 | h0 = (time[0] + dh) * rad; 172 | 173 | Jset = getSetJ(h0, lw, phi, dec, n, M, L); 174 | Jrise = Jnoon - (Jset - Jnoon); 175 | 176 | result[time[1]] = fromJulian(Jrise); 177 | result[time[2]] = fromJulian(Jset); 178 | } 179 | 180 | return result; 181 | }; 182 | 183 | 184 | // moon calculations, based on http://aa.quae.nl/en/reken/hemelpositie.html formulas 185 | 186 | function moonCoords(d) { // geocentric ecliptic coordinates of the moon 187 | 188 | var L = rad * (218.316 + 13.176396 * d), // ecliptic longitude 189 | M = rad * (134.963 + 13.064993 * d), // mean anomaly 190 | F = rad * (93.272 + 13.229350 * d), // mean distance 191 | 192 | l = L + rad * 6.289 * sin(M), // longitude 193 | b = rad * 5.128 * sin(F), // latitude 194 | dt = 385001 - 20905 * cos(M); // distance to the moon in km 195 | 196 | return { 197 | ra: rightAscension(l, b), 198 | dec: declination(l, b), 199 | dist: dt 200 | }; 201 | } 202 | 203 | SunCalc.getMoonPosition = function (date, lat, lng) { 204 | 205 | var lw = rad * -lng, 206 | phi = rad * lat, 207 | d = toDays(date), 208 | 209 | c = moonCoords(d), 210 | H = siderealTime(d, lw) - c.ra, 211 | h = altitude(H, phi, c.dec), 212 | // formula 14.1 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998. 213 | pa = atan(sin(H), tan(phi) * cos(c.dec) - sin(c.dec) * cos(H)); 214 | 215 | h = h + astroRefraction(h); // altitude correction for refraction 216 | 217 | return { 218 | azimuth: azimuth(H, phi, c.dec), 219 | altitude: h, 220 | distance: c.dist, 221 | parallacticAngle: pa 222 | }; 223 | }; 224 | 225 | 226 | // calculations for illumination parameters of the moon, 227 | // based on http://idlastro.gsfc.nasa.gov/ftp/pro/astro/mphase.pro formulas and 228 | // Chapter 48 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998. 229 | 230 | SunCalc.getMoonIllumination = function (date) { 231 | 232 | var d = toDays(date || new Date()), 233 | s = sunCoords(d), 234 | m = moonCoords(d), 235 | 236 | sdist = 149598000, // distance from Earth to Sun in km 237 | 238 | phi = acos(sin(s.dec) * sin(m.dec) + cos(s.dec) * cos(m.dec) * cos(s.ra - m.ra)), 239 | inc = atan(sdist * sin(phi), m.dist - sdist * cos(phi)), 240 | angle = atan(cos(s.dec) * sin(s.ra - m.ra), sin(s.dec) * cos(m.dec) - 241 | cos(s.dec) * sin(m.dec) * cos(s.ra - m.ra)); 242 | 243 | return { 244 | fraction: (1 + cos(inc)) / 2, 245 | phase: 0.5 + 0.5 * inc * (angle < 0 ? -1 : 1) / Math.PI, 246 | angle: angle 247 | }; 248 | }; 249 | 250 | 251 | function hoursLater(date, h) { 252 | return new Date(date.valueOf() + h * dayMs / 24); 253 | } 254 | 255 | // calculations for moon rise/set times are based on http://www.stargazing.net/kepler/moonrise.html article 256 | 257 | SunCalc.getMoonTimes = function (date, lat, lng, inUTC) { 258 | var t = new Date(date); 259 | if (inUTC) t.setUTCHours(0, 0, 0, 0); 260 | else t.setHours(0, 0, 0, 0); 261 | 262 | var hc = 0.133 * rad, 263 | h0 = SunCalc.getMoonPosition(t, lat, lng).altitude - hc, 264 | h1, h2, rise, set, a, b, xe, ye, d, roots, x1, x2, dx; 265 | 266 | // go in 2-hour chunks, each time seeing if a 3-point quadratic curve crosses zero (which means rise or set) 267 | for (var i = 1; i <= 24; i += 2) { 268 | h1 = SunCalc.getMoonPosition(hoursLater(t, i), lat, lng).altitude - hc; 269 | h2 = SunCalc.getMoonPosition(hoursLater(t, i + 1), lat, lng).altitude - hc; 270 | 271 | a = (h0 + h2) / 2 - h1; 272 | b = (h2 - h0) / 2; 273 | xe = -b / (2 * a); 274 | ye = (a * xe + b) * xe + h1; 275 | d = b * b - 4 * a * h1; 276 | roots = 0; 277 | 278 | if (d >= 0) { 279 | dx = Math.sqrt(d) / (Math.abs(a) * 2); 280 | x1 = xe - dx; 281 | x2 = xe + dx; 282 | if (Math.abs(x1) <= 1) roots++; 283 | if (Math.abs(x2) <= 1) roots++; 284 | if (x1 < -1) x1 = x2; 285 | } 286 | 287 | if (roots === 1) { 288 | if (h0 < 0) rise = i + x1; 289 | else set = i + x1; 290 | 291 | } else if (roots === 2) { 292 | rise = i + (ye < 0 ? x2 : x1); 293 | set = i + (ye < 0 ? x1 : x2); 294 | } 295 | 296 | if (rise && set) break; 297 | 298 | h0 = h2; 299 | } 300 | 301 | var result = {}; 302 | 303 | if (rise) result.rise = hoursLater(t, rise); 304 | if (set) result.set = hoursLater(t, set); 305 | 306 | if (!rise && !set) result[ye > 0 ? 'alwaysUp' : 'alwaysDown'] = true; 307 | 308 | return result; 309 | }; 310 | 311 | 312 | // export as Node module / AMD module / browser variable 313 | if (typeof exports === 'object' && typeof module !== 'undefined') module.exports = SunCalc; 314 | else if (typeof define === 'function' && define.amd) define(SunCalc); 315 | else window.SunCalc = SunCalc; 316 | 317 | }()); 318 | -------------------------------------------------------------------------------- /server/scripts/vendor/auto/swiped-events.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * swiped-events.js - v@version@ 3 | * Pure JavaScript swipe events 4 | * https://github.com/john-doherty/swiped-events 5 | * @inspiration https://stackoverflow.com/questions/16348031/disable-scrolling-when-touch-moving-certain-element 6 | * @author John Doherty 7 | * @license MIT 8 | */ 9 | (function (window, document) { 10 | 11 | 'use strict'; 12 | 13 | // patch CustomEvent to allow constructor creation (IE/Chrome) 14 | if (typeof window.CustomEvent !== 'function') { 15 | 16 | window.CustomEvent = function (event, params) { 17 | 18 | params = params || { bubbles: false, cancelable: false, detail: undefined }; 19 | 20 | var evt = document.createEvent('CustomEvent'); 21 | evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail); 22 | return evt; 23 | }; 24 | 25 | window.CustomEvent.prototype = window.Event.prototype; 26 | } 27 | 28 | document.addEventListener('touchstart', handleTouchStart, false); 29 | document.addEventListener('touchmove', handleTouchMove, false); 30 | document.addEventListener('touchend', handleTouchEnd, false); 31 | 32 | var xDown = null; 33 | var yDown = null; 34 | var xDiff = null; 35 | var yDiff = null; 36 | var timeDown = null; 37 | var startEl = null; 38 | var touchCount = 0; 39 | 40 | /** 41 | * Fires swiped event if swipe detected on touchend 42 | * @param {object} e - browser event object 43 | * @returns {void} 44 | */ 45 | function handleTouchEnd(e) { 46 | 47 | // if the user released on a different target, cancel! 48 | if (startEl !== e.target) return; 49 | 50 | var swipeThreshold = parseInt(getNearestAttribute(startEl, 'data-swipe-threshold', '20'), 10); // default 20 units 51 | var swipeUnit = getNearestAttribute(startEl, 'data-swipe-unit', 'px'); // default px 52 | var swipeTimeout = parseInt(getNearestAttribute(startEl, 'data-swipe-timeout', '500'), 10); // default 500ms 53 | var timeDiff = Date.now() - timeDown; 54 | var eventType = ''; 55 | var changedTouches = e.changedTouches || e.touches || []; 56 | 57 | if (swipeUnit === 'vh') { 58 | swipeThreshold = Math.round((swipeThreshold / 100) * document.documentElement.clientHeight); // get percentage of viewport height in pixels 59 | } 60 | if (swipeUnit === 'vw') { 61 | swipeThreshold = Math.round((swipeThreshold / 100) * document.documentElement.clientWidth); // get percentage of viewport height in pixels 62 | } 63 | 64 | if (Math.abs(xDiff) > Math.abs(yDiff)) { // most significant 65 | if (Math.abs(xDiff) > swipeThreshold && timeDiff < swipeTimeout) { 66 | if (xDiff > 0) { 67 | eventType = 'swiped-left'; 68 | } 69 | else { 70 | eventType = 'swiped-right'; 71 | } 72 | } 73 | } 74 | else if (Math.abs(yDiff) > swipeThreshold && timeDiff < swipeTimeout) { 75 | if (yDiff > 0) { 76 | eventType = 'swiped-up'; 77 | } 78 | else { 79 | eventType = 'swiped-down'; 80 | } 81 | } 82 | 83 | if (eventType !== '') { 84 | 85 | var eventData = { 86 | dir: eventType.replace(/swiped-/, ''), 87 | touchType: (changedTouches[0] || {}).touchType || 'direct', 88 | fingers: touchCount, // Number of fingers used 89 | xStart: parseInt(xDown, 10), 90 | xEnd: parseInt((changedTouches[0] || {}).clientX || -1, 10), 91 | yStart: parseInt(yDown, 10), 92 | yEnd: parseInt((changedTouches[0] || {}).clientY || -1, 10) 93 | }; 94 | 95 | // fire `swiped` event event on the element that started the swipe 96 | startEl.dispatchEvent(new CustomEvent('swiped', { bubbles: true, cancelable: true, detail: eventData })); 97 | 98 | // fire `swiped-dir` event on the element that started the swipe 99 | startEl.dispatchEvent(new CustomEvent(eventType, { bubbles: true, cancelable: true, detail: eventData })); 100 | } 101 | 102 | // reset values 103 | xDown = null; 104 | yDown = null; 105 | timeDown = null; 106 | } 107 | /** 108 | * Records current location on touchstart event 109 | * @param {object} e - browser event object 110 | * @returns {void} 111 | */ 112 | function handleTouchStart(e) { 113 | 114 | // if the element has data-swipe-ignore="true" we stop listening for swipe events 115 | if (e.target.getAttribute('data-swipe-ignore') === 'true') return; 116 | 117 | startEl = e.target; 118 | 119 | timeDown = Date.now(); 120 | xDown = e.touches[0].clientX; 121 | yDown = e.touches[0].clientY; 122 | xDiff = 0; 123 | yDiff = 0; 124 | touchCount = e.touches.length; 125 | } 126 | 127 | /** 128 | * Records location diff in px on touchmove event 129 | * @param {object} e - browser event object 130 | * @returns {void} 131 | */ 132 | function handleTouchMove(e) { 133 | 134 | if (!xDown || !yDown) return; 135 | 136 | var xUp = e.touches[0].clientX; 137 | var yUp = e.touches[0].clientY; 138 | 139 | xDiff = xDown - xUp; 140 | yDiff = yDown - yUp; 141 | } 142 | 143 | /** 144 | * Gets attribute off HTML element or nearest parent 145 | * @param {object} el - HTML element to retrieve attribute from 146 | * @param {string} attributeName - name of the attribute 147 | * @param {any} defaultValue - default value to return if no match found 148 | * @returns {any} attribute value or defaultValue 149 | */ 150 | function getNearestAttribute(el, attributeName, defaultValue) { 151 | 152 | // walk up the dom tree looking for attributeName 153 | while (el && el !== document.documentElement) { 154 | 155 | var attributeValue = el.getAttribute(attributeName); 156 | 157 | if (attributeValue) { 158 | return attributeValue; 159 | } 160 | 161 | el = el.parentNode; 162 | } 163 | 164 | return defaultValue; 165 | } 166 | 167 | }(window, document)); 168 | -------------------------------------------------------------------------------- /server/styles/main.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["scss/_page.scss","scss/shared/_utils.scss","scss/shared/_colors.scss","scss/_weather-display.scss","scss/shared/_margins.scss","scss/_current-weather.scss","scss/_extended-forecast.scss","scss/_latest-observations.scss","scss/_local-forecast.scss","scss/_progress.scss","scss/_regional-forecast.scss","scss/_almanac.scss","scss/_hazards.scss"],"names":[],"mappings":"AAGA,WACC,sBAAA,CACA,mDAAA,CAGD,KACC,sBAAA,CAEA,mCAHD,KAIE,qBAAA,CACA,UAAA,CAAA,CAIA,mCADD,OAEE,aAAA,CAAA,CAIF,WACC,UAAA,CAIF,UACC,eAAA,CAEA,mBACC,oBAAA,CACA,WAAA,CACA,gBAAA,CAEA,8BACC,WAAA,CACA,qBAAA,CAGD,0BACC,cAAA,CAOA,wBAAA,CALA,mCAHD,0BAIE,qBAAA,CACA,UAAA,CAAA,CASA,uCACC,YAAA,CAEA,mCAHD,uCAIE,oBAAA,CAAA,CAKD,mCADD,wCAEE,YAAA,CAAA,CAKH,qCACC,qBAAA,CAEA,mCAHD,qCAIE,qBAAA,CAAA,CAGD,yCACC,gBAAA,CAMJ,iCAEC,sBAAA,CAGD,sBACC,wBAAA,CACA,eAAA,CACA,cAAA,CACA,eAAA,CACA,oBAAA,CAEA,mCAPD,sBAQE,qBAAA,CACA,UAAA,CACA,wBAAA,CAAA,CAOH,0BACC,qBAAA,CACA,qBAAA,CACA,iBAAA,CACA,YAAA,CAEA,mCAND,0BAOE,qBAAA,CAAA,CAGD,8BAEC,kBAAA,CACA,eAAA,CACA,sBAAA,CACA,cAAA,CAEA,uCACC,qBAAA,CACA,UAAA,CAMH,QACC,aAAA,CACA,qBAAA,CACA,UAAA,CACA,UAAA,CACA,eAAA,CAEA,aACC,eAAA,CAMF,YACC,YAAA,CACA,gBAAA,CACA,qBAAA,CACA,qBAAA,CAGD,gBACC,MAAA,CACA,kBAAA,CACA,YAAA,CACA,qBAAA,CACA,sBAAA,CAGD,aACC,eAAA,CACA,YAAA,CACA,qBAAA,CACA,qBAAA,CAGD,iBACC,MAAA,CACA,iBAAA,CACA,YAAA,CACA,qBAAA,CACA,sBAAA,CAGD,cAEC,YAAA,CACA,kBAAA,CACA,qBAAA,CAMA,UAAA,CACA,UAAA,CALA,mCAND,cAOE,wBAAA,CAAA,CAOF,kBACC,gBAAA,CACA,iBAAA,CAGD,kBACC,MAAA,CACA,eAAA,CAGD,oBACC,MAAA,CACA,iBAAA,CAGD,mBACC,MAAA,CACA,gBAAA,CAGD,oBACC,YAAA,CAGD,WACC,UAAA,CACA,YAAA,CACA,kBAAA,CACA,qBAAA,CACA,UAAA,CACA,eAAA,CAGD,eACC,gBAAA,CACA,iBAAA,CAGD,eACC,MAAA,CACA,eAAA,CAGD,iBACC,MAAA,CACA,iBAAA,CAGD,gBACC,MAAA,CACA,gBAAA,CAGD,YACC,iBAAA,CACA,iBAAA,CAGD,YACC,sBAAA,CAGD,eACC,WAAA,CAGD,WACC,sBAAA,CACA,mDAAA,CAGD,WACC,+BAAA,CACA,4DAAA,CAGD,WACC,4BAAA,CACA,yDAAA,CAGD,WACC,4BAAA,CACA,yDAAA,CAGD,SACC,sBAAA,CACA,cAAA,CACA,UAAA,CAGD,WACC,iBAAA,CACA,WAAA,CACA,YAAA,CAEA,iDAAA,CACA,oBAAA,CAGD,iBACC,kBAAA,CACA,mBAAA,CACA,2BAAA,CACA,gDAAA,CAGD,uCAGC,WAAA,CACA,YAAA,CACA,sBAAA,CALD,wDAGC,WAAA,CACA,YAAA,CACA,sBAAA,CAGD,0BACC,oBAAA,CAGD,SACC,WAAA,CACA,YAAA,CACA,cAAA,CACA,wBAAA,CACA,YAAA,CACA,kBAAA,CACA,iBAAA,CACA,sBAAA,CAEA,gBACC,0BAAA,CACA,cAAA,CACA,UAAA,CACA,iBAAA,CAGD,kBACC,kBAAA,CAGD,uBACC,cAAA,CAIF,SACC,gBAAA,CACA,eAAA,CAGD,UACC,kBAAA,CAGD,2BAEC,kBAAA,CCrUA,4FAEC,UAAA,CAGD,mDACC,UAAA,CACA,cAAA,CAGD,2CACC,SAAA,CAGD,6CACC,YAAA,CAGD,+CACC,YAAA,CDqTD,mDACC,UAAA,CAGD,oCAEC,4FAEC,UAAA,CAGD,mDACC,UAAA,CACA,cAAA,CAGD,2CACC,UAAA,CAGD,6CACC,aAAA,CAGD,+CACC,aAAA,CAAA,CAIF,uCACC,aAAA,CACA,eAAA,CAEA,qDACC,YAAA,CAEA,+DACC,cAAA,CACA,SAAA,CAMJ,kBACC,qBAAA,CAGD,4BAEC,YAAA,CACA,kBAAA,CACA,sBAAA,CACA,oBAAA,CALD,kCAEC,YAAA,CACA,kBAAA,CACA,sBAAA,CACA,oBAAA,CAEA,sCACC,WAAA,CADD,sDACC,WAAA,CAIF,eACC,qBAAA,CAGD,qCAEC,iBAAA,CAFD,oDAEC,iBAAA,CAGD,0CAEC,YAAA,CACA,kBAAA,CACA,+BAAA,CACA,UAAA,CACA,UAAA,CACA,iBAAA,CACA,UAAA,CARD,8DAEC,YAAA,CACA,kBAAA,CACA,+BAAA,CACA,UAAA,CACA,UAAA,CACA,iBAAA,CACA,UAAA,CAKC,iCACC,YAAA,CAKH,WACC,cAAA,CAGD,SACC,kBAAA,CACA,SAAA,CACA,6BAAA,CAGD,oCACC,iBAAA,CACA,SAAA,CACA,qDAAA,CAAA,6CAAA,CAHD,2BACC,iBAAA,CACA,SAAA,CACA,6CAAA,CAGD,cACC,WAAA,CACA,4BAAA,CACA,YAAA,CACA,4BAAA,CACA,cAAA,CAGC,qBACC,oBAAA,CACA,SAAA,CAGD,2BACC,oBAAA,CACA,eAAA,CACA,gFAAA,CACA,WAAA,CACA,aAAA,CACA,kBAAA,CAGD,yDAEC,iBAAA,CACA,oBAAA,CACA,mBAAA,CACA,WAAA,CACA,eAAA,CACA,cAAA,CACA,eAAA,CACA,gBAAA,CACA,qBAAA,CACA,cAAA,CACA,wBAAA,CACA,qBAAA,CAEA,gBAAA,CACA,0BAAA,CACA,6BAAA,CACA,yBAAA,CACA,gBAAA,CAGD,wBACC,mBAAA,CAGD,yCACC,6BAAA,CAGD,iCACC,aAAA,CACA,6BAAA,CAGD,+EAEC,WAAA,CACA,gBAAA,CACA,cAAA,CACA,gBAAA,CAGD,4BACC,oBAAA,CACA,uBAAA,CACA,iBAAA,CACA,gBAAA,CAGD,qFAEC,yBAAA,CACA,mBAAA,CAGD,wBACC,aAAA,CACA,wBAAA,CACA,oBAAA,CACA,+BAAA,CACA,6SAAA,CAEA,8DAAA,CACA,qGAAA,CAGD,8BACC,WAAA,CAGD,4DAEC,wBAAA,CACA,4BAAA,CACA,oBAAA,CACA,+BAAA,CACA,6SAAA,CAEA,8DAAA,CACA,qGAAA,CAGD,wEAEC,WAAA,CAGD,+BACC,wBAAA,CACA,oBAAA,CACA,+BAAA,CACA,gDAAA,CACA,qBAAA,CACA,WAAA,CAGD,iCACC,aAAA,CACA,qBAAA,CACA,oBAAA,CACA,+BAAA,CAGD,8EAEC,aAAA,CAGD,kCACC,aAAA,CAGD,oCAEC,qFAEC,yBAAA,CACA,mBAAA,CAGD,wBACC,aAAA,CACA,wBAAA,CACA,oBAAA,CACA,+BAAA,CACA,6SAAA,CAEA,8DAAA,CACA,qGAAA,CAGD,8BACC,WAAA,CAGD,4DAEC,wBAAA,CACA,4BAAA,CACA,oBAAA,CACA,+BAAA,CACA,6SAAA,CAEA,8DAAA,CACA,qGAAA,CAGD,wEAEC,WAAA,CAGD,+BACC,wBAAA,CACA,oBAAA,CACA,+BAAA,CACA,gDAAA,CACA,qBAAA,CACA,WAAA,CAGD,iCACC,aAAA,CACA,qBAAA,CACA,oBAAA,CACA,+BAAA,CAGD,8EAEC,aAAA,CAGD,kCACC,aAAA,CAAA,CAIF,mCAEC,qFAEC,yBAAA,CACA,mBAAA,CAGD,wBACC,aAAA,CACA,wBAAA,CACA,oBAAA,CACA,iCAAA,CACA,6SAAA,CAEA,8DAAA,CACA,qGAAA,CAGD,8BACC,WAAA,CAGD,4DAEC,wBAAA,CACA,4BAAA,CACA,oBAAA,CACA,6SAAA,CAEA,8DAAA,CACA,qGAAA,CAGD,wEAEC,WAAA,CAGD,+BACC,wBAAA,CACA,oBAAA,CACA,6CAAA,CACA,qBAAA,CACA,WAAA,CAGD,iCACC,aAAA,CACA,wBAAA,CACA,oBAAA,CACA,iCAAA,CAGD,8EAEC,aAAA,CAGD,kCACC,aAAA,CAAA,CAMJ,mBACC,UEhtBa,CFitBb,YAAA,CAGD,yBACC,YAAA,CAKA,uGAMC,YAAA,CG5tBF,iBACC,WAAA,CACA,YAAA,CACA,eAAA,CACA,iBAAA,CACA,iDAAA,CAGA,UAAA,CAEA,sBACC,YAAA,CAGD,2BACC,YAAA,CAGD,yBACC,WAAA,CACA,gBAAA,CACA,gBCzBW,CD0BX,iBC1BW,CD4BX,gCACC,UD7BW,CDMb,wKACC,CEwBC,4BAAA,CACA,cAAA,CACA,WAAA,CAEA,uCACC,WAAA,CACA,QAAA,CAGD,qCACC,WAAA,CACA,eAAA,CACA,sBAAA,CACA,iBAAA,CAGD,qCACC,WAAA,CAEA,yCACC,iBAAA,CAGD,0CACC,QAAA,CAGD,6CACC,QAAA,CAMH,uCACC,QAAA,CAKF,uBACC,iBAAA,CAEA,kCACC,WAAA,CACA,YAAA,CACA,eAAA,CAEA,4CACC,YAAA,CAIF,+BACC,gBCrFU,CDsFV,iBCtFU,CDuFV,uBAAA,CAMF,yBFvFA,wKACC,CEwFA,WAAA,CACA,WAAA,CACA,eAAA,CACA,cAAA,CACA,gBCnGW,CDoGX,iBCpGW,CDsGX,gCACC,sBAAA,CACA,cAAA,CACA,eAAA,CACA,eAAA,CAEA,6CACC,gBAAA,CACA,iBAAA,CAOF,oCACC,UDrHS,CCsHT,4BAAA,CACA,cAAA,CACA,WAAA,CACA,iBAAA,CAEA,kFAEC,SAAA,CACA,SAAA,CAGD,yCACC,UAAA,CACA,eAAA,CAGD,yCACC,WAAA,CACA,gBAAA,CAGD,gDACC,UAAA,CACA,UAAA,CACA,aAAA,CE3IJ,uCACC,gBDLY,CCMZ,iBDNY,CCOZ,uBAAA,CACA,eAAA,CACA,YAAA,CAEA,gDACC,sBAAA,CACA,cAAA,CJPD,wKACC,CIQA,iBAAA,CACA,WAAA,CAEA,oDACC,eAAA,CACA,eAAA,CChBF,wDACC,eAAA,CACA,gBAAA,CAGD,8CLHA,wKACC,CKIA,WAAA,CACA,YAAA,CACA,WAAA,CACA,oBAAA,CACA,eAAA,CACA,sBAAA,CACA,cAAA,CACA,kBAAA,CAEA,oDACC,wBAAA,CACA,UJtBW,CIyBZ,yDACC,WAAA,CACA,eAAA,CAGD,4DACC,UAAA,CACA,eAAA,CAEA,+EACC,kBAAA,CAEA,UAAA,CAEA,mFACC,SAAA,CAGD,sFACC,UAAA,CAGD,sFACC,WAAA,CC3CJ,2CACC,iBAAA,CAEA,2DACC,WAAA,CACA,iBAAA,CACA,UAAA,CAGD,2DACC,OAAA,CAEA,+DACC,oBAAA,CACA,4BAAA,CACA,cAAA,CACA,iBAAA,CACA,SAAA,CNhBH,wKACC,CMmBC,iEAEC,YAAA,CAEA,sEACC,oBAAA,CAKH,iDACC,UAAA,CAGD,oDACC,UAAA,CAGD,iDACC,UAAA,CAGD,8DACC,gBAAA,CACA,gBAAA,CAEA,+EACC,sBAAA,CACA,cAAA,CNhDH,wKACC,CMiDE,iBAAA,CACA,WAAA,CAEA,mFACC,iBAAA,CACA,OAAA,CAGD,qFACC,eAAA,CACA,gBAAA,CC9DJ,4CACC,iBAAA,CACA,QAAA,CACA,eAAA,CACA,qBAAA,CACA,YAAA,CACA,eAAA,CAGD,4CACC,iBAAA,CAGD,2CACC,sBAAA,CACA,cAAA,CACA,wBAAA,CPdD,wKACC,COeA,gBAAA,CACA,gBAAA,CCpBF,2BRGC,wKACC,CQFD,+BAAA,CACA,cAAA,CAEA,sCACC,iBAAA,CACA,QAAA,CACA,qBAAA,CACA,YAAA,CACA,eAAA,CAEA,4CACC,iBAAA,CAEA,kDACC,kBAAA,CAEA,yDACC,kFAAA,CAIF,mDACC,iBAAA,CACA,gBAAA,CACA,SAAA,CACA,OAAA,CAEA,uDACC,wBPxBM,COyBN,YAAA,CACA,gBAAA,CRfJ,yHAEC,UAAA,CAGD,+DACC,UAAA,CACA,cAAA,CAGD,2DACC,SAAA,CAGD,4DACC,YAAA,CAGD,6DACC,YAAA,CQCE,gaAMC,aAAA,CAYJ,2BACC,GACC,2BAAA,CAGD,KACC,0BAAA,CAAA,CAIF,+DACC,qBAAA,CACA,qBAAA,CACA,gBAAA,CACA,WAAA,CACA,iBAAA,CACA,YAAA,CAEA,oEACC,aAAA,CAGD,6EACC,WAAA,CACA,UAAA,CACA,WAAA,CACA,4OAAA,CAiBA,qBAAA,CACA,4BAAA,CACA,kCAAA,CACA,8BAAA,CACA,uCAAA,CAGD,sEACC,iBAAA,CACA,OAAA,CACA,SAAA,CACA,qBAAA,CACA,UAAA,CACA,WAAA,CACA,4BAAA,CC9GF,yCACC,iBAAA,CAEA,yDACC,WAAA,CACA,iBAAA,CACA,UAAA,CAGD,yDACC,OAAA,CAEA,6DACC,oBAAA,CACA,4BAAA,CACA,cAAA,CACA,iBAAA,CACA,SAAA,CThBH,wKACC,CSoBA,kDACC,UAAA,CAGD,mDACC,UAAA,CAGD,oDACC,SAAA,CAGD,yDACC,gBAAA,CACA,gBAAA,CAEA,uEACC,sBAAA,CACA,cAAA,CTvCH,wKACC,CSwCE,iBAAA,CACA,WAAA,CAEA,2EACC,iBAAA,CACA,OAAA,CAGD,6EACC,eAAA,CACA,gBAAA,CCtDL,+BACC,sBAAA,CACA,cAAA,CVCA,wKACC,CUCD,wCACC,iBAAA,CACA,iBAAA,CAGD,yEAEC,aAAA,CACA,gBAAA,CACA,YAAA,CAEA,iFACC,iBAAA,CACA,iBAAA,CAEA,yFACC,kBAAA,CAIF,qFACC,gBAAA,CAEA,+FACC,kBAAA,CAIF,uFACC,gBAAA,CAEA,mGACC,WAAA,CAGD,2GACC,WAAA,CAGD,qGACC,WAAA,CC5CH,oCACC,iBAAA,CAEA,kDACC,gBAAA,CACA,gBAAA,CAEA,wBAAA,CAEA,0DACC,sBAAA,CACA,cAAA,CACA,UAAA,CXVH,wKACC,CWWE,iBAAA,CACA,wBAAA,CACA,gBAAA,CACA,gBAAA,CACA,iBAAA","file":"main.css"} -------------------------------------------------------------------------------- /server/styles/scss/_almanac.scss: -------------------------------------------------------------------------------- 1 | @use 'shared/_colors'as c; 2 | @use 'shared/_utils'as u; 3 | 4 | .weather-display .main.almanac { 5 | font-family: 'Star3000'; 6 | font-size: 24pt; 7 | @include u.text-shadow(); 8 | 9 | .heading { 10 | text-align: center; 11 | font-weight: unset; 12 | } 13 | 14 | .sun, 15 | .moon { 16 | display: table; 17 | margin-left: 50px; 18 | height: 100px; 19 | 20 | &>div { 21 | display: table-row; 22 | position: relative; 23 | 24 | &>div { 25 | display: table-cell; 26 | } 27 | } 28 | 29 | .days { 30 | text-align: right; 31 | 32 | .day { 33 | padding-right: 10px; 34 | } 35 | } 36 | 37 | .times { 38 | text-align: right; 39 | 40 | .name { 41 | width: 125px; 42 | } 43 | 44 | .sun-time { 45 | width: 200px; 46 | } 47 | 48 | .large { 49 | width: 400px; 50 | } 51 | } 52 | 53 | } 54 | } -------------------------------------------------------------------------------- /server/styles/scss/_current-weather.scss: -------------------------------------------------------------------------------- 1 | @use 'shared/_colors'as c; 2 | @use 'shared/_utils'as u; 3 | @use 'shared/_margins'as m; 4 | 5 | .weather-display .main.current-weather { 6 | margin-left: m.$left-right; 7 | margin-right: m.$left-right; 8 | width: calc(100% - 2 * m.$left-right); 9 | margin-top: 40px; 10 | height: 360px; 11 | 12 | .weather { 13 | font-family: 'Star3000'; 14 | font-size: 24pt; 15 | @include u.text-shadow(); 16 | position: relative; 17 | height: 40px; 18 | 19 | div { 20 | max-height: 40px; 21 | overflow: hidden; 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /server/styles/scss/_extended-forecast.scss: -------------------------------------------------------------------------------- 1 | @use 'shared/_colors'as c; 2 | @use 'shared/_utils'as u; 3 | 4 | .weather-display .main.extended-forecast { 5 | .day-container { 6 | margin-top: 16px; 7 | margin-left: 27px; 8 | } 9 | 10 | .day { 11 | @include u.text-shadow(); 12 | padding: 5px; 13 | height: 285px; 14 | width: 155px; 15 | display: inline-block; 16 | margin: 0px 15px; 17 | font-family: 'Star3000'; 18 | font-size: 24pt; 19 | vertical-align: top; 20 | 21 | .date { 22 | text-transform: uppercase; 23 | color: c.$title-color; 24 | } 25 | 26 | .condition { 27 | height: 95px; 28 | margin-top: 10px; 29 | } 30 | 31 | .temperatures { 32 | width: 100%; 33 | margin-top: 25px; 34 | 35 | .temperature-block { 36 | vertical-align: top; 37 | // clear-fix 38 | clear: both; 39 | 40 | div { 41 | width: 44%; 42 | } 43 | 44 | .label { 45 | float: left; 46 | } 47 | 48 | .value { 49 | float: right; 50 | } 51 | 52 | } 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /server/styles/scss/_hazards.scss: -------------------------------------------------------------------------------- 1 | @use 'shared/_colors'as c; 2 | @use 'shared/_utils'as u; 3 | 4 | .weather-display .main.hazards { 5 | &.main { 6 | overflow-y: hidden; 7 | 8 | .hazard-lines { 9 | min-height: 400px; 10 | padding-top: 10px; 11 | 12 | background-color: rgb(112, 35, 35); 13 | 14 | .hazard { 15 | font-family: 'Star3000'; 16 | font-size: 24pt; 17 | color: white; 18 | @include u.text-shadow(0px); 19 | position: relative; 20 | text-transform: uppercase; 21 | margin-top: 110px; 22 | margin-left: 80px; 23 | margin-right: 80px; 24 | } 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /server/styles/scss/_latest-observations.scss: -------------------------------------------------------------------------------- 1 | @use 'shared/_colors'as c; 2 | @use 'shared/_utils'as u; 3 | 4 | .weather-display .latest-observations { 5 | 6 | &.main { 7 | overflow-y: hidden; 8 | 9 | .column-headers { 10 | height: 20px; 11 | position: absolute; 12 | width: 100%; 13 | } 14 | 15 | .column-headers { 16 | top: 0px; 17 | 18 | div { 19 | display: inline-block; 20 | font-family: 'Star3000 Small'; 21 | font-size: 24pt; 22 | position: absolute; 23 | top: -10px; 24 | @include u.text-shadow(); 25 | } 26 | 27 | .temp { 28 | // hidden initially for english/metric switching 29 | display: none; 30 | 31 | &.show { 32 | display: inline-block; 33 | } 34 | } 35 | } 36 | 37 | .temp { 38 | left: 280px; 39 | } 40 | 41 | .weather { 42 | left: 330px; 43 | } 44 | 45 | .wind { 46 | left: 480px; 47 | } 48 | 49 | .observation-lines { 50 | min-height: 338px; 51 | padding-top: 10px; 52 | 53 | .observation-row { 54 | font-family: 'Star3000'; 55 | font-size: 24pt; 56 | @include u.text-shadow(); 57 | position: relative; 58 | height: 40px; 59 | 60 | >div { 61 | position: absolute; 62 | top: 8px; 63 | } 64 | 65 | .wind { 66 | white-space: pre; 67 | text-align: right; 68 | } 69 | } 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /server/styles/scss/_local-forecast.scss: -------------------------------------------------------------------------------- 1 | @use 'shared/_colors'as c; 2 | @use 'shared/_utils'as u; 3 | 4 | .weather-display .local-forecast { 5 | .container { 6 | position: relative; 7 | top: 15px; 8 | margin: 0px 10px; 9 | box-sizing: border-box; 10 | height: 280px; 11 | overflow: hidden; 12 | } 13 | 14 | .forecasts { 15 | position: relative; 16 | } 17 | 18 | .forecast { 19 | font-family: 'Star3000'; 20 | font-size: 24pt; 21 | text-transform: uppercase; 22 | @include u.text-shadow(); 23 | min-height: 280px; 24 | line-height: 40px; 25 | } 26 | } -------------------------------------------------------------------------------- /server/styles/scss/_progress.scss: -------------------------------------------------------------------------------- 1 | @use 'shared/_colors'as c; 2 | @use 'shared/_utils'as u; 3 | 4 | .weather-display .progress { 5 | @include u.text-shadow(); 6 | font-family: 'Star3000 Extended'; 7 | font-size: 19pt; 8 | 9 | .container { 10 | position: relative; 11 | top: 15px; 12 | box-sizing: border-box; 13 | height: 310px; 14 | overflow: hidden; 15 | 16 | .item { 17 | position: relative; 18 | 19 | .name { 20 | white-space: nowrap; 21 | 22 | &::after { 23 | content: '........................................................................'; 24 | } 25 | } 26 | 27 | .links { 28 | position: absolute; 29 | text-align: right; 30 | right: 0px; 31 | top: 0px; 32 | 33 | >div { 34 | background-color: c.$blue-box; 35 | display: none; 36 | padding-left: 8px; 37 | } 38 | 39 | @include u.status-colors(); 40 | 41 | &.loading .loading, 42 | &.press-here .press-here, 43 | &.failed .failed, 44 | &.no-data .no-data, 45 | &.disabled .disabled, 46 | &.retrying .retrying { 47 | display: block; 48 | } 49 | 50 | } 51 | } 52 | } 53 | 54 | 55 | } 56 | 57 | #progress-html.weather-display .scroll { 58 | 59 | @keyframes progress-scroll { 60 | 0% { 61 | background-position: -40px 0; 62 | } 63 | 64 | 100% { 65 | background-position: 40px 0; 66 | } 67 | } 68 | 69 | .progress-bar-container { 70 | border: 2px solid black; 71 | background-color: white; 72 | margin: 20px auto; 73 | width: 524px; 74 | position: relative; 75 | display: none; 76 | 77 | &.show { 78 | display: block; 79 | } 80 | 81 | .progress-bar { 82 | height: 20px; 83 | margin: 2px; 84 | width: 520px; 85 | background: repeating-linear-gradient(90deg, 86 | c.$gradient-loading-1 0px, 87 | c.$gradient-loading-1 5px, 88 | c.$gradient-loading-2 5px, 89 | c.$gradient-loading-2 10px, 90 | c.$gradient-loading-3 10px, 91 | c.$gradient-loading-3 15px, 92 | c.$gradient-loading-4 15px, 93 | c.$gradient-loading-4 20px, 94 | c.$gradient-loading-3 20px, 95 | c.$gradient-loading-3 25px, 96 | c.$gradient-loading-2 25px, 97 | c.$gradient-loading-2 30px, 98 | c.$gradient-loading-1 30px, 99 | c.$gradient-loading-1 40px, 100 | ); 101 | // animation 102 | animation-duration: 2s; 103 | animation-fill-mode: forwards; 104 | animation-iteration-count: infinite; 105 | animation-name: progress-scroll; 106 | animation-timing-function: steps(8, end); 107 | } 108 | 109 | .cover { 110 | position: absolute; 111 | top: 0px; 112 | right: 0px; 113 | background-color: white; 114 | width: 100%; 115 | height: 24px; 116 | transition: width 1s steps(6); 117 | } 118 | } 119 | } -------------------------------------------------------------------------------- /server/styles/scss/_regional-forecast.scss: -------------------------------------------------------------------------------- 1 | @use 'shared/_colors'as c; 2 | @use 'shared/_utils'as u; 3 | 4 | .weather-display .regional-forecast { 5 | 6 | &.main { 7 | overflow-y: hidden; 8 | 9 | .column-headers { 10 | height: 20px; 11 | position: absolute; 12 | width: 100%; 13 | } 14 | 15 | .column-headers { 16 | top: 0px; 17 | 18 | div { 19 | display: inline-block; 20 | font-family: 'Star3000 Small'; 21 | font-size: 24pt; 22 | position: absolute; 23 | top: -10px; 24 | @include u.text-shadow(); 25 | } 26 | } 27 | 28 | .weather { 29 | left: 280px; 30 | } 31 | 32 | .temp-low { 33 | right: 70px; 34 | } 35 | 36 | .temp-high { 37 | right: 0px; 38 | } 39 | 40 | .forecast-lines { 41 | min-height: 338px; 42 | padding-top: 10px; 43 | 44 | .forecast-row { 45 | font-family: 'Star3000'; 46 | font-size: 24pt; 47 | @include u.text-shadow(); 48 | position: relative; 49 | height: 40px; 50 | 51 | >div { 52 | position: absolute; 53 | top: 8px; 54 | } 55 | 56 | .wind { 57 | white-space: pre; 58 | text-align: right; 59 | } 60 | } 61 | } 62 | } 63 | 64 | } -------------------------------------------------------------------------------- /server/styles/scss/_weather-display.scss: -------------------------------------------------------------------------------- 1 | @use 'shared/_colors'as c; 2 | @use 'shared/_utils'as u; 3 | @use 'shared/_margins'as m; 4 | 5 | .weather-display { 6 | width: 640px; 7 | height: 480px; 8 | overflow: hidden; 9 | position: relative; 10 | background-image: url(../images/backgrounds/1.png); 11 | 12 | /* this method is required to hide blocks so they can be measured while off screen */ 13 | height: 0px; 14 | 15 | &.show { 16 | height: 480px; 17 | } 18 | 19 | .template { 20 | display: none; 21 | } 22 | 23 | .header { 24 | width: 640px; 25 | padding-top: 20px; 26 | margin-left: m.$left-right; 27 | margin-right: m.$left-right; 28 | 29 | .title { 30 | color: c.$title-color; 31 | @include u.text-shadow(3px, 1.5px); 32 | font-family: 'Star3000 Small'; 33 | font-size: 24pt; 34 | width: 570px; 35 | 36 | &.single { 37 | height: 70px; 38 | top: 25px; 39 | } 40 | 41 | &.tall { 42 | height: 50px; 43 | margin-top: 20px; 44 | font-family: 'Star3000'; 45 | text-align: center; 46 | } 47 | 48 | &.dual { 49 | height: 70px; 50 | 51 | &>div { 52 | position: absolute; 53 | } 54 | 55 | .top { 56 | top: -3px; 57 | } 58 | 59 | .bottom { 60 | top: 26px; 61 | } 62 | } 63 | 64 | } 65 | 66 | .title.single { 67 | top: 40px; 68 | } 69 | 70 | } 71 | 72 | .main { 73 | position: relative; 74 | 75 | &.has-scroll { 76 | width: 640px; 77 | height: 310px; 78 | overflow: hidden; 79 | 80 | &.no-header { 81 | height: 400px; 82 | } 83 | } 84 | 85 | &.has-box { 86 | margin-left: m.$left-right; 87 | margin-right: m.$left-right; 88 | width: calc(100% - 2 * m.$left-right); 89 | } 90 | 91 | } 92 | 93 | 94 | .scroll { 95 | @include u.text-shadow(3px, 1.5px); 96 | width: calc(640px - 2 * m.$left-right); 97 | height: 70px; 98 | overflow: hidden; 99 | margin-top: 5px; 100 | margin-left: m.$left-right; 101 | margin-right: m.$left-right; 102 | 103 | .fixed { 104 | font-family: 'Star3000'; 105 | font-size: 24pt; 106 | max-height: 40px; 107 | overflow: hidden; 108 | 109 | .scroll-area { 110 | text-wrap: nowrap; 111 | position: relative; 112 | // the following added by js code as it is dependent on the content of the element 113 | // transition: left (x)s; 114 | // left: calc((elem width) - 640px); 115 | } 116 | } 117 | 118 | .date-time { 119 | color: c.$date-time; 120 | font-family: 'Star3000 Small'; 121 | font-size: 24pt; 122 | height: 18px; 123 | position: relative; 124 | 125 | &.date, 126 | &.time { 127 | top: -10px; 128 | width: 40%; 129 | } 130 | 131 | &.date { 132 | float: left; 133 | text-align: left; 134 | } 135 | 136 | &.time { 137 | float: right; 138 | text-align: right; 139 | } 140 | 141 | &.time::after { 142 | content: ""; 143 | clear: both; 144 | display: table; 145 | } 146 | 147 | } 148 | } 149 | } -------------------------------------------------------------------------------- /server/styles/scss/main.scss: -------------------------------------------------------------------------------- 1 | @import 'page'; 2 | @import 'weather-display'; 3 | @import 'current-weather'; 4 | @import 'extended-forecast'; 5 | @import 'latest-observations'; 6 | @import 'local-forecast'; 7 | @import 'progress'; 8 | @import 'regional-forecast'; 9 | @import 'almanac'; 10 | @import 'hazards'; -------------------------------------------------------------------------------- /server/styles/scss/shared/_colors.scss: -------------------------------------------------------------------------------- 1 | $title-color: white; 2 | $date-time: white; 3 | $text-shadow: black; 4 | 5 | $gradient-loading-1: #09246f; 6 | $gradient-loading-2: #364ac0; 7 | $gradient-loading-3: #4f99f9; 8 | $gradient-loading-4: #8ffdfa; 9 | 10 | $blue-box: #33047b; -------------------------------------------------------------------------------- /server/styles/scss/shared/_margins.scss: -------------------------------------------------------------------------------- 1 | $left-right: 35px; -------------------------------------------------------------------------------- /server/styles/scss/shared/_utils.scss: -------------------------------------------------------------------------------- 1 | @use 'colors'as c; 2 | 3 | @mixin text-shadow($offset: 3px, $outline: 1.5px) { 4 | /* eventually, when chrome supports paint-order for html elements */ 5 | /* -webkit-text-stroke: 2px black; */ 6 | /* paint-order: stroke fill; */ 7 | text-shadow: 8 | $offset $offset 0 c.$text-shadow, 9 | (-$outline) (-$outline) 0 c.$text-shadow, 10 | 0 (-$outline) 0 c.$text-shadow, 11 | $outline (-$outline) 0 c.$text-shadow, 12 | $outline 0 0 c.$text-shadow, 13 | $outline $outline 0 c.$text-shadow, 14 | 0 $outline 0 c.$text-shadow, 15 | (-$outline) $outline 0 c.$text-shadow, 16 | (-$outline) 0 0 c.$text-shadow; 17 | } 18 | 19 | @mixin status-colors() { 20 | 21 | .loading, 22 | .retrying { 23 | color: #ffff00; 24 | } 25 | 26 | .press-here { 27 | color: #00ff00; 28 | cursor: pointer; 29 | } 30 | 31 | .failed { 32 | color: #ff0000; 33 | } 34 | 35 | .no-data { 36 | color: #C0C0C0; 37 | } 38 | 39 | .disabled { 40 | color: #C0C0C0; 41 | } 42 | } -------------------------------------------------------------------------------- /views/index.ejs: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | WeatherStar 3000+ 8 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | <% if (production) { %> 23 | 24 | 25 | 26 | 27 | <% } else { %> 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | <% } %> 48 | 49 | 50 | 51 | 52 | 53 | 54 |
55 | 56 |
57 | 61 | 62 | 63 |
64 |
65 | 68 | 69 |
70 |
71 |
72 |
73 |
WeatherStar 3000+
74 |
v<%- version %>
75 |
Enter your location above to continue
76 |
77 |
78 |
79 | <%- include('partials/progress.ejs') %> 80 |
81 |
82 | <%- include('partials/travel.ejs') %> 83 |
84 |
85 | <%- include('partials/current-weather.ejs') %> 86 |
87 |
88 | <%- include('partials/local-forecast.ejs') %> 89 |
90 |
91 | <%- include('partials/latest-observations.ejs') %> 92 |
93 |
94 | <%- include('partials/regional-forecast.ejs') %> 95 |
96 |
97 | <%- include('partials/almanac.ejs') %> 98 |
99 |
100 | <%- include('partials/extended-forecast.ejs') %> 101 |
102 |
103 | <%- include('partials/hazards.ejs') %> 104 |
105 |
106 |
107 |
108 | 109 | 111 | 112 | 113 |
114 |
115 | 116 |
117 |
118 | 120 |
121 |
122 |
123 | 124 |
125 | 126 |
127 | More information 128 |
129 | 130 |
Selected displays
131 |
132 | 133 |
134 | 135 |
Settings
136 |
137 |
138 | 139 |
Sharing
140 |
141 | Copy Permalink Link copied to clipboard! 142 | 146 |
147 | 148 | 149 |
Forecast Information
150 |
151 | Location:
152 | Station Id:
153 | Radar Id:
154 | Zone Id:
155 | Ws4kp Version: <%- version %> 156 |
157 | 158 | 159 | 160 | -------------------------------------------------------------------------------- /views/partials/almanac.ejs: -------------------------------------------------------------------------------- 1 | <%- include('header.ejs', {title:'The Weatherstar Almanac', titleTall: true}) %> 2 |
3 |
4 |
5 |
6 |
Monday
7 |
Tuesday
8 |
9 |
10 |
Sunrise
11 |
6:24 am
12 |
6:25 am
13 |
14 |
15 |
Sunset
16 |
6:24 am
17 |
6:25 am
18 |
19 |
20 |
21 |
Moon Phases
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | <%- include('scroll.ejs') %> -------------------------------------------------------------------------------- /views/partials/current-weather.ejs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | Conditions at 5 | 6 |
7 |
8 | 9 |
10 |
11 | Temperature: 12 | 13 |
14 |
15 | Humidity: 16 | 17 | Dewpoint: 18 | 19 |
20 |
21 | Barometric Pressure: 22 | 23 |
24 |
25 | Wind: 26 | 27 |
28 |
29 | Visib: 30 | 31 | Ceiling: 32 | 33 |
34 |
35 | 36 | 37 |
38 |
39 |
40 | <%- include('scroll.ejs') %> -------------------------------------------------------------------------------- /views/partials/extended-forecast.ejs: -------------------------------------------------------------------------------- 1 | <%- include('header.ejs', {title: 'Extended Forecast' , titleTall: true }) %> 2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
Lo:
10 |
11 |
12 |
13 |
Hi:
14 |
15 |
16 |
17 |
18 |
19 |
20 | <%- include('scroll.ejs') %> -------------------------------------------------------------------------------- /views/partials/hazards.ejs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | <%- include('scroll.ejs') %> -------------------------------------------------------------------------------- /views/partials/header.ejs: -------------------------------------------------------------------------------- 1 |
2 | <% if (locals?.titleDual) { %> 3 |
4 |
5 | <%-titleDual.top %> 6 |
7 |
8 | <%-titleDual.bottom %> 9 |
10 |
11 | <% } else if (locals?.titleTall) { %> 12 |
13 | <%-title %> 14 |
15 | <% } else { %> 16 |
17 | <%-title %> 18 |
19 | <% } %> 20 |
-------------------------------------------------------------------------------- /views/partials/latest-observations.ejs: -------------------------------------------------------------------------------- 1 | <%- include('header.ejs', {title: 'Latest Hourly Observations', titleTall: true }) %> 2 |
3 |
4 |
5 |
Location
6 |
°F
7 |
°C
8 |
Weather
9 |
Wind
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | <%- include('scroll.ejs') %> -------------------------------------------------------------------------------- /views/partials/local-forecast.ejs: -------------------------------------------------------------------------------- 1 | <%- include('header.ejs', {title: 'Your NWS Forecast'}) %> 2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | <%- include('scroll.ejs') %> -------------------------------------------------------------------------------- /views/partials/progress.ejs: -------------------------------------------------------------------------------- 1 | <%- include('header.ejs', {titleDual:{ top: 'WeatherStar' , bottom: '3000+ v' + version }, hasTime: true}) %> 2 |
3 |
4 |
5 |
Current Conditions
6 | 14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
-------------------------------------------------------------------------------- /views/partials/regional-forecast.ejs: -------------------------------------------------------------------------------- 1 | <%- include('header.ejs', {titleTall: true, title: 'Forecast Across The Region' , hasTime: true }) %> 2 |
3 |
4 |
5 |
City
6 |
Weather
7 |
Low
8 |
Hi
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | <%- include('scroll.ejs') %> -------------------------------------------------------------------------------- /views/partials/scroll.ejs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 | 7 |
8 |
9 |
-------------------------------------------------------------------------------- /views/partials/travel.ejs: -------------------------------------------------------------------------------- 1 | <%- include('header.ejs', {titleDual: {top: 'Travel Forecast', bottom: 'For '} , hasTime: true }) %> 2 |
3 |
4 |
LOW
5 |
HIGH
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | <%- include('scroll.ejs') %> -------------------------------------------------------------------------------- /ws3kp.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": { 8 | "search.exclude": { 9 | "**/*.code-search": true, 10 | "**/*.css": true, 11 | "**/*.min.js": true, 12 | "**/bower_components": true, 13 | "**/node_modules": true, 14 | "**/vendor": true, 15 | "dist/**": true 16 | }, 17 | "cSpell.enabledFileTypes": { 18 | "markdown": true, 19 | "JavaScript": true 20 | }, 21 | "cSpell.enabled": true, 22 | "cSpell.ignoreWords": [ 23 | "'storm", 24 | "arcgis", 25 | "Battaglia", 26 | "devbridge", 27 | "gifs", 28 | "ltrim", 29 | "Noaa", 30 | "nosleep", 31 | "Pngs", 32 | "PRECIP", 33 | "rtrim", 34 | "sonarjs", 35 | "T", 36 | "T'storm", 37 | "uscomp", 38 | "Visib", 39 | "Waukegan" 40 | ], 41 | "cSpell.ignorePaths": [ 42 | "**/package-lock.json", 43 | "**/node_modules/**", 44 | "**/vscode-extension/**", 45 | "**/.git/objects/**", 46 | ".vscode", 47 | ".vscode-insiders", 48 | "**/vendor/auto/**", 49 | "**/twc3.js", 50 | ], 51 | "editor.tabSize": 2, 52 | "emmet.includeLanguages": { 53 | "ejs": "html", 54 | }, 55 | "[html]": { 56 | "editor.defaultFormatter": "j69.ejs-beautify" 57 | }, 58 | "files.exclude": {}, 59 | "files.eol": "\n", 60 | "editor.formatOnSave": true, 61 | "editor.codeActionsOnSave": { 62 | "source.fixAll.eslint": "explicit" 63 | } 64 | }, 65 | } --------------------------------------------------------------------------------