├── .gitattributes ├── .gitignore ├── README.md ├── favicon.ico ├── icons ├── favicon_old.ico ├── icon.png ├── icon.svg ├── icon_128.png ├── icon_192.png ├── icon_256.png ├── icon_32.png ├── icon_512.png ├── icon_64.png ├── icon_maskable.svg └── icon_old.png ├── index.html ├── libs ├── astronomy │ ├── LICENSE.txt │ ├── astronomy.browser.js │ └── astronomy.browser.min.js └── suncalc │ ├── LICENSE │ ├── README.md │ └── suncalc.js ├── manifest.json ├── pop.html ├── resources ├── icon-generator.svg └── icons │ ├── info.svg │ ├── info2.svg │ ├── info3.svg │ ├── info4.svg │ ├── settings.svg │ ├── settings2.svg │ └── settings3.svg ├── scripts ├── app.js ├── calendar.js └── clock.js ├── styles ├── colors.css └── main.css └── worker.js /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | resources 2 | test 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sun Clock 2 | 3 | A 24-hour clock that shows sunrise, sunset, golden hour, and twilight times for your 4 | current location. It also shows the current position and phase of the moon, and its 5 | rising and setting times. 6 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/virtualgeoff/sunclock/a33effb73b353d497b0667c69329fb1ab9ca3d0a/favicon.ico -------------------------------------------------------------------------------- /icons/favicon_old.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/virtualgeoff/sunclock/a33effb73b353d497b0667c69329fb1ab9ca3d0a/icons/favicon_old.ico -------------------------------------------------------------------------------- /icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/virtualgeoff/sunclock/a33effb73b353d497b0667c69329fb1ab9ca3d0a/icons/icon.png -------------------------------------------------------------------------------- /icons/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /icons/icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/virtualgeoff/sunclock/a33effb73b353d497b0667c69329fb1ab9ca3d0a/icons/icon_128.png -------------------------------------------------------------------------------- /icons/icon_192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/virtualgeoff/sunclock/a33effb73b353d497b0667c69329fb1ab9ca3d0a/icons/icon_192.png -------------------------------------------------------------------------------- /icons/icon_256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/virtualgeoff/sunclock/a33effb73b353d497b0667c69329fb1ab9ca3d0a/icons/icon_256.png -------------------------------------------------------------------------------- /icons/icon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/virtualgeoff/sunclock/a33effb73b353d497b0667c69329fb1ab9ca3d0a/icons/icon_32.png -------------------------------------------------------------------------------- /icons/icon_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/virtualgeoff/sunclock/a33effb73b353d497b0667c69329fb1ab9ca3d0a/icons/icon_512.png -------------------------------------------------------------------------------- /icons/icon_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/virtualgeoff/sunclock/a33effb73b353d497b0667c69329fb1ab9ca3d0a/icons/icon_64.png -------------------------------------------------------------------------------- /icons/icon_maskable.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /icons/icon_old.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/virtualgeoff/sunclock/a33effb73b353d497b0667c69329fb1ab9ca3d0a/icons/icon_old.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sun Clock 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 64 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 |
86 |
87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 |
126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 |
176 |

Sun Clock

177 |

Getting date…

178 |

179 |

Getting location…

180 |

181 |

show all times

182 | 183 |

Sun Calendar

184 | 187 |
188 | 189 |
190 | 191 |
192 |
193 |

About

194 |

Sun Clock is a 24-hour clock that displays the position of the sun, and times of sunrise, solar noon, sunset, golden hour, and twilight for your current location.

195 |

It also shows the position and phase of the moon, and its rising and setting times.

196 | 197 | 198 |

In the Northern Hemisphere the Sun moves across the sky in a clockwise direction; in the Southern Hemisphere, it moves anti-clockwise. Sun Clock matches this by setting its direction of rotation based on your latitude1. You can change it in the settings if you wish.

199 |

1. Ideally you want the clock to turn in the same direction as the sun, regardless of which hemisphere you are in. If you are facing North when at your computer set it to anti-clockwise.

200 | 201 |

Tips

202 |

Tap on or hover over the segments to get their start and end times. You can also tap/hover on the moon, the hour hand, and the centre dot.

203 |

See updates for change history.

204 | 205 |

Support

206 |

Sun Clock is free to use, and contains no advertising. If you would like to help support Sun Clock, please —

207 |

208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 |

217 | 218 |

Privacy

219 |

We collect aggregate user stats only. Your location and settings are stored in your web browser and are not sent to the server. No cookies are saved or sent.

220 | 221 |
222 |

223 | Feedback   〜   224 | Source   〜   225 | SunCalc 226 |

227 | 228 | 229 |
230 |
231 | 232 |
233 |
234 |

Updates

235 | 236 |

2024-05-23

237 |

Added the option to show the odd numbers on the clock face.

238 |

The "use 12-hour times" option now applies to the numbers on the clock face also.

239 | 240 |

2024-02-13

241 |

Added an annual calendar. Try it out. Feedback welcome!

242 | 243 |

2023-10-20

244 |

Sun Clock is now a Progressive Web App. This means you can install it on your device homepage and it will be available when your are offline.

245 | 246 |

2022-10-24

247 |

Added auto-color mode (dynamic colors that change with the time periods.)

248 | 249 |

2022-09-07

250 |

Added dark mode.

251 | 252 |

2022-05-27

253 |

Live!

254 | 255 | 256 |
257 |
258 | 259 |
260 |
261 |

All Times

262 |

Times update at Solar Midnight

263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 |
EventTime
273 | 274 | 275 |
276 |
277 | 278 |
279 |
280 |

Settings

281 | 282 |
283 |

284 | 285 |

286 |

287 |
288 |
289 |
290 |

291 |

292 |
293 |
294 |
295 |

296 |

297 | 298 |

299 |

300 | 301 |

302 |

303 | 304 |

305 |

306 |   307 | 308 |

309 |

310 | 311 |

312 |

313 | ℹ︎
314 | Use decimal degrees. Should be a number between -90.0 and 90.0
1–2 decimal places is sufficient.
315 | ℹ︎
316 | Use decimal degrees. Should be a number between -180.0 and 180.0
1–2 decimal places is sufficient.
317 | 318 |

319 |

Color mode:

320 |

321 |   322 |   323 |
324 | 325 |

326 |
327 | 328 | 329 |
330 |
331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | -------------------------------------------------------------------------------- /libs/astronomy/LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2023 Don Cross 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 | -------------------------------------------------------------------------------- /libs/suncalc/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Vladimir Agafonkin 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are 5 | permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this list of 8 | conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, this list 11 | of conditions and the following disclaimer in the documentation and/or other materials 12 | provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY 15 | EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 16 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 17 | COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 19 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 20 | HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR 21 | TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 22 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /libs/suncalc/README.md: -------------------------------------------------------------------------------- 1 | 2 | SunCalc 3 | ======= 4 | 5 | [![Build Status](https://travis-ci.org/mourner/suncalc.svg?branch=master)](https://travis-ci.org/mourner/suncalc) 6 | 7 | SunCalc is a tiny BSD-licensed JavaScript library for calculating sun position, 8 | sunlight phases (times for sunrise, sunset, dusk, etc.), 9 | moon position and lunar phase for the given location and time, 10 | created by [Vladimir Agafonkin](http://agafonkin.com/en) ([@mourner](https://github.com/mourner)) 11 | as a part of the [SunCalc.net project](http://suncalc.net). 12 | 13 | Most calculations are based on the formulas given in the excellent Astronomy Answers articles 14 | about [position of the sun](http://aa.quae.nl/en/reken/zonpositie.html) 15 | and [the planets](http://aa.quae.nl/en/reken/hemelpositie.html). 16 | You can read about different twilight phases calculated by SunCalc 17 | in the [Twilight article on Wikipedia](http://en.wikipedia.org/wiki/Twilight). 18 | 19 | 20 | ## Usage example 21 | 22 | ```javascript 23 | // get today's sunlight times for London 24 | var times = SunCalc.getTimes(new Date(), 51.5, -0.1); 25 | 26 | // format sunrise time from the Date object 27 | var sunriseStr = times.sunrise.getHours() + ':' + times.sunrise.getMinutes(); 28 | 29 | // get position of the sun (azimuth and altitude) at today's sunrise 30 | var sunrisePos = SunCalc.getPosition(times.sunrise, 51.5, -0.1); 31 | 32 | // get sunrise azimuth in degrees 33 | var sunriseAzimuth = sunrisePos.azimuth * 180 / Math.PI; 34 | ``` 35 | 36 | SunCalc is also available as an NPM package: 37 | 38 | ```bash 39 | $ npm install suncalc 40 | ``` 41 | 42 | ```js 43 | var SunCalc = require('suncalc'); 44 | ``` 45 | 46 | 47 | ## Reference 48 | 49 | ### Sunlight times 50 | 51 | ```javascript 52 | SunCalc.getTimes(/*Date*/ date, /*Number*/ latitude, /*Number*/ longitude, /*Number (default=0)*/ height) 53 | ``` 54 | 55 | Returns an object with the following properties (each is a `Date` object): 56 | 57 | | Property | Description | 58 | | --------------- | ------------------------------------------------------------------------ | 59 | | `sunrise` | sunrise (top edge of the sun appears on the horizon) | 60 | | `sunriseEnd` | sunrise ends (bottom edge of the sun touches the horizon) | 61 | | `goldenHourEnd` | morning golden hour (soft light, best time for photography) ends | 62 | | `solarNoon` | solar noon (sun is in the highest position) | 63 | | `goldenHour` | evening golden hour starts | 64 | | `sunsetStart` | sunset starts (bottom edge of the sun touches the horizon) | 65 | | `sunset` | sunset (sun disappears below the horizon, evening civil twilight starts) | 66 | | `dusk` | dusk (evening nautical twilight starts) | 67 | | `nauticalDusk` | nautical dusk (evening astronomical twilight starts) | 68 | | `night` | night starts (dark enough for astronomical observations) | 69 | | `nadir` | nadir (darkest moment of the night, sun is in the lowest position) | 70 | | `nightEnd` | night ends (morning astronomical twilight starts) | 71 | | `nauticalDawn` | nautical dawn (morning nautical twilight starts) | 72 | | `dawn` | dawn (morning nautical twilight ends, morning civil twilight starts) | 73 | 74 | ```javascript 75 | SunCalc.addTime(/*Number*/ angleInDegrees, /*String*/ morningName, /*String*/ eveningName) 76 | ``` 77 | 78 | Adds a custom time when the sun reaches the given angle to results returned by `SunCalc.getTimes`. 79 | 80 | `SunCalc.times` property contains all currently defined times. 81 | 82 | 83 | ### Sun position 84 | 85 | ```javascript 86 | SunCalc.getPosition(/*Date*/ timeAndDate, /*Number*/ latitude, /*Number*/ longitude) 87 | ``` 88 | 89 | Returns an object with the following properties: 90 | 91 | * `altitude`: sun altitude above the horizon in radians, 92 | e.g. `0` at the horizon and `PI/2` at the zenith (straight over your head) 93 | * `azimuth`: sun azimuth in radians (direction along the horizon, measured from south to west), 94 | e.g. `0` is south and `Math.PI * 3/4` is northwest 95 | 96 | 97 | ### Moon position 98 | 99 | ```javascript 100 | SunCalc.getMoonPosition(/*Date*/ timeAndDate, /*Number*/ latitude, /*Number*/ longitude) 101 | ``` 102 | 103 | Returns an object with the following properties: 104 | 105 | * `altitude`: moon altitude above the horizon in radians 106 | * `azimuth`: moon azimuth in radians 107 | * `distance`: distance to moon in kilometers 108 | * `parallacticAngle`: parallactic angle of the moon in radians 109 | 110 | 111 | ### Moon illumination 112 | 113 | ```javascript 114 | SunCalc.getMoonIllumination(/*Date*/ timeAndDate) 115 | ``` 116 | 117 | Returns an object with the following properties: 118 | 119 | * `fraction`: illuminated fraction of the moon; varies from `0.0` (new moon) to `1.0` (full moon) 120 | * `phase`: moon phase; varies from `0.0` to `1.0`, described below 121 | * `angle`: midpoint angle in radians of the illuminated limb of the moon reckoned eastward from the north point of the disk; 122 | the moon is waxing if the angle is negative, and waning if positive 123 | 124 | Moon phase value should be interpreted like this: 125 | 126 | | Phase | Name | 127 | | -----:| --------------- | 128 | | 0 | New Moon | 129 | | | Waxing Crescent | 130 | | 0.25 | First Quarter | 131 | | | Waxing Gibbous | 132 | | 0.5 | Full Moon | 133 | | | Waning Gibbous | 134 | | 0.75 | Last Quarter | 135 | | | Waning Crescent | 136 | 137 | By subtracting the `parallacticAngle` from the `angle` one can get the zenith angle of the moons bright limb (anticlockwise). 138 | The zenith angle can be used do draw the moon shape from the observers perspective (e.g. moon lying on its back). 139 | 140 | ### Moon rise and set times 141 | 142 | ```js 143 | SunCalc.getMoonTimes(/*Date*/ date, /*Number*/ latitude, /*Number*/ longitude[, inUTC]) 144 | ``` 145 | 146 | Returns an object with the following properties: 147 | 148 | * `rise`: moonrise time as `Date` 149 | * `set`: moonset time as `Date` 150 | * `alwaysUp`: `true` if the moon never rises/sets and is always _above_ the horizon during the day 151 | * `alwaysDown`: `true` if the moon is always _below_ the horizon 152 | 153 | By default, it will search for moon rise and set during local user's day (frou 0 to 24 hours). 154 | If `inUTC` is set to true, it will instead search the specified date from 0 to 24 UTC hours. 155 | 156 | ## Changelog 157 | 158 | #### 1.8.0 — Dec 22, 2016 159 | 160 | - Improved precision of moonrise/moonset calculations. 161 | - Added `parallacticAngle` calculation to `getMoonPosition`. 162 | - Default to today's date in `getMoonIllumination`. 163 | - Fixed incompatibility when using Browserify/Webpack together with a global AMD loader. 164 | 165 | #### 1.7.0 — Nov 11, 2015 166 | 167 | - Added `inUTC` argument to `getMoonTimes`. 168 | 169 | #### 1.6.0 — Oct 27, 2014 170 | 171 | - Added `SunCalc.getMoonTimes` for calculating moon rise and set times. 172 | 173 | #### 1.5.1 — May 16, 2014 174 | 175 | - Exposed `SunCalc.times` property with defined daylight times. 176 | - Slightly improved `SunCalc.getTimes` performance. 177 | 178 | #### 1.4.0 — Apr 10, 2014 179 | 180 | - Added `phase` to `SunCalc.getMoonIllumination` results (moon phase). 181 | - Switched from mocha to tape for tests. 182 | 183 | #### 1.3.0 — Feb 21, 2014 184 | 185 | - Added `SunCalc.getMoonIllumination` (in place of `getMoonFraction`) that returns an object with `fraction` and `angle` 186 | (angle of illuminated limb of the moon). 187 | 188 | #### 1.2.0 — Mar 07, 2013 189 | 190 | - Added `SunCalc.getMoonFraction` function that returns illuminated fraction of the moon. 191 | 192 | #### 1.1.0 — Mar 06, 2013 193 | 194 | - Added `SunCalc.getMoonPosition` function. 195 | - Added nadir (darkest time of the day, middle of the night). 196 | - Added tests. 197 | 198 | #### 1.0.0 — Dec 07, 2011 199 | 200 | - Published to NPM. 201 | - Added `SunCalc.addTime` function. 202 | 203 | #### 0.0.0 — Aug 25, 2011 204 | 205 | - First commit. 206 | -------------------------------------------------------------------------------- /libs/suncalc/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 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Sun Clock", 3 | "short_name": "SunClock", 4 | "description": "Sun Clock is a 24-hour clock that shows sunrise, sunset, golden hour, and twilight times for your current location. It also shows the current position and phase of the moon, and its rising and setting times.", 5 | "start_url": "./", 6 | "theme_color": "#000", 7 | "background_color": "#ccc", 8 | "display": "standalone", 9 | "icons": [ 10 | { 11 | "src": "icons/icon_32.png", 12 | "sizes": "32x32", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "icons/icon_64.png", 17 | "sizes": "64x64", 18 | "type": "image/png" 19 | }, 20 | { 21 | "src": "icons/icon_128.png", 22 | "sizes": "128x128", 23 | "type": "image/png" 24 | }, 25 | { 26 | "src": "icons/icon_192.png", 27 | "sizes": "192x192", 28 | "type": "image/png" 29 | }, 30 | { 31 | "src": "icons/icon_256.png", 32 | "sizes": "256x256", 33 | "type": "image/png" 34 | }, 35 | { 36 | "src": "icons/icon.png", 37 | "sizes": "512x512", 38 | "type": "image/png" 39 | }, 40 | { 41 | "src": "icons/icon.svg", 42 | "sizes": "any", 43 | "type": "image/svg+xml" 44 | }, 45 | { 46 | "src": "icons/icon_maskable.svg", 47 | "sizes": "any", 48 | "type": "image/svg+xml", 49 | "purpose": "maskable" 50 | } 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /pop.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | 14 | 15 | 16 |

Window Popup

17 | 18 |

300 × 300

19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /resources/icon-generator.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 9 | Sun Clock Icon Generator 10 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | -------------------------------------------------------------------------------- /resources/icons/info.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /resources/icons/info2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/info3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | i 4 | 5 | -------------------------------------------------------------------------------- /resources/icons/info4.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /resources/icons/settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/icons/settings2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /resources/icons/settings3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /scripts/app.js: -------------------------------------------------------------------------------- 1 | /* 2 | Sun Clock 3 | A 24-hour clock that shows sunrise, sunset, golden hour, and twilight times for your current location 4 | 5 | Geoff Pack, May 2022 6 | https://github.com/virtualgeoff/sunclock 7 | */ 8 | 9 | /* jshint esversion: 6 */ 10 | /* globals SunClock, SunCalendar */ 11 | 12 | // shortcuts 13 | const $ = document.querySelector.bind(document); 14 | const $All = document.querySelectorAll.bind(document); 15 | const debug = false; 16 | 17 | 18 | /* 19 | App handles navigation, routes, settings, dark mode, and date formatting 20 | */ 21 | 22 | var App = (function() { 23 | 'use strict'; 24 | 25 | let prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); 26 | let supportsHover = window.matchMedia('(hover: hover)').matches; 27 | let isPortrait = window.matchMedia('(orientation:portrait)').matches; 28 | let isLandscape = window.matchMedia('(orientation:landscape)').matches; 29 | let lastSection = ''; 30 | 31 | // app settings - stored in localStorage 32 | let settings = { 33 | direction : 1, // 1 = clockwise, -1 = anticlockwise 34 | location : null, // {"latitude":0,"longitude":0} 35 | hour12 : false, // use 24 hr times 36 | colorScheme: 'dynamic' // 'light' | 'dark' | 'auto' | 'dynamic' 37 | }; 38 | 39 | const geoOptions = {enableHighAccuracy: true, timeout: 5000, maximumAge: 0}; 40 | const geoErrors = ['', 'PERMISSION_DENIED', 'POSITION_UNAVAILABLE', 'TIMEOUT']; 41 | 42 | 43 | /* --- full screen --- */ 44 | 45 | function toggleFullscreen(e) { 46 | // toggle fullscreen mode 47 | e.preventDefault(); 48 | var d = document, dE = d.documentElement; 49 | 50 | if (d.fullscreenElement || d.webkitFullscreenElement) { 51 | if (d.exitFullscreen) { 52 | d.exitFullscreen(); 53 | } else if (d.webkitCancelFullScreen) { 54 | d.webkitCancelFullScreen(); 55 | } 56 | // change icon 57 | setTimeout(() => { $('#fullscreen .enter').style.display = 'block'; }, 600); 58 | setTimeout(() => { $('#fullscreen .exit').style.display = 'none'; }, 600); 59 | } else { 60 | if (dE.requestFullscreen) { 61 | dE.requestFullscreen(); 62 | } else if (dE.webkitRequestFullScreen) { 63 | dE.webkitRequestFullScreen(); 64 | } 65 | // change icon 66 | setTimeout(() => { $('#fullscreen .enter').style.display = 'none'; }, 600); 67 | setTimeout(() => { $('#fullscreen .exit').style.display = 'block'; }, 600); 68 | } 69 | } 70 | 71 | function fullscreenAvailable() { 72 | // check if fullscreen mode is available (iPhone does not support fullscreen) 73 | var dE = document.documentElement; 74 | if (dE.requestFullscreen || dE.webkitRequestFullScreen) { 75 | return true; 76 | } 77 | return false; 78 | } 79 | 80 | 81 | /* --- resize --- */ 82 | 83 | function handleResize(e) { 84 | // on resizing (esp. orientation change), make sure #info1 is visible 85 | // otherwise if you go from portrait to landscape (on touch devices) with #info2 visible then #info1 stays hidden 86 | // n.b. Screen.orientation does not work in Safari < 16.4 87 | $('#info1').style.display = 'block'; 88 | //$('#info2').style.display = 'none'; 89 | } 90 | 91 | 92 | /* --- dark mode --- */ 93 | 94 | function isDarkModeEnabled() { 95 | return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; 96 | } 97 | 98 | function setDark(e) { 99 | // set dark mode based on OS settings 100 | console.log(e); 101 | if (settings.colorScheme === 'auto') { 102 | document.documentElement.setAttribute("data-theme", ((e.matches) ? 'dark' : 'light')); 103 | } 104 | } 105 | 106 | function updateColorScheme() { 107 | if (debug) { console.log('updateColorScheme: ' + settings.colorScheme); } 108 | SunClock.clearDynamicTheme(); // clear in case previously set 109 | 110 | if (settings.colorScheme === 'auto') { 111 | // set based on OS settings 112 | setDark({matches: prefersDark.matches}); 113 | } else if (settings.colorScheme === 'dynamic') { 114 | SunClock.updateDynamicTheme(); 115 | } else { 116 | document.documentElement.setAttribute("data-theme", settings.colorScheme); 117 | } 118 | } 119 | 120 | 121 | /* --- navigation --- */ 122 | 123 | function showSection(e) { 124 | // hide all sections, show one 125 | let hash = window.location.hash; 126 | if (debug) { console.log(e); } 127 | $All('section').forEach( section => { section.style.display = 'none'; }); 128 | 129 | // show section, and clock or calendar 130 | if (hash) { 131 | if (hash === '#calendar') { 132 | $('#clock').style.display = 'none'; 133 | $('#calendar').style.display = 'block'; 134 | $('#nav1 a[title="Clock"]').style.display = 'inline'; 135 | $('#nav1 a[title="Calendar"]').style.display = 'none'; 136 | } else { 137 | if ($(hash)) { $(hash).style.display = 'block'; } 138 | } 139 | } else { 140 | $('#clock').style.display = 'block'; 141 | $('#calendar').style.display = 'none'; 142 | $('#nav1 a[title="Clock"]').style.display = 'none'; 143 | $('#nav1 a[title="Calendar"').style.display = 'inline'; 144 | } 145 | 146 | // save lastSection, unless user came to page via direct link to a section 147 | if (e && e.type === 'hashchange') { lastSection = hash; } 148 | } 149 | 150 | function closeSection(e) { 151 | // use back instead of #link when closing section overlays 152 | // unless user came to page via direct link to a section 153 | if (debug) { console.log(e); } 154 | if (lastSection) { 155 | if (e) { e.preventDefault(); } 156 | history.back(); 157 | } 158 | } 159 | 160 | function showInfo(str) { 161 | // show info2 + hide info1 if portrait 162 | if (isPortrait) { $('#info1').style.display = 'none'; } 163 | $('#info2').style.display = 'block'; 164 | $('#info2').innerHTML = str + '\n

ok

'; 165 | $('p.done').onclick = (e) => { e.preventDefault(); hideInfo(); }; 166 | } 167 | 168 | function hideInfo() { 169 | // hide info2 + show info1 if portrait 170 | if (isPortrait) { $('#info1').style.display = 'block'; } 171 | $('#info2').style.display = 'none'; 172 | $('#info2').innerHTML = ''; 173 | } 174 | 175 | function showInfoOnHover(object, func, arg) { 176 | // add hover or click events to a dom object 177 | if (supportsHover) { 178 | object.onmouseover = (e) => { e.stopPropagation(); showInfo( func(arg) ); } 179 | object.onmouseout = () => hideInfo(); 180 | } else { 181 | object.onclick = (e) => { e.stopPropagation(); showInfo( func(arg) ); } 182 | } 183 | } 184 | 185 | function decodeURL(anchor) { 186 | // decodes data in data-address attribute of an anchor tag — used to obfuscate mailto link 187 | // if email addresses are present in the HTML Cloudflare will obfuscate them itself and add its own decoder 188 | let input = anchor.dataset.address.replace(/\s+/g, ',').split(','); 189 | let output = ''; 190 | 191 | for (let i=0; iStorage not available: settings can not be saved!

'); 251 | return; 252 | } 253 | if (getItem('showMoon') === false) { 254 | $('input[name="showMoon"]').checked = false; 255 | $('#moonHand').style.display = 'none'; 256 | } 257 | if (getItem('showHourNumbers') === false) { 258 | $('input[name="showHourNumbers"]').checked = false; 259 | $('#hourNumbers').style.display = 'none'; 260 | // if hour numbers are hidden, make the even hour marks the longer ones (rotate long marks 15° = 1 hr) 261 | $('#hourMarks2').setAttribute('transform', 'rotate(15)'); 262 | } 263 | if (getItem('showOddHourNumbers') === true) { 264 | $('input[name="showOddHourNumbers"]').checked = true; 265 | $('#hourNumbers').classList.add('showOdd'); 266 | $('#hourMarks2').style.display = 'none'; 267 | } 268 | if (getItem('showHourMarks') === false) { 269 | $('input[name="showHourMarks"]').checked = false; 270 | $('#hourMarks').style.display = 'none'; 271 | $('#hourMarks2').style.display = 'none'; 272 | } 273 | if (getItem('showMinuteHand') === false) { 274 | $('input[name="showMinuteHand"]').checked = false; 275 | $('#minuteHand').style.display = 'none'; 276 | } 277 | if (getItem('showMinuteNumbers') === false) { 278 | $('input[name="showMinuteNumbers"]').checked = false; 279 | $('#minuteNumbers').style.display = 'none'; 280 | } 281 | if (getItem('showMinuteMarks') === false) { 282 | $('input[name="showMinuteMarks"]').checked = false; 283 | $('#minuteMarks').style.display = 'none'; 284 | } 285 | if (getItem('showSecondHand') === false) { 286 | $('input[name="showSecondHand"]').checked = false; 287 | $('#secondHand').style.display = 'none'; 288 | } 289 | if (getItem('hour12') === true) { 290 | $('input[name="hour12"]').checked = true; 291 | settings.hour12 = true; 292 | } 293 | 294 | // direction 295 | if (getItem('setDirectionManually') === true) { 296 | $('input[name="setDirectionManually"]').checked = true; 297 | $('#setDirection').style.display = 'block'; 298 | } 299 | if (getItem('direction') !== null) { 300 | settings.direction = getItem('direction'); 301 | $('#direction_cw').checked = (settings.direction > 0) ? true : false; 302 | $('#direction_ccw').checked = (settings.direction > 0) ? false : true; 303 | } 304 | 305 | // location 306 | if (getItem('setLocationManually') === true) { 307 | $('input[name="setLocationManually"]').checked = true; 308 | $('#setLocation').style.display = 'block'; 309 | } 310 | if (getItem('location') !== null) { 311 | settings.location = getItem('location'); 312 | $('input[name="latitude"]').value = settings.location.latitude; 313 | $('input[name="longitude"]').value = settings.location.longitude; 314 | } 315 | 316 | // color scheme 317 | if (getItem('colorScheme') !== null) { 318 | settings.colorScheme = getItem('colorScheme'); 319 | $('#scheme_light').checked = (settings.colorScheme === 'light') ? true : false; 320 | $('#scheme_dark').checked = (settings.colorScheme === 'dark') ? true : false; 321 | $('#scheme_auto').checked = (settings.colorScheme === 'auto') ? true : false; 322 | $('#scheme_dynamic').checked = (settings.colorScheme === 'dynamic') ? true : false; 323 | updateColorScheme(); 324 | } 325 | } 326 | 327 | function setOption(checkbox) { 328 | // handle options checkboxes and radio buttons 329 | if (debug) { console.log(checkbox.name, checkbox.checked); } 330 | switch (checkbox.name) { 331 | case 'showMoon': 332 | $('#moonHand').style.display = (checkbox.checked) ? 'block' : 'none'; 333 | break; 334 | case 'showHourNumbers': 335 | $('#hourNumbers').style.display = (checkbox.checked) ? 'block' : 'none'; 336 | // if hour numbers are hidden, make the even hour marks the longer ones (rotate long marks 15° = 1 hr) 337 | $('#hourMarks2').setAttribute('transform', ((checkbox.checked) ? 'rotate(0)' : 'rotate(15)')); 338 | break; 339 | case 'showOddHourNumbers': 340 | $('#hourNumbers').classList.toggle('showOdd'); 341 | $('#hourMarks2').style.display = (checkbox.checked) ? 'none' : 'block'; 342 | break; 343 | case 'showHourMarks': 344 | $('#hourMarks').style.display = (checkbox.checked) ? 'block' : 'none'; 345 | let oddHours = $('input[name="showOddHourNumbers"]'); 346 | $('#hourMarks2').style.display = (checkbox.checked && !oddHours.checked) ? 'block' : 'none'; 347 | break; 348 | case 'showMinuteHand': 349 | $('#minuteHand').style.display = (checkbox.checked) ? 'block' : 'none'; 350 | break; 351 | case 'showMinuteNumbers': 352 | $('#minuteNumbers').style.display = (checkbox.checked) ? 'block' : 'none'; 353 | break; 354 | case 'showMinuteMarks': 355 | $('#minuteMarks').style.display = (checkbox.checked) ? 'block' : 'none'; 356 | break; 357 | case 'showSecondHand': 358 | $('#secondHand').style.display = (checkbox.checked) ? 'block' : 'none'; 359 | break; 360 | case 'hour12': 361 | settings.hour12 = checkbox.checked; 362 | SunClock.writeMainTimes(); // rewrite the main times (the info2 times update when shown) 363 | SunClock.drawNumbers(); // redraw the numbers on the clock face 364 | break; 365 | 366 | case 'setDirectionManually': 367 | $('#setDirection').style.display = (checkbox.checked) ? 'block' : 'none'; 368 | if (checkbox.checked) { 369 | // was unchecked, now checked - set radio buttons to current direction, and save direction 370 | $('#direction_cw').checked = (settings.direction > 0) ? true : false; 371 | $('#direction_ccw').checked = (settings.direction > 0) ? false : true; 372 | setItem('direction', settings.direction); 373 | } else { 374 | // was checked, now unchecked - update direction 375 | if (settings.location && settings.location.latitude) { 376 | settings.direction = (settings.location.latitude >= 0) ? 1 : -1; 377 | } else { 378 | settings.direction = 1; 379 | } 380 | setItem('direction', settings.direction); 381 | SunClock.updateDirection(); 382 | } 383 | break; 384 | case 'setDirection': 385 | // note: radio buttons have name="setDirection" but the *setting* is 'direction'; 386 | settings.direction = (checkbox.value === 'clockwise') ? 1 : -1; 387 | setItem('direction', settings.direction); 388 | SunClock.updateDirection(); 389 | break; 390 | 391 | case 'setLocationManually': 392 | $('#setLocation').style.display = (checkbox.checked) ? 'block' : 'none'; 393 | if (checkbox.checked) { 394 | // was unchecked, now checked - show location 395 | settings.location = getItem('location'); 396 | if (settings.location) { 397 | // in case text fields have been modified or cleared: 398 | $('input[name=latitude]').value = settings.location.latitude; 399 | $('input[name=longitude]').value = settings.location.longitude; 400 | } 401 | showLocation({coords: settings.location}); 402 | } else { 403 | // was checked, now unchecked - need to get location again 404 | setItem(event.target.name, checkbox.checked); // need to save *before* getLocation 405 | getLocation(); 406 | } 407 | break; 408 | 409 | case 'setColorScheme': 410 | // note: radio buttons have name="setColorScheme" but the *setting* is 'colorScheme'; 411 | settings.colorScheme = checkbox.value; 412 | setItem('colorScheme', JSON.stringify(settings.colorScheme)); 413 | updateColorScheme(); 414 | break; 415 | 416 | default: 417 | alert('wot?'); 418 | } 419 | setItem(checkbox.name, checkbox.checked); 420 | } 421 | 422 | 423 | /* --- location --- */ 424 | 425 | function updateLocation(form) { 426 | // handle location form submit 427 | console.log(`updating location to ${form.latitude.value}, ${form.longitude.value}`); 428 | // parseFloat returns a number or NaN 429 | // TODO: check values are valid, or use default values (should be handled by input type/min/max) 430 | settings.location = {latitude:parseFloat(form.latitude.value), longitude:parseFloat(form.longitude.value)}; 431 | setItem('location', JSON.stringify(settings.location)); 432 | showLocation({coords: settings.location}); 433 | closeSection(); // close settings on location submit 434 | return false; 435 | } 436 | 437 | function getLocation() { 438 | // get location from localStorage or Geolocation API 439 | if (getItem('setLocationManually') === true) { 440 | showLocation({coords: settings.location}); 441 | } else if (navigator.geolocation) { 442 | navigator.geolocation.getCurrentPosition(showLocation, showLocationError, geoOptions); 443 | } else { 444 | showLocationError({message: 'Geolocation is not supported. Please set location manually.'}); 445 | } 446 | } 447 | 448 | function showLocation(position) { 449 | // show location then get times 450 | let location = position.coords; 451 | settings.location = location; 452 | if (debug) { console.log(location); } 453 | 454 | if (location) { 455 | $('#location').innerHTML = `Location: 456 | ${Math.abs(location.latitude.toFixed(3))}°${(location.latitude >=0) ? 'N' : 'S'}, 457 | ${Math.abs(location.longitude.toFixed(3))}°${(location.longitude >=0) ? 'E' : 'W'}`; 458 | //
(Accuracy: ${location.accuracy} m)`; 459 | 460 | // if setDirectionManually option is not set (or false), choose direction based on latitude 461 | if (getItem('setDirectionManually') !== true) { 462 | settings.direction = (location.latitude >= 0) ? 1 : -1; 463 | setItem('direction', settings.direction); // save direction for next time - to prevent jump when geolocation loads 464 | SunClock.updateDirection(); 465 | } 466 | 467 | // get times for this location 468 | SunClock.getSunTimes(); 469 | // update calendar face to reflect latidude 470 | SunCalendar.drawFace(); 471 | } else { 472 | $('#location').innerHTML = 'Location not set'; 473 | clearLocation(); 474 | } 475 | } 476 | 477 | function showLocationError(err) { 478 | console.error(err); 479 | $('#location').innerHTML = `Location error: ${err.message || geoErrors[err.code]}`; 480 | clearLocation(); 481 | } 482 | 483 | function clearLocation() { 484 | // clear previous (e.g. if going from location to no location) 485 | settings.location = null; 486 | $('#mainTimes').innerHTML = ''; 487 | $('#info2').innerHTML = ''; 488 | $('#allTimes table tbody').innerHTML = ''; 489 | 490 | // update times from clock 491 | SunClock.clearSunTimes(); 492 | } 493 | 494 | 495 | /* --- date and time formatting --- */ 496 | 497 | function zeroPad(num, n) { 498 | // zero pad number 499 | return num.toString().padStart(n, '0'); 500 | } 501 | 502 | function formatDateUTC(d) { 503 | // format date in UTC (ISO-8601) 504 | if (d == 'Invalid Date') { return 'Does not occur'; } 505 | 506 | //return d.toISOString(); // overly precise — construct myself 507 | let date = new Date( Math.round(d/60000) * 60000 ); // round to nearest minute 508 | let yyyy = date.getUTCFullYear(); 509 | let mm = zeroPad(date.getUTCMonth()+1, 2); 510 | let dd = zeroPad(date.getUTCDate(), 2); 511 | let HH = zeroPad(date.getUTCHours(), 2); 512 | let MM = zeroPad(date.getUTCMinutes(), 2); 513 | return `${yyyy}-${mm}-${dd}T${HH}:${MM}Z`; 514 | } 515 | 516 | function formatAllTimes(d) { 517 | // shows time + timezone 518 | // if time is yesterday or tomorrow, also show the date (in compact form) 519 | if (d == 'Invalid Date') { return 'Does not occur'; } 520 | 521 | let now = new Date(); 522 | let date = new Date( Math.round(d/60000) * 60000 ); // round to nearest minute 523 | let yyyy = date.getUTCFullYear(); 524 | let mm = zeroPad(date.getMonth()+1, 2); 525 | let dd = zeroPad(date.getDate(), 2); 526 | 527 | let timeOptions = { 528 | hour: "numeric", 529 | minute: "numeric", 530 | timeZoneName: "short", 531 | //hour12: settings.hour12, // hour12 is broken in Chrome (12:00 shows as 0:00), so: 532 | hourCycle: (settings.hour12) ? 'h12' : 'h23' 533 | }; 534 | 535 | if (date.getDate() === now.getDate()) { 536 | return date.toLocaleTimeString([], timeOptions); 537 | } 538 | return `${yyyy}-${mm}-${dd}
${date.toLocaleTimeString([], timeOptions)}`; 539 | } 540 | 541 | function formatDate(d) { 542 | // format date in local time 543 | if (d == 'Invalid Date') { return 'Does not occur'; } 544 | 545 | let date = new Date( Math.round(d/60000) * 60000 ); // round to nearest minute 546 | let dateOptions = { 547 | dateStyle: 'full', 548 | }; 549 | let timeOptions = { 550 | hour: "numeric", 551 | minute: "numeric", 552 | timeZoneName: "short", 553 | //hour12: settings.hour12, // hour12 is broken in Chrome (12:00 shows as 0:00), so: 554 | hourCycle: (settings.hour12) ? 'h12' : 'h23' 555 | }; 556 | return `${new Intl.DateTimeFormat(undefined, dateOptions).format(date)}
557 | ${new Intl.DateTimeFormat(undefined, timeOptions).format(date)}`; 558 | } 559 | 560 | function formatTime(t) { 561 | // local time, in 12 or 24 hour format, rounded to nearest minute 562 | if (t == 'Invalid Date') { return 'Does not occur'; } 563 | 564 | let time = new Date( Math.round(t/60000) * 60000 ); // round to nearest minute 565 | let timeOptions = { 566 | hour: "numeric", 567 | minute: "numeric", 568 | //hour12: settings.hour12, // hour12 is broken in Chrome (12:00 shows as 0:00), so: 569 | hourCycle: (settings.hour12) ? 'h12' : 'h23' 570 | }; 571 | //return t.toLocaleTimeString(); // hh:mm:ss 572 | return time.toLocaleTimeString([], timeOptions); 573 | } 574 | 575 | 576 | /* --- initialise --- */ 577 | 578 | function init() { 579 | // load settings from localStorage 580 | loadOptions(); 581 | 582 | // initialise the clock and calendar 583 | SunClock.init(); 584 | SunCalendar.init(); 585 | 586 | // make overlays, handle section links 587 | $All('section').forEach(item => { item.classList.add('overlay'); }); // visible if JS disabled 588 | $All('a.close').forEach(link => { link.addEventListener('click', closeSection); }); // handle close links 589 | window.addEventListener('hashchange', showSection); // listen to hashchange events 590 | if (window.location.hash) { showSection(); } // open section if initial URL has a hash 591 | 592 | // note links 593 | $All('#note1, #note2, #note3').forEach(link => { link.classList.add('hide'); }); 594 | $('a[href="#note1"]').onclick = (e) => { e.preventDefault(); $('#note1').classList.toggle('hide'); }; 595 | $('a[href="#note2"]').onclick = (e) => { e.preventDefault(); $('#note2').classList.toggle('hide'); $('#note3').classList.add('hide'); }; 596 | $('a[href="#note3"]').onclick = (e) => { e.preventDefault(); $('#note3').classList.toggle('hide'); $('#note2').classList.add('hide'); }; 597 | 598 | // show fullscreen link 599 | if (fullscreenAvailable()) { $('#fullscreen').style.display = 'inline'; } 600 | $('#fullscreen').addEventListener('click', toggleFullscreen); 601 | 602 | // decode email URL 603 | $All('a[data-address]').forEach( (a) => { decodeURL(a); }); 604 | 605 | // handle resize events 606 | window.addEventListener('resize', handleResize); 607 | 608 | // listen for color scheme change 609 | prefersDark.addEventListener("change", e => { setDark(e); }); 610 | 611 | // finally, get location (so geolocation prompt doesn't block) 612 | getLocation(); 613 | } 614 | 615 | return { 616 | supportsHover, 617 | settings, 618 | isDarkModeEnabled, 619 | toggleFullscreen, 620 | showInfo, 621 | hideInfo, 622 | showInfoOnHover, 623 | formatDateUTC, 624 | formatAllTimes, 625 | formatDate, 626 | formatTime, 627 | setOption, 628 | updateLocation, 629 | init 630 | }; 631 | })(); 632 | 633 | window.addEventListener('DOMContentLoaded', App.init); 634 | 635 | 636 | 637 | /* 638 | Service worker for PWA 639 | */ 640 | 641 | if ("serviceWorker" in navigator) { 642 | navigator.serviceWorker.register("worker.js").then( 643 | (registration) => { 644 | console.log("Service worker registration successful"); 645 | }, 646 | (error) => { 647 | console.error("Service worker registration failed:", error); 648 | } 649 | ); 650 | } else { 651 | console.error("Service workers are not supported"); 652 | } 653 | 654 | 655 | -------------------------------------------------------------------------------- /scripts/calendar.js: -------------------------------------------------------------------------------- 1 | /* 2 | SunClock Calendar 3 | 4 | Geoff Pack, January 2024 5 | https://github.com/virtualgeoff/sunclock 6 | */ 7 | 8 | /* jshint esversion: 6 */ 9 | /* globals $, $All, debug, App, SunClock, Astronomy */ 10 | 11 | const SunCalendar = (function() { 12 | 'use strict'; 13 | 14 | const tau = 2 * Math.PI; 15 | const msPerDay = 24 * 60 * 60 * 1000; // milliseconds per day 16 | const snap = true; 17 | 18 | let radius = 136; 19 | let angleDegrees = 0; 20 | let now, then = null; 21 | let thisYear, yearStart, yearEnd, leapYear; 22 | let daysPerYear, msPerYear; 23 | 24 | function dateToAngle(date) { 25 | // get angle on face for date 26 | let angle = (date - yearStart) / msPerYear * 360; 27 | return angle; 28 | } 29 | 30 | function angleToDate(angle) { 31 | // get date from angle (of pointer) 32 | let ms = yearStart.valueOf() + (angle/360) * msPerYear; 33 | return new Date(ms); 34 | } 35 | 36 | function getPointFromAngle(angle, radius) { 37 | // get point from angle and radius 38 | let theta = (angle + 180) * -tau/360; // convert to radians 39 | return `${Math.sin(theta) * radius}, ${Math.cos(theta) * radius}`; // return as string for svg path attribute 40 | } 41 | 42 | function deltaT(date2, date1) { 43 | // get the time delta (in days) between 2 dates 44 | let delta = (date2.valueOf() - date1.valueOf()) / msPerDay; 45 | return delta; 46 | } 47 | 48 | function formatDelta(date2, date1) { 49 | // format the time delta between 2 dates 50 | // TODO: format as days, (months, weeks, days, hours, minutes) 51 | let delta = deltaT(date2, date1); 52 | let str = `∆: ${delta.toFixed(1)} days`; 53 | return str.replace('-', '−'); // replace hyphen-minus with minus 54 | } 55 | 56 | function getDate() { 57 | // get the current date 58 | now = new Date(); 59 | thisYear = now.getFullYear(); 60 | yearStart = new Date(thisYear, 0, 1); 61 | yearEnd = new Date(thisYear+1, 0, 1); 62 | leapYear = (new Date(thisYear, 1, 29).getDate() === 29); 63 | daysPerYear = leapYear ? 366 : 365; 64 | msPerYear = daysPerYear * msPerDay; // milliseconds per year 65 | } 66 | 67 | function update() { 68 | // set hand position, repeat on the minute 69 | now = new Date(); 70 | if (debug) { console.log(`updating calendar: ${now.toTimeString()}`); } 71 | 72 | if (then && (now.getFullYear() !== then.getFullYear())) { 73 | alert("Happy New Year!\nupdating calendar"); 74 | getDate(); 75 | // redraw face 76 | drawFace(); 77 | // clear moon phases and redraw astronomical events 78 | $All('.moonPhase').forEach((o) => { o.remove(); }); 79 | drawAstronomicalEvents(); 80 | } 81 | 82 | // set date hand 83 | $('#dateHand').setAttribute('transform', `rotate(${dateToAngle(now)})`); 84 | 85 | // calendar icon text 86 | $('#calendarIconMonth').textContent = (now.toLocaleString('default', { month: 'short' })).toUpperCase(); 87 | $('#calendarIconDate').textContent = now.getDate(); 88 | 89 | // update every minute, on the minute 90 | then = now; 91 | let delay = 60000 - now.getSeconds() * 1000 - now.getMilliseconds(); 92 | setTimeout(update, delay); 93 | } 94 | 95 | function drawFace() { 96 | // draw the calendar face: month arcs and names, day marks 97 | let length, angle, month, d1, d2, d3, p1, p2; 98 | let str1 = '', str2 = '', str3 = ''; 99 | let r1 = radius - 17; 100 | let r2 = radius - 28; 101 | let r3 = radius - 24.5; 102 | let daysPerMonth = [31, (leapYear ? 29 : 28), 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; 103 | let j; 104 | 105 | // months 106 | for (let i=0; i<12; i++) { 107 | // get dates for beginning and end of month 108 | d1 = new Date(thisYear, i, 1); 109 | d2 = new Date(thisYear, i+1, 1); 110 | month = d1.toLocaleString('default', { month: 'long' }); 111 | 112 | // month background colors (sets style via a class) - offset 6 months for southern latitudes 113 | if ((App.settings.location) && (App.settings.location.latitude < 0)) { 114 | j = i + 6; 115 | if (j >= 12) { j -= 12; } 116 | } else { 117 | j = i; 118 | } 119 | 120 | // get points for each date and draw an arc 121 | p1 = getPointFromAngle(dateToAngle(d1), r1); 122 | p2 = getPointFromAngle(dateToAngle(d2), r1); 123 | str1 += ``; 124 | 125 | // write month name on arc 126 | if ((i < 3) || (i > 8)) { 127 | p1 = getPointFromAngle(dateToAngle(d1), r2); 128 | p2 = getPointFromAngle(dateToAngle(d2), r2); 129 | str2 += ``; 130 | } else { 131 | p1 = getPointFromAngle(dateToAngle(d1), r3); 132 | p2 = getPointFromAngle(dateToAngle(d2), r3); 133 | str2 += ``; 134 | } 135 | str2 += `${month}`; 136 | 137 | // days 138 | for (let j=1; j<=daysPerMonth[i]; j++) { 139 | d3 = new Date(thisYear, i, j); 140 | 141 | length = 5; 142 | if ((d3.getDay() === 0) || (d3.getDay() === 6)) { length = 8; } 143 | angle = dateToAngle(d3); 144 | if (d3.getDate() === 1) { 145 | str3 += ``; 146 | } 147 | str3 += ``; 148 | } 149 | } 150 | $('#monthArcs').innerHTML = str1; 151 | $('#monthNames').innerHTML = str2; 152 | $('#dayMarks').innerHTML = str3; 153 | 154 | drawAstronomicalEvents(); 155 | } 156 | 157 | function drawAstronomicalEvents() { 158 | // draw astronomical events: moon phases, equinoxes, solstices, perihelion, aphelion 159 | let seasons = Astronomy.Seasons(thisYear); 160 | let perihelion = Astronomy.SearchPlanetApsis(Astronomy.Body.Earth, yearStart); 161 | let aphelion = Astronomy.NextPlanetApsis(Astronomy.Body.Earth, perihelion); 162 | 163 | let South = ((App.settings.location) && (App.settings.location.latitude < 0)); 164 | let thisYearsEvents = { 165 | 'springEquinox': (South ? seasons.sep_equinox : seasons.mar_equinox), 166 | 'summerSolstice': (South ? seasons.dec_solstice : seasons.jun_solstice), 167 | 'autumnEquinox': (South ? seasons.mar_equinox : seasons.sep_equinox), 168 | 'winterSolstice': (South ? seasons.jun_solstice : seasons.dec_solstice), 169 | 'perihelion': perihelion.time, 170 | 'aphelion': aphelion.time 171 | }; 172 | 173 | if (debug) { console.group('Astronomical events'); } 174 | $All('#springEquinox, #summerSolstice, #autumnEquinox, #winterSolstice, #perihelion, #aphelion').forEach((o) => { 175 | if (debug) { console.log(o.id, thisYearsEvents[o.id]); } 176 | o.dataset.date = thisYearsEvents[o.id]; 177 | o.setAttribute('transform', `rotate(${ dateToAngle(new Date(thisYearsEvents[o.id])) })`); 178 | }); 179 | if (debug) { console.groupEnd(); } 180 | 181 | // draw phases of the moon 182 | let quarters = []; 183 | let qAngle, qDate, qDate2, qTitle, qIcon, str; 184 | 185 | const moons = [ 186 | ['New Moon', '🌑'], 187 | ['First Quarter Moon', '🌓'], 188 | ['Full Moon', '🌕'], 189 | ['Third Quarter Moon', '🌗'] 190 | ]; 191 | 192 | // get first quarter of year 193 | quarters[0] = Astronomy.SearchMoonQuarter(yearStart); 194 | // get the other quarters of year 195 | for (let i=1; i<51; i++) { 196 | quarters[i] = Astronomy.NextMoonQuarter(quarters[i-1]); 197 | } 198 | 199 | // draw all the quarters 200 | if (debug) { console.group('Moon quarters'); } 201 | for (let i=0; i yearEnd) { continue; } 207 | if (debug) { console.log(`${i}: ${qTitle}: ${qDate}`); } 208 | 209 | qAngle = dateToAngle(qDate); 210 | 211 | // old: use unicode icons for moon phase 212 | /* str += ` 213 | 214 | 215 | ${qIcon} 216 | `; */ 217 | 218 | // new: use SVG path element 219 | str += ` 220 | 221 | 222 | 223 | 224 | ${SunClock.drawMoonIcon(quarters[i].quarter/4, 3)} 225 | 226 | `; 227 | } 228 | if (debug) { console.groupEnd(); } 229 | $('#astronomicalEvents').innerHTML += str; 230 | 231 | // add hover events to all astronomicalEvents 232 | $All('#astronomicalEvents > g').forEach((o) => { 233 | App.showInfoOnHover(o, getAstronomicalEventInfo, o.id); 234 | }); 235 | } 236 | 237 | function getAstronomicalEventInfo(id) { 238 | // get info for astronomical events 239 | let obj = $('#'+id); 240 | let title = obj.dataset.title; 241 | let now = new Date(); 242 | let date = new Date(obj.dataset.date); 243 | 244 | // shouldn't be needed, but on touch pointerout event doesn't always fire when astro events are tapped on 245 | $('#dateHand2').style.display = 'none'; 246 | 247 | return `

${title}

${App.formatDate(date)}

${formatDelta(date, now)}

`; 248 | } 249 | 250 | function updateAngle() { 251 | // update the angle of dateHand2 and text 252 | let date = angleToDate(angleDegrees); 253 | let str, date2, dayOfYear; 254 | 255 | if (snap) { 256 | date2 = new Date(thisYear, date.getMonth(), date.getDate(), 0, 0, 0, 0); // round down 257 | // rotate indicator line 258 | $('#dateHand2').setAttribute('transform', `rotate(${ dateToAngle(date2) })`); 259 | } else { 260 | date2 = date; 261 | // rotate indicator line 262 | $('#dateHand2').setAttribute('transform', `rotate(${ angleDegrees })`); 263 | } 264 | dayOfYear = Math.round((date2 - yearStart) / msPerDay) + 1; // no day 0 265 | 266 | // update info2 267 | str = `

Day ${dayOfYear}

${App.formatDate(date2)}

${formatDelta(date2, now)}

`; 268 | if (debug) { str += `

${angleDegrees.toFixed(3)}°

`; } 269 | App.showInfo(str); 270 | } 271 | 272 | function getPointerAngle(e) { 273 | // get pointer position and calculate angle 274 | // note atan2(y,x) gives the counterclockwise angle, in radians, between the +ve x-axis and the point (x,y) 275 | e.preventDefault(); 276 | let x = e.offsetX - $('#calendar').clientWidth/2; 277 | let y = e.offsetY - $('#calendar').clientHeight/2; 278 | angleDegrees = Math.atan2(y,x) * 360/tau + 90; 279 | if (angleDegrees < 0) { angleDegrees += 360; } 280 | updateAngle(); 281 | } 282 | 283 | function showPointer(e) { 284 | // show dateHand2 285 | $('#dateHand2').style.display = 'block'; 286 | getPointerAngle(e); 287 | } 288 | 289 | function hidePointer(e) { 290 | // hide dateHand2 and clear info2 291 | if (App.supportsHover) { 292 | $('#dateHand2').style.display = 'none'; 293 | App.hideInfo(); 294 | } 295 | } 296 | 297 | function init() { 298 | getDate(); 299 | drawFace(); 300 | update(); 301 | 302 | // mouse & touch 303 | $('#calendarOverlay').addEventListener("pointermove", getPointerAngle); 304 | $('#calendarOverlay').addEventListener("pointerover", showPointer); 305 | $('#calendarOverlay').addEventListener("pointerout", hidePointer); 306 | } 307 | 308 | return { 309 | update, 310 | drawFace, 311 | init 312 | }; 313 | })(); 314 | -------------------------------------------------------------------------------- /scripts/clock.js: -------------------------------------------------------------------------------- 1 | /* 2 | Sun Clock 3 | A 24-hour clock that shows sunrise, sunset, golden hour, and twilight times for your current location 4 | 5 | Geoff Pack, May 2022 6 | https://github.com/virtualgeoff/sunclock 7 | */ 8 | 9 | /* jshint esversion: 6 */ 10 | /* globals $, $All, debug, App, SunCalc */ 11 | 12 | var SunClock = (function() { 13 | 'use strict'; 14 | 15 | let now, then, timerStart; 16 | let hours, minutes, seconds; 17 | let hourHand, minuteHand, secondHand; 18 | let clockIconHours, clockIconMinutes; 19 | let sunTimes, sunPosition, noonPosition, nadirPosition, sunAlwaysUp, sunAlwaysDown; 20 | let periodsTemp, currentPeriod, nextPeriodTime; 21 | let moonTimes, moonPosition, moonPhase, moonHand, moonIcon, moonPath; 22 | let radius = 130; 23 | 24 | const periods = [ 25 | // name: from: to: color: darkColor: 26 | ['earlyMorning', 'nadir', 'nightEnd', '#192029', '#030303'], 27 | ['astronomicalMorningTwilight', 'nightEnd', 'nauticalDawn', '#213c66', '#101d33'], 28 | ['nauticalMorningTwilight', 'nauticalDawn', 'dawn', '#4574bc', '#325489'], 29 | ['civilMorningTwilight', 'dawn', 'sunrise', '#88a6d4', '#677ea1'], 30 | ['sunrise', 'sunrise', 'sunriseEnd', '#ff9900', '#cc7a00'], 31 | ['morningGoldenHour', 'sunriseEnd', 'goldenHourEnd', '#ffe988', '#ccba6c'], 32 | ['morning', 'goldenHourEnd', 'solarNoon', '#dceaff', '#b0bbcc'], 33 | ['afternoon', 'solarNoon', 'goldenHour', '#dceaff', '#b0bbcc'], 34 | ['eveningGoldenHour', 'goldenHour', 'sunsetStart', '#ffe988', '#ccba6c'], 35 | ['sunset', 'sunsetStart', 'sunset', '#ff9900', '#cc7a00'], 36 | ['civilEveningTwilight', 'sunset', 'dusk', '#88a6d4', '#677ea1'], 37 | ['nauticalEveningTwilight', 'dusk', 'nauticalDusk', '#4574bc', '#325489'], 38 | ['astronomicalEveningTwilight', 'nauticalDusk', 'night', '#213c66', '#101d33'], 39 | ['lateEvening', 'night', 'nadir2', '#192029', '#030303'] 40 | ]; 41 | const textReplacements = { 42 | 'nadir' : 'Solar Midnight', 43 | 'earlyMorning' : 'Early Morning', 44 | 'nightEnd' : 'Astronomical Dawn', 45 | 'astronomicalMorningTwilight' : 'Astronomical Morning Twilight', 46 | 'nauticalDawn' : 'Nautical Dawn', 47 | 'nauticalMorningTwilight' : 'Nautical Morning Twilight', 48 | 'dawn' : 'Civil Dawn', 49 | 'civilMorningTwilight' : 'Civil Morning Twilight', 50 | 'sunrise' : 'Sunrise', 51 | 'sunriseEnd' : 'End of Sunrise', 52 | 'morningGoldenHour' : 'Morning Golden Hour', 53 | 'goldenHourEnd' : 'End of Golden Hour', 54 | 'morning' : 'Morning', 55 | 'solarNoon' : ' Solar Noon', 56 | 'afternoon' : 'Afternoon', 57 | 'goldenHour' : 'Start of Golden Hour', 58 | 'eveningGoldenHour' : 'Evening Golden Hour', 59 | 'sunsetStart' : 'Beginning of Sunset', 60 | 'sunset' : 'Sunset', 61 | 'civilEveningTwilight' : 'Civil Evening Twilight', 62 | 'dusk' : 'Civil Dusk', 63 | 'nauticalEveningTwilight' : 'Nautical Evening Twilight', 64 | 'nauticalDusk' : 'Nautical Dusk', 65 | 'astronomicalEveningTwilight' : 'Astronomical Evening Twilight', 66 | 'night' : 'Astronomical Dusk', 67 | 'lateEvening' : 'Late Evening', 68 | 'nadir2' : 'Solar Midnight' 69 | }; 70 | 71 | function toDegrees(angle) { 72 | // convert radians to degrees 73 | return (angle / (2 * Math.PI) * 360); 74 | } 75 | 76 | function convertAzimuth(angle) { 77 | // convert azimuth to degrees clockwise from North. SunCalc returns radians clockwise from South 78 | return ((360 + 180 + toDegrees(angle)) % 360); 79 | } 80 | 81 | function getPointFromTime(date) { 82 | // get point on clock perimeter from time 83 | // note: when daylight savings changes, some times may be in a different time zone to current time, so check offsets 84 | let nowOffset = now.getTimezoneOffset(); 85 | let dateOffset = date.getTimezoneOffset(); 86 | let direction = App.settings.direction; 87 | //var angle = ((date.getHours() + date.getMinutes()/60 + date.getSeconds()/3600) / 24 * 2 * Math.PI); // radians 88 | var angle = ((date.getHours() + date.getMinutes()/60 + (dateOffset-nowOffset)/60 + date.getSeconds()/3600) / 24 * 2 * Math.PI); // radians 89 | return `${Math.sin(angle) * radius * -direction}, ${Math.cos(angle) * radius}`; // return as string for svg path attribute 90 | } 91 | 92 | function getEarlier(time) { 93 | // get now - 24 hours 94 | return new Date(time.valueOf() - 86400000); 95 | } 96 | function getLater(time) { 97 | // get now + 24 hours 98 | return new Date(time.valueOf() + 86400000); 99 | } 100 | 101 | function getSunTimes() { 102 | // get times from suncalc.js 103 | let location = App.settings.location; 104 | if (!location) { return; } 105 | 106 | sunTimes = null; 107 | sunTimes = SunCalc.getTimes(now, location.latitude, location.longitude, 0); 108 | // get the sun times for the next day so I can get the next nadir 109 | // (can't just add 24 hrs to first one, or hack SunCalc.js (nadir2: fromJulian(Jnoon + 0.5)) 110 | sunTimes.nadir2 = SunCalc.getTimes(getLater(sunTimes.solarNoon), location.latitude, location.longitude, 0).nadir; 111 | 112 | if (debug) { 113 | console.log(`now: ${now}`); 114 | console.log(`location: ${location.latitude}, ${location.longitude}`); 115 | console.log(sunTimes); 116 | } 117 | 118 | // sometimes now is not in the range of times output by SunCalc (e.g. "2022-04-03T00:59:00+1100") 119 | while (now < sunTimes.nadir) { 120 | if (debug) { console.log('now is earlier than nadir: get earlier sun times'); } 121 | sunTimes = SunCalc.getTimes(getEarlier(sunTimes.solarNoon), location.latitude, location.longitude, 0); 122 | sunTimes.nadir2 = SunCalc.getTimes(getLater(sunTimes.solarNoon), location.latitude, location.longitude, 0).nadir; 123 | } 124 | while (now > sunTimes.nadir2) { 125 | // is this possible? 126 | if (debug) { console.log('now is later than nadir2: get later time sun times'); } 127 | sunTimes = SunCalc.getTimes(getLater(sunTimes.solarNoon), location.latitude, location.longitude, 0); 128 | sunTimes.nadir2 = SunCalc.getTimes(getLater(sunTimes.solarNoon), location.latitude, location.longitude, 0).nadir; 129 | } 130 | 131 | noonPosition = SunCalc.getPosition(sunTimes.solarNoon, location.latitude, location.longitude); 132 | nadirPosition = SunCalc.getPosition(sunTimes.nadir, location.latitude, location.longitude); 133 | sunAlwaysUp = (toDegrees(nadirPosition.altitude) > -0.833) ? true : false; // sun is always above horizon 134 | sunAlwaysDown = (toDegrees(noonPosition.altitude) < -0.833) ? true : false; // sun is always below horizon 135 | 136 | if (debug) { 137 | console.log(sunTimes); 138 | console.log(`sunAlwaysUp: ${sunAlwaysUp}, sunAlwaysDown: ${sunAlwaysDown}`); 139 | } 140 | 141 | // write times to table and below date 142 | writeMainTimes(); 143 | writeAllTimes(); 144 | 145 | // draw time period arcs on clock face 146 | drawTimePeriods(); 147 | if (App.settings.colorScheme === 'dynamic') { updateDynamicTheme(); } 148 | } 149 | 150 | function clearSunTimes() { 151 | // clear all times - called by App.clearLocation(); 152 | sunTimes = null; 153 | clearTimePeriods(); 154 | if (App.settings.colorScheme === 'dynamic') { updateDynamicTheme(); } 155 | } 156 | 157 | function writeMainTimes() { 158 | // write subset of times below date 159 | let subset = ['sunrise', 'solarNoon', 'sunset']; // subset of times to show below location 160 | if (!sunTimes) { return; } 161 | 162 | $('#mainTimes').innerHTML = ''; 163 | for (let i=0; i${App.formatTime(sunTimes[subset[i]])}
`; 165 | } 166 | $('#mainTimes').innerHTML += (sunAlwaysUp) ? 'Sun is above horizon all day' : ''; 167 | $('#mainTimes').innerHTML += (sunAlwaysDown) ? 'Sun is below horizon all day' : ''; 168 | } 169 | 170 | function writeAllTimes() { 171 | // write all times to table 172 | let p; 173 | $('#allTimes table tbody').innerHTML = ''; 174 | for (let i=0; i${textReplacements[p]}${App.formatAllTimes(sunTimes[p])}`; 177 | } 178 | $('#allTimes table tbody').innerHTML += `${textReplacements.nadir2}${App.formatAllTimes(sunTimes.nadir2)}`; 179 | } 180 | 181 | function clearTimePeriods() { 182 | // clear any previous arcs (i.e. if changing direction or setting location manually) 183 | let arcs = $('#arcs'); 184 | while (arcs.firstChild) { 185 | arcs.removeChild(arcs.firstChild); 186 | } 187 | // clear solar noon and midnight lines 188 | $('#noon').setAttribute('d','M 0,0 L 0,0'); 189 | $('#midnight').setAttribute('d','M 0,0 L 0,0'); 190 | } 191 | 192 | function drawTimePeriods() { 193 | // draw time periods on clock face 194 | let p, t1, t2, point1, point2, path; 195 | let validTimeCount = 0; 196 | let direction = App.settings.direction; 197 | 198 | if (!sunTimes) { return; } 199 | 200 | // clear any previous arcs 201 | clearTimePeriods(); 202 | 203 | // make a deep copy of periods (so can modify 'from' and 'to', but keep original for next time); 204 | periodsTemp = JSON.parse(JSON.stringify(periods)); 205 | 206 | // check time periods for valid times 207 | for (let i=0; i= 6) { 248 | pt1 = 6; pt2 = 7; // morning/afternoon (daytime) 249 | } else if ((alt < 6) && (alt >= -0.3)) { 250 | pt1 = 5; pt2 = 8; // morning/evening goldenHour 251 | } else if ((alt < -0.3) && (alt >= -0.833)) { 252 | pt1 = 4; pt2 = 9; // sunrise/sunset 253 | } else if ((alt <= -0.833) && (alt > -6)) { 254 | pt1 = 3; pt2 = 10; // civil twilight 255 | } else if ((alt <= -6) && (alt > -12)) { 256 | pt1 = 2; pt2 = 11; // nautical twilight 257 | } else if ((alt <= -12) && (alt > -18)) { 258 | pt1 = 1; pt2 = 12; // astronomical twilight 259 | } else if (alt <= -18) { 260 | pt1 = 0; pt2 = 13; // night 261 | } 262 | if (debug) { console.log(pt1, pt2); } 263 | pT[pt1][1] = 'nadir'; 264 | pT[pt1][2] = 'solarNoon'; 265 | pT[pt2][1] = 'solarNoon'; 266 | pT[pt2][2] = 'nadir2'; 267 | } 268 | 269 | // draw time periods - finally 270 | for (let i=0; i0) ? 1 : 0} ${point2} z`); // sweep-flag depends on direction 285 | $('#arcs').appendChild(path); 286 | 287 | // add hover event to the arc 288 | App.showInfoOnHover(path, getPeriodInfo, i); 289 | } 290 | } 291 | 292 | // draw solar noon and midnight lines 293 | $('#noon').setAttribute('d',`M 0,0 L ${getPointFromTime(sunTimes.solarNoon)}`); 294 | $('#midnight').setAttribute('d',`M 0,0 L ${getPointFromTime(sunTimes.nadir2)}`); 295 | } 296 | 297 | function getCurrentTimePeriod() { 298 | // find the time period are we in now 299 | let t0, t1, t2, p; 300 | t0 = now.valueOf(); 301 | 302 | for (let i=0; i t1) && (t0 < t2)) { 310 | currentPeriod = i; 311 | break; 312 | } else { 313 | continue; 314 | } 315 | } 316 | if (debug) { console.log(`currentPeriod is ${currentPeriod}: ${periodsTemp[currentPeriod][0]}`); } 317 | } 318 | 319 | function getPeriodInfo(i) { 320 | // get info for time periods 321 | let p = periodsTemp[i]; 322 | 323 | let str = `

${textReplacements[p[0]]}

324 |

${textReplacements[p[1]]}
${App.formatTime(sunTimes[p[1]])}

325 |

— to —

326 |

${textReplacements[p[2]]}
${App.formatTime(sunTimes[p[2]])}

`; 327 | 328 | return str; 329 | } 330 | 331 | function getSunInfo() { 332 | // get info for Sun 333 | let location = App.settings.location; 334 | let str = ''; 335 | 336 | if (!location) { return str; } 337 | 338 | sunPosition = SunCalc.getPosition(now, location.latitude, location.longitude); 339 | if (sunPosition) { 340 | str = `

Sun

341 |

Altitude: ${toDegrees(sunPosition.altitude).toFixed(2)}°
342 | Azimuth: ${convertAzimuth(sunPosition.azimuth).toFixed(2)}°

343 |

Altitude at:
344 | noon: ${toDegrees(noonPosition.altitude).toFixed(2)}°
345 | midnight: ${toDegrees(nadirPosition.altitude).toFixed(2)}°

`; 346 | } 347 | return str; 348 | } 349 | 350 | function getMoonPhase() { 351 | moonPhase = SunCalc.getMoonIllumination(now).phase; // note: does not require location 352 | if (debug) { console.log('moon phase: ' + moonPhase); } 353 | $('#moonIcon').innerHTML += drawMoonIcon(moonPhase); 354 | } 355 | 356 | function getMoonPhaseName(phase) { 357 | // get name of moon phase 358 | const moons = [ 359 | ['New Moon', '🌑'], 360 | ['Waxing Crescent', '🌒'], 361 | ['First Quarter', '🌓'], 362 | ['Waxing Gibbous', '🌔'], 363 | ['Full Moon', '🌕'], 364 | ['Waning Gibbous', '🌖'], 365 | ['Last Quarter', '🌗'], 366 | ['Waning Crescent', '🌘'] 367 | ]; 368 | 369 | const d = 0.0167; // 1.67 % ~= 1/2 day per month ? 370 | let i = 0; 371 | 372 | // there's probably a really elegant way to do this, but... 373 | if ((phase > 0.0 + d) && (phase < 0.25 - d)) { 374 | i = 1; 375 | } else if ((phase >= 0.25 - d) && (phase <= 0.25 + d)) { 376 | i = 2; 377 | } else if ((phase > 0.25 + d) && (phase < 0.50 - d)) { 378 | i = 3; 379 | } else if ((phase >= 0.50 - d) && (phase <= 0.50 + d)) { 380 | i = 4; 381 | } else if ((phase > 0.50 + d) && (phase < 0.75 - d)) { 382 | i = 5; 383 | } else if ((phase >= 0.75 - d) && (phase <= 0.75 + d)) { 384 | i = 6; 385 | } else if ((phase > 0.75 + d) && (phase < 1.0 - d)) { 386 | i = 7; 387 | } 388 | return {'index':i, 'name':moons[i][0], 'icon':moons[i][1]}; 389 | } 390 | 391 | function drawMoonIcon(phase, radius) { 392 | // draw the moon icon (instead of using unicode characters) 393 | // get x radius and sweep direction for each half of the path 394 | let r = radius || 6; // moon radius 395 | let cosX = Math.cos( phase * 2 * Math.PI ); 396 | let rx1 = (phase < 0.50) ? r * cosX : r; 397 | let rx2 = (phase < 0.50) ? r : r * -cosX; 398 | let sweep1 = (phase < 0.25) ? 0 : 1; 399 | let sweep2 = (phase < 0.75) ? 1 : 0; 400 | 401 | // return svg path element (2 elliptical arcs) 402 | return ``; 403 | } 404 | 405 | function getMoonInfo() { 406 | // get info for moon 407 | // note: moon phase does not require a location, but positon and times do 408 | let location = App.settings.location; 409 | let str = `

Moon

410 |

${getMoonPhaseName(moonPhase).name}
(${(moonPhase * 29.53).toFixed(1)} days old)

`; 411 | 412 | if (location) { 413 | moonTimes = SunCalc.getMoonTimes(now, location.latitude, location.longitude); 414 | moonPosition = SunCalc.getMoonPosition(now, location.latitude, location.longitude); 415 | //if (debug) { console.log(moonTimes, moonPosition); }; 416 | 417 | if (moonTimes) { 418 | if ((moonTimes.rise) && (moonTimes.set)) { 419 | // sort by time 420 | if (moonTimes.rise <= moonTimes.set) { 421 | str += `

Rises: ${App.formatTime(moonTimes.rise)}
Sets: ${App.formatTime(moonTimes.set)}

`; 422 | } else { 423 | str += `

Sets: ${App.formatTime(moonTimes.set)}
Rises: ${App.formatTime(moonTimes.rise)}

`; 424 | } 425 | } else if (moonTimes.rise) { 426 | str += `

Rises: ${App.formatTime(moonTimes.rise)}

`; 427 | } else if (moonTimes.set) { 428 | str += `

Sets: ${App.formatTime(moonTimes.set)}

`; 429 | } else if (moonTimes.alwaysUp) { 430 | str += '

Moon is up all day

'; 431 | } else if (moonTimes.alwaysDown) { 432 | str += '

Moon is down all day

'; 433 | } else { 434 | // ??? 435 | } 436 | } 437 | if (moonPosition) { 438 | str += ` 439 |

Altitude: ${toDegrees(moonPosition.altitude).toFixed(2)}°
440 | Azimuth: ${convertAzimuth(moonPosition.azimuth).toFixed(2)}°

`; 441 | } 442 | } 443 | return str; 444 | } 445 | 446 | function drawMarks2(parent, n, q, length) { 447 | // draw the number marks on the clock face 448 | var m; 449 | 450 | for (let i=0; i<=(n-1); i++) { 451 | if ((i%q === 0)) { continue; } 452 | m = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 453 | m.setAttribute('x1', 0); 454 | m.setAttribute('y1', 0); 455 | m.setAttribute('x2', 0); 456 | m.setAttribute('y2', length); 457 | m.setAttribute('transform', `rotate(${i * (360/n)}) translate(0,${radius})`); 458 | $(parent).appendChild(m); 459 | } 460 | } 461 | 462 | function drawMarks() { 463 | drawMarks2('#hourMarks', 24, 0, -4); 464 | drawMarks2('#hourMarks2', 24, 2, -8); 465 | drawMarks2('#minuteMarks', 60, 0, 6); 466 | } 467 | 468 | function pad2(n) { 469 | // make 2 digits 470 | return (n < 10) ? ('0' + n) : n; 471 | } 472 | 473 | function drawNumbers2(parent, n, m, offset, startAtTop, vertical, zeroPad) { 474 | // draw the numbers on the clock face 475 | let g, angle, str; 476 | let p = $(parent); 477 | let h = parseInt(p.getAttribute('font-size')); 478 | let angleOffset = startAtTop ? 180 : 0; 479 | let direction = App.settings.direction; 480 | 481 | // clear any previous numbers (e.g. if changing direction) 482 | while (p.firstChild) { 483 | p.removeChild(p.firstChild); 484 | } 485 | 486 | // create new numbers 487 | for (let i=0; i<=n; i+=m) { 488 | if (i===0) { continue; } // start counting from zero, but don't draw zeros (can't just start at 1, since counting by m) 489 | g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); 490 | g.setAttribute('x', 0); 491 | g.setAttribute('y', 0); 492 | angle = ((i * direction * (360/n) + angleOffset + 360) % 360); // 0 <= angle < 360 493 | g.setAttribute('transform', `rotate(${angle}) translate(0,${radius + h * offset})`); 494 | 495 | if ((parent === '#hourNumbers') && App.settings.hour12) { 496 | let j = i; 497 | if (i>12) { j = i-12; } 498 | str = zeroPad ? pad2(j) : j; 499 | } else { 500 | str = zeroPad ? pad2(i) : i; 501 | } 502 | 503 | if (vertical) { 504 | g.innerHTML = ``; 505 | g.innerHTML += `${str}`; 506 | } else { 507 | if ((angle >= 90) && (angle <= 270)) { 508 | g.innerHTML = `${str}`; 509 | } else { 510 | g.innerHTML = `${str}`; 511 | } 512 | } 513 | p.appendChild(g); 514 | } 515 | } 516 | 517 | function drawNumbers() { 518 | drawNumbers2('#hourNumbers', 24, 1, -1.5, false, true, false); 519 | drawNumbers2('#minuteNumbers', 60, 5, 0.25, true, false, true); 520 | } 521 | 522 | function updateDirection() { 523 | // update direction after setOption or loadOptions 524 | // n.b. clock hands will update automatically on next animationFrame 525 | drawNumbers(); 526 | if (sunTimes) { drawTimePeriods(); } 527 | } 528 | 529 | function clearDynamicTheme() { 530 | //reset values 531 | document.documentElement.setAttribute("data-theme", 'light'); 532 | document.documentElement.style.backgroundColor = ''; 533 | document.body.style.backgroundColor = ''; 534 | // clear section background color 535 | $All('section.overlay').forEach((o) => { o.style.backgroundColor = ''; }); 536 | 537 | $('#hourNumbers').style.fill = ''; 538 | $('#minuteNumbers').style.fill = ''; 539 | } 540 | 541 | function RGBtoRGBA(s, a) { 542 | // convert RGB color (a string) to RGBA color 543 | let s2 = ', ' + a + ')'; 544 | return s.replace(')', s2); 545 | } 546 | 547 | function updateDynamicTheme() { 548 | if (App.settings.colorScheme !== 'dynamic') { return; } 549 | 550 | clearDynamicTheme(); 551 | 552 | if (sunTimes) { 553 | getCurrentTimePeriod(); 554 | let p = periods[currentPeriod]; 555 | let isDark = ((currentPeriod <= 2) || (currentPeriod >= 11)) ? true : false; 556 | 557 | if (isDark) { 558 | document.documentElement.setAttribute("data-theme", 'dark'); 559 | document.documentElement.style.backgroundColor = p[4]; 560 | document.body.style.backgroundColor = p[4]; 561 | $('#hourNumbers').style.fill = '#222'; 562 | $('#minuteNumbers').style.fill = '#222'; 563 | } else { 564 | document.documentElement.style.backgroundColor = p[3]; 565 | document.body.style.backgroundColor = p[3]; 566 | } 567 | 568 | // set section background color 569 | $All('section.overlay').forEach((o) => { 570 | o.style.backgroundColor = RGBtoRGBA(document.body.style.backgroundColor, 0.9); 571 | }); 572 | 573 | // get time of next period change 574 | nextPeriodTime = sunTimes[p[2]]; 575 | if (debug) { console.log(`Next theme update at ${sunTimes[p[2]]}`); } 576 | } 577 | } 578 | 579 | function writeDate() { 580 | // write the date to info1 581 | $('#dateText').innerHTML = `${now.toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}`; 582 | } 583 | 584 | function tick(timestamp) { 585 | // animation loop 586 | 587 | let direction = App.settings.direction; 588 | now = new Date(); 589 | 590 | seconds = now.getSeconds() + (now.getMilliseconds())/1000; 591 | minutes = now.getMinutes() + seconds/60; 592 | hours = now.getHours() + minutes/60; 593 | 594 | // move hands 595 | secondHand.setAttribute('transform', `rotate(${ seconds * direction * 6 })`); // 6° per second 596 | minuteHand.setAttribute('transform', `rotate(${ minutes * direction * 6 })`); // 6° per minute 597 | hourHand.setAttribute('transform', `rotate(${ hours * direction * 15 })`); // 15° per hour 598 | moonHand.setAttribute('transform', `rotate(${ (hours * direction * 15) - (moonPhase * direction * 360) })`); // ~14.5° per hour 599 | moonIcon.setAttribute('transform', `translate(0 80) rotate(${90 + direction * 90})`); // only on direction change 600 | 601 | // clock icon hand 602 | clockIconHours.setAttribute('transform', `rotate(${ hours * direction * 15 })`); 603 | clockIconMinutes.setAttribute('transform', `rotate(${ minutes * direction * 6 })`); 604 | 605 | // one minute timer 606 | if (!timerStart) { timerStart = timestamp || 0; } 607 | if ((timestamp - timerStart) >= 60000) { 608 | // update moon phase every minute — does not need to be recalculated each frame 609 | // 29.53 days per 360° phase change = ~12.2° per day = ~0.51° per hour = ~0.0085° per minute (i.e. even every minute is excessive!) 610 | getMoonPhase(); 611 | // reset 612 | timerStart = null; 613 | } 614 | 615 | // check if device clock has been changed 616 | // if the time/date has changed we need to get the moon phase and update the moon position on the next tick 617 | // (n.b. the timer above won't detect this) 618 | if ( then && (Math.abs(now - then) > 60000) ) { 619 | getMoonPhase(); 620 | getSunTimes(); 621 | writeDate(); 622 | SunCalendar.update(); // force calendar to update also 623 | } 624 | 625 | // update the sun times at midnight 626 | if ( then && (now.getDate() !== then.getDate()) ) { 627 | console.log('midnight: updating sun times!'); 628 | getSunTimes(); 629 | writeDate(); 630 | } 631 | 632 | // update the sun times at solar midnight 633 | if ( then && sunTimes && (now >= sunTimes.nadir2) ) { 634 | console.log('solar midnight: updating sun times!'); 635 | getSunTimes(); 636 | } 637 | 638 | // redraw time periods if the time zone changes (e.g. daylight savings changes) 639 | if ( then && (now.getTimezoneOffset() !== then.getTimezoneOffset()) ) { 640 | console.log('time zone change: redrawing time periods!'); 641 | drawTimePeriods(); 642 | } 643 | 644 | // update theme at next period change time 645 | if ( sunTimes && (App.settings.colorScheme === 'dynamic') && (now >= nextPeriodTime) ) { 646 | updateDynamicTheme(); 647 | } 648 | 649 | // write date on first tick 650 | if (!then) { writeDate(); } 651 | 652 | then = now; 653 | window.requestAnimationFrame(tick); 654 | } 655 | 656 | function init() { 657 | hourHand = $('#hourHand'); 658 | minuteHand = $('#minuteHand'); 659 | secondHand = $('#secondHand'); 660 | moonHand = $('#moonHand'); 661 | moonIcon = $('#moonIcon'); 662 | moonPath = $('#moonPath'); 663 | 664 | clockIconHours = $('#clockIconHours'); 665 | clockIconMinutes = $('#clockIconMinutes'); 666 | 667 | // draw clock 668 | drawMarks(); 669 | drawNumbers(); 670 | 671 | // start clock 672 | getMoonPhase(); 673 | tick(); 674 | 675 | // add hover events to the hour and moon hands 676 | App.showInfoOnHover(hourHand, getSunInfo); 677 | App.showInfoOnHover($('#centerCircle'), getSunInfo); 678 | App.showInfoOnHover(moonHand, getMoonInfo); 679 | } 680 | 681 | return { 682 | getSunTimes, 683 | clearSunTimes, 684 | writeMainTimes, 685 | clearDynamicTheme, 686 | updateDynamicTheme, 687 | updateDirection, 688 | drawNumbers, 689 | drawMoonIcon, 690 | init 691 | }; 692 | })(); 693 | -------------------------------------------------------------------------------- /styles/colors.css: -------------------------------------------------------------------------------- 1 | /* sunclock colors.css */ 2 | 3 | :root { 4 | --text: #000; 5 | --bg-color1: #fff; 6 | --bg-color2: #ddd; 7 | 8 | --link-color1: #00e; 9 | --vlink-color1: #a0e; 10 | --alink-color1: #00e; 11 | 12 | --link-color2: #003; 13 | --vlink-color2: #003; 14 | --alink-color2: #f00; 15 | 16 | --bg-overlay1: rgba(255,255,255,0.90); 17 | --bg-overlay2: rgba(204,204,204,0.90); 18 | --bg-overlay3: rgba(255,255,255,0.70); 19 | --overlay-border: #999; 20 | 21 | /* time period bg colors */ 22 | --bg-day: #dceaff; 23 | --bg-goldenHour: #ffe988; 24 | --bg-sunset: #ff9900; 25 | --bg-twilight1: #88a6d4; 26 | --bg-twilight2: #4574bc; 27 | --bg-twilight3: #213c66; 28 | --bg-night: #192029; 29 | 30 | /* months bg colors*/ 31 | --bg-m1: hsl(180 60% 90%); 32 | --bg-m2: hsl(150 60% 90%); 33 | --bg-m3: hsl(120 60% 90%); 34 | --bg-m4: hsl(90 60% 90%); 35 | --bg-m5: hsl(60 60% 90%); 36 | --bg-m6: hsl(30 60% 90%); 37 | --bg-m7: hsl(0 60% 90%); 38 | --bg-m8: hsl(-30 60% 90%); 39 | --bg-m9: hsl(-60 60% 90%); 40 | --bg-m10: hsl(-90 60% 90%); 41 | --bg-m11: hsl(-120 60% 90%); 42 | --bg-m12: hsl(-150 60% 90%); 43 | 44 | /* astronomical events bg colors */ 45 | --bg-sE: hsl(120 60% 70%); 46 | --bg-sS: hsl(30 60% 70%); 47 | --bg-aE: hsl(-60 60% 70%); 48 | --bg-wS: hsl(-150 60% 70%); 49 | --bg-pH: hsl(180 0% 70%); 50 | --bg-aH: hsl(0 0% 70%); 51 | 52 | --sky-gradient: conic-gradient(from 180deg, 53 | #192029 72deg, #213c66 78deg, #4574bc 84deg, #88a6d4 90deg, #ff9900 90deg, #ffe988 95deg, #dceaff 110deg, 54 | #dceaff 250deg, #ffe988 265deg, #ff9900 270deg, #88a6d4 270deg, #4574bc 276deg, #213c66 282deg, #192029 288deg); 55 | 56 | } 57 | 58 | :root[data-theme="dark"] { 59 | --text: #ddd; 60 | --bg-color1: #222; 61 | --bg-color2: #222; 62 | 63 | --link-color1: #79f; 64 | --vlink-color1: #a7d; 65 | --alink-color1: #79f; 66 | 67 | --link-color2: #ddd; 68 | --vlink-color2: #ddd; 69 | --alink-color2: #f00; 70 | 71 | --bg-overlay1: rgba(34,34,34,0.90); 72 | --bg-overlay2: rgba(22,22,22,0.90); 73 | --bg-overlay3: rgba(34,34,34,0.70); 74 | --overlay-border: #4c4c4c; 75 | 76 | /* time period bg colors */ 77 | --bg-day: #b0bbcc; 78 | --bg-goldenHour: #ccba6c; 79 | --bg-sunset: #cc7a00; 80 | --bg-twilight1: #677ea1; 81 | --bg-twilight2: #325489; 82 | --bg-twilight3: #101d33; 83 | --bg-night: #030303; 84 | 85 | /* months bg colors*/ 86 | --bg-m1: hsl(180 60% 25%); 87 | --bg-m2: hsl(150 60% 25%); 88 | --bg-m3: hsl(120 60% 25%); 89 | --bg-m4: hsl(90 60% 25%); 90 | --bg-m5: hsl(60 60% 25%); 91 | --bg-m6: hsl(30 60% 25%); 92 | --bg-m7: hsl(0 60% 25%); 93 | --bg-m8: hsl(-30 60% 25%); 94 | --bg-m9: hsl(-60 60% 25%); 95 | --bg-m10: hsl(-90 60% 25%); 96 | --bg-m11: hsl(-120 60% 25%); 97 | --bg-m12: hsl(-150 60% 25%); 98 | 99 | /* astronomical events bg colors */ 100 | --bg-sE: hsl(120 60% 33%); 101 | --bg-sS: hsl(30 60% 33%); 102 | --bg-aE: hsl(-60 60% 33%); 103 | --bg-wS: hsl(-150 60% 33%); 104 | --bg-pH: hsl(180 0% 33%); 105 | --bg-aH: hsl(0 0% 33%); 106 | 107 | --sky-gradient: conic-gradient(from 180deg, 108 | #030303 72deg, #101d33 78deg, #325489 84deg, #677ea1 90deg, #cc7a00 90deg, #ccba6c 95deg, #b0bbcc 110deg, 109 | #b0bbcc 250deg, #ccba6c 265deg, #cc7a00 270deg, #677ea1 270deg, #325489 276deg, #101d33 282deg, #030303 288deg); 110 | 111 | } 112 | 113 | 114 | /* body */ 115 | html, body {color:var(--text); background:var(--bg-color1);} 116 | 117 | /* links */ 118 | a {color:var(--link-color1);} 119 | a:visited {color:var(--vlink-color1);} 120 | a:hover {color:var(--link-color1);} 121 | .done a:visited {color:var(--link-color1);} 122 | 123 | /* nav links */ 124 | nav a:link, a.close {color:var(--link-color2);} 125 | nav a:visited, a.close:visited {color:var(--vlink-color2);} 126 | nav a:hover, a.close:hover {color:var(--alink-color2);} 127 | 128 | /* section overlays */ 129 | section.overlay {background:var(--bg-overlay1);} 130 | 131 | /* clock background */ 132 | .skyBackground {background-image:var(--sky-gradient);} 133 | 134 | /* time periods */ 135 | #earlyMorning {fill:#192029; fill:var(--bg-night);} 136 | #astronomicalMorningTwilight {fill:#213c66; fill:var(--bg-twilight3);} 137 | #nauticalMorningTwilight {fill:#4574bc; fill:var(--bg-twilight2);} 138 | #civilMorningTwilight {fill:#88a6d4; fill:var(--bg-twilight1);} 139 | #sunrise {fill:#ff9900; fill:var(--bg-sunset);} 140 | #morningGoldenHour {fill:#ffe988; fill:var(--bg-goldenHour);} 141 | #morning {fill:#dceaff; fill:var(--bg-day);} 142 | #afternoon {fill:#dceaff; fill:var(--bg-day);} 143 | #eveningGoldenHour {fill:#ffe988; fill:var(--bg-goldenHour);} 144 | #sunset {fill:#ff9900; fill:var(--bg-sunset);} 145 | #civilEveningTwilight {fill:#88a6d4; fill:var(--bg-twilight1);} 146 | #nauticalEveningTwilight {fill:#4574bc; fill:var(--bg-twilight2);} 147 | #astronomicalEveningTwilight {fill:#213c66; fill:var(--bg-twilight3);} 148 | #lateEvening {fill:#192029; fill:var(--bg-night);} 149 | 150 | /* months */ 151 | .m1 {stroke:hsl(180 60% 90%); stroke:var(--bg-m1);} 152 | .m2 {stroke:hsl(150 60% 90%); stroke:var(--bg-m2);} 153 | .m3 {stroke:hsl(120 60% 90%); stroke:var(--bg-m3);} 154 | .m4 {stroke:hsl(90 60% 90%); stroke:var(--bg-m4);} 155 | .m5 {stroke:hsl(60 60% 90%); stroke:var(--bg-m5);} 156 | .m6 {stroke:hsl(30 60% 90%); stroke:var(--bg-m6);} 157 | .m7 {stroke:hsl(0 60% 90%); stroke:var(--bg-m7);} 158 | .m8 {stroke:hsl(-30 60% 90%); stroke:var(--bg-m8);} 159 | .m9 {stroke:hsl(-60 60% 90%); stroke:var(--bg-m9);} 160 | .m10 {stroke:hsl(-90 60% 90%); stroke:var(--bg-m10);} 161 | .m11 {stroke:hsl(-120 60% 90%); stroke:var(--bg-m11);} 162 | .m12 {stroke:hsl(-150 60% 90%); stroke:var(--bg-m12);} 163 | 164 | /* astronomical events */ 165 | #springEquinox {stroke:hsl(120 60% 70%); stroke:var(--bg-sE);} 166 | #summerSolstice {stroke:hsl(30 60% 70%); stroke:var(--bg-sS);} 167 | #autumnEquinox {stroke:hsl(-60 60% 70%); stroke:var(--bg-aE);} 168 | #winterSolstice {stroke:hsl(-150 60% 70%); stroke:var(--bg-wS);} 169 | #perihelion {stroke:hsl(180 0% 70%); stroke:var(--bg-pH);} 170 | #aphelion {stroke:hsl(0 0% 70%); stroke:var(--bg-aH);} 171 | 172 | 173 | @media (min-width:30rem) { 174 | html, html body {background:var(--bg-color2);} 175 | section.overlay {background:var(--bg-overlay2);} 176 | section.overlay > div {background:var(--bg-overlay3); border-color: var(--overlay-border);} 177 | } 178 | -------------------------------------------------------------------------------- /styles/main.css: -------------------------------------------------------------------------------- 1 | /* sunclock main.css */ 2 | 3 | html, body {color:#000; background:#fff;} 4 | body {margin:0; padding:0; font-size:1em; font-family:'Helvetica Neue', Helvetica, Arial, sans-serif; overflow:auto;} 5 | 6 | /* text */ 7 | h1 {display:none; font-size:1.2em; margin:0; font-weight:normal; text-align:center;} 8 | h2 {font-size:1.2em; margin:0 0 1em 0;} 9 | h3 {font-size:1.0em; margin:2em 0 1em 0} 10 | h4 {font-size:0.9em; margin:2em 0 1em 0} 11 | p {margin:0 0 1em 0;} 12 | p.to {margin:-0.5em 0 0.5em 0; font-style:italic;} 13 | big {font-size:1.2em;} 14 | sup {font-size:0.75em; margin:0 0.2em;} 15 | .center {text-align:center;} 16 | .nobr {white-space:nowrap;} 17 | .hide {display:none;} 18 | 19 | table {margin:0 0 1em 0; border-collapse:collapse;} 20 | th {text-align:left;} 21 | th, td {padding:0.3em 0.5em; border:1px solid rgba(127,127,127,0.2);} 22 | /*td:nth-child(2) {text-align:right;}*/ 23 | 24 | /* links */ 25 | a {color:#00e; text-decoration:none;} 26 | a:visited {color:#a0e;} 27 | a:hover {text-decoration:underline;} 28 | 29 | /* nav links */ 30 | nav a:link, a.close {text-decoration:none; font-weight:bold; color:#003;} 31 | nav a:visited, a.close:visited {color:#003;} 32 | nav a:hover, a.close:hover {color:#f00;} 33 | .done a:visited {color:#00e;} 34 | 35 | /* nav positions */ 36 | nav, main, footer, section, article, div {box-sizing:border-box;} 37 | nav {position:absolute; margin:0;} 38 | nav svg {width:1.625em; height:1.625em;} 39 | #nav1 {left:16px; top:16px;} 40 | #nav2 {right:16px; top:16px; text-align:right;} 41 | #nav1 {left:16px; top:16px;} 42 | #nav1 a[title="Clock"] {display:none;} 43 | 44 | #fullscreen {display:none; margin-left:0em;} 45 | #fullscreen .exit {display:none;} 46 | 47 | 48 | /* --- LAYOUT --- */ 49 | #clock {display:block; width:100vmin; width:100dvmin; height:100vmin; height:100dvmin; margin:0 auto; padding:0;} 50 | #calendar {display:none; width:100vmin; width:100dvmin; height:100vmin; height:100dvmin; margin:0 auto; padding:0;} 51 | .info {display:block; width:100vmin; width:100dvmin; max-width:40em; min-height:12em; margin:0 auto; padding:0.01em 1em; text-align:center;} 52 | section {display:block; width:100vmin; width:100dvmin; max-width:40em; margin:0 auto; padding:0; overflow-x:hidden; overflow-y:scroll; overscroll-behavior:contain;} /* default for no-js */ 53 | section.overlay {display:none; position:fixed; left:0; top:0; width:100vw; max-width:100vw; height:100vh; height:100dvh; margin:0; background:rgba(255,255,255,0.90); backdrop-filter:blur(3px); -webkit-backdrop-filter: blur(3px);} /* .overlay class added via js */ 54 | 55 | /* clock bg */ 56 | .skyBackground { 57 | width:300px; height:300px; border-radius:150px; 58 | background-image:conic-gradient(from 180deg, 59 | #192029 72deg, #213c66 78deg, #4574bc 84deg, #88a6d4 90deg, #ff9900 90deg, #ffe988 95deg, #dceaff 110deg, 60 | #dceaff 250deg, #ffe988 265deg, #ff9900 270deg, #88a6d4 270deg, #4574bc 276deg, #213c66 282deg, #192029 288deg); 61 | } 62 | 63 | /* hide odd hour numbers by default */ 64 | #hourNumbers g:nth-child(odd) {display:none;} 65 | #hourNumbers.showOdd g:nth-child(odd) {display:block;} 66 | 67 | /* info */ 68 | #info1 {display:block;} 69 | #info2 {display:block;} 70 | .info h3 {margin:1em 0;} 71 | .info .done {display:none;} 72 | #timeText {display:none;} 73 | 74 | /* sections */ 75 | section > div {position:relative; margin:0; padding:1em;} 76 | .close {position:absolute; right:1.3em; top:1.3em; margin:0; line-height:1;} 77 | 78 | /* about */ 79 | #bmc {margin:1em auto; text-align:center;} 80 | #bmc a {display:inline-block; padding:13px 27px 9px 24px; color:#0d0c23; background:#fd0; border-radius:11px;} 81 | #bmc a:hover {color:#fd0c23;} 82 | 83 | /* settings */ 84 | #settings {display:none;} 85 | #settings p {line-height:1.7em;} 86 | #settings input[name="showOddHourNumbers"], #settings input[name="showHourMarks"], 87 | #settings input[name="showMinuteMarks"], #settings input[name="showMinuteNumbers"] {margin-left:2.2em;} 88 | #setDirection {display:none; margin:-1em 0 1em 1.5em;} 89 | #setDirection label {font-size:0.9em;} 90 | #setLocation {display:none; margin:-0.5em 0 1em 1.5em;} 91 | #setLocation label {display:inline-block; width:6em; font-size:0.9em;} 92 | #setLocation input {width:8em; font-size:12px;} 93 | #setLocation input[type="submit"] {width:auto; font-size:1em;} 94 | #setLocation span {display:inline-block; line-height:1.2;} 95 | #setLocation span.hide {display:none;} 96 | 97 | /* all times */ 98 | #allTimes {display:none;} 99 | #allTimes table {margin:1em auto;} 100 | #allTimes td:nth-child(2) {max-width:22em;} 101 | 102 | 103 | @media (orientation:landscape) { 104 | #clock, #calendar {width:100vmin; width:100dvmin; height:100vmin; height:100dvmin;} 105 | 106 | .info {position:absolute; left:0; top:4em; width:calc(50vw - 50vmin); width:calc(50vw - 50dvmin); height:calc(100vh - 4em); height:calc(100dvh - 4em); overflow:visible;} 107 | #info1 {padding-right:0; text-align:left;} 108 | #info2 {display:block; left:auto; right:0em; padding-left:0; text-align:right;} 109 | } 110 | 111 | @media (orientation:portrait) { 112 | #clock, #calendar {margin-top:1.8em;} 113 | } 114 | 115 | @media (min-width:30rem) { 116 | html, html body {background:#ccc;} 117 | section.overlay {background:rgba(204,204,204,0.9); overflow:hidden;} 118 | section.overlay > div {width:40em; max-width:80vw; height:auto; max-height:90vh; max-height:90dvh; margin:5vh auto; margin:5dvh auto; background:rgba(255,255,255,0.7); overflow-x:hidden; overflow-y:auto; border:1px solid #999; border-radius:7px; box-shadow:0 5px 10px 8px rgba(0, 0, 0, 0.2);;} 119 | #settings.overlay > div {width:20em;} 120 | } 121 | 122 | @media (hover:none) { 123 | .info .done {display:block;} 124 | } 125 | -------------------------------------------------------------------------------- /worker.js: -------------------------------------------------------------------------------- 1 | /* jshint esversion: 6 */ 2 | /* globals self, caches */ 3 | 4 | const currentCache = 'v4.6'; 5 | const assets = [ 6 | "/", 7 | "/index.html", 8 | "/styles/main.css", 9 | "/styles/colors.css", 10 | "/scripts/app.js", 11 | "/scripts/clock.js", 12 | "/scripts/calendar.js", 13 | "/libs/suncalc/suncalc.js", 14 | "/libs/astronomy/astronomy.browser.min.js" 15 | ]; 16 | 17 | // install event 18 | self.addEventListener('install', event => { 19 | console.log('Service worker install event', event); 20 | 21 | // cache assets 22 | event.waitUntil( 23 | caches.open(currentCache) 24 | .then(cache => { 25 | console.log('Caching assets'); 26 | cache.addAll(assets); 27 | }) 28 | ); 29 | 30 | }); 31 | 32 | // activate event 33 | self.addEventListener('activate', event => { 34 | console.log('Service worker activate event', event); 35 | 36 | // delete old caches 37 | event.waitUntil( 38 | caches.keys() 39 | .then(cacheNames => { 40 | cacheNames.forEach(cacheName => { 41 | if (cacheName !== currentCache) { 42 | return caches.delete(cacheName); 43 | } 44 | }); 45 | }) 46 | ); 47 | }); 48 | 49 | // fetch event: cache first, then network 50 | // see: https://developer.mozilla.org/en-US/docs/Web/API/Cache#examples 51 | self.addEventListener('fetch', (event) => { 52 | console.log(`Fetching: ${event.request.url}`); 53 | 54 | event.respondWith( 55 | caches.open(currentCache) 56 | .then(cache => { 57 | return cache 58 | .match(event.request) 59 | .then(response => { 60 | if (response) { 61 | console.log(`Getting from cache: ${response.url}`); 62 | return response; 63 | } 64 | 65 | return fetch(event.request.clone()).then((response) => { 66 | if (response.status < 400) { 67 | console.log(`Response: ${response.status} ${response.statusText}, Caching: ${response.url}`); 68 | cache.put(event.request, response.clone()); 69 | } else { 70 | console.log(`Response: ${response.status} ${response.statusText}, Not caching: ${event.request.url}`); 71 | } 72 | return response; 73 | }); 74 | }) 75 | .catch((error) => { 76 | console.error("Error fetching:", error); 77 | throw error; 78 | }); 79 | }) 80 | ); 81 | }); 82 | --------------------------------------------------------------------------------