From bd2574768f7a73850cbad4aae335e14c5eac940d Mon Sep 17 00:00:00 2001 From: Pavel Mihaylov Date: Thu, 13 Jul 2023 20:25:40 +0300 Subject: [PATCH] Calculation and graph overhaul + Moon support (#56) - Completely revamped calculations and Sun graph, now way more realistic - Moon support: Moon on the graph, moonrise, moonset, Moon phase, Moon elevation and Moon azimuth - Obeys Home Assistant's settings for time and number formatting - Cleaned up config options - Font size adjustments, including the smaller AM/PM text from the original card - Lots of tests - All lint warnings gone Resolves the following issues: - #11 - Moon support - #30 - Horizon-card customize with card-mod - #40 - More realistic visualization and own source of Sun data - #54 - 24h format not working by default - #58 - Support HA 2023.7's local/server time zone setting --- .eslintrc | 31 - .gitignore | 3 + README.md | 119 +- dev/curve_scale.py | 31 + dev/dev.css | 25 +- dev/dev.js | 281 +++- dev/generate-test-data.py | 103 -- dev/test-data.json | 618 -------- global-setup.js | 4 + jest.config.js | 4 +- package.json | 16 +- src/assets/localization/languages/bg.json | 7 +- src/assets/localization/languages/ca.json | 7 +- src/assets/localization/languages/cs.json | 7 +- src/assets/localization/languages/da.json | 7 +- src/assets/localization/languages/de.json | 7 +- src/assets/localization/languages/en.json | 7 +- src/assets/localization/languages/es.json | 7 +- src/assets/localization/languages/et.json | 7 +- src/assets/localization/languages/fi.json | 7 +- src/assets/localization/languages/fr.json | 7 +- src/assets/localization/languages/he.json | 7 +- src/assets/localization/languages/hr.json | 7 +- src/assets/localization/languages/hu.json | 7 +- src/assets/localization/languages/is.json | 7 +- src/assets/localization/languages/it.json | 7 +- src/assets/localization/languages/ja.json | 7 +- src/assets/localization/languages/ko.json | 7 +- src/assets/localization/languages/lt.json | 7 +- src/assets/localization/languages/ms.json | 22 +- src/assets/localization/languages/nb.json | 7 +- src/assets/localization/languages/nl.json | 7 +- src/assets/localization/languages/nn.json | 7 +- src/assets/localization/languages/pl.json | 7 +- src/assets/localization/languages/pt-BR.json | 7 +- src/assets/localization/languages/ro.json | 7 +- src/assets/localization/languages/ru.json | 7 +- src/assets/localization/languages/sk.json | 7 +- src/assets/localization/languages/sl.json | 7 +- src/assets/localization/languages/sv.json | 7 +- src/assets/localization/languages/tr.json | 7 +- src/assets/localization/languages/uk.json | 7 +- .../localization/languages/zh-Hans.json | 7 +- .../localization/languages/zh-Hant.json | 7 +- src/cardStyles.ts | 171 +-- src/components/HorizonErrorContent.ts | 10 +- src/components/horizonCard/HorizonCard.ts | 612 ++++---- .../horizonCard/HorizonCardContent.ts | 30 +- .../horizonCard/HorizonCardFooter.ts | 136 +- .../horizonCard/HorizonCardGraph.ts | 292 ++-- .../horizonCard/HorizonCardHeader.ts | 38 +- .../horizonCardEditor/HorizonCardEditor.ts | 64 - .../HorizonCardEditorContent.ts | 124 -- src/components/horizonCardEditor/index.ts | 2 - src/constants.ts | 133 +- src/index.ts | 2 - src/types/index.ts | 138 +- src/utils/EventUtils.ts | 23 - src/utils/HelperFunctions.ts | 158 +- src/utils/I18N.ts | 114 +- suncalc3/LICENSE | 22 + suncalc3/README-HORIZON-CARD.md | 25 + suncalc3/README.md | 621 ++++++++ suncalc3/package.json | 234 +++ suncalc3/suncalc.js | 1251 ++++++++++++++++ tests/helpers/TestHelpers.ts | 73 +- tests/mocks/HelperFunctions.ts | 45 - tests/mocks/HorizonCardEditorContent.ts | 17 - tests/mocks/I18N.ts | 8 + tests/mocks/SunCalc.ts | 60 + .../components/HorizonErrorContent.spec.ts | 45 +- .../HorizonErrorContent.spec.ts.snap | 10 +- .../horizonCard/HorizonCard.spec.ts | 1294 ++++++++++++----- .../horizonCard/HorizonCardContent.spec.ts | 88 +- .../horizonCard/HorizonCardFooter.spec.ts | 141 +- .../horizonCard/HorizonCardGraph.spec.ts | 104 +- .../horizonCard/HorizonCardHeader.spec.ts | 188 +-- .../__snapshots__/HorizonCard.spec.ts.snap | 22 +- .../HorizonCardContent.spec.ts.snap | 148 +- .../HorizonCardFooter.spec.ts.snap | 587 +++++--- .../HorizonCardGraph.spec.ts.snap | 760 ++++++++-- .../HorizonCardHeader.spec.ts.snap | 155 +- .../HorizonCardEditor.spec.ts | 153 -- .../HorizonCardEditor.spec.ts.snap | 9 - tests/unit/utils/EventUtils.spec.ts | 75 - tests/unit/utils/HelperFunctions.spec.ts | 252 ++-- tests/unit/utils/I18.spec.ts | 118 -- tests/unit/utils/I18N.spec.ts | 153 ++ .../HelperFunctions.spec.ts.snap | 116 +- yarn.lock | 849 +++++------ 90 files changed, 7057 insertions(+), 4094 deletions(-) delete mode 100644 .eslintrc create mode 100644 dev/curve_scale.py delete mode 100644 dev/generate-test-data.py delete mode 100644 dev/test-data.json create mode 100644 global-setup.js delete mode 100644 src/components/horizonCardEditor/HorizonCardEditor.ts delete mode 100644 src/components/horizonCardEditor/HorizonCardEditorContent.ts delete mode 100644 src/components/horizonCardEditor/index.ts delete mode 100644 src/utils/EventUtils.ts create mode 100644 suncalc3/LICENSE create mode 100644 suncalc3/README-HORIZON-CARD.md create mode 100644 suncalc3/README.md create mode 100644 suncalc3/package.json create mode 100644 suncalc3/suncalc.js delete mode 100644 tests/mocks/HelperFunctions.ts delete mode 100644 tests/mocks/HorizonCardEditorContent.ts create mode 100644 tests/mocks/SunCalc.ts delete mode 100644 tests/unit/components/horizonCardEditor/HorizonCardEditor.spec.ts delete mode 100644 tests/unit/components/horizonCardEditor/__snapshots__/HorizonCardEditor.spec.ts.snap delete mode 100644 tests/unit/utils/EventUtils.spec.ts delete mode 100644 tests/unit/utils/I18.spec.ts create mode 100644 tests/unit/utils/I18N.spec.ts diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 273625e..0000000 --- a/.eslintrc +++ /dev/null @@ -1,31 +0,0 @@ -{ - "parser": "@typescript-eslint/parser", - "plugins": [ - "simple-import-sort" - ], - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/eslint-recommended", - "plugin:@typescript-eslint/recommended" - ], - "rules": { - "@typescript-eslint/no-non-null-assertion": "off", - "@typescript-eslint/member-ordering": "error", - "eol-last": "error", - "object-curly-spacing": ["error", "always"], - "semi": ["error", "never"], - "space-before-function-paren": ["error", "always"], - "indent": ["error", 2], - "no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 1 }], - "prefer-const": ["error", { - "destructuring": "all", - "ignoreReadBeforeAssign": true - }], - "comma-dangle": ["error", "never"], - "no-trailing-spaces": ["error"], - "simple-import-sort/imports": "error", - "simple-import-sort/exports": "error", - "object-shorthand": ["error", "always"], - "lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }] - } -} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6704566..61a8bb0 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,6 @@ dist # TernJS port file .tern-port + +# IDEs +.idea diff --git a/README.md b/README.md index 3f7fdf5..f781e2e 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,28 @@ Consider joining us! ## Introduction -The Horizon Card tracks the position of the Sun over the horizon and shows the times of various Sun events, as well as the current azimuth and elevation, in a visually appealing and easy-to-read format. +The Horizon Card tracks the position of the Sun and the Moon over the horizon and shows the times of various Sun and Moon events, as well as their current azimuth and elevation, in a visually appealing and easy-to-read format.

Light mode preview Dark mode preview

+### How it works + +The card will show the Sun and the Moon as they travel across the horizon from East to West. Both celestial bodies will be shown when above or below the horizon. + +The current view shows a period of 24 hours centered around the local solar noon. This means that the Sun will continue to travel to the far end of the graph until it reaches solar midnight, which may be some time before or after midnight in your local time zone. Once solar midnight is reached, the view will reset and start showing the data for the next day. + +In the Northern hemisphere, East is on the left, South is in the middle (when the Sun is in its highest position), and West is on the right. You are facing South and the Sun travels left-to-right. + +In the Southern hemisphere, West is on the left, North is in the middle (when the Sun is in its highest position), and East is on the right. You are facing North and the Sun travels right-to-left. You can disable the direction flip by setting `southern_flip: false`. + +The elevation of the Sun follows a predetermined curve that approximates the actual elevation, while the elevation of the Moon affects its vertical position in the graph. The scale for the Moon elevation is logarithmic, so lower elevations will appear higher (above horizon) or lower (below horizon). + +If showing the moon phase is enabled, the icon will be rotated to match the approximate view for your latitude. You can disable this by setting `moon_phase_rotation: 0` or set a different angle to match your location or preferences. + + ## Installation Please ensure you have the [Sun integration](https://www.home-assistant.io/integrations/sun/) enabled in your Home Assistant setup. @@ -79,28 +94,53 @@ Installation via HACS is recommended, but a manual setup is supported. ## Configuration -| Name | Accepted values | Description | Default | -| -------------- | -------------------- | -------------------------------------- | --------------------------------------------------- | -| component | `string` | Changes which sun component to use | Home Assistant `sun.sun` | -| darkMode | `boolean` | Changes card colors to dark or light | Home Assistant dark mode state | -| fields | See below | Fine-tuned control over visible fields | | -| language | See below | Changes card language | Home Assistant language or english if not supported | -| use12hourClock | `boolean` | Use 12/24 hour clock | Uses locale of configured language to decide | -| title | `string` | Card title | Doesn't display a title by default | +### General options + +| Name | Accepted values | Description | Default | +|---------------------|-----------------|---------------------------------------------------|----------------------------------------------------------------| +| title | *string* | Card title | Doesn't display a title by default | +| moon | *boolean* | Shows the Moon together with the Sun | `true` | +| refresh_period | *number* | Refresh period between updates, in seconds | 60 | +| fields | See below | Fine-tuned control over visible fields | | +| southern_flip | *boolean* | Draws the graph in the opposite direction | `true` in the Southern hemisphere, `false` in the Northern one | +| moon_phase_rotation | *number* | Angle in degrees for rotating the moon phase icon | Determined from the latitude | + +### Advanced options + +In general, you should not need to set any of these as they override Home Assistant's settings or set debug options. + +| Name | Accepted values | Description | Default | +|-----------|----------------------------------------------|--------------------------------------------------------------------------------|-----------------------------------------------------| +| language | See below | Changes card language | Home Assistant language or English if not supported | +| time_format | `language`, `12`, `24` | Set to `12` or `24` to force 12/24 hour clock | `language` - uses default for configured language | +| number_format | `language`, `comma_decimal`, `decimal_comma` | Set to `comma_decimal` or `decimal_comma` to force 123.45/123,45 number format | `language` - uses default for configured language | +| latitude | *number* | Latitude used for calculations | Home Assistant's latitude | +| longitude | *number* | Longitude used for calculations | Home Assistant's longitude | +| elevation | *number* | Elevation (above sea) used for calculations | Home Assistant's elevation | +| time_zone | *string* | Time zone (IANA) used for calculations and time presentation | Home Assistant's time zone | +| now | *Date* | Overrides the current moment shown on the card | None, i.e., always show the current moment | +| debug_level | *number* | Sets debug level, 0 and up | 0, i.e., no debug | ### Visibility Fields Supported settings inside the `fields` setting: -| Name | Accepted values | Description | Default | -|----------------|-----------------|----------------|---------| -| sunrise | `boolean` | Show sunrise | `true` | -| sunset | `boolean` | Show sunset | `true` | -| dawn | `boolean` | Show dawn | `true` | -| noon | `boolean` | Show noon | `true` | -| dusk | `boolean` | Show dusk | `true` | -| azimuth | `boolean` | Show azimuth | `false` | -| elevation | `boolean` | Show elevation | `false` | +| Name | Accepted values | Description | Default | +|----------------|-----------------|-----------------------------|--------------------------| +| sunrise | *boolean* | Show sunrise time | `true` | +| sunset | *boolean* | Show sunset time | `true` | +| dawn | *boolean* | Show dawn time | `true` | +| noon | *boolean* | Show solar noon time | `true` | +| dusk | *boolean* | Show dusk time | `true` | +| azimuth | *boolean* | Show Sun and Moon azimuth | `false` | +| sun_azimuth | *boolean* | Show Sun azimuth | the value of `azimuth` | +| moon_azimuth | *boolean* | Show Moon azimuth | the value of `azimuth` | +| elevation | *boolean* | Show Sun and Moon elevation | `false` | +| sun_elevation | *boolean* | Show Sun elevation | the value of `elevation` | +| moon_elevation | *boolean* | Show Moon elevation | the value of `elevation` | +| moonrise | *boolean* | Show moonrise time | `false` | +| moonset | *boolean* | Show moonset time | `false` | +| moon_phase | *boolean* | Show the Moon phase | `false` | ### Languages @@ -140,6 +180,43 @@ Supported options for the `language` setting: - `zh-Hans` Chinese, simplified - `zh-Hant` Chinese, traditional -## Known Issues - -- Home Assistant reports the time of the next occurring Sun event. For example, if you look at the card during the day, the time for sunrise will reflect tomorrow's sunrise and not the one that occurred on the same day. +### Caveats + +The Moon phase name (if the field `moon_phase` is enabled) is obtained via the [Moon integration](https://www.home-assistant.io/integrations/moon/). If the integration is not installed, the card will still show the Moon phase as a human-readable constant followed by `(!)`, e.g., `waning_gibbuous (!)`. Due to the way Home Assistant works, the localized Moon phase name will always be in Home Assistant's language and not in the language set for the card via the `language` option. + +### Example config + +The following YAML configuration illustrates the use of all options. + +```yaml +type: custom:horizon-card +title: Example Horizon Card +moon: true +refresh_period: 60 +fields: + sunrise: true + sunset: true + dawn: true + noon: true + dusk: true + azimuth: true + sun_azimuth: true + moon_azimuth: true + elevation: true + sun_elevation: true + moon_elevation: true + moonrise: true + moonset: true + moon_phase: true +southern_flip: false +moon_phase_rotation: -10 +language: en +time_format: language +number_format: language +latitude: 42.55 +longitude: 23.25 +elevation: 1500 +time_zone: Europe/Sofia +now: 2023-07-06T00:30:05+0300 +debug_level: 0 +``` diff --git a/dev/curve_scale.py b/dev/curve_scale.py new file mode 100644 index 0000000..a083a56 --- /dev/null +++ b/dev/curve_scale.py @@ -0,0 +1,31 @@ +import re + + +def scale(group, x_offset, x_length, y_offset, y_length): + x = float(group[2]) + y = float(group[3]) + + x *= x_length/400 + x += x_offset + x = round(x, 3) + + y *= y_length/100 + y += y_offset + y = round(y, 3) + + return f"{group[1]}{x},{y}" + + +# Base curve composed of two segments +# Full +# base_curve = "M0,100 C72.84,100 127.16,0 200,0 C272.84,0 327.16,100 400,100" +# Simplified +base_curve = "M0,100 C72.84,100 127.16,0 200,0 S327.16,100 400,100" + +groups = re.findall(r"(([A-Z ]+)([0-9.]+)[, ]+([0-9.]+))", base_curve) +scaled = [] +for g in groups: + scaled.append(scale(g, 5, 540, 20, 126)) + +# Prints scaled curve +print("".join(scaled)) diff --git a/dev/dev.css b/dev/dev.css index 95ce540..52be1b9 100644 --- a/dev/dev.css +++ b/dev/dev.css @@ -1,5 +1,18 @@ @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@100;200;300;400;500;700;900&display=swap'); +horizon-card { + --primary-text-color: #e1e1e1; + --secondary-text-color: #9b9b9b; + --primary-color: rgb(3, 169, 244); + font-size: 18px; +} + +body.light horizon-card { + --primary-text-color: #212121; + --secondary-text-color: #727272; + --primary-color: rgb(3, 169, 244); +} + body { font-family: 'Roboto', sans-serif; background: #111111; @@ -14,8 +27,9 @@ body.light { border-radius: 4px; box-shadow: 0px 2px 1px -1px rgba(0, 0, 0, 0.2), 0px 1px 1px 0px rgba(0, 0, 0, 0.14), 0px 1px 3px 0px rgba(0, 0, 0, 0.12); padding: 1rem; - max-width: 1000px; + max-width: 700px; margin: auto; + box-sizing: border-box; } body.light .card { @@ -27,18 +41,21 @@ body:not(.light) #dev-panel { } #dev-panel > div { - max-width: 1000px; + max-width: 1200px; margin: auto; } #time { text-align: center; padding-top: 1em; + padding-bottom: 1em; font-size: 120%; } #buttons { display: flex; + flex-wrap: wrap; + gap: 20px; } #langs { @@ -48,13 +65,13 @@ body:not(.light) #dev-panel { } #buttons > div { - flex-grow: 1; + /*flex-grow: 1;*/ } .radio { display: flex; flex-flow: column wrap; - max-height: 150px; + max-height: 180px; } #buttons div { diff --git a/dev/dev.js b/dev/dev.js index 99b7e7b..84e4b0e 100644 --- a/dev/dev.js +++ b/dev/dev.js @@ -1,58 +1,149 @@ const settings = { - darkMode: false, + // Dates in dev panel + dates: [ + "2023-01-19", + "2023-03-17", + "2023-05-18", + "2023-06-21", + "2023-08-08", + "2023-09-21", + "2023-11-01", + "2023-11-10", + "2023-12-22", + ], + + // Places in dev panel + places: [ + {city: "Sofia", country: "BG", tz: "Europe/Sofia", tzOffset: "+02:00", lat: 42.7, lon: 23.3}, + {city: "Berlin", country: "DE", tz: "Europe/Berlin", tzOffset: "+01:00", lat: 52.5, lon: 13.4}, + {city: "Karasjok", country: "NO", tz: "Europe/Oslo", tzOffset: "+01:00", lat: 69.5, lon: 25.5}, + {city: "Quito", country: "EC", tz: "America/Lima", tzOffset: "-05:00", lat: -0.15, lon: -78.5}, + {city: "Sao Paulo", country: "BR", tz: "America/Sao_Paulo", tzOffset: "-03:00", lat: -23.5, lon: -46.6}, + {city: "Melbourne", country: "AU", tz: "Australia/Melbourne", tzOffset: "+10:00", lat: -37.8, lon: 144.9}, + //{city: "Cape Town", country: "SA", tz: "Africa/Johannesburg", tzOffset: "+02:00", lat: -34, lon: 18.5} + ], + + // Card config + config: { + title: "Sunrise & Sunset", + moon: true, + fields: { + // sunrise: false, + // sunset: false, + // dawn: false, + // dusk: false, + // noon: false, + azimuth: true, + elevation: true, + moonrise: true, + moonset: true, + moon_phase: true + }, + time_format: "24", + number_format: "language", + refresh_period: 0, + debug_level: 0 + }, + + darkMode: true, intervalUpdateMs: 200, - stepMinutes: 20, + stepMinutes: 0, date: null, place: null, - lang: "en" + lang: "en", + fixedOffsetInitial: 12 * 60 * 60 * 1000, // 06:00:00 + fixedOffset: 0, }; -fetch("/test-data.json") - .then((response) => response.json()) - .then((json) => init(json)); +init(); + +function init() { + defineHaIcon(); -function init(testData) { const test = document.querySelector("#test"); - const dates = Object.keys(testData); - const places = Object.keys(testData[dates[0]]); - settings.date = dates[0]; - settings.place = places[0]; + settings.date = settings.dates[0]; + settings.place = settings.places[0]; + settings.fixedOffset = settings.fixedOffsetInitial; - createRadioButtons("Date", "date", dates, settings.date, + const dateLabel = document.createElement("div"); + dateLabel.appendChild(document.createTextNode("Date ")); + createButton(dateLabel, "Reset", () => { + settings.fixedOffset = settings.fixedOffsetInitial; + update(); + }); + createRadioButtons(dateLabel, "date", settings.dates, settings.date, (date) => { settings.date = date; update(); }); - createRadioButtons("Place", "place", places, settings.place, + createRadioButtons("Place", "place", settings.places, settings.place, (place) => { settings.place = place; update(); - }); + }, + (value) => `${value.city} ${value.country} (${value.lat} ${value.lon})`); - createRadioButtons("Step (minutes)", "step", [20, 10, 5], settings.stepMinutes, (min) => { - settings.stepMinutes = min; + const stepContainer = createRadioButtons("Step (minutes)", "step", [20, 10, 5, 1440, 0], + settings.stepMinutes, (min) => { + settings.stepMinutes = min; + update(); + }, + (value, index) => [20, 10, 5, "1 day", "Manual"][index]); + const stepButtons = document.createElement("div"); + stepContainer.appendChild(stepButtons); + createButton(stepButtons, "+1m", () => { + settings.fixedOffset += 60 * 1000; + update(); + }); + createButton(stepButtons, "-1m", () => { + settings.fixedOffset -= 60 * 1000; + update(); + }); + createButton(stepButtons, "+1h", () => { + settings.fixedOffset += 60 * 60 * 1000; + update(); + }); + createButton(stepButtons, "-1h", () => { + settings.fixedOffset -= 60 * 60 * 1000; + update(); + }); + createButton(stepButtons, "+1d", () => { + settings.fixedOffset += 24 * 60 * 60 * 1000; + update(); + }); + createButton(stepButtons, "-1d", () => { + settings.fixedOffset -= 24 * 60 * 60 * 1000; update(); }); - createRadioButtons("Interval (ms)", "interval", [500, 200, 100], settings.intervalUpdateMs, + createRadioButtons("Interval (ms)", "interval", [500, 200, 100], + settings.intervalUpdateMs, (ms) => { settings.intervalUpdateMs = ms; resetInterval(); }); - createRadioButtons("Theme", "theme", ["Light", "Dark"], settings.darkMode ? "Dark" : "Light", + createRadioButtons("Theme", "theme", [false, true], settings.darkMode, (theme) => { - settings.darkMode = theme !== "Light"; + settings.darkMode = theme; update(); - }); + }, + (value, index) => ["Light", "Dark"][index]); + + createRadioButtons("Clock", "clock", ["24", "12", "language"], + settings.config.time_format, + (clock) => { + settings.config.time_format = clock; + update(); + }, + (value, index) => ["24-hour", "12-hour", "Language"][index]); createLanguageButtons((lang) => { settings.lang = lang; update(); }); - let fixedOffset = 6 * 60 * 60 * 1000; // 06:00:00 let interval = null; update(); @@ -60,35 +151,18 @@ function init(testData) { resetInterval(); function update() { - const hours = Math.floor(fixedOffset / (60 * 60 * 1000)); - const remainingMillis = fixedOffset % (60 * 60 * 1000); - const minutes = Math.floor(remainingMillis / (60 * 1000)); - const fixedTime = String(hours).padStart(2, "0") + ":" + String(minutes).padStart(2, "0"); - test.setFixedNow(new Date(settings.date + "T" + fixedTime + ":00")); - document.querySelector("#time").innerText = fixedTime; - if (settings.darkMode) { document.body.classList.remove("light"); } else { document.body.classList.add("light"); } - test.setConfig({ - title: "Sunrise & Sunset", - fields: { - // sunrise: false, - // sunset: false, - // dawn: false, - // dusk: false, - // noon: false, - azimuth: true, - elevation: true - } - }); - const tzOffset = testData[settings.date][settings.place]["tzOffset"]; - const sunData = Object.assign({}, testData[settings.date][settings.place]["sun"]); - fixAllTimesTz(sunData, tzOffset); + const midnightInTz = window.HelperFunctions.midnightAtTimeZone(new Date(settings.date), settings.place.tz); + const nowInTz = new Date(midnightInTz.getTime() + settings.fixedOffset); + document.querySelector("#time").innerText = formatDT(nowInTz, settings.place.tz); + settings.config.now = nowInTz; + test.setConfig({ ...settings.config }); test.hass = { language: settings.lang, locale: { @@ -97,12 +171,12 @@ function init(testData) { themes: { darkMode: settings.darkMode }, - states: { - "sun.sun": { - state: "above_horizon", - attributes: sunData - } - } + config: { + latitude: settings.place.lat, + longitude: settings.place.lon, + time_zone: settings.place.tz + }, + localize: localizeMock }; } @@ -111,42 +185,44 @@ function init(testData) { clearInterval(interval); } interval = setInterval(function() { - fixedOffset += settings.stepMinutes * 60 * 1000; - if (fixedOffset >= 24 * 60 * 60 * 1000) { - fixedOffset = 0; + settings.fixedOffset += settings.stepMinutes * 60 * 1000; + if (settings.stepMinutes > 0) { + update(); } - update(); }, settings.intervalUpdateMs); } - function fixAllTimesTz(sunData, tzOffset) { - Object.keys(sunData).forEach(key => { - if (key.startsWith("next_")) { - sunData[key] = fixTz(sunData[key], tzOffset); - } - }); - } - - function fixTz(date, tzOffset) { - const original = new Date(date); - const localTzOffset = original.getTimezoneOffset() * 60; - return new Date(original.getTime() + localTzOffset * 1000 + tzOffset * 1000).toISOString(); + function formatDT(date, timeZone) { + return new Intl.DateTimeFormat("fr-CA", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + timeZoneName: "shortOffset", + timeZone + }).format(date); } } -function createRadioButtons(desc, id, values, defaultValue, callback) { +function createRadioButtons(desc, id, values, defaultValue, callback, labelMapper) { const buttons = document.querySelector("#buttons"); const container = document.createElement("div"); container.id = id; - if (values.length > 5) { + if (values.length > 6) { // I couldn't figure how to make the div grow on its own when the items wrap :( - container.style.flexGrow = "2.4"; + container.style.minWidth = "250px"; } buttons.appendChild(container); - const label = document.createElement("div"); - label.innerText = desc; - container.appendChild(label); + if (desc instanceof HTMLElement) { + container.appendChild(desc); + } else { + const label = document.createElement("div"); + label.innerText = desc; + container.appendChild(label); + } const radios = document.createElement("div"); radios.classList.add("radio"); @@ -169,14 +245,28 @@ function createRadioButtons(desc, id, values, defaultValue, callback) { const label = document.createElement("label"); label.setAttribute("for", input.id); - label.innerText = value; + if (labelMapper !== undefined) { + label.innerText = labelMapper(value, i); + } else { + label.innerText = value; + } div.appendChild(label); }); + + return container; } function createLanguageButtons(callback) { const langs = document.querySelector("#langs"); - Object.keys(window.Constants.LOCALIZATION_LANGUAGES).forEach(lang => { + const languages = [ + ...Object.keys(window.Constants.LOCALIZATION_LANGUAGES), + "en-GB", // English (GB) in Home Assistant + "es-419", // Español (Latin America) + "eo", // Esperanto (unsupported by Horizon Card) + ]; + languages.filter((value, index) => languages.indexOf(value) === index) + .sort() + .forEach(lang => { const langButton = document.createElement("button"); langButton.innerText = lang; langButton.onclick = () => callback(lang); @@ -184,3 +274,48 @@ function createLanguageButtons(callback) { }); } +function createButton(container, label, callback) { + const button = document.createElement("button"); + button.innerText = label; + button.onclick = () => callback(); + container.appendChild(button); +} + +function localizeMock(key) { + if (key.startsWith("component.sensor.state.moon__phase.")) { + const words = key.split(".")[4].split("_"); + return words[0][0].toUpperCase() + words[0].substring(1) + " " + words[1]; + } else { + return key; + } +} + +function defineHaIcon() { + // Minimal support for + class HaIcon extends HTMLElement { + constructor() { + super(); + } + + static get observedAttributes() { + return ["icon"]; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (this.span) { + this.span.className = `mdi ${newValue.replace(":", "-")}`; + } + } + + connectedCallback() { + this.attachShadow({mode: "open"}); + this.shadowRoot.innerHTML = ` + + `; + this.span = document.createElement("span"); + this.span.style.fontSize = "var(--mdc-icon-size)"; + this.shadowRoot.appendChild(this.span); + } + } + customElements.define("ha-icon", HaIcon); +} diff --git a/dev/generate-test-data.py b/dev/generate-test-data.py deleted file mode 100644 index 2e257e8..0000000 --- a/dev/generate-test-data.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -Generates test data (sun event times) for some places and dates. -The test data will be saved to test-data.json in the same directory. - -The values are generated with the assumption that the current moment is either -before noon (but after sunrise) or after noon (but before dusk) to mimic -how Home Assistant provides those values: - -- next_dawn will be tomorrow's value -- next_dusk will be today's value -- next_midnight will be today's value -- next_noon will be today or tomorrow's value -- next_sunrise will be tomorrow's value -- next_sunset will be today's value - -All times will be serialized with TZ +00:00 to mimic Home Assistant. - -The generated values for azimuth and elevation correspond to the moment of solar noon. -The elevation is used to detect certain weirdness at high latitudes when the sun is -below the horizon -- since the generated elevation is the maximum for the day it -does the job fine even though it doesn't change as it would in a real environment. -""" -import datetime -import json - -from astral import LocationInfo -from astral.sun import azimuth, elevation, dawn, dusk, noon, sunrise, sunset, midnight - -# Places to generate data for -PLACES = [ - LocationInfo("Sofia", "BG", "Europe/Sofia", 42.7, 23.3), - LocationInfo("Berlin", "DE", "Europe/Berlin", 52.5, 13.4), - LocationInfo("Karasjok", "NO", "Europe/Oslo", 69.5, 25.5), - LocationInfo("Quito", "EC", "America/Lima", -0.15, -78.5), - LocationInfo("Sao Paulo", "BR", "America/Sao_Paulo", -23.5, -46.6) -] - -# Dates to generate data for with a boolean that indicates if next_noon is the same day -DATES = [ - # Quirky in Karasjok - sunrise for next day, no sunrise/sunset on actual day - (datetime.date(2023, 1, 19), False), - (datetime.date(2023, 3, 17), True), - # Quirky in Karasjok - sunrise is right after sunset before midnight - (datetime.date(2023, 5, 18), False), - # Summer solstice - (datetime.date(2023, 6, 21), True), - (datetime.date(2023, 9, 21), False), - (datetime.date(2023, 8, 8), True), - (datetime.date(2023, 11, 1), False), - # Winter solstice - (datetime.date(2023, 12, 22), True) -] - - -def next_event(place, date, event_fun): - mod = 0 - while True: - # Loop until we find the next event, this mimics Home Assistant's Sun integration - try: - check_date = date + datetime.timedelta(days=mod) - return event_fun(place.observer, date=check_date, tzinfo="UTC").isoformat() - except ValueError: - mod += 1 - - -def generate_data(place, date_today, tomorrow_noon): - date_tomorrow = date_today + datetime.timedelta(days=1) - - _noon = noon(place, date_today, place.tzinfo) - _azimuth = round(azimuth(place, _noon), 2) - _elevation = round(elevation(place, _noon), 2) - - # tz and tzOffset are used by the dev code - return { - "tz": place.tzinfo.zone, - "tzOffset": noon(place, date_today, place.tzinfo).utcoffset().total_seconds(), - "sun": { - 'next_dawn': next_event(place, date_tomorrow, dawn), - 'next_dusk': next_event(place, date_today, dusk), - 'next_midnight': next_event(place, date_tomorrow, midnight), - 'next_noon': next_event(place, - date_tomorrow if tomorrow_noon else date_today, - noon), - 'next_rising': next_event(place, date_tomorrow, sunrise), - 'next_setting': next_event(place, date_today, sunset), - 'elevation': _elevation, - 'azimuth': _azimuth, - 'rising': False - }, - } - - -data = {} -for date, tomorrow_noon in DATES: - place_data = {} - data[str(date)] = place_data - for place in PLACES: - place_name = f"{place.name} {place.region} ({place.latitude} {place.longitude})" - place_data[place_name] = generate_data(place, date, tomorrow_noon) - -with open('test-data.json', 'w') as fp: - json.dump(data, fp, indent=2) -print(json.dumps(data, indent=2)) diff --git a/dev/test-data.json b/dev/test-data.json deleted file mode 100644 index c46f91b..0000000 --- a/dev/test-data.json +++ /dev/null @@ -1,618 +0,0 @@ -{ - "2023-01-19": { - "Sofia BG (42.7 23.3)": { - "tz": "Europe/Sofia", - "tzOffset": 7200.0, - "sun": { - "next_dawn": "2023-01-20T05:20:34.701056+00:00", - "next_dusk": "2023-01-19T15:54:05.553026+00:00", - "next_midnight": "2023-01-19T22:37:44+00:00", - "next_noon": "2023-01-19T10:37:17+00:00", - "next_rising": "2023-01-20T05:52:04.941248+00:00", - "next_setting": "2023-01-19T15:22:29.828225+00:00", - "elevation": 26.99, - "azimuth": 179.96, - "rising": false - } - }, - "Berlin DE (52.5 13.4)": { - "tz": "Europe/Berlin", - "tzOffset": 3600.0, - "sun": { - "next_dawn": "2023-01-20T06:25:40.472481+00:00", - "next_dusk": "2023-01-19T16:07:56.544234+00:00", - "next_midnight": "2023-01-19T23:17:20+00:00", - "next_noon": "2023-01-19T11:16:53+00:00", - "next_rising": "2023-01-20T07:05:22.552192+00:00", - "next_setting": "2023-01-19T15:28:03.522693+00:00", - "elevation": 17.22, - "azimuth": 179.96, - "rising": false - } - }, - "Karasjok NO (69.5 25.5)": { - "tz": "Europe/Oslo", - "tzOffset": 3600.0, - "sun": { - "next_dawn": "2023-01-20T07:14:24.073955+00:00", - "next_dusk": "2023-01-19T13:40:41.699964+00:00", - "next_midnight": "2023-01-19T22:28:56+00:00", - "next_noon": "2023-01-19T10:28:29+00:00", - "next_rising": "2023-01-20T09:08:10.288817+00:00", - "next_setting": "2023-01-20T11:49:47.477063+00:00", - "elevation": 0.62, - "azimuth": 179.97, - "rising": false - } - }, - "Quito EC (-0.15 -78.5)": { - "tz": "America/Lima", - "tzOffset": -18000.0, - "sun": { - "next_dawn": "2023-01-20T10:58:54.555713+00:00", - "next_dusk": "2023-01-19T23:50:49.987523+00:00", - "next_midnight": "2023-01-20T05:25:00+00:00", - "next_noon": "2023-01-19T17:24:29+00:00", - "next_rising": "2023-01-20T11:21:21.340243+00:00", - "next_setting": "2023-01-19T23:28:22.344728+00:00", - "elevation": 69.87, - "azimuth": 179.84, - "rising": false - } - }, - "Sao Paulo BR (-23.5 -46.6)": { - "tz": "America/Sao_Paulo", - "tzOffset": -10800.0, - "sun": { - "next_dawn": "2023-01-20T08:11:45.656481+00:00", - "next_dusk": "2023-01-19T22:22:54.474958+00:00", - "next_midnight": "2023-01-20T03:17:22+00:00", - "next_noon": "2023-01-19T15:16:53+00:00", - "next_rising": "2023-01-20T08:36:52.165109+00:00", - "next_setting": "2023-01-19T21:57:47.889490+00:00", - "elevation": 86.8, - "azimuth": 0.85, - "rising": false - } - } - }, - "2023-03-17": { - "Sofia BG (42.7 23.3)": { - "tz": "Europe/Sofia", - "tzOffset": 7200.0, - "sun": { - "next_dawn": "2023-03-18T04:05:59.061298+00:00", - "next_dusk": "2023-03-17T17:03:28.064852+00:00", - "next_midnight": "2023-03-17T22:34:56+00:00", - "next_noon": "2023-03-18T10:35:02+00:00", - "next_rising": "2023-03-18T04:34:37.520174+00:00", - "next_setting": "2023-03-17T16:34:45.702887+00:00", - "elevation": 45.95, - "azimuth": 180.04, - "rising": false - } - }, - "Berlin DE (52.5 13.4)": { - "tz": "Europe/Berlin", - "tzOffset": 3600.0, - "sun": { - "next_dawn": "2023-03-18T04:40:17.638068+00:00", - "next_dusk": "2023-03-17T17:48:04.393420+00:00", - "next_midnight": "2023-03-17T23:14:31+00:00", - "next_noon": "2023-03-18T11:14:38+00:00", - "next_rising": "2023-03-18T05:14:53.243046+00:00", - "next_setting": "2023-03-17T17:13:22.263397+00:00", - "elevation": 36.17, - "azimuth": 180.04, - "rising": false - } - }, - "Karasjok NO (69.5 25.5)": { - "tz": "Europe/Oslo", - "tzOffset": 3600.0, - "sun": { - "next_dawn": "2023-03-18T03:28:08.526916+00:00", - "next_dusk": "2023-03-17T17:22:32.413028+00:00", - "next_midnight": "2023-03-17T22:26:08+00:00", - "next_noon": "2023-03-18T10:26:14+00:00", - "next_rising": "2023-03-18T04:28:33.884680+00:00", - "next_setting": "2023-03-17T16:21:45.133636+00:00", - "elevation": 19.18, - "azimuth": 180.03, - "rising": false - } - }, - "Quito EC (-0.15 -78.5)": { - "tz": "America/Lima", - "tzOffset": -18000.0, - "sun": { - "next_dawn": "2023-03-18T10:57:52.906638+00:00", - "next_dusk": "2023-03-17T23:46:29.219650+00:00", - "next_midnight": "2023-03-18T05:22:02+00:00", - "next_noon": "2023-03-18T17:22:14+00:00", - "next_rising": "2023-03-18T11:18:56.587993+00:00", - "next_setting": "2023-03-17T23:25:25.470504+00:00", - "elevation": 88.9, - "azimuth": 182.68, - "rising": false - } - }, - "Sao Paulo BR (-23.5 -46.6)": { - "tz": "America/Sao_Paulo", - "tzOffset": -10800.0, - "sun": { - "next_dawn": "2023-03-18T08:46:22.299952+00:00", - "next_dusk": "2023-03-17T21:43:09.951656+00:00", - "next_midnight": "2023-03-18T03:14:27+00:00", - "next_noon": "2023-03-18T15:14:38+00:00", - "next_rising": "2023-03-18T09:09:22.332073+00:00", - "next_setting": "2023-03-17T21:20:11.247469+00:00", - "elevation": 67.79, - "azimuth": 359.88, - "rising": false - } - } - }, - "2023-05-18": { - "Sofia BG (42.7 23.3)": { - "tz": "Europe/Sofia", - "tzOffset": 10800.0, - "sun": { - "next_dawn": "2023-05-19T02:27:50.000199+00:00", - "next_dusk": "2023-05-18T18:18:18.232833+00:00", - "next_midnight": "2023-05-18T22:23:17+00:00", - "next_noon": "2023-05-18T10:23:13+00:00", - "next_rising": "2023-05-19T03:01:15.927086+00:00", - "next_setting": "2023-05-18T17:44:51.490832+00:00", - "elevation": 66.85, - "azimuth": 179.99, - "rising": false - } - }, - "Berlin DE (52.5 13.4)": { - "tz": "Europe/Berlin", - "tzOffset": 7200.0, - "sun": { - "next_dawn": "2023-05-19T02:20:35.887046+00:00", - "next_dusk": "2023-05-18T19:44:37.543464+00:00", - "next_midnight": "2023-05-18T23:02:53+00:00", - "next_noon": "2023-05-18T11:02:49+00:00", - "next_rising": "2023-05-19T03:05:21.816303+00:00", - "next_setting": "2023-05-18T18:59:49.938193+00:00", - "elevation": 57.06, - "azimuth": 179.99, - "rising": false - } - }, - "Karasjok NO (69.5 25.5)": { - "tz": "Europe/Oslo", - "tzOffset": 7200.0, - "sun": { - "next_dawn": "2023-08-14T22:58:30.526991+00:00", - "next_dusk": "2023-08-14T21:47:48.075164+00:00", - "next_midnight": "2023-05-18T22:14:29+00:00", - "next_noon": "2023-05-18T10:14:25+00:00", - "next_rising": "2023-05-18T22:31:00.181858+00:00", - "next_setting": "2023-05-18T21:54:09.961878+00:00", - "elevation": 40.06, - "azimuth": 179.99, - "rising": false - } - }, - "Quito EC (-0.15 -78.5)": { - "tz": "America/Lima", - "tzOffset": -18000.0, - "sun": { - "next_dawn": "2023-05-19T10:44:57.156312+00:00", - "next_dusk": "2023-05-18T23:35:57.950731+00:00", - "next_midnight": "2023-05-19T05:10:29+00:00", - "next_noon": "2023-05-18T17:10:25+00:00", - "next_rising": "2023-05-19T11:07:20.386438+00:00", - "next_setting": "2023-05-18T23:13:35.567343+00:00", - "elevation": 70.25, - "azimuth": 0.02, - "rising": false - } - }, - "Sao Paulo BR (-23.5 -46.6)": { - "tz": "America/Sao_Paulo", - "tzOffset": -10800.0, - "sun": { - "next_dawn": "2023-05-19T09:10:38.571679+00:00", - "next_dusk": "2023-05-18T20:55:16.815316+00:00", - "next_midnight": "2023-05-19T03:02:53+00:00", - "next_noon": "2023-05-18T15:02:49+00:00", - "next_rising": "2023-05-19T09:35:06.489892+00:00", - "next_setting": "2023-05-18T20:30:51.047830+00:00", - "elevation": 46.93, - "azimuth": 0.01, - "rising": false - } - } - }, - "2023-06-21": { - "Sofia BG (42.7 23.3)": { - "tz": "Europe/Sofia", - "tzOffset": 10800.0, - "sun": { - "next_dawn": "2023-06-22T02:13:26.215505+00:00", - "next_dusk": "2023-06-21T18:43:52.334486+00:00", - "next_midnight": "2023-06-21T22:28:46+00:00", - "next_noon": "2023-06-22T10:28:39+00:00", - "next_rising": "2023-06-22T02:49:14.258609+00:00", - "next_setting": "2023-06-21T18:08:04.297815+00:00", - "elevation": 70.74, - "azimuth": 179.92, - "rising": false - } - }, - "Berlin DE (52.5 13.4)": { - "tz": "Europe/Berlin", - "tzOffset": 7200.0, - "sun": { - "next_dawn": "2023-06-22T01:52:35.461744+00:00", - "next_dusk": "2023-06-21T20:23:55.898875+00:00", - "next_midnight": "2023-06-21T23:08:23+00:00", - "next_noon": "2023-06-22T11:08:15+00:00", - "next_rising": "2023-06-22T02:43:46.190614+00:00", - "next_setting": "2023-06-21T19:32:45.187262+00:00", - "elevation": 60.95, - "azimuth": 179.94, - "rising": false - } - }, - "Karasjok NO (69.5 25.5)": { - "tz": "Europe/Oslo", - "tzOffset": 7200.0, - "sun": { - "next_dawn": "2023-08-14T22:58:30.526991+00:00", - "next_dusk": "2023-08-14T21:47:48.075164+00:00", - "next_midnight": "2023-06-21T22:19:58+00:00", - "next_noon": "2023-06-22T10:19:51+00:00", - "next_rising": "2023-07-25T22:55:36.313990+00:00", - "next_setting": "2023-07-25T21:55:43.614588+00:00", - "elevation": 43.96, - "azimuth": 179.97, - "rising": false - } - }, - "Quito EC (-0.15 -78.5)": { - "tz": "America/Lima", - "tzOffset": -18000.0, - "sun": { - "next_dawn": "2023-06-22T10:49:49.182980+00:00", - "next_dusk": "2023-06-21T23:42:00.533818+00:00", - "next_midnight": "2023-06-22T05:16:01+00:00", - "next_noon": "2023-06-22T17:15:51+00:00", - "next_rising": "2023-06-22T11:12:47.309013+00:00", - "next_setting": "2023-06-21T23:19:02.387671+00:00", - "elevation": 66.42, - "azimuth": 0.1, - "rising": false - } - }, - "Sao Paulo BR (-23.5 -46.6)": { - "tz": "America/Sao_Paulo", - "tzOffset": -10800.0, - "sun": { - "next_dawn": "2023-06-22T09:22:49.694270+00:00", - "next_dusk": "2023-06-21T20:53:45.490994+00:00", - "next_midnight": "2023-06-22T03:08:24+00:00", - "next_noon": "2023-06-22T15:08:15+00:00", - "next_rising": "2023-06-22T09:48:00.012770+00:00", - "next_setting": "2023-06-21T20:28:35.130854+00:00", - "elevation": 43.08, - "azimuth": 0.05, - "rising": false - } - } - }, - "2023-09-21": { - "Sofia BG (42.7 23.3)": { - "tz": "Europe/Sofia", - "tzOffset": 10800.0, - "sun": { - "next_dawn": "2023-09-22T03:45:06.419294+00:00", - "next_dusk": "2023-09-21T16:55:13.164052+00:00", - "next_midnight": "2023-09-21T22:19:40+00:00", - "next_noon": "2023-09-21T10:20:09+00:00", - "next_rising": "2023-09-22T04:13:51.838870+00:00", - "next_setting": "2023-09-21T16:26:31.303001+00:00", - "elevation": 48.04, - "azimuth": 180.05, - "rising": false - } - }, - "Berlin DE (52.5 13.4)": { - "tz": "Europe/Berlin", - "tzOffset": 7200.0, - "sun": { - "next_dawn": "2023-09-22T04:17:09.174185+00:00", - "next_dusk": "2023-09-21T17:42:36.414673+00:00", - "next_midnight": "2023-09-21T22:59:15+00:00", - "next_noon": "2023-09-21T10:59:45+00:00", - "next_rising": "2023-09-22T04:51:57.860409+00:00", - "next_setting": "2023-09-21T17:07:53.683123+00:00", - "elevation": 38.23, - "azimuth": 180.05, - "rising": false - } - }, - "Karasjok NO (69.5 25.5)": { - "tz": "Europe/Oslo", - "tzOffset": 7200.0, - "sun": { - "next_dawn": "2023-09-22T02:55:45.169683+00:00", - "next_dusk": "2023-09-21T17:28:03.215497+00:00", - "next_midnight": "2023-09-21T22:10:52+00:00", - "next_noon": "2023-09-21T10:11:21+00:00", - "next_rising": "2023-09-22T03:57:17.787178+00:00", - "next_setting": "2023-09-21T16:26:50.103142+00:00", - "elevation": 21.27, - "azimuth": 180.04, - "rising": false - } - }, - "Quito EC (-0.15 -78.5)": { - "tz": "America/Lima", - "tzOffset": -18000.0, - "sun": { - "next_dawn": "2023-09-22T10:42:38.160469+00:00", - "next_dusk": "2023-09-21T23:31:13.986066+00:00", - "next_midnight": "2023-09-22T05:06:45+00:00", - "next_noon": "2023-09-21T17:07:21+00:00", - "next_rising": "2023-09-22T11:03:41.605945+00:00", - "next_setting": "2023-09-21T23:10:10.504623+00:00", - "elevation": 89.24, - "azimuth": 355.54, - "rising": false - } - }, - "Sao Paulo BR (-23.5 -46.6)": { - "tz": "America/Sao_Paulo", - "tzOffset": -10800.0, - "sun": { - "next_dawn": "2023-09-22T08:33:29.799070+00:00", - "next_dusk": "2023-09-21T21:24:55.233500+00:00", - "next_midnight": "2023-09-22T02:59:11+00:00", - "next_noon": "2023-09-21T14:59:45+00:00", - "next_rising": "2023-09-22T08:56:27.072457+00:00", - "next_setting": "2023-09-21T21:01:56.519390+00:00", - "elevation": 65.86, - "azimuth": 359.87, - "rising": false - } - } - }, - "2023-08-08": { - "Sofia BG (42.7 23.3)": { - "tz": "Europe/Sofia", - "tzOffset": 10800.0, - "sun": { - "next_dawn": "2023-08-09T02:54:50.264806+00:00", - "next_dusk": "2023-08-08T18:10:31.242611+00:00", - "next_midnight": "2023-08-08T22:32:23+00:00", - "next_noon": "2023-08-09T10:32:25+00:00", - "next_rising": "2023-08-09T03:26:37.845135+00:00", - "next_setting": "2023-08-08T17:38:44.965030+00:00", - "elevation": 63.45, - "azimuth": 180.02, - "rising": false - } - }, - "Berlin DE (52.5 13.4)": { - "tz": "Europe/Berlin", - "tzOffset": 7200.0, - "sun": { - "next_dawn": "2023-08-09T02:57:48.754564+00:00", - "next_dusk": "2023-08-08T19:26:53.444837+00:00", - "next_midnight": "2023-08-08T23:11:59+00:00", - "next_noon": "2023-08-09T11:12:01+00:00", - "next_rising": "2023-08-09T03:38:45.464042+00:00", - "next_setting": "2023-08-08T18:45:59.228175+00:00", - "elevation": 53.65, - "azimuth": 180.02, - "rising": false - } - }, - "Karasjok NO (69.5 25.5)": { - "tz": "Europe/Oslo", - "tzOffset": 7200.0, - "sun": { - "next_dawn": "2023-08-14T22:58:30.526991+00:00", - "next_dusk": "2023-08-14T21:47:48.075164+00:00", - "next_midnight": "2023-08-08T22:23:35+00:00", - "next_noon": "2023-08-09T10:23:37+00:00", - "next_rising": "2023-08-09T00:48:37.936073+00:00", - "next_setting": "2023-08-08T19:59:55.246113+00:00", - "elevation": 36.67, - "azimuth": 180.01, - "rising": false - } - }, - "Quito EC (-0.15 -78.5)": { - "tz": "America/Lima", - "tzOffset": -18000.0, - "sun": { - "next_dawn": "2023-08-09T10:54:33.612793+00:00", - "next_dusk": "2023-08-08T23:44:39.577622+00:00", - "next_midnight": "2023-08-09T05:19:31+00:00", - "next_noon": "2023-08-09T17:19:37+00:00", - "next_rising": "2023-08-09T11:16:27.406846+00:00", - "next_setting": "2023-08-08T23:22:44.903157+00:00", - "elevation": 73.79, - "azimuth": 359.93, - "rising": false - } - }, - "Sao Paulo BR (-23.5 -46.6)": { - "tz": "America/Sao_Paulo", - "tzOffset": -10800.0, - "sun": { - "next_dawn": "2023-08-09T09:12:54.830254+00:00", - "next_dusk": "2023-08-08T21:10:51.818628+00:00", - "next_midnight": "2023-08-09T03:11:56+00:00", - "next_noon": "2023-08-09T15:12:01+00:00", - "next_rising": "2023-08-09T09:36:46.616446+00:00", - "next_setting": "2023-08-08T20:46:57.640348+00:00", - "elevation": 50.42, - "azimuth": 359.97, - "rising": false - } - } - }, - "2023-11-01": { - "Sofia BG (42.7 23.3)": { - "tz": "Europe/Sofia", - "tzOffset": 7200.0, - "sun": { - "next_dawn": "2023-11-02T04:31:38.926735+00:00", - "next_dusk": "2023-11-01T15:49:39.111829+00:00", - "next_midnight": "2023-11-01T22:10:20+00:00", - "next_noon": "2023-11-01T10:10:21+00:00", - "next_rising": "2023-11-02T05:01:38.203718+00:00", - "next_setting": "2023-11-01T15:19:45.809559+00:00", - "elevation": 32.92, - "azimuth": 180.0, - "rising": false - } - }, - "Berlin DE (52.5 13.4)": { - "tz": "Europe/Berlin", - "tzOffset": 3600.0, - "sun": { - "next_dawn": "2023-11-02T05:27:11.934534+00:00", - "next_dusk": "2023-11-01T16:13:36.939347+00:00", - "next_midnight": "2023-11-01T22:49:56+00:00", - "next_noon": "2023-11-01T10:49:57+00:00", - "next_rising": "2023-11-02T06:04:03.758088+00:00", - "next_setting": "2023-11-01T15:36:55.972963+00:00", - "elevation": 23.12, - "azimuth": 180.0, - "rising": false - } - }, - "Karasjok NO (69.5 25.5)": { - "tz": "Europe/Oslo", - "tzOffset": 3600.0, - "sun": { - "next_dawn": "2023-11-02T05:33:00.135809+00:00", - "next_dusk": "2023-11-01T14:32:27.056790+00:00", - "next_midnight": "2023-11-01T22:01:32+00:00", - "next_noon": "2023-11-01T10:01:33+00:00", - "next_rising": "2023-11-02T06:46:48.804775+00:00", - "next_setting": "2023-11-01T13:19:49.833458+00:00", - "elevation": 6.23, - "azimuth": 180.0, - "rising": false - } - }, - "Quito EC (-0.15 -78.5)": { - "tz": "America/Lima", - "tzOffset": -18000.0, - "sun": { - "next_dawn": "2023-11-02T10:32:18.860265+00:00", - "next_dusk": "2023-11-01T23:22:42.869105+00:00", - "next_midnight": "2023-11-02T04:57:30+00:00", - "next_noon": "2023-11-01T16:57:33+00:00", - "next_rising": "2023-11-02T10:54:05.837690+00:00", - "next_setting": "2023-11-01T23:00:56.767300+00:00", - "elevation": 75.66, - "azimuth": 180.01, - "rising": false - } - }, - "Sao Paulo BR (-23.5 -46.6)": { - "tz": "America/Sao_Paulo", - "tzOffset": -10800.0, - "sun": { - "next_dawn": "2023-11-02T07:56:02.075759+00:00", - "next_dusk": "2023-11-01T21:43:32.402498+00:00", - "next_midnight": "2023-11-02T02:49:54+00:00", - "next_noon": "2023-11-01T14:49:57+00:00", - "next_rising": "2023-11-02T08:20:07.699323+00:00", - "next_setting": "2023-11-01T21:19:26.572804+00:00", - "elevation": 80.97, - "azimuth": 359.98, - "rising": false - } - } - }, - "2023-12-22": { - "Sofia BG (42.7 23.3)": { - "tz": "Europe/Sofia", - "tzOffset": 7200.0, - "sun": { - "next_dawn": "2023-12-23T05:21:59.128983+00:00", - "next_dusk": "2023-12-22T15:28:55.141710+00:00", - "next_midnight": "2023-12-22T22:25:43+00:00", - "next_noon": "2023-12-23T10:25:29+00:00", - "next_rising": "2023-12-23T05:54:50.281840+00:00", - "next_setting": "2023-12-22T14:56:03.795161+00:00", - "elevation": 23.9, - "azimuth": 179.94, - "rising": false - } - }, - "Berlin DE (52.5 13.4)": { - "tz": "Europe/Berlin", - "tzOffset": 3600.0, - "sun": { - "next_dawn": "2023-12-23T06:33:34.583204+00:00", - "next_dusk": "2023-12-22T15:36:32.717532+00:00", - "next_midnight": "2023-12-22T23:05:20+00:00", - "next_noon": "2023-12-23T11:05:05+00:00", - "next_rising": "2023-12-23T07:16:02.037505+00:00", - "next_setting": "2023-12-22T14:54:04.830272+00:00", - "elevation": 14.13, - "azimuth": 179.94, - "rising": false - } - }, - "Karasjok NO (69.5 25.5)": { - "tz": "Europe/Oslo", - "tzOffset": 3600.0, - "sun": { - "next_dawn": "2023-12-23T08:01:39.185505+00:00", - "next_dusk": "2023-12-22T12:31:33.057419+00:00", - "next_midnight": "2023-12-22T22:16:55+00:00", - "next_noon": "2023-12-23T10:16:41+00:00", - "next_rising": "2024-01-21T09:02:53.525668+00:00", - "next_setting": "2024-01-21T11:55:43.087430+00:00", - "elevation": -2.83, - "azimuth": 179.95, - "rising": false - } - }, - "Quito EC (-0.15 -78.5)": { - "tz": "America/Lima", - "tzOffset": -18000.0, - "sun": { - "next_dawn": "2023-12-23T10:46:14.858017+00:00", - "next_dusk": "2023-12-22T23:39:21.210113+00:00", - "next_midnight": "2023-12-23T05:13:02+00:00", - "next_noon": "2023-12-23T17:12:41+00:00", - "next_rising": "2023-12-23T11:09:13.459524+00:00", - "next_setting": "2023-12-22T23:16:22.568330+00:00", - "elevation": 66.72, - "azimuth": 179.79, - "rising": false - } - }, - "Sao Paulo BR (-23.5 -46.6)": { - "tz": "America/Sao_Paulo", - "tzOffset": -10800.0, - "sun": { - "next_dawn": "2023-12-23T07:52:05.838857+00:00", - "next_dusk": "2023-12-22T22:18:13.350120+00:00", - "next_midnight": "2023-12-23T03:05:24+00:00", - "next_noon": "2023-12-23T15:05:05+00:00", - "next_rising": "2023-12-23T08:17:59.341449+00:00", - "next_setting": "2023-12-22T21:52:19.840293+00:00", - "elevation": 89.9, - "azimuth": 50.07, - "rising": false - } - } - } -} \ No newline at end of file diff --git a/global-setup.js b/global-setup.js new file mode 100644 index 0000000..bb5de57 --- /dev/null +++ b/global-setup.js @@ -0,0 +1,4 @@ +module.exports = async () => { + // Assures stable TZ for all tests + process.env.TZ = 'UTC' +} diff --git a/jest.config.js b/jest.config.js index b7f5182..354ab35 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,5 @@ module.exports = { + globalSetup: './global-setup.js', transformIgnorePatterns: ['node_modules/?!(lit-html)'], preset: 'ts-jest/presets/js-with-ts', testEnvironment: 'jsdom', @@ -21,5 +22,6 @@ module.exports = { }, setupFiles: [ './tests/helpers/TestHelpers.ts' - ] + ], + snapshotSerializers: ["jest-serializer-html"] } diff --git a/package.json b/package.json index ea215db..9a6fabd 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@rollup/plugin-commonjs": "^24.0.1", "@rollup/plugin-json": "^6.0.0", "@rollup/plugin-node-resolve": "^15.0.1", - "@types/jest": "29.4.0", + "@types/jest": "29.5.0", "@typescript-eslint/eslint-plugin": "^5.54.1", "@typescript-eslint/parser": "^5.54.1", "eslint": "^8.36.0", @@ -41,14 +41,22 @@ "eslint-plugin-simple-import-sort": "10.0.0", "jest": "29.5.0", "jest-environment-jsdom": "29.5.0", + "jest-serializer-html": "^7.1.0", "rollup": "^3.19.1", "rollup-plugin-serve": "^2.0.2", "rollup-plugin-typescript2": "^0.34.1", - "ts-jest": "29.0.5", - "typescript": "^4.4.4" + "ts-jest": "29.1.0", + "typescript": "^5.0.4" }, "dependencies": { "custom-card-helpers": "^1.8.0", - "lit-element": "^2.4.0" + "lit": "^2.7.2", + "suncalc3": "link:suncalc3" + }, + "resolutions": { + "@formatjs/intl-utils": "https://registry.yarnpkg.com/@favware/skip-dependency/-/skip-dependency-1.2.1.tgz" + }, + "resolutionsComments": { + "@formatjs/intl-utils": "Outdated, producing a warning at build, unused by us, brought by custom-card-helpers => skip it" } } diff --git a/src/assets/localization/languages/bg.json b/src/assets/localization/languages/bg.json index dce4993..c557540 100644 --- a/src/assets/localization/languages/bg.json +++ b/src/assets/localization/languages/bg.json @@ -3,10 +3,9 @@ "dawn": "Зора", "dusk": "Здрач", "elevation": "Височина", + "moonrise": "Лунен изгрев", + "moonset": "Лунен залез", "noon": "Пладне", "sunrise": "Изгрев", - "sunset": "Залез", - "errors": { - "SunIntegrationNotFound": "Интеграцията Sun не е намерена" - } + "sunset": "Залез" } diff --git a/src/assets/localization/languages/ca.json b/src/assets/localization/languages/ca.json index 8062910..271031a 100644 --- a/src/assets/localization/languages/ca.json +++ b/src/assets/localization/languages/ca.json @@ -3,10 +3,9 @@ "dawn": "Alba", "dusk": "Capvespre", "elevation": "Elevació", + "moonrise": "Sortida de la lluna", + "moonset": "Posta de lluna", "noon": "Migdia solar", "sunrise": "Sortida del sol", - "sunset": "Posta del sol", - "errors": { - "SunIntegrationNotFound": "No s'ha trobat la integració Sun" - } + "sunset": "Posta del sol" } diff --git a/src/assets/localization/languages/cs.json b/src/assets/localization/languages/cs.json index 811ee97..4f05b2b 100644 --- a/src/assets/localization/languages/cs.json +++ b/src/assets/localization/languages/cs.json @@ -3,10 +3,9 @@ "dawn": "Svítání", "dusk": "Soumrak", "elevation": "Výška", + "moonrise": "Východ měsíce", + "moonset": "Západ měsíce", "noon": "Sluneční poledne", "sunrise": "Východ slunce", - "sunset": "Západ slunce", - "errors": { - "SunIntegrationNotFound": "Integrace Sun nenalezena" - } + "sunset": "Západ slunce" } diff --git a/src/assets/localization/languages/da.json b/src/assets/localization/languages/da.json index a02fd98..348d274 100644 --- a/src/assets/localization/languages/da.json +++ b/src/assets/localization/languages/da.json @@ -3,10 +3,9 @@ "dawn": "Daggry", "dusk": "Skumring", "elevation": "Højde", + "moonrise": "Måneopgang", + "moonset": "Månenedgang", "noon": "Middag", "sunrise": "Solopgang", - "sunset": "Solnedgang", - "errors": { - "SunIntegrationNotFound": "kunne ikke finde integrationen for Sol" - } + "sunset": "Solnedgang" } diff --git a/src/assets/localization/languages/de.json b/src/assets/localization/languages/de.json index b798edc..1dd716b 100644 --- a/src/assets/localization/languages/de.json +++ b/src/assets/localization/languages/de.json @@ -3,10 +3,9 @@ "dawn": "Morgendämmerung", "dusk": "Abenddämmerung", "elevation": "Zenitwinkel", + "moonrise": "Mondaufgang", + "moonset": "Monduntergang", "noon": "Zenit", "sunrise": "Sonnenaufgang", - "sunset": "Sonnenuntergang", - "errors": { - "SunIntegrationNotFound": "Sun integration not found" - } + "sunset": "Sonnenuntergang" } diff --git a/src/assets/localization/languages/en.json b/src/assets/localization/languages/en.json index f3ccecb..4acb23c 100644 --- a/src/assets/localization/languages/en.json +++ b/src/assets/localization/languages/en.json @@ -3,10 +3,9 @@ "dawn": "Dawn", "dusk": "Dusk", "elevation": "Elevation", + "moonrise": "Moonrise", + "moonset": "Moonset", "noon": "Solar noon", "sunrise": "Sunrise", - "sunset": "Sunset", - "errors": { - "SunIntegrationNotFound": "Sun integration not found" - } + "sunset": "Sunset" } diff --git a/src/assets/localization/languages/es.json b/src/assets/localization/languages/es.json index 2c1f592..2aa6940 100644 --- a/src/assets/localization/languages/es.json +++ b/src/assets/localization/languages/es.json @@ -3,10 +3,9 @@ "dawn": "Amanecer", "dusk": "Anochecer", "elevation": "Elevación", + "moonrise": "Salida de la luna", + "moonset": "Puesta de la luna", "noon": "Mediodía solar", "sunrise": "Salida del sol", - "sunset": "Atardecer", - "errors": { - "SunIntegrationNotFound": "No se encontró la integración de Sun" - } + "sunset": "Atardecer" } diff --git a/src/assets/localization/languages/et.json b/src/assets/localization/languages/et.json index 936d427..5b7a556 100644 --- a/src/assets/localization/languages/et.json +++ b/src/assets/localization/languages/et.json @@ -3,10 +3,9 @@ "dawn": "Koidik", "dusk": "Hämarik", "elevation": "Kõrgus", + "moonrise": "Kuutõus", + "moonset": "Kuuloojang", "noon": "Keskpäev", "sunrise": "Päikesetõus", - "sunset": "Päikeseloojang", - "errors": { - "SunIntegrationNotFound": "Sun integration not found" - } + "sunset": "Päikeseloojang" } diff --git a/src/assets/localization/languages/fi.json b/src/assets/localization/languages/fi.json index df7385f..6e43fcb 100644 --- a/src/assets/localization/languages/fi.json +++ b/src/assets/localization/languages/fi.json @@ -3,10 +3,9 @@ "dawn": "Sarastus", "dusk": "Hämärä", "elevation": "Korkeus", + "moonrise": "Kuunnousu", + "moonset": "Kuunlasku", "noon": "Keskipäivä", "sunrise": "Auringonnousu", - "sunset": "Auringonlasku", - "errors": { - "SunIntegrationNotFound": "Sun integration not found" - } + "sunset": "Auringonlasku" } diff --git a/src/assets/localization/languages/fr.json b/src/assets/localization/languages/fr.json index 0d5c64e..6befa51 100644 --- a/src/assets/localization/languages/fr.json +++ b/src/assets/localization/languages/fr.json @@ -3,10 +3,9 @@ "dawn": "Aube", "dusk": "Crépuscule", "elevation": "Élévation", + "moonrise": "Lever de lune", + "moonset": "Coucher de lune", "noon": "Midi solaire", "sunrise": "Lever du soleil", - "sunset": "Coucher du soleil", - "errors": { - "SunIntegrationNotFound": "Sun integration not found" - } + "sunset": "Coucher du soleil" } diff --git a/src/assets/localization/languages/he.json b/src/assets/localization/languages/he.json index ba18ec1..aa1af4a 100644 --- a/src/assets/localization/languages/he.json +++ b/src/assets/localization/languages/he.json @@ -3,10 +3,9 @@ "dawn": "עלות השחר", "dusk": "בין הערבים", "elevation": "גובה", + "moonrise": "זריחה ירח", + "moonset": "שקיעה ירח", "noon": "אמצע היום", "sunrise": "זריחה", - "sunset": "שקיעה", - "errors": { - "SunIntegrationNotFound": "אינטגרצית שמש לא נמצאה" - } + "sunset": "שקיעה" } diff --git a/src/assets/localization/languages/hr.json b/src/assets/localization/languages/hr.json index 4a61bea..546e307 100644 --- a/src/assets/localization/languages/hr.json +++ b/src/assets/localization/languages/hr.json @@ -3,10 +3,9 @@ "dawn": "Zora", "dusk": "Sumrak", "elevation": "Visina", + "moonrise": "Izlazak mjeseca", + "moonset": "Zalazak mjeseca", "noon": "Sunčano podne", "sunrise": "Izlazak sunca", - "sunset": "Zalazak sunca", - "errors": { - "SunIntegrationNotFound": "Sun integracija nije pronađena" - } + "sunset": "Zalazak sunca" } diff --git a/src/assets/localization/languages/hu.json b/src/assets/localization/languages/hu.json index c802b6e..7f67743 100644 --- a/src/assets/localization/languages/hu.json +++ b/src/assets/localization/languages/hu.json @@ -3,10 +3,9 @@ "dawn": "Hajnal", "dusk": "Szürkület", "elevation": "Magasság", + "moonrise": "Holdkelte", + "moonset": "Holdnyugta", "noon": "Dél", "sunrise": "Napkelte", - "sunset": "Napnyugta", - "errors": { - "SunIntegrationNotFound": "Sun integration not found" - } + "sunset": "Napnyugta" } diff --git a/src/assets/localization/languages/is.json b/src/assets/localization/languages/is.json index 4d90c42..7a14b34 100644 --- a/src/assets/localization/languages/is.json +++ b/src/assets/localization/languages/is.json @@ -3,10 +3,9 @@ "dawn": "Dögun", "dusk": "Rökkur", "elevation": "Hækkun", + "moonrise": "Tunglupprás", + "moonset": "Tunglsetur", "noon": "Sólarhádegi", "sunrise": "Sólarupprás", - "sunset": "Sólsetur", - "errors": { - "SunIntegrationNotFound": "Sólar eining fannst ekki" - } + "sunset": "Sólsetur" } diff --git a/src/assets/localization/languages/it.json b/src/assets/localization/languages/it.json index 034a0f6..ee84d60 100644 --- a/src/assets/localization/languages/it.json +++ b/src/assets/localization/languages/it.json @@ -3,10 +3,9 @@ "dawn": "Aurora", "dusk": "Crepuscolo", "elevation": "Elevazione", + "moonrise": "Sorgere della luna", + "moonset": "Tramonto della luna", "noon": "Mezzogiorno solare", "sunrise": "Alba", - "sunset": "Tramonto", - "errors": { - "SunIntegrationNotFound": "Sun integration not found" - } + "sunset": "Tramonto" } diff --git a/src/assets/localization/languages/ja.json b/src/assets/localization/languages/ja.json index 94d83f2..93a5cb4 100644 --- a/src/assets/localization/languages/ja.json +++ b/src/assets/localization/languages/ja.json @@ -3,10 +3,9 @@ "dawn": "明け方", "dusk": "夕", "elevation": "仰俯角", + "moonrise": "月の出", + "moonset": "月の入り", "noon": "太陽の正午", "sunrise": "日出", - "sunset": "日沒", - "errors": { - "SunIntegrationNotFound": "インテグレーション Sun は検索できません" - } + "sunset": "日沒" } diff --git a/src/assets/localization/languages/ko.json b/src/assets/localization/languages/ko.json index 35178a0..a873cfe 100644 --- a/src/assets/localization/languages/ko.json +++ b/src/assets/localization/languages/ko.json @@ -3,10 +3,9 @@ "dawn": "새벽", "dusk": "저녁", "elevation": "태양 고도", + "moonrise": "월출", + "moonset": "월몰", "noon": "태양 정오", "sunrise": "해돋이", - "sunset": "해넘이", - "errors": { - "SunIntegrationNotFound": "태양 통합구성요소를 찾을 수 없습니다" - } + "sunset": "해넘이" } diff --git a/src/assets/localization/languages/lt.json b/src/assets/localization/languages/lt.json index 9c37410..2531e3c 100644 --- a/src/assets/localization/languages/lt.json +++ b/src/assets/localization/languages/lt.json @@ -3,10 +3,9 @@ "dawn": "Aušra", "dusk": "Prieblanda", "elevation": "Pakilimas", + "moonrise": "Mėnulio kilimas", + "moonset": "Mėnulio leidimasis", "noon": "Vidurdienis", "sunrise": "Saulėtekis", - "sunset": "Saulėlydis", - "errors": { - "SunIntegrationNotFound": "Sun integration not found" - } + "sunset": "Saulėlydis" } diff --git a/src/assets/localization/languages/ms.json b/src/assets/localization/languages/ms.json index 0f97bdd..5fafa50 100644 --- a/src/assets/localization/languages/ms.json +++ b/src/assets/localization/languages/ms.json @@ -1,13 +1,11 @@ { - "azimuth": "Azimuth", - "dawn": "Fajar", - "dusk": "Senja", - "elevation": "Ketinggian", - "noon": "Tengahari", - "sunrise": "Fajar", - "sunset": "Senja", - "errors": { - "SunIntegrationNotFound": "Integrasi Sun tidak ditemui." - } - } - \ No newline at end of file + "azimuth": "Azimut", + "dawn": "Fajar", + "dusk": "Senja", + "elevation": "Ketinggian", + "moonrise": "Bulan terbit", + "moonset": "Bulan terbenam", + "noon": "Tengahari", + "sunrise": "Matahari terbit", + "sunset": "Matahari terbenam" +} diff --git a/src/assets/localization/languages/nb.json b/src/assets/localization/languages/nb.json index faa833e..64c8799 100644 --- a/src/assets/localization/languages/nb.json +++ b/src/assets/localization/languages/nb.json @@ -3,10 +3,9 @@ "dawn": "Daggry", "dusk": "Skumring", "elevation": "Elevasjon", + "moonrise": "Måneoppgang", + "moonset": "Månenedgang", "noon": "Middag", "sunrise": "Soloppgang", - "sunset": "Solnedgang", - "errors": { - "SunIntegrationNotFound": "Fant ikke Sol-integrasjonen" - } + "sunset": "Solnedgang" } diff --git a/src/assets/localization/languages/nl.json b/src/assets/localization/languages/nl.json index 56c6fe2..56e23a0 100644 --- a/src/assets/localization/languages/nl.json +++ b/src/assets/localization/languages/nl.json @@ -3,10 +3,9 @@ "dawn": "Dageraad", "dusk": "Schemer", "elevation": "Hoogte", + "moonrise": "Maanopkomst", + "moonset": "Maanondergang", "noon": "Middaguur", "sunrise": "Zonsopkomst", - "sunset": "Zonsondergang", - "errors": { - "SunIntegrationNotFound": "Sun integration not found" - } + "sunset": "Zonsondergang" } diff --git a/src/assets/localization/languages/nn.json b/src/assets/localization/languages/nn.json index 39d1665..b19da6d 100644 --- a/src/assets/localization/languages/nn.json +++ b/src/assets/localization/languages/nn.json @@ -3,10 +3,9 @@ "dawn": "Daggry", "dusk": "Skumring", "elevation": "Høgde", + "moonrise": "Måneoppgang", + "moonset": "Månenedgang", "noon": "Middag", "sunrise": "Soloppgang", - "sunset": "Solnedgang", - "errors": { - "SunIntegrationNotFound": "Kunne ikkje finne sol-integrasjonen" - } + "sunset": "Solnedgang" } diff --git a/src/assets/localization/languages/pl.json b/src/assets/localization/languages/pl.json index 9568277..14de716 100644 --- a/src/assets/localization/languages/pl.json +++ b/src/assets/localization/languages/pl.json @@ -3,10 +3,9 @@ "dawn": "Świt", "dusk": "Zmierzch", "elevation": "Wysokość", + "moonrise": "Wschód księżyca", + "moonset": "Zachód księżyca", "noon": "Górowanie", "sunrise": "Wschód", - "sunset": "Zachód", - "errors": { - "SunIntegrationNotFound": "Nie odnaleziono integracji sun" - } + "sunset": "Zachód" } diff --git a/src/assets/localization/languages/pt-BR.json b/src/assets/localization/languages/pt-BR.json index 2cb4612..ee85d7c 100644 --- a/src/assets/localization/languages/pt-BR.json +++ b/src/assets/localization/languages/pt-BR.json @@ -3,10 +3,9 @@ "dawn": "Amanhecer", "dusk": "Anoitecer", "elevation": "Elevação", + "moonrise": "Nascer da lua", + "moonset": "Pôr da lua", "noon": "Meio dia solar", "sunrise": "Nascer do sol", - "sunset": "Pôr do sol", - "errors": { - "SunIntegrationNotFound": "Integração Sun não encontrada" - } + "sunset": "Pôr do sol" } diff --git a/src/assets/localization/languages/ro.json b/src/assets/localization/languages/ro.json index f939bc3..854d9c3 100644 --- a/src/assets/localization/languages/ro.json +++ b/src/assets/localization/languages/ro.json @@ -3,10 +3,9 @@ "dawn": "Zori", "dusk": "Amurg", "elevation": "Elevație", + "moonrise": "Răsărit lunii", + "moonset": "Apus lunii", "noon": "Zenit", "sunrise": "Răsărit", - "sunset": "Apus", - "errors": { - "SunIntegrationNotFound": "Integrare solară indisponibilă" - } + "sunset": "Apus" } diff --git a/src/assets/localization/languages/ru.json b/src/assets/localization/languages/ru.json index b96b7d2..17caeba 100644 --- a/src/assets/localization/languages/ru.json +++ b/src/assets/localization/languages/ru.json @@ -3,10 +3,9 @@ "dawn": "Рассвет", "dusk": "Сумерки", "elevation": "Высота", + "moonrise": "Восход луны", + "moonset": "Закат луны", "noon": "Зенит", "sunrise": "Восход", - "sunset": "Закат", - "errors": { - "SunIntegrationNotFound": "Sun integration not found" - } + "sunset": "Закат" } diff --git a/src/assets/localization/languages/sk.json b/src/assets/localization/languages/sk.json index 37ea76a..6f111ff 100644 --- a/src/assets/localization/languages/sk.json +++ b/src/assets/localization/languages/sk.json @@ -3,10 +3,9 @@ "dawn": "Úsvit", "dusk": "Súmrak", "elevation": "Výška", + "moonrise": "Východ mesiaca", + "moonset": "Západ mesiaca", "noon": "Slnečné poludnie", "sunrise": "Východ slnka", - "sunset": "Západ slnka", - "errors": { - "SunIntegrationNotFound": "Integrácia slnka sa nenašla" - } + "sunset": "Západ slnka" } diff --git a/src/assets/localization/languages/sl.json b/src/assets/localization/languages/sl.json index 464e948..2cadfc7 100644 --- a/src/assets/localization/languages/sl.json +++ b/src/assets/localization/languages/sl.json @@ -3,10 +3,9 @@ "dawn": "Zora", "dusk": "Mrak", "elevation": "Višina", + "moonrise": "Lunin vzhod", + "moonset": "Lunin zahod", "noon": "Sončno poldne", "sunrise": "Sončni vzhod", - "sunset": "Sončni zahod", - "errors": { - "SunIntegrationNotFound": "Sun integration not found" - } + "sunset": "Sončni zahod" } diff --git a/src/assets/localization/languages/sv.json b/src/assets/localization/languages/sv.json index 044163c..9ae5ab0 100644 --- a/src/assets/localization/languages/sv.json +++ b/src/assets/localization/languages/sv.json @@ -3,10 +3,9 @@ "dawn": "Gryning", "dusk": "Skymning", "elevation": "Elevation", + "moonrise": "Månuppgång", + "moonset": "Månnedgång", "noon": "Middag", "sunrise": "Soluppgång", - "sunset": "Solnedgång", - "errors": { - "SunIntegrationNotFound": "Sun integration not found" - } + "sunset": "Solnedgång" } diff --git a/src/assets/localization/languages/tr.json b/src/assets/localization/languages/tr.json index 5aba572..9c60915 100644 --- a/src/assets/localization/languages/tr.json +++ b/src/assets/localization/languages/tr.json @@ -3,10 +3,9 @@ "dawn": "Şafak", "dusk": "Alacakaranlık", "elevation": "Yükseklik", + "moonrise": "Ayın doğuşu", + "moonset": "Ayın batışı", "noon": "Öğle", "sunrise": "Gündoğumu", - "sunset": "Günbatımı", - "errors": { - "SunIntegrationNotFound": "Güneş entegrasyonu bulunamadı" - } + "sunset": "Günbatımı" } diff --git a/src/assets/localization/languages/uk.json b/src/assets/localization/languages/uk.json index 7e47f7f..1688e81 100644 --- a/src/assets/localization/languages/uk.json +++ b/src/assets/localization/languages/uk.json @@ -3,10 +3,9 @@ "dawn": "Світанок", "dusk": "Сутінки", "elevation": "Висота", + "moonrise": "Схід місяця", + "moonset": "Захід місяця", "noon": "Заніт", "sunrise": "Схід", - "sunset": "Захід", - "errors": { - "SunIntegrationNotFound": "Інтеграцію Sun не знайдено" - } + "sunset": "Захід" } diff --git a/src/assets/localization/languages/zh-Hans.json b/src/assets/localization/languages/zh-Hans.json index f0bd47a..90200b9 100644 --- a/src/assets/localization/languages/zh-Hans.json +++ b/src/assets/localization/languages/zh-Hans.json @@ -3,10 +3,9 @@ "dawn": "拂晓", "dusk": "傍晚", "elevation": "仰角", + "moonrise": "月出", + "moonset": "月落", "noon": "日中", "sunrise": "日出", - "sunset": "日落", - "errors": { - "SunIntegrationNotFound": "未搜索到集成 Sun" - } + "sunset": "日落" } diff --git a/src/assets/localization/languages/zh-Hant.json b/src/assets/localization/languages/zh-Hant.json index ca1eb66..f0b4cb4 100644 --- a/src/assets/localization/languages/zh-Hant.json +++ b/src/assets/localization/languages/zh-Hant.json @@ -3,10 +3,9 @@ "dawn": "黎明", "dusk": "黃昏", "elevation": "仰角", + "moonrise": "月出", + "moonset": "月落", "noon": "日正當中", "sunrise": "日昇", - "sunset": "日落", - "errors": { - "SunIntegrationNotFound": "沒有找到整合 Sun" - } + "sunset": "日落" } diff --git a/src/cardStyles.ts b/src/cardStyles.ts index b248e72..ef67d58 100644 --- a/src/cardStyles.ts +++ b/src/cardStyles.ts @@ -1,35 +1,62 @@ import { css } from 'lit' export default css` - .horizon-card { - --horizon-card-primary: var(--primary-text-color, #000000); - --horizon-card-secondary: var(--secondary-text-color, #828282); - --horizon-card-accent: #d7d7d7; - - --horizon-card-lines: var(--horizon-card-accent); - --horizon-card-field-name-color: var(--horizon-card-secondary); - --horizon-card-field-value-color: var(--horizon-card-primary); - - --horizon-card-stop-invisible: rgb(0,0,0,0); - --horizon-card-stop-sun-color: #f9d05e; - --horizon-card-stop-dawn-color: #393b78; - --horizon-card-stop-day-color: #8ebeeb; - --horizon-card-stop-dusk-color: #393b78; - - padding: 0.5rem; - font-family: var(--primary-font-family); + :host { + --hc-primary: var(--primary-text-color); + --hc-secondary: var(--secondary-text-color); + + --hc-field-name-color: var(--hc-secondary); + --hc-field-value-color: var(--hc-primary); + + --hc-day-color: #8ebeeb; + --hc-night-color: #393b78; + + --hc-accent: #d7d7d7; + --hc-lines: var(--hc-accent); + + --hc-sun-hue: 44; + --hc-sun-saturation: 93%; + --hc-sun-lightness: 67%; + --hc-sun-hue-reduce: 0; + --hc-sun-saturation-reduce: 0%; + --hc-sun-lightness-reduce: 0%; + --hc-sun-color: hsl( + calc(var(--hc-sun-hue) - var(--hc-sun-hue-reduce)), + calc(var(--hc-sun-saturation) - var(--hc-sun-saturation-reduce)), + calc(var(--hc-sun-lightness) - var(--hc-sun-lightness-reduce)) + ); + + --hc-moon-hue: 52; + --hc-moon-saturation: 77%; + --hc-moon-lightness: 57%; + --hc-moon-saturation-reduce: 0%; + --hc-moon-lightness-reduce: 0%; + --hc-moon-color: hsl( + var(--hc-moon-hue), + calc(var(--hc-moon-saturation) - var(--hc-moon-saturation-reduce)), + calc(var(--hc-moon-lightness) - var(--hc-moon-lightness-reduce)) + ); + --hc-moon-shadow-color: #eeeeee; + --hc-moon-spot-color: rgba(170, 170, 170, 0.1); + } + + :host(.horizon-card-dark) { + --hc-accent: #464646; + --hc-moon-saturation: 80%; + --hc-moon-lightness: 74%; + --hc-moon-shadow-color: #272727; } - .horizon-card.horizon-card-dark { - --horizon-card-primary: #ffffff; - --horizon-card-secondary: #828282; - --horizon-card-accent: #464646; + .horizon-card { + padding: 0.5em; + font-family: var(--primary-font-family); } .horizon-card-field-row { display: flex; justify-content: space-around; - margin-top: 1rem; + margin-top: 1em; + margin-bottom: -0.3em; } .horizon-card-text-container { @@ -39,95 +66,71 @@ export default css` } .horizon-card-field-name { - color: var(--horizon-card-field-name-color); + color: var(--hc-field-name-color); } .horizon-card-field-value { - color: var(--horizon-card-field-value-color); - font-size: 1.3em; + color: var(--hc-field-value-color); + font-size: 1.2em; line-height: 1.1em; + text-align: center; } - .horizon-card-header { - display: flex; - justify-content: space-around; - margin-top: 1rem; - margin-bottom: -1rem; - } - - .horizon-card-header .horizon-card-text-container { - font-size: 1.3rem; + .horizon-card-field-value-moon-phase { + font-size: inherit; } - .horizon-card-footer { - margin-bottom: 1rem; + .horizon-card-field-moon-phase { + --mdc-icon-size: 2em; + color: var(--primary-color); } - .horizon-card-title { - margin: 1rem 1rem 2rem 1rem; - font-size: 1.5rem; - color: var(--horizon-card-primary); - } - - .horizon-card-graph { - shape-rendering="geometricPrecision"; - margin: 1rem 0 1rem 0; + .horizon-card-field-value-secondary { + font-size: 0.7em; } - .horizon-card-graph .sunInitialStop { - stop-color: var(--horizon-card-stop-sun-color); + .horizon-card-sun-value:before { + content: "☉"; + padding-right: 0.5em; } - .horizon-card-graph .sunMiddleStop { - stop-color: var(--horizon-card-stop-sun-color); + .horizon-card-moon-value:before { + content: "☽"; + padding-right: 0.5em; } - .horizon-card-graph .sunEndStop { - stop-color: var(--horizon-card-stop-invisible); - } - - .horizon-card-graph .dawnInitialStop { - stop-color: var(--horizon-card-stop-dawn-color); - } - - .horizon-card-graph .dawnMiddleStop { - stop-color: var(--horizon-card-stop-dawn-color); - } - - .horizon-card-graph .dawnEndStop { - stop-color: var(--horizon-card-stop-invisible); - } - - .horizon-card-graph .dayInitialStop { - stop-color: var(--horizon-card-stop-day-color); - } - - .horizon-card-graph .dayMiddleStop { - stop-color: var(--horizon-card-stop-day-color); + .horizon-card-header { + display: flex; + justify-content: space-around; + margin-top: 1em; + margin-bottom: -0.3em; } - .horizon-card-graph .dayEndStop { - stop-color: var(--horizon-card-stop-invisible); + .horizon-card-header .horizon-card-text-container { + font-size: 1.2em; } - .horizon-card-graph .duskInitialStop { - stop-color: var(--horizon-card-stop-dusk-color); + .horizon-card-footer { + margin-bottom: 1em; } - .horizon-card-graph .duskMiddleStop { - stop-color: var(--horizon-card-stop-dusk-color); + .horizon-card-title { + margin: 1em 1em 1em 1em; + font-size: 1.5em; + color: var(--hc-primary); } - .horizon-card-graph .duskEndStop { - stop-color: var(--horizon-card-stop-invisible); + .horizon-card-graph { + margin: 1em 0.5em 1em 0.5em; } - .card-config ul { - list-style: none; - padding: 0 0 0 1.5rem; + .horizon-card-graph .dawn { + fill: var(--hc-night-color); + stroke: var(--hc-night-color); } - .card-config li { - padding: 0.5rem 0; + .horizon-card-graph .day { + fill: var(--hc-day-color); + stroke: var(--hc-day-color); } ` diff --git a/src/components/HorizonErrorContent.ts b/src/components/HorizonErrorContent.ts index 7ad0b4b..3067b25 100644 --- a/src/components/HorizonErrorContent.ts +++ b/src/components/HorizonErrorContent.ts @@ -1,15 +1,15 @@ import { html, TemplateResult } from 'lit' -import { EHorizonCardErrors, IHorizonCardConfig } from '../types' +import { EHorizonCardErrors } from '../types' import { I18N } from '../utils/I18N' export class HorizonErrorContent { - private i18n: I18N - private error: EHorizonCardErrors + private readonly i18n: I18N + private readonly error: EHorizonCardErrors - constructor (config: IHorizonCardConfig, error: EHorizonCardErrors) { - this.i18n = config.i18n! + constructor (error: EHorizonCardErrors, i18n: I18N) { this.error = error + this.i18n = i18n } public render (): TemplateResult { diff --git a/src/components/horizonCard/HorizonCard.ts b/src/components/horizonCard/HorizonCard.ts index f381424..654c2a4 100644 --- a/src/components/horizonCard/HorizonCard.ts +++ b/src/components/horizonCard/HorizonCard.ts @@ -1,10 +1,19 @@ -import { HomeAssistant } from 'custom-card-helpers' -import { CSSResult, LitElement, TemplateResult } from 'lit' -import { customElement, state } from 'lit-element' +import { HomeAssistant, round } from 'custom-card-helpers' +import { CSSResult, html, LitElement, TemplateResult } from 'lit' +import { customElement, state } from 'lit/decorators.js' +import { default as SunCalc } from 'suncalc3' import cardStyles from '../../cardStyles' import { Constants } from '../../constants' -import { EHorizonCardErrors, IHorizonCardConfig, THorizonCardData, THorizonCardTimes, TSunInfo } from '../../types' +import { + EHorizonCardErrors, + IHorizonCardConfig, + THorizonCardFields, + TMoonData, + TMoonPosition, + TSunPosition, + TSunTimes +} from '../../types' import { HelperFunctions } from '../../utils/HelperFunctions' import { I18N } from '../../utils/I18N' import { HorizonErrorContent } from '../HorizonErrorContent' @@ -17,28 +26,27 @@ export class HorizonCard extends LitElement { static readonly cardDescription = 'Custom card that display a graph to track the sun position and related events' @state() - private config: IHorizonCardConfig = { type: HorizonCard.cardType } + private config!: IHorizonCardConfig @state() - private data!: THorizonCardData + private data = Constants.DEFAULT_CARD_DATA + + @state() + private error: EHorizonCardErrors | undefined - private hasRendered = false private lastHass!: HomeAssistant - private fixedNow!: Date + + private hasCalculated = false + + private wasDisconnected = false static get styles (): CSSResult { return cardStyles } set hass (hass: HomeAssistant) { + this.debug(() => `set hass :: ${hass.locale.language} :: ${hass.locale.time_format}`, 2) this.lastHass = hass - - if (!this.hasRendered) { - this.populateConfigFromHass() - return - } - - this.processLastHass() } /** @@ -49,320 +57,430 @@ export class HorizonCard extends LitElement { public getCardSize (): number { let height = 4 // Smallest possible card (only graph) is roughly 200px + const fieldConfig = this.expandedFieldConfig() + // Each element of card (title, header, content, footer) adds roughly 50px to the height if (this.config.title && this.config.title.length > 0) { height += 1 } - if (this.config.fields?.sunrise || this.config.fields?.sunset) { + if (fieldConfig.sunrise || fieldConfig.sunset) { + height += 1 + } + + if (fieldConfig.dawn || fieldConfig.noon || fieldConfig.dusk) { height += 1 } - if (this.config.fields?.dawn || this.config.fields?.noon || this.config.fields?.dusk) { + if (fieldConfig.sun_azimuth || fieldConfig.moon_azimuth || fieldConfig.sun_elevation || fieldConfig.moon_elevation) { height += 1 } - if (this.config.fields?.azimuth || this.config.fields?.elevation) { + if (fieldConfig.moonrise || fieldConfig.moon_phase || fieldConfig.moonset) { height += 1 } + this.debug(() => `getCardSize() => ${height}`, 2) + return height } - // Visual editor disabled because it's broken, see https://developers.home-assistant.io/blog/2022/02/18/paper-elements/ - // static getConfigElement (): HTMLElement { - // return document.createElement(HorizonCardEditor.cardType) - // } - // called by HASS whenever config changes public setConfig (config: IHorizonCardConfig): void { - const newConfig = { ...this.config } - newConfig.title = config.title - newConfig.darkMode = config.darkMode - newConfig.language = config.language - newConfig.use12hourClock = config.use12hourClock - newConfig.component = config.component ?? Constants.DEFAULT_CONFIG.component - - if (newConfig.language && !HelperFunctions.isValidLanguage(newConfig.language)) { + if (config.language && !HelperFunctions.isValidLanguage(config.language)) { throw Error(`${config.language} is not a supported language. Supported languages: ${Object.keys(Constants.LOCALIZATION_LANGUAGES)}`) } - const defaultFields = Constants.DEFAULT_CONFIG.fields! + if (config.latitude === undefined && config.longitude !== undefined + || config.latitude !== undefined && config.longitude == undefined) { + throw Error('Latitude and longitude must be both set or unset') + } - newConfig.fields = { - sunrise: config.fields?.sunrise ?? defaultFields.sunrise, - sunset: config.fields?.sunset ?? defaultFields.sunset, + this.config = config + this.hasCalculated = false - dawn: config.fields?.dawn ?? defaultFields.dawn, - noon: config.fields?.noon ?? defaultFields.noon, - dusk: config.fields?.dusk ?? defaultFields.dusk, + this.debug('setConfig()', 2) + } - azimuth: config.fields?.azimuth ?? defaultFields.azimuth, - elevation: config.fields?.elevation ?? defaultFields.elevation + public override render (): TemplateResult { + if (!this.lastHass) { + this.debug('render() [no hass]', 2) + return html`` } - this.config = newConfig - if (this.lastHass) { - this.populateConfigFromHass() - } - } + this.debug('render()', 2) + + const expandedConfig = this.expandedConfig() + this.classList.toggle('horizon-card-dark', expandedConfig.dark_mode) - public render (): TemplateResult { - if (this.data?.error) { - return new HorizonErrorContent(this.config, this.data.error).render() + if (this.error) { + return new HorizonErrorContent(this.error, this.i18n(expandedConfig)).render() } - // TODO: Move - // init i18n component (assume set config has run at least once) - this.config.i18n = new I18N(this.config.language!, this.config.use12hourClock) + const moonLightnessReduceSign = expandedConfig.dark_mode ? 1 : -1 + + this.style.setProperty('--hc-sun-hue-reduce', `${this.data.sunData.hueReduce}`) + this.style.setProperty('--hc-sun-saturation-reduce', `${this.data.sunData.saturationReduce}%`) + this.style.setProperty('--hc-sun-lightness-reduce', `${this.data.sunData.lightnessReduce}%`) + + this.style.setProperty('--hc-moon-saturation-reduce', `${this.data.moonData.saturationReduce}%`) + this.style.setProperty('--hc-moon-lightness-reduce', + `${this.data.moonData.lightnessReduce * moonLightnessReduceSign}%`) // render components - return new HorizonCardContent(this.config, this.data).render() + return new HorizonCardContent(expandedConfig, this.data, this.i18n(expandedConfig)).render() } - protected updated (changedProperties: Map): void { + protected override updated (changedProperties: Map): void { super.updated(changedProperties) - if (!this.hasRendered) { - this.hasRendered = true - this.processLastHass() + this.debug(() => `updated() - ${JSON.stringify(Array.from(changedProperties.keys()))}`, 2) + + if (!this.config) { + // This happens only in dev mode, hass will call setConfig() before first update + return + } + + if (!this.hasCalculated) { + this.hasCalculated = true + this.calculateStatePartial() + } else if (this.data.partial) { + this.calculateStateFinal() + const refreshPeriod = this.refreshPeriod() + if (refreshPeriod > 0) { + window.setTimeout(() => { + if (!this.wasDisconnected) { + this.debug('refresh via setTimeout()', 2) + if (this.hasCalculated) { + this.calculateStatePartial() + } + } + }, refreshPeriod) + } } } - /** - * Sets a fixed now value to use instead of the actual time. - * Used for development only. Called from js code in the dev directory. - * @param fixedNow a Date - * @protected - */ - private setFixedNow (fixedNow: Date) { - this.fixedNow = fixedNow + override disconnectedCallback () { + this.wasDisconnected = true + this.debug('disconnectedCallback()', 2) } - private populateConfigFromHass () { - // respect setting in hass - // NOTE: custom-card-helpers types are not up to date with home assistant - // NOTE: Old releases from Home Assistant doesn't provide the locale property - this.config.darkMode = this.config.darkMode ?? (this.lastHass.themes as unknown as { darkMode: boolean })?.darkMode - this.config.language = this.config.language ?? (this.lastHass as unknown as { locale?: { language: string } }).locale?.language ?? this.lastHass.language + private calculateStateFinal () { + this.debug('calculateStateFinal()') + + const sunInfo = this.computeSunPosition(this.data.sunData.times, + this.isWinterDarkness(this.data.latitude, this.data.sunData.times.now), this.data.sunPosition.scaleY) + + this.data = { ...this.data, partial: false, sunPosition: sunInfo } } - private processLastHass () { - if (!this.lastHass) { - return + private calculateStatePartial () { + const now = this.now() + const latitude = this.latitude() + const longitude = this.longitude() + + this.debug(() => `calculateStatePartial() :: ${now?.toISOString()} ${this.timeZone()} :: ${latitude}, ${longitude}`) + + const times = this.readSunTimes(now, latitude, longitude, this.elevation()) + + const sunCalcPosition = SunCalc.getPosition(times.now, latitude, longitude) + const azimuth = this.roundDegree(sunCalcPosition['azimuthDegrees']) + const elevation = this.roundDegree(sunCalcPosition['altitudeDegrees']) + + const sunPosition = this.computeSunPosition(times, this.isWinterDarkness(latitude, times.now)) + + const moonData = this.computeMoonData(times.now, latitude, longitude) + const moonPosition = this.computeMoonPosition(moonData) + + const hueReduce = HelperFunctions.rangeScale(-10, 10, elevation, 15) + const saturationReduce = HelperFunctions.rangeScale(-23, 10, elevation, 50) + const lightnessReduce = HelperFunctions.rangeScale(-10, 10, elevation, 12) + + this.data = { + partial: true, + latitude, + longitude, + sunPosition, + sunData: { + azimuth, + elevation, + times, + hueReduce, + saturationReduce, + lightnessReduce + }, + moonPosition, + moonData + } + } + + private readSunTimes (now: Date, latitude: number, longitude: number, elevation: number): TSunTimes { + const nowDayBefore = new Date(now.getTime() - Constants.MS_24_HOURS) + const sunTimesNow = SunCalc.getSunTimes(HelperFunctions.noonAtTimeZone(now, this.timeZone()), + latitude, longitude, elevation, false, false, true) + const sunTimesDayBefore = SunCalc.getSunTimes(HelperFunctions.noonAtTimeZone(nowDayBefore, this.timeZone()), + latitude, longitude, elevation, false, false, true) + + const noonDelta = now.getTime() - sunTimesDayBefore.solarNoon.value.getTime() + if (noonDelta < Constants.MS_12_HOURS) { + // We are past local standard midnight but previous solar noon was sooner than 12 hours, use previous day's data + return this.convertSunCalcTimes(sunTimesDayBefore) + } + + return this.convertSunCalcTimes(sunTimesNow) + } + + private convertSunCalcTimes (data): TSunTimes { + return { + now: this.now(), + dawn: this.validOrUndefined(data['civilDawn']), + dusk: this.validOrUndefined(data['civilDusk']), + midnight: this.validOrUndefined(data['nadir']), + noon: this.validOrUndefined(data['solarNoon']), + sunrise: this.validOrUndefined(data['sunriseStart']), + sunset: this.validOrUndefined(data['sunsetEnd']), } + } - this.populateConfigFromHass() + private validOrUndefined (event) { + return event.valid ? event.value : undefined + } - const sunComponent = this.config.component! + private findPointOnCurve (time: Date, noon: Date, useUnscaledPath?: boolean | false) { + const sunPath = this.shadowRoot?.querySelector('#sun-path' + (useUnscaledPath ? '-unscaled' : '')) as SVGPathElement + const delta = noon.getTime() - time.getTime() + const len = sunPath.getTotalLength() + const position = len / 2 - len * (delta / Constants.MS_24_HOURS) + return sunPath.getPointAtLength(position) + } - if (this.lastHass.states[sunComponent]) { - const sunAttrs = this.lastHass.states[sunComponent].attributes - const now = this.now() - const times = this.readTimes(sunAttrs, now) + private isWinterDarkness (latitude: number, now: Date) { + const month = now.getMonth() // months are zero-based, UTC or local TZ doesn't matter here + const northernWinter = month < 2 || month > 8 + // winter darkness when winter in the northern hemisphere and north of the equator + // or + // winter darkness when summer in the northern hemisphere and south of the equator + return northernWinter && latitude > 0 || !northernWinter && latitude < 0 + } - const sunInfo = this.calculateSunInfo(sunAttrs.elevation, now, times) + private computeScale (sunrise: Date | undefined, noon: Date, canBeWinterDarkness: boolean) { + const sunrisePoint = this.findPointOnCurve(this.sunriseForComputation(sunrise, noon, canBeWinterDarkness), + noon, true) + // Sun path curve top is at 20 + const horizonPosInCurve = sunrisePoint.y - 20 + // Sun path curve midpoint, from 20 (top) to 146 (bottom), halved + const curveHalfSpan = 63 - this.data = { - azimuth: sunAttrs.azimuth, - elevation: sunAttrs.elevation, - sunInfo, - times - } + const diff = Math.abs(horizonPosInCurve - curveHalfSpan) + const scaleY = curveHalfSpan / (diff + curveHalfSpan) + + this.debug(() => `scale factor ${scaleY}`) + return scaleY + } + + private sunriseForComputation (sunrise: Date | undefined, noon: Date, canBeWinterDarkness: boolean) { + return sunrise ?? (canBeWinterDarkness ? noon : new Date(noon.getTime() - Constants.MS_12_HOURS)) + } + + private computeSunPosition (times: TSunTimes, canBeWinterDarkness: boolean, + previousScaleY?: number | undefined): TSunPosition { + // Sun position along the curve + const sunPosition = this.findPointOnCurve(times.now, times.noon) + + let sunsetX = -10 + let sunriseX = -10 + const sunriseForComputation = this.sunriseForComputation(times.sunrise, times.noon, canBeWinterDarkness) + const sunrisePosition = this.findPointOnCurve(sunriseForComputation, times.noon) + + if (times.sunrise !== undefined && times.sunset !== undefined) { + // Sunset and sunrise both occur and will be drawn as vertical bars + sunriseX = sunrisePosition.x + const sunsetPosition = this.findPointOnCurve(times.sunset, times.noon) + sunsetX = sunsetPosition.x + } + + const horizonY = sunrisePosition.y + + let offsetY + let scaleY + if (previousScaleY === undefined) { + // First (partial) run: computes the scale factor + offsetY = 0 + scaleY = this.computeScale(times.sunrise, times.noon, canBeWinterDarkness) } else { - this.data = { - azimuth: 0, - elevation: 0, - sunInfo: Constants.DEFAULT_SUN_INFO, - times: Constants.DEFAULT_TIMES, - error: EHorizonCardErrors.SunIntegrationNotFound - } + // Second (final) run: uses the scaled curve (from the partial run) to offset the horizon + offsetY = Constants.HORIZON_Y - horizonY + this.debug(() => `scaled horizonY = ${horizonY}, offset ${offsetY}`) + scaleY = previousScaleY } - } - /* For the math to work in #calculateSunInfo(sunrise, sunset, noon, elevation, now), we need the - * date part of the given 'date-time' to be equal. This will not be the - * case whenever we pass one of the 'times', ie: when we pass dawn, hass - * will update that variable with tomorrows dawn. - * - * This function safe-guards that through standardizing the 'date'-part on - * the last 'time' to now. This means that all dates will have the date of the - * current moment, thus ensuring equal date across all times of day. - */ - private readTimes (sunAttributes, now: Date): THorizonCardTimes { - const noon = new Date(sunAttributes.next_noon) return { - dawn: this.normalizeSunEventTime(sunAttributes.next_dawn, now, noon), - dusk: this.normalizeSunEventTime(sunAttributes.next_dusk, now, noon), - noon: this.combineDateTime(now, noon), - sunrise: this.normalizeSunEventTime(sunAttributes.next_rising, now, noon), - sunset: this.normalizeSunEventTime(sunAttributes.next_setting, now, noon), + scaleY, + offsetY, + horizonY, + sunsetX, + sunriseX, + x: sunPosition.x, + y: sunPosition.y } } - /** - * Normalizes a sun event time and returns it as a Date whose date part is set to the provided now Date, - * or undefined if the event does not occur within 24h of the provided noon moment. - * Dawn, dusk, sunset and sunrise events may not occur for certain times of the year at high latitudes. - * @param eventTime event time as string - * @param now the current time - * @param noon the time of next noon - * @private - */ - private normalizeSunEventTime (eventTime: string, now: Date, noon: Date) { - const event = new Date(eventTime) - - if (Math.abs(event.getTime() - noon.getTime()) > 24 * 60 * 60 * 1000) { - // No such event within 24h, happens at higher latitudes for certain times of the year. - // This can happen for dusk, dawn, sunset, sunrise but not noon since solar noon is defined as the highest - // elevation of the sun, even if it's below the horizon. - return undefined - } + private computeMoonData (now: Date, lat: number, lon: number): TMoonData { + const moonRawData = SunCalc.getMoonData(now, lat, lon) - return this.combineDateTime(now, event) - } + const azimuth = this.roundDegree(moonRawData.azimuthDegrees) + const elevation = this.roundDegree(moonRawData.altitudeDegrees) - /** - * Takes the date from dateSource and the time from timeSource and returns a Date combining both - * @param dateSource a Date - * @param timeSource a Date - * @private - */ - private combineDateTime (dateSource: Date, timeSource: Date) { - // Note: these need to be the non-UTC versions of the methods! - return new Date(dateSource.getFullYear(), dateSource.getMonth(), dateSource.getDate(), - timeSource.getHours(), timeSource.getMinutes(), timeSource.getSeconds(), timeSource.getMilliseconds()) - } + const moonRawTimes = SunCalc.getMoonTimes(HelperFunctions.midnightAtTimeZone(now, this.timeZone()), + lat, lon, false, true) - /** - * Returns the current moment in time, used to normalize the event times and calculate the position of the sun. - * @private - */ - private now () { - if (this.fixedNow == null) { - // normal operation - return new Date() - } else { - // for development: pretend the current moment is the fixed value - return this.fixedNow + const moonPhase = Constants.MOON_PHASES[moonRawData.illumination.phase.id] + + const clampedLat = HelperFunctions.clamp(-66, 66, lat) + const phaseRotation = this.config.moon_phase_rotation ?? 90 * clampedLat/66 - 90 + + const saturationReduce = HelperFunctions.rangeScale(-33, 10, elevation, 60) + const lightnessReduce = HelperFunctions.rangeScale(-10, 0, elevation, 15) + + return { + azimuth, + elevation, + fraction: moonRawData.illumination.fraction, + phase: moonPhase, + phaseRotation, + zenithAngle: -moonRawData.zenithAngle * 180/Math.PI, + parallacticAngle: moonRawData.parallacticAngleDegrees, + times: { + now, + moonrise: isNaN(moonRawTimes.rise) ? undefined : moonRawTimes.rise, + moonset: isNaN(moonRawTimes.set) ? undefined : moonRawTimes.set + }, + saturationReduce, + lightnessReduce } } - /** - * Calculates a usable sunrise value even if the true sunrise doesn't occur (sun is above/below the horizon) - * on a given day. - * @param dayStartMs day start time as ms since epoch - * @param elevation sun elevation - * @param noon normalized noon time - * @param sunrise normalized sunrise time - * @param sunset normalized sunset time - * @private - */ - private calculateUsableSunrise (dayStartMs: number, elevation: number, noon: Date, sunrise: Date | undefined, - sunset: Date | undefined) { - if (sunrise === undefined) { - // No sunrise - if (elevation < Constants.BELOW_HORIZON_ELEVATION) { - // Sun is below horizon, fake sunrise 1 ms before noon - return noon.getTime() - 1 - } else { - // Sun is above horizon, fake sunrise at 00:00:00 - return dayStartMs - } - } else if (sunset !== undefined && sunrise > sunset) { - // Quirk - happens when the sun rises shortly after it sets on the same day before midnight, - // fake sunrise at 00:00:00 - return dayStartMs + private computeMoonPosition (moonData: TMoonData): TMoonPosition { + // East to West goes left to right (or right to left, if southern-flipped!), like the Sun. + // The canvas is 550 units wide, minus 5 units (padding) + // and minus Constants.MOON_RADIUS on either side to keep the moon inside. + // Left is 0 degrees, 180 degrees is in the middle. + const availableSpanX = 550 - 2 * (Constants.MOON_RADIUS + 5) + const calcAzimuth = this.southernFlip() ? (moonData.azimuth + 180) % 360 : moonData.azimuth + const x = 5 + Constants.MOON_RADIUS + availableSpanX * calcAzimuth/360 + + const yLimit = Constants.HORIZON_Y - Constants.MOON_RADIUS + const calcElevation = Math.abs(moonData.elevation) / 2 + 1 + const maxLog = 90 / 2 + 1 + + // The Moon's elevation scaled logarithmically to appear higher/lower from the drawn horizon + const offset = yLimit * Math.log(calcElevation) / Math.log(maxLog) * Math.sign(moonData.elevation) + const y = Constants.HORIZON_Y - offset + + return { + x, + y, } + } - return sunrise.getTime() + private latitude (): number { + return this.config.latitude ?? this.lastHass.config.latitude } - /** - * Calculates a usable sunset value even if the true sunset doesn't occur (sun is above/below the horizon) - * on a given day. - * @param dayEndMs day end time as ms since epoch - * @param elevation sun elevation - * @param noon normalized noon time - * @param sunset normalized sunset time - * @private - */ - private calculateUsableSunset (dayEndMs: number, elevation: number, noon: Date, sunset: Date | undefined) { - if (sunset === undefined) { - if (elevation < Constants.BELOW_HORIZON_ELEVATION) { - // Sun is below horizon, fake sunset 1 ms after noon - return noon.getTime() + 1 - } else { - // Sun is above horizon, fake sunset at 23:59:59 - return dayEndMs - } - } + private longitude (): number { + return this.config.longitude ?? this.lastHass.config.longitude + } - return sunset.getTime() + private elevation (): number { + return this.config.elevation ?? this.lastHass.config.elevation } - private calculateSunInfo (elevation: number, now: Date, times: THorizonCardTimes): TSunInfo { - const sunLine = this.shadowRoot?.querySelector('path') as SVGPathElement + private southernFlip (): boolean { + return this.config.southern_flip ?? this.latitude() < 0 + } - // find the instances of time for today - const nowMs = now.getTime() - const dayStartMs = HelperFunctions.startOfDay(now).getTime() - const dayEndMs = HelperFunctions.endOfDay(now).getTime() + private timeZone (): string { + return this.config.time_zone ?? this.lastHass.config.time_zone + } - // Here it gets fuzzy for higher latitudes - the sun may not rise or set within 24h - const sunriseMs = this.calculateUsableSunrise(dayStartMs, elevation, times.noon, times.sunrise, times.sunset) - const sunsetMs = this.calculateUsableSunset(dayEndMs, elevation, times.noon, times.sunset) + private now (): Date { + return this.config.now !== undefined ? new Date(this.config.now) : new Date() + } - // calculate relevant moments in time - const msSinceStartOfDay = Math.max(nowMs - dayStartMs, 0) - const msSinceSunrise = Math.max(nowMs - sunriseMs, 0) - const msSinceSunset = Math.max(nowMs - sunsetMs, 0) + private refreshPeriod (): number { + return this.config.refresh_period ?? Constants.DEFAULT_REFRESH_PERIOD + } - const msOfDaylight = sunsetMs - sunriseMs - // We need at least 1ms to avoid division by zero - const msUntilSunrise = Math.max(sunriseMs - dayStartMs, 1) - const msUntilEndOfDay = Math.max(dayEndMs - sunsetMs, 1) + private debugLevel (): number { + return this.config?.debug_level ?? 0 + } - // find section positions - const dawnSectionPosition = HelperFunctions.findSectionPosition(msSinceStartOfDay, msUntilSunrise, Constants.SUN_SECTIONS.dawn) - const daySectionPosition = HelperFunctions.findSectionPosition(msSinceSunrise, msOfDaylight, Constants.SUN_SECTIONS.day) - const duskSectionPosition = HelperFunctions.findSectionPosition(msSinceSunset, msUntilEndOfDay, Constants.SUN_SECTIONS.dusk) + private expandedFieldConfig (): THorizonCardFields { + const fieldConfig = { + ...Constants.DEFAULT_CONFIG.fields, + ...this.config.fields + } + + // Elevation and azimuth have a shared property and a per sun/moon dedicated property too + fieldConfig.sun_elevation = fieldConfig.sun_elevation ?? fieldConfig.elevation + fieldConfig.moon_elevation = fieldConfig.moon_elevation ?? fieldConfig.elevation + fieldConfig.sun_azimuth = fieldConfig.sun_azimuth ?? fieldConfig.azimuth + fieldConfig.moon_azimuth = fieldConfig.moon_azimuth ?? fieldConfig.azimuth + + return fieldConfig + } - // find the sun position - const position = dawnSectionPosition + daySectionPosition + duskSectionPosition - const sunPosition = sunLine.getPointAtLength(position) + private expandedConfig (): IHorizonCardConfig { + const config = { + ...Constants.DEFAULT_CONFIG, + ...this.config, + fields: this.expandedFieldConfig() + } - // calculate section progress, in percentage - const dawnProgressPercent = HelperFunctions.findSunProgress( - sunPosition.x, Constants.EVENT_X_POSITIONS.dayStart, Constants.EVENT_X_POSITIONS.sunrise - ) + // Default values for these come from Home Assistant + config.language = this.config.language ?? this.lastHass.locale.language + config.time_format = this.config.time_format ?? this.lastHass.locale.time_format + config.number_format = this.config.number_format ?? this.lastHass.locale.number_format + config.dark_mode = this.config.dark_mode ?? (this.lastHass.themes as unknown as { darkMode: boolean })?.darkMode + config.latitude = this.latitude() + config.longitude = this.longitude() + config.elevation = this.elevation() + config.southern_flip = this.southernFlip() // default is via latitude + config.time_zone = this.timeZone() + + // The default value is the current time + config.now = this.now() + + return config + } - const dayProgressPercent = HelperFunctions.findSunProgress( - sunPosition.x, Constants.EVENT_X_POSITIONS.sunrise, Constants.EVENT_X_POSITIONS.sunset - ) + private i18n (config: IHorizonCardConfig) { + let display_time_zone - const duskProgressPercent = HelperFunctions.findSunProgress( - sunPosition.x, Constants.EVENT_X_POSITIONS.sunset, Constants.EVENT_X_POSITIONS.dayEnd - ) + // Since 2023.7, HA can show times in the local (for the browser) TZ or the server TZ. + if (this.lastHass.locale['time_zone'] === 'local') { + display_time_zone = Intl.DateTimeFormat().resolvedOptions().timeZone + } else { + // 'server' or missing value (older HA version) + display_time_zone = config.time_zone + } - // calculate sun position in regards to the horizon - const sunCenterY = sunPosition.y - Constants.SUN_RADIUS - const sunCenterYAboveHorizon = Constants.HORIZON_Y - sunCenterY - const sunAboveHorizon = sunCenterYAboveHorizon > 0 + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return new I18N(config.language!, display_time_zone, config.time_format!, config.number_format!, this.lastHass.localize) + } - let sunPercentOverHorizon = (100 * sunCenterYAboveHorizon) / (2 * Constants.SUN_RADIUS) - sunPercentOverHorizon = HelperFunctions.clamp(0, 100, sunPercentOverHorizon) + private roundDegree (value: number) { + return round(value, 1) + } - return { - sunrise: sunriseMs, - sunset: sunsetMs, - dawnProgressPercent, - dayProgressPercent, - duskProgressPercent, - sunAboveHorizon, - sunPercentOverHorizon, - sunPosition: { x: sunPosition.x, y: sunPosition.y } + private debug (message, level = 1) { + if (this.debugLevel() >= level) { + if (typeof message === 'function') { + message = message() + } + // eslint-disable-next-line no-console + console.debug(`custom:${HorizonCard.cardType} :: ${message}`) } } } diff --git a/src/components/horizonCard/HorizonCardContent.ts b/src/components/horizonCard/HorizonCardContent.ts index 45917a9..38c3999 100644 --- a/src/components/horizonCard/HorizonCardContent.ts +++ b/src/components/horizonCard/HorizonCardContent.ts @@ -1,7 +1,7 @@ -import { html,TemplateResult } from 'lit' +import { html, TemplateResult } from 'lit' import { IHorizonCardConfig,THorizonCardData } from '../../types' -import { HelperFunctions } from '../../utils/HelperFunctions' +import { I18N } from '../../utils/I18N' import { HorizonCardFooter } from './HorizonCardFooter' import { HorizonCardGraph } from './HorizonCardGraph' import { HorizonCardHeader } from './HorizonCardHeader' @@ -9,43 +9,35 @@ import { HorizonCardHeader } from './HorizonCardHeader' export class HorizonCardContent { private config: IHorizonCardConfig private data: THorizonCardData + private i18n: I18N - constructor (config: IHorizonCardConfig, data: THorizonCardData) { + constructor (config: IHorizonCardConfig, data: THorizonCardData, i18n: I18N) { this.config = config this.data = data + this.i18n = i18n } render (): TemplateResult { return html` -
- ${ this.showHeader() ? this.renderHeader() : HelperFunctions.nothing() } +
+ ${ this.renderHeader() } ${ this.renderGraph() } - ${ this.showFooter() ? this.renderFooter() : HelperFunctions.nothing() } + ${ this.renderFooter() }
` } private renderHeader (): TemplateResult { - return new HorizonCardHeader(this.config, this.data).render() + return new HorizonCardHeader(this.config, this.data, this.i18n).render() } private renderGraph (): TemplateResult { - return new HorizonCardGraph(this.data).render() + return new HorizonCardGraph(this.config, this.data).render() } private renderFooter (): TemplateResult { - return new HorizonCardFooter(this.config, this.data).render() - } - - private showHeader (): boolean { - // logic based on config - return true - } - - private showFooter (): boolean { - // logic based on config - return true + return new HorizonCardFooter(this.config, this.data, this.i18n).render() } } diff --git a/src/components/horizonCard/HorizonCardFooter.ts b/src/components/horizonCard/HorizonCardFooter.ts index b369fc6..79f130a 100644 --- a/src/components/horizonCard/HorizonCardFooter.ts +++ b/src/components/horizonCard/HorizonCardFooter.ts @@ -1,57 +1,115 @@ -import { html, TemplateResult } from 'lit' +import { html, nothing, TemplateResult } from 'lit' -import { EHorizonCardI18NKeys, IHorizonCardConfig, THorizonCardData, THorizonCardFields, THorizonCardTimes } from '../../types' +import { + EHorizonCardI18NKeys, + IHorizonCardConfig, + THorizonCardData, + THorizonCardFields, + TMoonTimes, + TSunTimes +} from '../../types' import { HelperFunctions } from '../../utils/HelperFunctions' import { I18N } from '../../utils/I18N' export class HorizonCardFooter { - private data: THorizonCardData - private i18n: I18N - private times: THorizonCardTimes - private fields: THorizonCardFields + private readonly data: THorizonCardData + private readonly i18n: I18N + private readonly sunTimes: TSunTimes + private readonly moonTimes: TMoonTimes + private readonly fields: THorizonCardFields + private readonly azimuths + private readonly azimuthExtraClasses: string[] + private readonly elevations + private readonly elevationExtraClasses: string[] - constructor (config: IHorizonCardConfig, data: THorizonCardData) { + constructor (config: IHorizonCardConfig, data: THorizonCardData, i18n: I18N) { this.data = data - this.i18n = config.i18n! - this.times = data?.times + this.i18n = i18n + this.sunTimes = data.sunData.times + this.moonTimes = data.moonData.times + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.fields = config.fields! + + this.azimuths = [] + if (this.fields.sun_azimuth) { + this.azimuths.push(this.data.sunData.azimuth) + } + if (this.fields.moon_azimuth) { + this.azimuths.push(this.data.moonData.azimuth) + } + if (this.fields.sun_azimuth && this.fields.moon_azimuth) { + this.azimuthExtraClasses = ['horizon-card-sun-value', 'horizon-card-moon-value'] + } else { + this.azimuthExtraClasses = [] + } + + this.elevations = [] + if (this.fields.sun_elevation) { + this.elevations.push(this.data.sunData.elevation) + } + if (this.fields.moon_elevation) { + this.elevations.push(this.data.moonData.elevation) + } + if (this.fields.sun_elevation && this.fields.moon_elevation) { + this.elevationExtraClasses = ['horizon-card-sun-value', 'horizon-card-moon-value'] + } else { + this.elevationExtraClasses = [] + } } public render (): TemplateResult { return html` ` } + + private renderRow (...args: (typeof nothing | TemplateResult)[]) { + const nonEmpty = args.filter((tr) => tr !== nothing) + return nonEmpty.length > 0 + ? html` +
+ ${nonEmpty} +
` + : nothing + } } diff --git a/src/components/horizonCard/HorizonCardGraph.ts b/src/components/horizonCard/HorizonCardGraph.ts index cf841c1..2affdc8 100644 --- a/src/components/horizonCard/HorizonCardGraph.ts +++ b/src/components/horizonCard/HorizonCardGraph.ts @@ -1,105 +1,215 @@ -import { html, TemplateResult } from 'lit' +import { html, nothing, svg, TemplateResult } from 'lit' import { Constants } from '../../constants' -import { THorizonCardData, TSunInfo } from '../../types' +import { IHorizonCardConfig, THorizonCardData, TMoonData, TMoonPosition, TSunData, TSunPosition } from '../../types' export class HorizonCardGraph { - private sunInfo: TSunInfo - - constructor (data: THorizonCardData) { - this.sunInfo = data?.sunInfo ?? Constants.DEFAULT_SUN_INFO + private readonly config: IHorizonCardConfig + private readonly sunData: TSunData + private readonly sunPosition: TSunPosition + private readonly moonData: TMoonData + private readonly moonPosition: TMoonPosition + private readonly southernFlip: boolean + private readonly debugLevel: number + + constructor (config: IHorizonCardConfig, data: THorizonCardData) { + this.config = config + this.sunData = data.sunData + this.sunPosition = data.sunPosition + this.moonData = data.moonData + this.moonPosition = data.moonPosition + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.southernFlip = this.config.southern_flip! + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.debugLevel = this.config.debug_level! } public render (): TemplateResult { - const sunID = 'sun-gradient' - const dawnID = 'dawn-gradient' - const dayID = 'day-gradient' - const duskID = 'dusk-gradient' - - const viewBox = '0 0 550 150' - // TODO: Check sun opacity - return html`
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + ${this.renderSvg()}
` } + + private renderSvg () { + const curve = this.sunCurve(this.sunPosition.scaleY) + + return svg` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ${this.debugRect()} + + + + + + + + + + + + + + + + + + + + + + + + + + + + ${this.debugSun()} + + + ${this.moon()} + + ${this.debugHorizon()} + + ${this.debugCurve()} + ` + } + + private sunCurve (scale): string { + // M5,146 C103.334,146 176.666,20 275,20 S446.666,146 545,146 + const sy = (y) => y * scale + + return `M 5,${sy(146)} + C 103.334,${sy(146)} 176.666,${sy(20)} 275,${sy(20)} + S 446.666,${sy(146)} 545,${sy(146)}` + } + + private moon () { + const smallSpotR = Constants.MOON_RADIUS / 5 + const bigSpotR = Constants.MOON_RADIUS / 4 + const hugeSpotR = Constants.MOON_RADIUS / 3 + const spotFill = 'var(--hc-moon-spot-color)' + return this.config.moon ? + svg` + + + + + + + + + + + + + + ` : nothing + } + + private debugCurve () { + return this.debugLevel >= 1 ? + svg`` : nothing + } + + private debugRect () { + return this.debugLevel >= 1 ? + svg`` : nothing + } + + private debugHorizon () { + return this.debugLevel >= 1 ? + svg`` : nothing + } + + private debugSun () { + return this.debugLevel >= 1 ? + svg` + + + ` : nothing + } } diff --git a/src/components/horizonCard/HorizonCardHeader.ts b/src/components/horizonCard/HorizonCardHeader.ts index b4c4959..69d8e84 100644 --- a/src/components/horizonCard/HorizonCardHeader.ts +++ b/src/components/horizonCard/HorizonCardHeader.ts @@ -1,26 +1,26 @@ -import { html, TemplateResult } from 'lit' +import { html, nothing, TemplateResult } from 'lit' -import { EHorizonCardI18NKeys, IHorizonCardConfig, THorizonCardData, THorizonCardFields, THorizonCardTimes } from '../../types' +import { EHorizonCardI18NKeys, IHorizonCardConfig, THorizonCardData, THorizonCardFields, TSunTimes } from '../../types' import { HelperFunctions } from '../../utils/HelperFunctions' import { I18N } from '../../utils/I18N' export class HorizonCardHeader { - private title?: string - private times: THorizonCardTimes - private fields: THorizonCardFields - private i18n: I18N + private readonly title?: string + private readonly times: TSunTimes + private readonly fields: THorizonCardFields + private readonly i18n: I18N - constructor (config: IHorizonCardConfig, data: THorizonCardData) { + constructor (config: IHorizonCardConfig, data: THorizonCardData, i18n: I18N) { this.title = config.title + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.fields = config.fields! - this.times = data?.times - - this.i18n = config.i18n! + this.times = data.sunData.times + this.i18n = i18n } public render (): TemplateResult { return html` - ${ this.showTitle() ? this.renderTitle() : HelperFunctions.nothing() } + ${ this.showTitle() ? this.renderTitle() : nothing } ${ this.renderHeader() } ` } @@ -33,15 +33,15 @@ export class HorizonCardHeader { return html`
${ - this.fields?.sunrise - ? HelperFunctions.renderFieldElement(this.i18n, EHorizonCardI18NKeys.Sunrise, this.times?.sunrise) - : HelperFunctions.nothing() -} + this.fields.sunrise + ? HelperFunctions.renderFieldElement(this.i18n, EHorizonCardI18NKeys.Sunrise, this.times.sunrise) + : nothing + } ${ - this.fields?.sunset - ? HelperFunctions.renderFieldElement(this.i18n, EHorizonCardI18NKeys.Sunset, this.times?.sunset) - : HelperFunctions.nothing() -} + this.fields.sunset + ? HelperFunctions.renderFieldElement(this.i18n, EHorizonCardI18NKeys.Sunset, this.times.sunset) + : nothing + }
` } diff --git a/src/components/horizonCardEditor/HorizonCardEditor.ts b/src/components/horizonCardEditor/HorizonCardEditor.ts deleted file mode 100644 index 8e96546..0000000 --- a/src/components/horizonCardEditor/HorizonCardEditor.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { HomeAssistant, LovelaceCardEditor } from 'custom-card-helpers' -import { CSSResult, LitElement, TemplateResult } from 'lit' -import { customElement, property } from 'lit-element' - -import cardStyles from '../../cardStyles' -import { EHorizonCardI18NKeys, IHorizonCardConfig } from '../../types' -import { HorizonCardEditorContent, THorizonCardEditorContentEvents } from './HorizonCardEditorContent' - -@customElement('horizon-card-editor') -export class HorizonCardEditor extends LitElement implements LovelaceCardEditor { - static readonly cardType = 'horizon-card-editor' - private static readonly CONFIG_CHANGED_EVENT = 'config-changed' - - @property({ type: Object }) hass!: HomeAssistant - @property() private config!: IHorizonCardConfig - - static get styles (): CSSResult { - return cardStyles - } - - public setConfig (config: IHorizonCardConfig): void { - this.config = config - } - - public configChanged (event: THorizonCardEditorContentEvents['configChanged']): void { - const property = event.target?.configValue - const value = event.detail?.value ?? event.target?.selected ?? event.target?.checked - - const newConfig = { ...this.config, [property]: value } - - // Handles default or empty values by deleting the config property - if (value === 'default' || value === undefined || value === '') { - delete newConfig[property] - } - - // Handles boolean values - if (value === 'true' || value === 'false') { - newConfig[property] = value === 'true' - } - - // Handles fields config - if (Object.values(EHorizonCardI18NKeys).includes(property as EHorizonCardI18NKeys)) { - delete newConfig[property] - newConfig.fields = { - ...newConfig.fields, - [property]: value - } - } - - const customEvent = new CustomEvent(HorizonCardEditor.CONFIG_CHANGED_EVENT, { - bubbles: true, - composed: true, - detail: { config: newConfig } - }) - - this.dispatchEvent(customEvent) - } - - protected render (): TemplateResult { - const content = new HorizonCardEditorContent(this.config!) - content.on('configChanged', (event) => this.configChanged(event)) - return content.render() - } -} diff --git a/src/components/horizonCardEditor/HorizonCardEditorContent.ts b/src/components/horizonCardEditor/HorizonCardEditorContent.ts deleted file mode 100644 index 3114f48..0000000 --- a/src/components/horizonCardEditor/HorizonCardEditorContent.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { html,TemplateResult } from 'lit' - -import { Constants } from '../../constants' -import { EHorizonCardI18NKeys,IConfigChangedEvent, IHorizonCardConfig } from '../../types' -import { EventUtils } from '../../utils/EventUtils' - -export type THorizonCardEditorContentEvents = { - configChanged: IConfigChangedEvent<{ value: unknown }> -} - -export class HorizonCardEditorContent extends EventUtils { - config: IHorizonCardConfig - - constructor (config: IHorizonCardConfig) { - super() - this.config = config - } - - public render (): TemplateResult { - return html` -
-
- ${this.renderTitleEditor()} -
-
- ${this.renderLanguageEditor()} -
-
- ${this.renderDarkModeEditor()} -
-
- ${this.render12HourClockEditor()} -
-
- ${this.renderFieldsEditor()} -
-
- ` - } - - private onConfigChanged (event: THorizonCardEditorContentEvents['configChanged']) { - this.emit('configChanged', event) - } - - private renderTitleEditor (): TemplateResult { - return html` - this.onConfigChanged(event)} - > - - ` - } - - private renderLanguageEditor (): TemplateResult { - // TODO: Add language full name - const selectedLanguage = Object.keys(Constants.LOCALIZATION_LANGUAGES).indexOf(this.config?.language ?? '') + 1 - - return html` - this.onConfigChanged(event)} - > - - Default - ${Object.keys(Constants.LOCALIZATION_LANGUAGES).map((language) => html` - ${language} - `)} - - - ` - } - - private renderDarkModeEditor (): TemplateResult { - const selectedDarkMode = this.config?.darkMode ?? 'default' - return html` - - this.onConfigChanged(event)} - > - Default - Dark - Light - - ` - } - - private render12HourClockEditor (): TemplateResult { - const selectedClockMode = this.config?.use12hourClock ?? 'default' - - return html` - - this.onConfigChanged(event)} - > - Default - 12 hours - 24 hours - - ` - } - - private renderFieldsEditor (): TemplateResult { - return html` - -
    - ${Object.entries(EHorizonCardI18NKeys).map(([name, configValue]) => { - return html` -
  • this.onConfigChanged(event)}> ${name}
  • - ` - })} -
- ` - } -} diff --git a/src/components/horizonCardEditor/index.ts b/src/components/horizonCardEditor/index.ts deleted file mode 100644 index b2eb642..0000000 --- a/src/components/horizonCardEditor/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './HorizonCardEditor' -export * from './HorizonCardEditorContent' diff --git a/src/constants.ts b/src/constants.ts index 13f1f2a..c24e511 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -31,15 +31,43 @@ import tr from './assets/localization/languages/tr.json' import uk from './assets/localization/languages/uk.json' import zh_Hans from './assets/localization/languages/zh-Hans.json' import zh_Hant from './assets/localization/languages/zh-Hant.json' -import { IHorizonCardConfig, THorizonCardI18NKeys, THorizonCardTimes, TSunInfo } from './types' +import { + IHorizonCardConfig, + SunCalcMoonPhase, + THorizonCardData, + THorizonCardI18NKeys, + TMoonPhase +} from './types' export class Constants { + static readonly FALLBACK_LOCALIZATION = en + + static readonly DEFAULT_REFRESH_PERIOD = 20 * 1000 + + // 24 hours in milliseconds + static readonly MS_24_HOURS = 24 * 60 * 60 * 1000 + + // 12 hours in milliseconds + static readonly MS_12_HOURS = 12 * 60 * 60 * 1000 + + // Mapping of SunCalc moon phases to Home Assistant moon phase state and icon + static readonly MOON_PHASES: Record = { + newMoon: {state: 'new_moon', icon: 'moon-new'}, + waxingCrescentMoon: {state: 'waxing_crescent', icon: 'moon-waxing-crescent'}, + firstQuarterMoon: {state: 'first_quarter', icon: 'moon-first-quarter'}, + waxingGibbousMoon: {state: 'waxing_gibbous', icon: 'moon-waxing-gibbous'}, + fullMoon: {state: 'full_moon', icon: 'moon-full'}, + waningGibbousMoon: {state: 'waning_gibbous', icon: 'moon-waning-gibbous'}, + thirdQuarterMoon: {state: 'last_quarter', icon: 'moon-last-quarter'}, + waningCrescentMoon: {state: 'waning_crescent', icon: 'moon-waning-crescent'} + } + + // Default config values, they will be used if the user hasn't provided a value in the card config static readonly DEFAULT_CONFIG: IHorizonCardConfig = { type: 'horizon-card', - darkMode: true, - language: 'en', - use12hourClock: false, - component: 'sun.sun', + moon: true, + debug_level: 0, + refresh_period: Constants.DEFAULT_REFRESH_PERIOD, fields: { sunrise: true, sunset: true, @@ -47,53 +75,74 @@ export class Constants { noon: true, dusk: true, azimuth: false, - elevation: false + elevation: false, + moonrise: false, + moonset: false, + moon_phase: false } + // These keys must not be in the default config as they are provided by Home Assistant: + // language, dark_mode, latitude, longitude, elevation, time_zone. + // The default for 'now' is the current time and must not be specified here either. } - static readonly EVENT_X_POSITIONS = { - dayStart: 5, - sunrise: 101, - sunset: 449, - dayEnd: 545 - } - - static readonly HORIZON_Y = 108 - static readonly SUN_RADIUS = 17 - static readonly SUN_SECTIONS = { - dawn: 105, - day: 499 - 106, - dusk: 605 - 500 - } - - static readonly DEFAULT_SUN_INFO: TSunInfo = { - dawnProgressPercent: 0, - dayProgressPercent: 0, - duskProgressPercent: 0, - sunAboveHorizon: false, - sunPercentOverHorizon: 0, + static readonly DEFAULT_CARD_DATA: THorizonCardData = { + partial: false, + latitude: 0, + longitude: 0, + sunData: { + azimuth: 0, + elevation: 0, + times: { + now: new Date(), + dawn: new Date(), + dusk: new Date(), + midnight: new Date(), + noon: new Date(), + sunrise: new Date(), + sunset: new Date() + }, + hueReduce: 0, + saturationReduce: 0, + lightnessReduce: 0 + }, sunPosition: { x: 0, - y: 0 + y: 0, + scaleY: 1, + offsetY: 0, + horizonY: 0, + sunriseX: 0, + sunsetX: 0, + }, + moonData: { + azimuth: 0, + elevation: 0, + fraction: 0, + phase: Constants.MOON_PHASES.fullMoon, + phaseRotation: 0, + zenithAngle: 0, + parallacticAngle: 0, + times: { + now: new Date(), + moonrise: new Date(), + moonset: new Date() + }, + saturationReduce: 0, + lightnessReduce: 0 }, - sunrise: 0, - sunset: 0 + moonPosition: { + x: 0, + y: 0 + } } - static readonly DEFAULT_TIMES: THorizonCardTimes = { - dawn: new Date(), - dusk: new Date(), - noon: new Date(), - sunrise: new Date(), - sunset: new Date() - } + static readonly HORIZON_Y = 84 + + static readonly SUN_RADIUS = 17 + + static readonly MOON_RADIUS = 14 static readonly LOCALIZATION_LANGUAGES: Record = { bg, ca, cs, da, de, en, es, et, fi, fr, he, hr, hu, is, it, ja, ko, lt, ms, nb, nl, nn, pl, 'pt-BR': ptBR, ro, ru, sk, sl, sv, tr, uk, 'zh-Hans': zh_Hans, 'zh-Hant': zh_Hant } - - static readonly FALLBACK_LOCALIZATION = en - - // Magic number - used by Home Assistant and the library (astral) it uses to calculate the sun events - static readonly BELOW_HORIZON_ELEVATION = 0.83 } diff --git a/src/index.ts b/src/index.ts index db32aa9..8eb7d50 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1 @@ import './components/horizonCard' -import './components/horizonCardEditor' - diff --git a/src/types/index.ts b/src/types/index.ts index a92e8b8..3c3f9c7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,6 +1,4 @@ -import { LovelaceCardConfig } from 'custom-card-helpers' - -import { I18N } from '../utils/I18N' +import { LovelaceCardConfig, NumberFormat, TimeFormat } from 'custom-card-helpers' export type THorizonCardFields = { sunrise?: boolean @@ -11,66 +9,118 @@ export type THorizonCardFields = { dusk?: boolean azimuth?: boolean + sun_azimuth?: boolean + moon_azimuth?: boolean elevation?: boolean + sun_elevation?: boolean + moon_elevation?: boolean + + moonrise?: boolean, + moonset?: boolean, + moon_phase?: boolean } export interface IHorizonCardConfig extends LovelaceCardConfig { - i18n?: I18N - darkMode?: boolean + dark_mode?: boolean language?: string title?: string, - component?: string - use12hourClock?: boolean + moon?: boolean, + time_format?: TimeFormat + number_format?: NumberFormat + + latitude?: number + longitude?: number + elevation?: number + southern_flip?: boolean + moon_phase_rotation?: number + now?: Date + time_zone?: string + refresh_period?: number + debug_level?: number fields?: THorizonCardFields } -export interface IConfigChangedEvent extends CustomEvent { - target: CustomEvent['target'] & { - configValue: string - selected?: string - checked?: boolean - } +export type TSunTimes = { + now: Date + dawn?: Date + dusk?: Date + midnight: Date + noon: Date + sunrise?: Date + sunset?: Date } -export type TSunInfo = { - sunrise: number, - sunset: number - - dawnProgressPercent: number - dayProgressPercent: number - duskProgressPercent: number +export type TSunData = { + readonly azimuth: number + readonly elevation: number + readonly times: TSunTimes + readonly hueReduce: number + readonly saturationReduce: number + readonly lightnessReduce: number +} - sunAboveHorizon: boolean - sunPercentOverHorizon: number - sunPosition: { - x: number - y: number - } +export type TSunPosition = { + readonly x: number + readonly y: number + readonly horizonY: number + readonly sunriseX: number + readonly sunsetX: number + readonly scaleY: number + readonly offsetY: number } -export enum EHorizonCardErrors { - SunIntegrationNotFound = 'SunIntegrationNotFound' +export type TMoonTimes = { + readonly now: Date, + readonly moonrise: Date, + readonly moonset: Date } -export type THorizonCardTimes = { - dawn?: Date - dusk?: Date - noon: Date - sunrise?: Date - sunset?: Date +export type TMoonPhase = { + state: string + icon: string } -export type THorizonCardData = { - azimuth: number - elevation: number +export type SunCalcMoonPhase = + 'newMoon' + | 'waxingCrescentMoon' + | 'firstQuarterMoon' + | 'waxingGibbousMoon' + | 'fullMoon' + | 'waningGibbousMoon' + | 'thirdQuarterMoon' + | 'waningCrescentMoon' + +export type TMoonData = { + readonly azimuth: number + readonly elevation: number + readonly fraction: number + readonly phase: TMoonPhase + readonly phaseRotation: number, + readonly zenithAngle: number + readonly parallacticAngle: number + readonly times: TMoonTimes, + readonly saturationReduce: number, + readonly lightnessReduce: number +} - sunInfo: TSunInfo +export type TMoonPosition = { + readonly x: number + readonly y: number +} - times: THorizonCardTimes +export enum EHorizonCardErrors { +} - error?: EHorizonCardErrors +export type THorizonCardData = { + readonly partial: boolean + readonly latitude: number + readonly longitude: number + readonly sunPosition: TSunPosition + readonly sunData: TSunData + readonly moonPosition: TMoonPosition + readonly moonData: TMoonData } export enum EHorizonCardI18NKeys { @@ -80,13 +130,13 @@ export enum EHorizonCardI18NKeys { Elevation = 'elevation', Noon = 'noon', Sunrise = 'sunrise', - Sunset = 'sunset' + Sunset = 'sunset', + Moonrise = 'moonrise', + Moonset = 'moonset' } -export type THorizonCardI18N = Record - export type THorizonCardI18NErrorKeys = { [key in EHorizonCardErrors]: string } -export type THorizonCardI18NKeys = { [key in EHorizonCardI18NKeys ]: string } | { errors: THorizonCardI18NErrorKeys } +export type THorizonCardI18NKeys = { [key in EHorizonCardI18NKeys ]?: string } | { errors: THorizonCardI18NErrorKeys } diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts deleted file mode 100644 index 313faf9..0000000 --- a/src/utils/EventUtils.ts +++ /dev/null @@ -1,23 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type TEventList = Record -type TEventName = Extract - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type TEventListener = (event: T) => void - -export class EventUtils { - eventMap: Map, TEventListener[]> = new Map() - - on = TEventName> (eventName: U, listener: TEventListener): void { - const eventListeners = this.eventMap.get(eventName) || [] - eventListeners.push(listener) - this.eventMap.set(eventName, eventListeners) - } - - emit = TEventName> (eventName: U, data: T[U]): void { - const eventListeners = this.eventMap.get(eventName) || [] - eventListeners.forEach((eventListener) => { - eventListener(data) - }) - } -} diff --git a/src/utils/HelperFunctions.ts b/src/utils/HelperFunctions.ts index 6be4b42..8e4c9ce 100644 --- a/src/utils/HelperFunctions.ts +++ b/src/utils/HelperFunctions.ts @@ -1,66 +1,81 @@ -import { html, TemplateResult } from 'lit' +import { html, nothing, TemplateResult } from 'lit' import { Constants } from '../constants' +import { EHorizonCardI18NKeys, TMoonPhase } from '../types' import { I18N } from './I18N' +type FieldValue = Date | number | string | undefined + export class HelperFunctions { - public static nothing (): TemplateResult { - return html`` - } + public static renderFieldElements (i18n: I18N, translationKey: string, values: FieldValue[], + extraClasses: string[] = []): TemplateResult { + const mappedValues = values + .map((value, index) => this.valueToHtml(i18n, translationKey, value, extraClasses[index])) - public static renderFieldElement (i18n: I18N, translationKey: string, value: Date | number | string | undefined): TemplateResult { - let display: string - if (value === undefined) { - display = '-' - } else if (value instanceof Date) { - display = i18n.formatDateAsTime(value) - } else { - display = value.toString() - if (translationKey === 'azimuth' || translationKey === 'elevation') { - display += '°' - } - } + return this.renderFieldElement(i18n, translationKey, mappedValues) + } + public static renderFieldElement (i18n: I18N, translationKey: string, value: FieldValue | TemplateResult[]): TemplateResult { return html`
- ${ i18n.tr(translationKey) } - ${ display } +
${ i18n.tr(translationKey) }
+ ${value instanceof Array ? value : this.valueToHtml(i18n, translationKey, value) }
` } - public static isValidLanguage (language: string): boolean { - return Object.keys(Constants.LOCALIZATION_LANGUAGES).includes(language) - } + public static renderMoonElement (i18n: I18N, phase: TMoonPhase, phaseRotation: number) { + if (phase === undefined) { + return nothing + } - public static startOfDay (now: Date): Date { - const today = new Date(now) - today.setHours(0) - today.setMinutes(0) - today.setSeconds(0) - today.setMilliseconds(0) + let moon_phase_localized: unknown = i18n.localize(`component.sensor.state.moon__phase.${phase.state}`) + if (!moon_phase_localized) { + moon_phase_localized = html`${phase.state} (!)` + } - return today + return html` +
+
+ +
+
${moon_phase_localized}
+
+ ` } - public static endOfDay (now: Date): Date { - const today = new Date(now) - today.setHours(23) - today.setMinutes(59) - today.setSeconds(59) - today.setMilliseconds(999) - - return today + private static valueToHtml (i18n: I18N, translationKey: string, value: FieldValue, klass='') { + const mappedValue = this.fieldValueToString(i18n, translationKey, value) + return html`
${mappedValue}
` } - public static findSectionPosition (msSinceSectionStart: number, msSectionEnd: number, section: number): number { - return (Math.min(msSinceSectionStart, msSectionEnd) * section) / msSectionEnd + private static fieldValueToString (i18n: I18N, translationKey: string, value: FieldValue) { + let pre = '' + let post = '' + if (value === undefined) { + value = '-' + } else if (value instanceof Date) { + value = i18n.formatDateAsTime(value) + const parts = value.match(/(.*?)(\d{1,2}[:.]\d{2})(.*)/) + if (parts != null) { + pre = parts[1] + value = parts[2] + post = parts[3] + } + } else if (typeof value === 'number') { + value = i18n.formatDecimal(value) + if (translationKey === EHorizonCardI18NKeys.Azimuth || translationKey === EHorizonCardI18NKeys.Elevation) { + value += '°' + } + } + const preHtml = pre ? html`${pre}` : nothing + const postHtml = post ? html`${post}` : nothing + + return html`${preHtml}${value}${postHtml}` } - public static findSunProgress (sunPosition: number, startPosition: number, endPosition: number): number { - return HelperFunctions.clamp(0, 100, - (100 * (sunPosition - startPosition)) / (endPosition - startPosition) - ) + public static isValidLanguage (language: string): boolean { + return Object.keys(Constants.LOCALIZATION_LANGUAGES).includes(language) } public static clamp (min: number, max: number, value: number): number { @@ -74,4 +89,63 @@ export class HelperFunctions { return Math.min(Math.max(value, min), max) } + + public static rangeScale (minRange: number, maxRange: number, range: number, value: number) { + const clamped = HelperFunctions.clamp(minRange, maxRange, range) - minRange + const rangeSize = maxRange - minRange + return (1 - clamped / rangeSize) * value + } + + public static noonAtTimeZone (date: Date, timeZone: string): Date { + let tzDate + try { + tzDate = this.getTimeInTimeZone(date, '12:00:00', timeZone) + } catch (e) { + // eslint-disable-next-line no-console + console.error(e) + tzDate = new Date(date) + tzDate.setHours(12) + tzDate.setMinutes(0) + tzDate.setSeconds(0) + tzDate.setMilliseconds(0) + } + return tzDate + } + + public static midnightAtTimeZone (date: Date, timeZone: string): Date { + let tzDate + try { + tzDate = this.getTimeInTimeZone(date, '00:00:00', timeZone) + } catch (e) { + // eslint-disable-next-line no-console + console.error(e) + tzDate = new Date(date) + tzDate.setHours(0) + tzDate.setMinutes(0) + tzDate.setSeconds(0) + tzDate.setMilliseconds(0) + } + return tzDate + } + + private static getTimeInTimeZone (date: Date, time: string, timeZone: string) { + const formatter = new Intl.DateTimeFormat('fr-CA', + { timeZone: timeZone, timeZoneName: 'longOffset' }) + // 'fr-CA' locale formats like '2023-04-11 UTC+03:00' or '2023-04-11 UTC-10:00' or '2023-04-11 UTC' + const formatted = formatter.format(date) + const parts = formatted + .replace('\u2212', '-') // minuses might be U+2212 instead of plain old ASCII hyphen-minus + .split(' ') + let tz = parts[1].replace('UTC', '') + if (tz === '') { + tz = 'Z' + } + const dateToParse = `${parts[0]}T${time}${tz}` + const result = new Date(dateToParse) + if (isNaN(result.getTime())) { + // Something went fishy with using the above method - generally should not happen + throw new Error(`Could not convert time to time zone: ${formatted} -> ${dateToParse}`) + } + return result + } } diff --git a/src/utils/I18N.ts b/src/utils/I18N.ts index 2d7e8d7..eb2b0f8 100644 --- a/src/utils/I18N.ts +++ b/src/utils/I18N.ts @@ -1,45 +1,42 @@ +import { formatNumber, FrontendLocaleData, LocalizeFunc, NumberFormat, TimeFormat } from 'custom-card-helpers' + import { Constants } from '../constants' -import { THorizonCardI18N, THorizonCardI18NKeys } from '../types' +import { THorizonCardI18NKeys } from '../types' export class I18N { - private localization: THorizonCardI18NKeys - private dateFormatter: Intl.DateTimeFormat + private readonly localization: THorizonCardI18NKeys + private readonly dateFormatter: Intl.DateTimeFormat + private readonly locale: FrontendLocaleData + private readonly localizeFunc: LocalizeFunc - constructor (language: string, use12HourClock: boolean | undefined) { - this.localization = Constants.LOCALIZATION_LANGUAGES[language] + constructor (language: string, timeZone: string, timeFormat: TimeFormat, numberFormat: NumberFormat, + localizeFunc: LocalizeFunc) { + this.localization = I18N.matchLanguageToLocalization(language) - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat - const dateTimeFormatOptions: Intl.DateTimeFormatOptions = { - timeStyle: 'short' - } + this.dateFormatter = I18N.createDateFormatter(language, timeZone, timeFormat) - // if user hasn't defined this specifically in config - // let the formatter figure it out based on language - if (use12HourClock !== undefined) { - dateTimeFormatOptions.hour12 = use12HourClock + this.locale = { + language, + time_format: timeFormat, + number_format: numberFormat } - this.dateFormatter = new Intl.DateTimeFormat(language, dateTimeFormatOptions) + this.localizeFunc = localizeFunc } public formatDateAsTime (date: Date): string { - return this.dateFormatter - .formatToParts(date) - .map(({ type, value }) => { - switch (type) { - // intentional fallthrough - case 'hour': - case 'minute': - case 'dayPeriod': - case 'literal': - return value + let time = this.dateFormatter.format(date) + if (this.locale.language === 'bg') { + // Strips " ч." from times in Bulgarian - some major browsers insist on putting it there: + // https://unicode-org.atlassian.net/browse/CLDR-11545 + // https://unicode-org.atlassian.net/browse/CLDR-15802 + time = time.replace(' ч.', '') + } + return time + } - /* istanbul ignore next */ - default: - return '' - } - }) - .join('') + public formatDecimal (decimal: number): string { + return formatNumber(decimal, this.locale) } /** @@ -48,22 +45,53 @@ export class I18N { * @returns The string specified in the translation files */ public tr (translationKey: string): string { - return this.getLocalizationElement(this.localization, translationKey).toString() + // if the translation isn't completed in the target language, fall back to english + // give ugly string for developers who misstype + return this.localization[translationKey] ?? Constants.FALLBACK_LOCALIZATION[translationKey] + ?? `Translation key '${translationKey}' doesn't have a valid translation` + } + + public localize (key: string): string { + return this.localizeFunc(key) } - // Janky recursive logic to handle nested values in i18n json sources - private getLocalizationElement (localization: THorizonCardI18N, translationKey: string): string | THorizonCardI18N { - if (translationKey.includes('.')) { - const parts = translationKey.split('.', 2) - // TODO: maybe add typecheck - const localization = this.getLocalizationElement(this.localization, parts[0]) as THorizonCardI18N - return this.getLocalizationElement(localization, parts[1]) + private static matchLanguageToLocalization (language: string) { + let data = Constants.LOCALIZATION_LANGUAGES[language] + if (data === undefined) { + // Matches things like en-GB to en, es-419 to es, etc. + data = Constants.LOCALIZATION_LANGUAGES[language.split('-', 2)[0]] + } + if (data === undefined) { + data = Constants.FALLBACK_LOCALIZATION + } + + return data + } + + private static createDateFormatter (language: string, timeZone: string, timeFormat: TimeFormat): Intl.DateTimeFormat { + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat + const dateTimeFormatOptions: Intl.DateTimeFormatOptions = { + hour: 'numeric', + minute: '2-digit', + timeZone: timeZone + } + + // mimics home assistant's logic + if (timeFormat === 'language' || timeFormat === 'system') { + const testLanguage = timeFormat === 'language' ? language : undefined + const test = new Date().toLocaleString(testLanguage) + dateTimeFormatOptions.hour12 = test.includes('AM') || test.includes('PM') } else { - // if the translation isn't completed in the target language, fall back to english - // give ugly string for developers who misstype - return (localization ? localization[translationKey] : undefined) - ?? Constants.FALLBACK_LOCALIZATION[translationKey] - ?? `Translation key '${translationKey}' doesn't have a valid translation` + // Casting to string allows both "time_format: 12" and "time_format: '12'" in YAML + dateTimeFormatOptions.hour12 = String(timeFormat) === '12' + } + + let timeLocale = language + if (!dateTimeFormatOptions.hour12) { + // Prevents times like 24:00, 24:15, etc. with the 24h clock in some locales. + // Home Assistant does this only for 'en' but zh-Hant for example suffers from the same problem. + timeLocale += '-u-hc-h23' } + return new Intl.DateTimeFormat(timeLocale, dateTimeFormatOptions) } } diff --git a/suncalc3/LICENSE b/suncalc3/LICENSE new file mode 100644 index 0000000..91a7b5e --- /dev/null +++ b/suncalc3/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2014, Vladimir Agafonkin +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are +permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, this list of + conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, this list + of conditions and the following disclaimer in the documentation and/or other materials + provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR +TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/suncalc3/README-HORIZON-CARD.md b/suncalc3/README-HORIZON-CARD.md new file mode 100644 index 0000000..0ece3d7 --- /dev/null +++ b/suncalc3/README-HORIZON-CARD.md @@ -0,0 +1,25 @@ +# Local copy of suncalc3 + +This is a copy of module suncalc3 v2.0.5 with some minor modifications to improve its time zone handling. + +Be careful if you feel you need to make adjustments in it. + +## Modifications + +`SunCalc.getSunTimes` and `SunCalc.getMoonTimes` - added additional argument `dateAsIs`, that makes the function use the provided `dateValue` as is, instead of using non-TZ friendly logic to set the time part of the value to noon. + +```js +SunCalc.getSunTimes = function (dateValue, lat, lng, height, addDeprecated, inUTC, dateAsIs) = { + ... +} + +SunCalc.getMoonTimes = function (dateValue, lat, lng, inUTC, dateAsIs) = { + ... +} +``` + +## References + +- https://github.com/hypnos3/suncalc3 +- https://yarn.pm/suncalc3 +- https://www.npmjs.com/package/suncalc3 diff --git a/suncalc3/README.md b/suncalc3/README.md new file mode 100644 index 0000000..b7584a9 --- /dev/null +++ b/suncalc3/README.md @@ -0,0 +1,621 @@ + +SunCalc +======= +[![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://github.com/hypnos3/suncalc3/graphs/commit-activity) +[![npm version](https://badge.fury.io/js/suncalc3.svg)](https://badge.fury.io/js/suncalc3) +[![Issues](https://img.shields.io/github/issues/hypnos3/suncalc3.svg?style=flat-square)](https://github.com/hypnos3/suncalc3/issues) +[![code style](https://img.shields.io/badge/Code%20Style-eslint-green.svg)](https://eslint.org/) +[![NPM](https://nodei.co/npm/suncalc3.png)](https://nodei.co/npm/suncalc3/) + + +SunCalc is a tiny BSD-licensed JavaScript library for calculating sun position, +sunlight phases (times for sunrise, sunset, dusk, etc.), +moon position and lunar phase for the given location and time, +created by [Vladimir Agafonkin](http://agafonkin.com/en) ([@mourner](https://github.com/mourner)) +as a part of the [SunCalc.net project](http://suncalc.net). +This version is reworked and enhanced by [@hypnos3](https://github.com/hypnos3). The output of the function is changed in the most times to objects with enhanced properies. + +Most calculations are based on the formulas given in the excellent Astronomy Answers articles +about [position of the sun](http://aa.quae.nl/en/reken/zonpositie.html) +and [the planets](http://aa.quae.nl/en/reken/hemelpositie.html). +You can read about different twilight phases calculated by SunCalc +in the [Twilight article on Wikipedia](http://en.wikipedia.org/wiki/Twilight). + +## table of contents + +- [SunCalc](#suncalc) + - [table of contents](#table-of-contents) + - [changed in this library](#changed-in-this-library) + - [Usage example](#usage-example) + - [Reference](#reference) + - [Sunlight times](#sunlight-times) + - [adding / getting own Sunlight times](#adding--getting-own-sunlight-times) + - [get specific Sunlight time](#get-specific-sunlight-time) + - [get Sunlight time for a given azimuth angle for a given date](#get-sunlight-time-for-a-given-azimuth-angle-for-a-given-date) + - [getting solar time](#getting-solar-time) + - [Sun position](#sun-position) + - [Moon position](#moon-position) + - [Moon illumination](#moon-illumination) + - [Moon illumination, position and zenith angle](#moon-illumination-position-and-zenith-angle) + - [Moon rise and set times](#moon-rise-and-set-times) + - [Moon transit](#moon-transit) + - [Changelog](#changelog) + - [2.0.5 — April 04, 2022](#205--april-04-2022) + - [2.0.4 — April 04, 2022](#204--april-04-2022) + - [2.0.2 — March 29, 2022](#202--march-29-2022) + - [2.0.1 — March 13, 2022](#201--march-13-2022) + - [2.0.0 — March 13, 2022](#200--march-13-2022) + - [1.8.0 — Dec 22, 2016](#180--dec-22-2016) + - [1.7.0 — Nov 11, 2015](#170--nov-11-2015) + - [1.6.0 — Oct 27, 2014](#160--oct-27-2014) + - [1.5.1 — May 16, 2014](#151--may-16-2014) + - [1.4.0 — Apr 10, 2014](#140--apr-10-2014) + - [1.3.0 — Feb 21, 2014](#130--feb-21-2014) + - [1.2.0 — Mar 07, 2013](#120--mar-07-2013) + - [1.1.0 — Mar 06, 2013](#110--mar-06-2013) + - [1.0.0 — Dec 07, 2011](#100--dec-07-2011) + - [0.0.0 — Aug 25, 2011](#000--aug-25-2011) + +## changed in this library + +| function names of original SunCalc | changes in this library | +|-------------------------------------|---------------------------| +| SunCalc.getTimes | SunCalc.getSunTimes | + +| name of the manes of original SunCalc | changes in this library | +|----------------------------------------|---------------------------| +| sunrise | sunriseEnd | +| sunset | sunsetStart | +| dawn | civilDawn | +| dusk | civilDusk | +| night | astronomicalDusk | +| nightEnd | astronomicalDawn | +| goldenHour | goldenHourDuskStart | +| goldenHourEnd | goldenHourDawnEnd | + +Additional are the output of the function is changed in the most times to objects with more properies. Also JSDOC is added ans type script definitions. + +## Usage example + +```javascript +// get today's sunlight times for London +let times = SunCalc.getSunTimes(new Date(), 51.5, -0.1); + +// format sunrise time from the Date object +let sunriseStr = times.sunriseStart.getHours() + ':' + times.sunrise.getMinutes(); + +// get position of the sun (azimuth and altitude) at today's sunrise +let sunrisePos = SunCalc.getPosition(times.sunrise, 51.5, -0.1); + +// get sunrise azimuth in degrees +let sunriseAzimuth = sunrisePos.azimuth * 180 / Math.PI; +``` + +SunCalc is also available as an NPM package: + +```bash +$ npm install suncalc3 +``` + +```js +let SunCalc = require('suncalc3'); +``` + +## Reference + +### Sunlight times + +```javascript +/** + * calculates sun times for a given date and latitude/longitude + * @param {number|Date} dateValue Date object or timestamp for calculating sun-times + * @param {number} lat latitude for calculating sun-times + * @param {number} lng longitude for calculating sun-times + * @param {number} [height=0] the observer height (in meters) relative to the horizon + * @param {boolean} [addDeprecated=false] if true to times from timesDeprecated array will be added to the object + * @param {boolean} [inUTC=false] defines if the calculation should be in utc or local time (default is local) + * @return {ISunTimeList} result object of sunTime + */ +SunCalc.getSunTimes(dateValue, lat, lng, height, addDeprecated, inUTC) +``` + +Returns an object with the following properties: + +```javascript +/** +* @typedef {Object} ISunTimeList +* @property {ISunTimeDef} solarNoon - The sun-time for the solar noon (sun is in the highest position) +* @property {ISunTimeDef} nadir - The sun-time for nadir (darkest moment of the night, sun is in the lowest position) +* @property {ISunTimeDef} goldenHourDawnStart - The sun-time for morning golden hour (soft light, best time for photography) +* @property {ISunTimeDef} goldenHourDawnEnd - The sun-time for morning golden hour (soft light, best time for photography) +* @property {ISunTimeDef} goldenHourDuskStart - The sun-time for evening golden hour starts +* @property {ISunTimeDef} goldenHourDuskEnd - The sun-time for evening golden hour starts +* @property {ISunTimeDef} sunriseStart - The sun-time for sunrise starts (top edge of the sun appears on the horizon) +* @property {ISunTimeDef} sunriseEnd - The sun-time for sunrise ends (bottom edge of the sun touches the horizon) +* @property {ISunTimeDef} sunsetStart - The sun-time for sunset starts (bottom edge of the sun touches the horizon) +* @property {ISunTimeDef} sunsetEnd - The sun-time for sunset ends (sun disappears below the horizon, evening civil twilight starts) +* @property {ISunTimeDef} blueHourDawnStart - The sun-time for blue Hour start (time for special photography photos starts) +* @property {ISunTimeDef} blueHourDawnEnd - The sun-time for blue Hour end (time for special photography photos end) +* @property {ISunTimeDef} blueHourDuskStart - The sun-time for blue Hour start (time for special photography photos starts) +* @property {ISunTimeDef} blueHourDuskEnd - The sun-time for blue Hour end (time for special photography photos end) +* @property {ISunTimeDef} civilDawn - The sun-time for dawn (morning nautical twilight ends, morning civil twilight starts) +* @property {ISunTimeDef} civilDusk - The sun-time for dusk (evening nautical twilight starts) +* @property {ISunTimeDef} nauticalDawn - The sun-time for nautical dawn (morning nautical twilight starts) +* @property {ISunTimeDef} nauticalDusk - The sun-time for nautical dusk end (evening astronomical twilight starts) +* @property {ISunTimeDef} amateurDawn - The sun-time for amateur astronomical dawn (sun at 12° before sunrise) +* @property {ISunTimeDef} amateurDusk - The sun-time for amateur astronomical dusk (sun at 12° after sunrise) +* @property {ISunTimeDef} astronomicalDawn - The sun-time for night ends (morning astronomical twilight starts) +* @property {ISunTimeDef} astronomicalDusk - The sun-time for night starts (dark enough for astronomical observations) +* @property {ISunTimeDef} [dawn] - Deprecated: alternate for civilDawn +* @property {ISunTimeDef} [dusk] - Deprecated: alternate for civilDusk +* @property {ISunTimeDef} [nightEnd] - Deprecated: alternate for astronomicalDawn +* @property {ISunTimeDef} [night] - Deprecated: alternate for astronomicalDusk +* @property {ISunTimeDef} [nightStart] - Deprecated: alternate for astronomicalDusk +* @property {ISunTimeDef} [goldenHour] - Deprecated: alternate for goldenHourDuskStart +* @property {ISunTimeDef} [sunset] - Deprecated: alternate for sunsetEnd +* @property {ISunTimeDef} [sunrise] - Deprecated: alternate for sunriseStart +* @property {ISunTimeDef} [goldenHourEnd] - Deprecated: alternate for goldenHourDawnEnd +* @property {ISunTimeDef} [goldenHourStart] - Deprecated: alternate for goldenHourDuskStart +*/ +``` + +These properties contains the sun times for these given times: + +| Property | Description | SunBH | +| ------------------- | ------------------------------------------------------------------------ | ----- | +| `astronomicalDawn` | night ends (morning astronomical twilight starts) | 18 | +| `amateurDawn` | amateur astronomical dawn (sun at 12° before sunrise) | 15 | +| `nauticalDawn` | nautical dawn (morning nautical twilight starts) | 12 | +| `blueHourDawnStart` | blue Hour start (time for special photography photos starts) | 8 | +| `civilDawn` | dawn (morning nautical twilight ends, morning civil twilight starts) | 6 | +| `blueHourDawnEnd` | blue Hour end (time for special photography photos end) | 4 | +| `goldenHourDawnStart` | morning golden hour (soft light, best time for photography) starts | -1 | +| `sunriseStart` | sunrise (top edge of the sun appears on the horizon) | 0.833 | +| `sunriseEnd` | sunrise ends (bottom edge of the sun touches the horizon) | 0.3 | +| `goldenHourDawnEnd` | morning golden hour (soft light, best time for photography) ends | -6 | +| `solarNoon` | solar noon (sun is in the highest position) | | +| `goldenHourDuskStart` | evening golden hour (soft light, best time for photography) starts | -6 | +| `sunsetStart` | sunset starts (bottom edge of the sun touches the horizon) | 0.3 | +| `sunsetEnd` | sunset (sun disappears below the horizon, evening civil twilight starts) | 0.833 | +| `goldenHourDuskEnd` | evening golden hour (soft light, best time for photography) ends | 1 | +| `blueHourDuskStart` | blue Hour start (time for special photography photos starts) | 4 | +| `civilDusk` | dusk (evening nautical twilight starts) | 6 | +| `blueHourDuskEnd` | blue Hour end (time for special photography photos end) | 8 | +| `nauticalDusk` | nautical dusk end (evening astronomical twilight starts) | 12 | +| `amateurDusk` | amateur astronomical dusk (sun at 12° after sunrise) | 15 | +| `astronomicalDusk` | night starts (dark enough for astronomical observations) | 18 | +| `nadir` | nadir (darkest moment of the night, sun is in the lowest position) | | + +SunBH is the angle of the sun below the horizon + +If `addDeprecated` is `true`, the object will have additional objects, with the same properties as other properties. This is to have backwards compatibility to original suncalc library. + +| Property | will equal to | +| ------------------- | ------------------------------------------------------------------------ | +| `dawn` | `civilDawn` | +| `dusk` | `civilDusk` | +| `nightEnd` | `astronomicalDawn` | +| `night` | `astronomicalDusk` | +| `nightStart` | `astronomicalDusk` | +| `sunrise` | `sunriseStart` | +| `sunset` | `sunsetEnd` | +| `goldenHour` | `goldenHourDuskStart` | +| `goldenHourEnd` | `goldenHourDawnEnd` | +| `goldenHourStart` | `goldenHourDuskStart` | + + +Each of the properties will be an object with the following properties: + +```javascript +/** +* @typedef {Object} ISunTimeDef +* @property {string} name - The Name of the time +* @property {Date} value - Date object with the calculated sun-time +* @property {number} ts - The time as Unix timestamp +* @property {number} pos - The position of the sun on the time +* @property {number} [elevation] - Angle of the sun on the time (except for solarNoon / nadir) +* @property {number} julian - The time as Julian calendar +* @property {boolean} valid - indicates if the time is valid or not +* @property {boolean} [deprecated] - indicates if the time is a deprecated time name +* @property {string} [nameOrg] - if it is a deprecated name, the original property name +* @property {number} [posOrg] - if it is a deprecated name, the original position +*/ +``` + +#### adding / getting own Sunlight times + +```javascript +/** adds a custom time to the times config + * @param {number} angleAltitude - angle of Altitude/elevation above the horizont of the sun in degrees + * @param {string} riseName - name of sun rise (morning name) + * @param {string} setName - name of sun set (evening name) + * @param {number} [risePos] - (optional) position at rise (morning) + * @param {number} [setPos] - (optional) position at set (evening) + * @param {boolean} [degree=true] defines if the elevationAngle is in degree not in radians + * @return {Boolean} true if new time could be added, false if not (parameter missing; riseName or setName already existing) + */ +SunCalc.addTime(angleInDegrees, riseName, setName, risePos, setPos) +``` + +Adds a custom time when the sun reaches the given angle to results returned by `SunCalc.getSunTimes`. + +- the function tests for validity of the given parameters + - `riseName` and `setName` must be a non empty `string` and match the regex `/^(?![0-9])[a-zA-Z0-9$_]+$/` + - `angleInDegrees` must be a number + - `originalName` must be in the array `SunCalc.times` as `riseName` or `setName` + - `riseName` and `setName` must not correspond to a `riseName` or `setName` already in the array `SunCalc.times` + +Additional this function removes all items from `SunCalc.timesDeprecated` array where the `riseName` or `setName` matches the `alternameName` to prevent errors. + +```javascript +/** + * @typedef ISunTimeNames + * @type {Object} + * @property {number} angle - angle of the sun position in degrees + * @property {string} riseName - name of sun rise (morning name) + * @property {string} setName - name of sun set (evening name) + * @property {number} [risePos] - (optional) position at rise + * @property {number} [setPos] - (optional) position at set + */ +``` + +`SunCalc.times` property contains all currently defined times of type `Array.`. + +```javascript +/** + * add an alternate name for a sun time + * @param {string} alternameName - alternate or deprecated time name + * @param {string} originalName - original time name from SunCalc.times array + * @return {Boolean} true if could be added, false if not (parameter missing; originalName does not exists; alternameName already existis) + */ +SunCalc.addDeprecatedTimeName(alternameName, originalName) +``` + +Add a deprecated name + +- the function tests for validity of the given parameters + - `alternameName` must be a non empty `string` and match the regex `/^(?![0-9])[a-zA-Z0-9$_]+$/` + - `originalName` must be in the array `SunCalc.times` as `riseName` or `setName` + - `alternameName` must not correspond to a `riseName` or `setName` in the array `SunCalc.times` + +`SunCalc.timesDeprecated` property contains all deprecated time names as an `Array.<[string, string]>` - `Array.`. + +#### get specific Sunlight time + +```javascript +/** + * calculates sun times for a given date and latitude/longitude + * calculates the time at which the sun will have a given elevation angle when rising and when setting for a given date and latitude/longitude. + * @param {number|Date} dateValue Date object or timestamp for calculating sun-times + * @param {number} lat latitude for calculating sun-times + * @param {number} lng longitude for calculating sun-times + * @param {number} elevationAngle sun angle for calculating sun-time + * @param {number} [height=0] the observer height (in meters) relative to the horizon + * @param {boolean} [degree] defines if the elevationAngle is in degree not in radians + * @param {boolean} [inUTC] defines if the calculation should be in utc or local time (default is local) + * @return {ISunTimeSingle} result object of single sunTime + */ +SunCalc.getSunTime(dateValue, lat, lng, elevationAngle, height, degree, inUTC) +``` + +Returns an object with the following properties: + +```javascript +/** +* @typedef {Object} ISunTimeSingle +* @property {ISunTimeDef} rise - sun-time for sun rise +* @property {ISunTimeDef} set - sun-time for sun set +* @property {string} [error] - string of an error message if an error occurs +*/ +``` + +`rise` and `set` will be an object equal to the times objects given by `SunCalc.getSunTimes`. + +#### get Sunlight time for a given azimuth angle for a given date + +```javascript +/** + * calculates time for a given azimuth angle for a given date and latitude/longitude + * @param {Date} date start date for calculating sun-position + * @param {number} nazimuth azimuth for calculating sun-position + * @param {number} lat latitude for calculating sun-position + * @param {number} lng longitude for calculating sun-position + * @param {boolean} [degree] true if the angle is in degree and not in rad + * @return {Date} result time of sun-position +*/ +SunCalc.getSunTimeByAzimuth(date, lat, lng, nazimuth, degree) +``` + +Returns an Date object + +#### getting solar time + + +```javascript +/** + * Calculaes the solar time of the given date in the given latitude and UTC offset. + * @param {number|Date} dateValue Date object or timestamp for calculating solar time + * @param {number} utcOffset + * @param {number} lng + * @returns Returns the solar time of the given date in the given latitude and UTC offset. + */ +SunCalc.getSolarTime(dateValue, utcOffset, lng) +``` + +Returns an Date object + +### Sun position + +```javascript +/** + * calculates sun position for a given date and latitude/longitude + * @param {number|Date} dateValue Date object or timestamp for calculating sun-position + * @param {number} lat latitude for calculating sun-position + * @param {number} lng longitude for calculating sun-position + * @return {ISunPosition} result object of sun-position +*/ +SunCalc.getPosition(dateValue, lat, lng) +``` + +Returns an object with the following properties: + +```javascript +/** + * @typedef {Object} ISunPosition + * @property {number} azimuth - The azimuth of the sun in radians + * @property {number} altitude - The altitude above the horizon of the sun in radians + * @property {number} zenith - The zenith of the sun in radians + * @property {number} azimuthDegrees - The azimuth of the sun in decimal degree + * @property {number} altitudeDegrees - The altitude of the sun in decimal degree + * @property {number} zenithDegrees - The zenith of the sun in decimal degree + * @property {number} declination - The declination of the sun + */ +``` + + * `altitude`: sun altitude above the horizon in radians, + e.g. `0` at the horizon and `PI/2` at the zenith (straight over your head) + * `azimuth`: sun azimuth in radians (direction along the horizon, measured from south to west), + e.g. `0` is south and `Math.PI * 3/4` is northwest + + +### Moon position + +```javascript +/** + * calculates moon position for a given date and latitude/longitude + * @param {number|Date} dateValue Date object or timestamp for calculating moon-position + * @param {number} lat latitude for calculating moon-position + * @param {number} lng longitude for calculating moon-position + * @return {IMoonPosition} result object of moon-position + */ +SunCalc.getMoonPosition(dateValue, lat, lng) +``` + +Returns an object with the following properties: + +```javascript +/** + * @typedef {Object} IMoonPosition + * @property {number} azimuth - The moon azimuth in radians + * @property {number} altitude - The moon altitude above the horizon in radians + * @property {number} azimuthDegrees - The moon azimuth in degree + * @property {number} altitudeDegrees - The moon altitude above the horizon in degree + * @property {number} distance - The distance of the moon to the earth in kilometers + * @property {number} parallacticAngle - The parallactic angle of the moon + * @property {number} parallacticAngleDegrees - The parallactic angle of the moon in degree + */ +``` + +### Moon illumination + +```javascript +/** + * calculations for illumination parameters of the moon, + * based on http://idlastro.gsfc.nasa.gov/ftp/pro/astro/mphase.pro formulas and + * Chapter 48 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998. + * @param {number|Date} dateValue Date object or timestamp for calculating moon-illumination + * @return {IMoonIllumination} result object of moon-illumination + */ +SunCalc.getMoonIllumination(dateValue) +``` + +Returns an object with the following properties: + +```javascript +/** + * @typedef {Object} IMoonIllumination + * @property {number} fraction - illuminated fraction of the moon; varies from `0.0` (new moon) to `1.0` (full moon) + * @property {IPhaseObj} phase - moon phase as object + * @property {number} phaseValue - The phase of the moon in the current cycle; varies from `0.0` to `1.0` + * @property {number} angle - The midpoint angle in radians of the illuminated limb of the moon reckoned eastward from the north point of the disk; + * @property {IMoonIlluminationNext} next - object containing information about the next phases of the moon + * @remarks the moon is waxing if the angle is negative, and waning if positive + */ + +/** + * @typedef {Object} IPhaseObj + * @property {number} from - The phase start + * @property {number} to - The phase end + * @property {('newMoon'|'waxingCrescentMoon'|'firstQuarterMoon'|'waxingGibbousMoon'|'fullMoon'|'waningGibbousMoon'|'thirdQuarterMoon'|'waningCrescentMoon')} id - id of the phase + * @property {string} emoji - unicode symbol of the phase + * @property {string} name - name of the phase + * @property {string} id - phase name + * @property {number} weight - weight of the phase + * @property {string} css - a css value of the phase + * @property {string} [nameAlt] - an alernate name (not used by this library) + * @property {string} [tag] - additional tag (not used by this library) + */ + +/** + * @typedef {Object} IMoonIlluminationNext + * @property {string} date - The Date as a ISO String YYYY-MM-TTTHH:MM:SS.mmmmZ of the next phase + * @property {number} value - The Date as the milliseconds since 1.1.1970 0:00 UTC of the next phase + * @property {string} type - The name of the next phase [newMoon, fullMoon, firstQuarter, thirdQuarter] + * @property {IDateObj} newMoon - Date of the next new moon + * @property {IDateObj} fullMoon - Date of the next full moon + * @property {IDateObj} firstQuarter - Date of the next first quater of the moon + * @property {IDateObj} thirdQuarter - Date of the next third/last quater of the moon + */ +``` + + +Moon phase value should be interpreted like this: + +By subtracting the `parallacticAngle` from the `angle` one can get the zenith angle of the moons bright limb (anticlockwise). +The zenith angle can be used do draw the moon shape from the observers perspective (e.g. moon lying on its back). The `SunCalc.getMoonData` function will return the zenith angle. + +`SunCalc.moonCycles` contains an array with objects of type `IPhaseObj` for every phase. + +## Moon illumination, position and zenith angle + +```javascript +/** + * calculations moon position and illumination for a given date and latitude/longitude of the moon, + * @param {number|Date} dateValue Date object or timestamp for calculating moon-illumination + * @param {number} lat latitude for calculating moon-position + * @param {number} lng longitude for calculating moon-position + * @return {IMoonData} result object of moon-illumination + */ +SunCalc.getMoonData(dateValue, lat, lng) +``` + +Returns an object with the following properties: + +```javascript +/** + * @typedef {Object} IMoonData + * @property {number} azimuth - The moon azimuth in radians + * @property {number} altitude - The moon altitude above the horizon in radians + * @property {number} azimuthDegrees - The moon azimuth in degree + * @property {number} altitudeDegrees - The moon altitude above the horizon in degree + * @property {number} distance - The distance of the moon to the earth in kilometers + * @property {number} parallacticAngle - The parallactic angle of the moon + * @property {number} parallacticAngleDegrees - The parallactic angle of the moon in degree + * @property {number} zenithAngle - The zenith angle of the moon + * @property {IMoonIllumination} illumination - object containing information about the next phases of the moon + */ +``` + +The `IMoonIllumination` object is the same as the `SunCalc.getMoonIllumination` functions returns. + +### Moon rise and set times + +```javascript +/** + * calculations for moon rise/set times are based on http://www.stargazing.net/kepler/moonrise.html article + * @param {number|Date} dateValue Date object or timestamp for calculating moon-times + * @param {number} lat latitude for calculating moon-times + * @param {number} lng longitude for calculating moon-times + * @param {boolean} [inUTC] defines if the calculation should be in utc or local time (default is local) + * @return {IMoonTimes} result object of sunTime + */ +SunCalc.getMoonTimes(dateValue, lat, lng, inUTC) +``` + +Returns an object with the following properties: + +```javascript +/** + * @typedef {Object} IMoonTimes + * @property {Date|NaN} rise - a Date object if the moon is rising on the given Date, otherwise NaN + * @property {Date|NaN} set - a Date object if the moon is setting on the given Date, otherwise NaN + * @property {boolean} alwaysUp - is true if the moon never rises/sets and is always _above_ the horizon during the day + * @property {boolean} alwaysDown - is true if the moon is always _below_ the horizon + */ +``` + +By default, it will search for moon rise and set during local user's day (frou 0 to 24 hours). +If `inUTC` is set to true, it will instead search the specified date from 0 to 24 UTC hours. + +### Moon transit +```javascript +/** + * calculated the moon transit + * @param {number|Date} rise rise time as Date object or timestamp for calculating moon-transit + * @param {number|Date} set set time as Date object or timestamp for calculating moon-transit + * @param {number} lat latitude for calculating moon-times + * @param {number} lng longitude for calculating moon-times + * @returns {IMoonTransit} + */ +SunCalc.moonTransit(rise, set, lat, lng) +``` +Returns an object with the following properties: + +```javascript +/** + * @typedef {Object} IMoonTransit + * @property {Date|NaN} main - the moon transit date + * @property {Date|NaN} invert - the inverted moon transit date + */ +``` + +## Changelog + +#### 2.0.5 — April 04, 2022 +- function `SunCalc.addTime(...)` removes all items from `SunCalc.timesDeprecated` array where the new rise or set name matches the `alternameName`. + +#### 2.0.4 — April 04, 2022 +- added `SunCalc.addDeprecatedTimeName(...)` function +- renamed `SunCalc.timesAlternate` array to `SunCalc.timesDeprecated` +- added validation to function `addTime` + +#### 2.0.2 — March 29, 2022 +- type definitions update + +#### 2.0.1 — March 13, 2022 + +- added type definitions + +#### 2.0.0 — March 13, 2022 + +- published as suncalc3 after this library was used by my own with various changes to the original one +- added getSolarTime and moonTransit + +#### 1.8.0 — Dec 22, 2016 + +- Improved precision of moonrise/moonset calculations. +- Added `parallacticAngle` calculation to `getMoonPosition`. +- Default to today's date in `getMoonIllumination`. +- Fixed incompatibility when using Browserify/Webpack together with a global AMD loader. + +#### 1.7.0 — Nov 11, 2015 + +- Added `inUTC` argument to `getMoonTimes`. + +#### 1.6.0 — Oct 27, 2014 + +- Added `SunCalc.getMoonTimes` for calculating moon rise and set times. + +#### 1.5.1 — May 16, 2014 + +- Exposed `SunCalc.times` property with defined daylight times. +- Slightly improved `SunCalc.getTimes` performance. + +#### 1.4.0 — Apr 10, 2014 + +- Added `phase` to `SunCalc.getMoonIllumination` results (moon phase). +- Switched from mocha to tape for tests. + +#### 1.3.0 — Feb 21, 2014 + +- Added `SunCalc.getMoonIllumination` (in place of `getMoonFraction`) that returns an object with `fraction` and `angle` +(angle of illuminated limb of the moon). + +#### 1.2.0 — Mar 07, 2013 + +- Added `SunCalc.getMoonFraction` function that returns illuminated fraction of the moon. + +#### 1.1.0 — Mar 06, 2013 + +- Added `SunCalc.getMoonPosition` function. +- Added nadir (darkest time of the day, middle of the night). +- Added tests. + +#### 1.0.0 — Dec 07, 2011 + +- Published to NPM. +- Added `SunCalc.addTime` function. + +#### 0.0.0 — Aug 25, 2011 + +- First commit. diff --git a/suncalc3/package.json b/suncalc3/package.json new file mode 100644 index 0000000..6b5ca69 --- /dev/null +++ b/suncalc3/package.json @@ -0,0 +1,234 @@ +{ + "name": "suncalc3", + "version": "2.0.5", + "description": "A tiny JavaScript library for calculating sun/moon positions and phases.", + "homepage": "https://github.com/hypnos3/suncalc3", + "keywords": [ + "sun", + "astronomy", + "math", + "calculation", + "sunrise", + "sunset", + "twilight", + "moon", + "illumination", + "solar" + ], + "author": { + "name": "Hypnos3", + "email": "hypnos3@online.de", + "url": "https://github.com/hypnos3" + }, + "contributors": [ + { + "name": "Hypnos3", + "url": "https://github.com/hypnos3" + }, + { + "name": "Vladimir Agafonkin", + "url": "https://github.com/mourner" + } + ], + "repository": { + "type": "git", + "url": "git://github.com/hypnos3/suncalc3.git" + }, + "main": "suncalc.js", + "types": "suncalc.d.ts", + "devDependencies": { + "eslint": ">=8.12.0", + "eslint-plugin-json": ">=3.1.0", + "eslint-plugin-node": ">=11.1.0", + "tape": "^5.5.2", + "typescript":"^4.6.3" + }, + "files": [ + "suncalc.js" + ], + "scripts": { + "pretest": "eslint suncalc.js test.js", + "test": "node test.js", + "prepublishOnly": "tsc && eslint suncalc.js test.js && npm test", + "build": "tsc --build", + "clean": "tsc --build --clean" + }, + "jshintConfig": { + "quotmark": "single", + "trailing": true, + "unused": true + }, + "eslintConfig": { + "env": { + "es6": true, + "node": true, + "browser": true, + "commonjs": true, + "jquery": true, + "amd": true + }, + "extends": "eslint:recommended", + "settings": { + "html/html-extensions": [ + ".html", + ".htm", + ".we" + ], + "html/indent": "+4", + "html/report-bad-indent": "error", + "import/resolver": { + "node": { + "extensions": [ + ".js", + ".jsx" + ] + } + } + }, + "globals": { + "RED": true + }, + "parserOptions": { + "ecmaVersion": 2018, + "sourceType": "module" + }, + "rules": { + "indent": [ + "error", + 4, + { + "SwitchCase": 1 + } + ], + "max-len": [ + "error", + { + "code": 250, + "ignoreComments": true, + "ignoreTrailingComments": true, + "ignoreUrls": true, + "ignoreRegExpLiterals": true, + "ignoreTemplateLiterals": true + } + ], + "no-eq-null": "error", + "eqeqeq": "error", + "no-else-return": "error", + "prefer-arrow-callback": "error", + "no-confusing-arrow": [ + "error", + { + "allowParens": true + } + ], + "arrow-parens": [ + "error", + "as-needed" + ], + "no-console": "warn", + "no-unused-vars": [ + "error", + { + "args": "after-used", + "argsIgnorePattern": "^_" + } + ], + "no-unused-expressions": "warn", + "no-useless-escape": "warn", + "no-constant-condition": "off", + "no-multiple-empty-lines": [ + "error", + { + "max": 2, + "maxEOF": 1 + } + ], + "no-var": "error", + "object-shorthand": [ + "error", + "always" + ], + "prefer-const": "error", + "prefer-rest-params": "error", + "no-useless-concat": "error", + "no-template-curly-in-string": "error", + "require-jsdoc": "warn", + "rest-spread-spacing": [ + "error", + "never" + ], + "symbol-description": "error", + "array-callback-return": "error", + "consistent-return": "error", + "no-lone-blocks": "error", + "linebreak-style": [ + "warn", + "unix" + ], + "brace-style": [ + 2, + "1tbs", + { + "allowSingleLine": true + } + ], + "quotes": [ + "warn", + "single", + { + "avoidEscape": true, + "allowTemplateLiterals": true + } + ], + "semi": [ + "error", + "always" + ], + "comma-dangle": [ + "error", + { + "arrays": "never", + "objects": "never", + "imports": "never", + "exports": "never", + "functions": "ignore" + } + ], + "no-trailing-spaces": "error", + "spaced-comment": [ + "warn", + "always", + { + "line": { + "markers": [ + "/", + "*" + ], + "exceptions": [ + "-", + "+", + "*", + "#" + ] + }, + "block": { + "markers": [ + "!" + ], + "exceptions": [ + "-", + "+", + "*", + "#" + ], + "balanced": true + } + } + ], + "eol-last": [ + "error", + "never" + ] + } + } +} diff --git a/suncalc3/suncalc.js b/suncalc3/suncalc.js new file mode 100644 index 0000000..d0a9d3b --- /dev/null +++ b/suncalc3/suncalc.js @@ -0,0 +1,1251 @@ +// @ts-check +/* + (c) 2011-2015, Vladimir Agafonkin + SunCalc is a JavaScript library for calculating sun/moon position and light phases. + https://github.com/mourner/suncalc + + Reworked and enhanced by Robert Gester + Additional Copyright (c) 2022 Robert Gester + https://github.com/hypnos3/suncalc3 +*/ + +/** +* @typedef {Object} ISunTimeDef +* @property {string} name - The Name of the time +* @property {Date} value - Date object with the calculated sun-time +* @property {number} ts - The time as timestamp +* @property {number} pos - The position of the sun on the time +* @property {number} [elevation] - Angle of the sun on the time (except for solarNoon / nadir) +* @property {number} julian - The time as Julian calendar +* @property {boolean} valid - indicates if the time is valid or not +* @property {boolean} [deprecated] - indicates if the time is a deprecated time name +* @property {string} [nameOrg] - if it is a deprecated name, the original property name +* @property {number} [posOrg] - if it is a deprecated name, the original position +*/ + +/** +* @typedef {Object} ISunTimeSingle +* @property {ISunTimeDef} rise - sun-time for sun rise +* @property {ISunTimeDef} set - sun-time for sun set +* @property {string} [error] - string of an error message if an error occurs +*/ + +/** +* @typedef {Object} ISunTimeList +* @property {ISunTimeDef} solarNoon - The sun-time for the solar noon (sun is in the highest position) +* @property {ISunTimeDef} nadir - The sun-time for nadir (darkest moment of the night, sun is in the lowest position) +* @property {ISunTimeDef} goldenHourDawnStart - The sun-time for morning golden hour (soft light, best time for photography) +* @property {ISunTimeDef} goldenHourDawnEnd - The sun-time for morning golden hour (soft light, best time for photography) +* @property {ISunTimeDef} goldenHourDuskStart - The sun-time for evening golden hour starts +* @property {ISunTimeDef} goldenHourDuskEnd - The sun-time for evening golden hour starts +* @property {ISunTimeDef} sunriseStart - The sun-time for sunrise starts (top edge of the sun appears on the horizon) +* @property {ISunTimeDef} sunriseEnd - The sun-time for sunrise ends (bottom edge of the sun touches the horizon) +* @property {ISunTimeDef} sunsetStart - The sun-time for sunset starts (bottom edge of the sun touches the horizon) +* @property {ISunTimeDef} sunsetEnd - The sun-time for sunset ends (sun disappears below the horizon, evening civil twilight starts) +* @property {ISunTimeDef} blueHourDawnStart - The sun-time for blue Hour start (time for special photography photos starts) +* @property {ISunTimeDef} blueHourDawnEnd - The sun-time for blue Hour end (time for special photography photos end) +* @property {ISunTimeDef} blueHourDuskStart - The sun-time for blue Hour start (time for special photography photos starts) +* @property {ISunTimeDef} blueHourDuskEnd - The sun-time for blue Hour end (time for special photography photos end) +* @property {ISunTimeDef} civilDawn - The sun-time for dawn (morning nautical twilight ends, morning civil twilight starts) +* @property {ISunTimeDef} civilDusk - The sun-time for dusk (evening nautical twilight starts) +* @property {ISunTimeDef} nauticalDawn - The sun-time for nautical dawn (morning nautical twilight starts) +* @property {ISunTimeDef} nauticalDusk - The sun-time for nautical dusk end (evening astronomical twilight starts) +* @property {ISunTimeDef} amateurDawn - The sun-time for amateur astronomical dawn (sun at 12° before sunrise) +* @property {ISunTimeDef} amateurDusk - The sun-time for amateur astronomical dusk (sun at 12° after sunrise) +* @property {ISunTimeDef} astronomicalDawn - The sun-time for night ends (morning astronomical twilight starts) +* @property {ISunTimeDef} astronomicalDusk - The sun-time for night starts (dark enough for astronomical observations) +* @property {ISunTimeDef} [dawn] - Deprecated: alternate for civilDawn +* @property {ISunTimeDef} [dusk] - Deprecated: alternate for civilDusk +* @property {ISunTimeDef} [nightEnd] - Deprecated: alternate for astronomicalDawn +* @property {ISunTimeDef} [night] - Deprecated: alternate for astronomicalDusk +* @property {ISunTimeDef} [nightStart] - Deprecated: alternate for astronomicalDusk +* @property {ISunTimeDef} [goldenHour] - Deprecated: alternate for goldenHourDuskStart +* @property {ISunTimeDef} [sunset] - Deprecated: alternate for sunsetEnd +* @property {ISunTimeDef} [sunrise] - Deprecated: alternate for sunriseStart +* @property {ISunTimeDef} [goldenHourEnd] - Deprecated: alternate for goldenHourDawnEnd +* @property {ISunTimeDef} [goldenHourStart] - Deprecated: alternate for goldenHourDuskStart +*/ + +/** + * @typedef ISunTimeNames + * @type {Object} + * @property {number} angle - angle of the sun position in degrees + * @property {string} riseName - name of sun rise (morning name) + * @property {string} setName - name of sun set (evening name) + * @property {number} [risePos] - (optional) position at rise + * @property {number} [setPos] - (optional) position at set + */ + + +/** + * @typedef {Object} ISunCoordinates + * @property {number} dec - The declination of the sun + * @property {number} ra - The right ascension of the sun + */ + +/** + * @typedef {Object} ISunPosition + * @property {number} azimuth - The azimuth above the horizon of the sun in radians + * @property {number} altitude - The altitude of the sun in radians + * @property {number} zenith - The zenith of the sun in radians + * @property {number} azimuthDegrees - The azimuth of the sun in decimal degree + * @property {number} altitudeDegrees - The altitude of the sun in decimal degree + * @property {number} zenithDegrees - The zenith of the sun in decimal degree + * @property {number} declination - The declination of the sun + */ + +/** + * @typedef {Object} IMoonPosition + * @property {number} azimuth - The moon azimuth in radians + * @property {number} altitude - The moon altitude above the horizon in radians + * @property {number} azimuthDegrees - The moon azimuth in degree + * @property {number} altitudeDegrees - The moon altitude above the horizon in degree + * @property {number} distance - The distance of the moon to the earth in kilometers + * @property {number} parallacticAngle - The parallactic angle of the moon + * @property {number} parallacticAngleDegrees - The parallactic angle of the moon in degree + */ + + +/** + * @typedef {Object} IDateObj + * @property {string} date - The Date as a ISO String YYYY-MM-TTTHH:MM:SS.mmmmZ + * @property {number} value - The Date as the milliseconds since 1.1.1970 0:00 UTC + */ + +/** + * @typedef {Object} IPhaseObj + * @property {number} from - The phase start + * @property {number} to - The phase end + * @property {('newMoon'|'waxingCrescentMoon'|'firstQuarterMoon'|'waxingGibbousMoon'|'fullMoon'|'waningGibbousMoon'|'thirdQuarterMoon'|'waningCrescentMoon')} id - id of the phase + * @property {string} emoji - unicode symbol of the phase + * @property {string} name - name of the phase + * @property {string} id - phase name + * @property {number} weight - weight of the phase + * @property {string} css - a css value of the phase + * @property {string} [nameAlt] - an alernate name (not used by this library) + * @property {string} [tag] - additional tag (not used by this library) + */ + +/** + * @typedef {Object} IMoonIlluminationNext + * @property {string} date - The Date as a ISO String YYYY-MM-TTTHH:MM:SS.mmmmZ of the next phase + * @property {number} value - The Date as the milliseconds since 1.1.1970 0:00 UTC of the next phase + * @property {string} type - The name of the next phase [newMoon, fullMoon, firstQuarter, thirdQuarter] + * @property {IDateObj} newMoon - Date of the next new moon + * @property {IDateObj} fullMoon - Date of the next full moon + * @property {IDateObj} firstQuarter - Date of the next first quater of the moon + * @property {IDateObj} thirdQuarter - Date of the next third/last quater of the moon + */ + +/** + * @typedef {Object} IMoonIllumination + * @property {number} fraction - illuminated fraction of the moon; varies from `0.0` (new moon) to `1.0` (full moon) + * @property {IPhaseObj} phase - moon phase as object + * @property {number} phaseValue - The phase of the moon in the current cycle; varies from `0.0` to `1.0` + * @property {number} angle - The midpoint angle in radians of the illuminated limb of the moon reckoned eastward from the north point of the disk; + * @property {IMoonIlluminationNext} next - object containing information about the next phases of the moon + * @remarks the moon is waxing if the angle is negative, and waning if positive + */ + +/** + * @typedef {Object} IMoonDataInst + * @property {number} zenithAngle - The zenith angle of the moon + * @property {IMoonIllumination} illumination - object containing information about the next phases of the moon + * + * @typedef {IMoonPosition & IMoonDataInst} IMoonData + */ + +/** + * @typedef {Object} IMoonTimes + * @property {Date|NaN} rise - a Date object if the moon is rising on the given Date, otherwise NaN + * @property {Date|NaN} set - a Date object if the moon is setting on the given Date, otherwise NaN + * @property {boolean} alwaysUp - is true if the moon never rises/sets and is always _above_ the horizon during the day + * @property {boolean} alwaysDown - is true if the moon is always _below_ the horizon + * @property {Date} [highest] - Date of the highest position, only avalílable if set and rise is not NaN + */ + +(function () { + 'use strict'; + // sun calculations are based on http://aa.quae.nl/en/reken/zonpositie.html formulas + + // shortcuts for easier to read formulas + const sin = Math.sin; + const cos = Math.cos; + const tan = Math.tan; + const asin = Math.asin; + const atan = Math.atan2; + const acos = Math.acos; + const rad = Math.PI / 180; + const degr = 180 / Math.PI; + + // date/time constants and conversions + const dayMs = 86400000; // 1000 * 60 * 60 * 24; + const J1970 = 2440587.5; + const J2000 = 2451545; + + const lunarDaysMs = 2551442778; // The duration in days of a lunar cycle is 29.53058770576 + const firstNewMoon2000 = 947178840000; // first newMoon in the year 2000 2000-01-06 18:14 + + /** + * convert date from Julian calendar + * @param {number} j - day number in Julian calendar to convert + * @return {number} result date as timestamp + */ + function fromJulianDay(j) { + return (j - J1970) * dayMs; + } + + /** + * get number of days for a dateValue since 2000 + * @param {number} dateValue date as timestamp to get days + * @return {number} count of days + */ + function toDays(dateValue) { + return ((dateValue / dayMs) + J1970) - J2000; + } + + // general calculations for position + + const e = rad * 23.4397; // obliquity of the Earth + + /** + * get right ascension + * @param {number} l + * @param {number} b + * @returns {number} + */ + function rightAscension(l, b) { + return atan(sin(l) * cos(e) - tan(b) * sin(e), cos(l)); + } + + /** + * get declination + * @param {number} l + * @param {number} b + * @returns {number} + */ + function declination(l, b) { + return asin(sin(b) * cos(e) + cos(b) * sin(e) * sin(l)); + } + + /** + * get azimuth + * @param {number} H - siderealTime + * @param {number} phi - PI constant + * @param {number} dec - The declination of the sun + * @returns {number} azimuth in rad + */ + function azimuthCalc(H, phi, dec) { + return atan(sin(H), cos(H) * sin(phi) - tan(dec) * cos(phi)) + Math.PI; + } + + /** + * get altitude + * @param {number} H - siderealTime + * @param {number} phi - PI constant + * @param {number} dec - The declination of the sun + * @returns {number} + */ + function altitudeCalc(H, phi, dec) { + return asin(sin(phi) * sin(dec) + cos(phi) * cos(dec) * cos(H)); + } + + /** + * side real time + * @param {number} d + * @param {number} lw + * @returns {number} + */ + function siderealTime(d, lw) { + return rad * (280.16 + 360.9856235 * d) - lw; + } + + /** + * get astro refraction + * @param {number} h + * @returns {number} + */ + function astroRefraction(h) { + if (h < 0) { // the following formula works for positive altitudes only. + h = 0; + } // if h = -0.08901179 a div/0 would occur. + + // formula 16.4 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998. + // 1.02 / tan(h + 10.26 / (h + 5.10)) h in degrees, result in arc minutes -> converted to rad: + return 0.0002967 / Math.tan(h + 0.00312536 / (h + 0.08901179)); + } + // general sun calculations + /** + * get solar mean anomaly + * @param {number} d + * @returns {number} + */ + function solarMeanAnomaly(d) { + return rad * (357.5291 + 0.98560028 * d); + } + + /** + * ecliptic longitude + * @param {number} M + * @returns {number} + */ + function eclipticLongitude(M) { + const C = rad * (1.9148 * sin(M) + 0.02 * sin(2 * M) + 0.0003 * sin(3 * M)); + // equation of center + const P = rad * 102.9372; // perihelion of the Earth + return M + C + P + Math.PI; + } + + /** + * sun coordinates + * @param {number} d days in Julian calendar + * @returns {ISunCoordinates} + */ + function sunCoords(d) { + const M = solarMeanAnomaly(d); + const L = eclipticLongitude(M); + + return { + dec: declination(L, 0), + ra: rightAscension(L, 0) + }; + } + + const SunCalc = {}; + + /** + * calculates sun position for a given date and latitude/longitude + * @param {number|Date} dateValue Date object or timestamp for calculating sun-position + * @param {number} lat latitude for calculating sun-position + * @param {number} lng longitude for calculating sun-position + * @return {ISunPosition} result object of sun-position + */ + SunCalc.getPosition = function (dateValue, lat, lng) { + // console.log(`getPosition dateValue=${dateValue} lat=${lat}, lng=${lng}`); + if (isNaN(lat)) { + throw new Error('latitude missing'); + } + if (isNaN(lng)) { + throw new Error('longitude missing'); + } + if (dateValue instanceof Date) { + dateValue = dateValue.valueOf(); + } + const lw = rad * -lng; + const phi = rad * lat; + const d = toDays(dateValue); + const c = sunCoords(d); + const H = siderealTime(d, lw) - c.ra; + const azimuth = azimuthCalc(H, phi, c.dec); + const altitude = altitudeCalc(H, phi, c.dec); + // console.log(`getPosition date=${date}, M=${H}, L=${H}, c=${JSON.stringify(c)}, d=${d}, lw=${lw}, phi=${phi}`); + + return { + azimuth, + altitude, + zenith: (90*Math.PI/180) - altitude, + azimuthDegrees: degr * azimuth, + altitudeDegrees: degr * altitude, + zenithDegrees: 90 - (degr * altitude), + declination: c.dec + }; + }; + + /** sun times configuration + * @type {Array.} + */ + const sunTimes = SunCalc.times = [ + { angle: 6, riseName: 'goldenHourDawnEnd', setName: 'goldenHourDuskStart'}, // GOLDEN_HOUR_2 + { angle: -0.3, riseName: 'sunriseEnd', setName: 'sunsetStart'}, // SUNRISE_END + { angle: -0.833, riseName: 'sunriseStart', setName: 'sunsetEnd'}, // SUNRISE + { angle: -1, riseName: 'goldenHourDawnStart', setName: 'goldenHourDuskEnd'}, // GOLDEN_HOUR_1 + { angle: -4, riseName: 'blueHourDawnEnd', setName: 'blueHourDuskStart'}, // BLUE_HOUR + { angle: -6, riseName: 'civilDawn', setName: 'civilDusk'}, // DAWN + { angle: -8, riseName: 'blueHourDawnStart', setName: 'blueHourDuskEnd'}, // BLUE_HOUR + { angle: -12, riseName: 'nauticalDawn', setName: 'nauticalDusk'}, // NAUTIC_DAWN + { angle: -15, riseName: 'amateurDawn', setName: 'amateurDusk'}, + { angle: -18, riseName: 'astronomicalDawn', setName: 'astronomicalDusk'} // ASTRO_DAWN + ]; + + /** alternate time names for backward compatibility + * @type {Array.<[string, string]>} + */ + const suntimesDeprecated = SunCalc.timesDeprecated = [ + ['dawn', 'civilDawn'], + ['dusk', 'civilDusk'], + ['nightEnd', 'astronomicalDawn'], + ['night', 'astronomicalDusk'], + ['nightStart', 'astronomicalDusk'], + ['goldenHour', 'goldenHourDuskStart'], + ['sunrise', 'sunriseStart'], + ['sunset', 'sunsetEnd'], + ['goldenHourEnd', 'goldenHourDawnEnd'], + ['goldenHourStart', 'goldenHourDuskStart'] + ]; + + /** adds a custom time to the times config + * @param {number} angleAltitude - angle of Altitude/elevation above the horizont of the sun in degrees + * @param {string} riseName - name of sun rise (morning name) + * @param {string} setName - name of sun set (evening name) + * @param {number} [risePos] - (optional) position at rise (morning) + * @param {number} [setPos] - (optional) position at set (evening) + * @param {boolean} [degree=true] defines if the elevationAngle is in degree not in radians + * @return {Boolean} true if new time could be added, false if not (parameter missing; riseName or setName already existing) + */ + SunCalc.addTime = function (angleAltitude, riseName, setName, risePos, setPos, degree) { + let isValid = (typeof riseName === 'string') && (riseName.length > 0) && + (typeof setName === 'string') && (setName.length > 0) && + (typeof angleAltitude === 'number'); + if (isValid) { + const EXP = /^(?![0-9])[a-zA-Z0-9$_]+$/; + // check for invalid names + for (let i=0; i= 0; i--) { + if (suntimesDeprecated[i][0] === riseName || suntimesDeprecated[i][0] === setName) { + suntimesDeprecated.splice(i, 1); + } + } + return true; + } + } + return false; + }; + + /** + * add an alternate name for a sun time + * @param {string} alternameName - alternate or deprecated time name + * @param {string} originalName - original time name from SunCalc.times array + * @return {Boolean} true if could be added, false if not (parameter missing; originalName does not exists; alternameName already existis) + */ + SunCalc.addDeprecatedTimeName = function (alternameName, originalName) { + let isValid = (typeof alternameName === 'string') && (alternameName.length > 0) && + (typeof originalName === 'string') && (originalName.length > 0); + if (isValid) { + let hasOrg = false; + const EXP = /^(?![0-9])[a-zA-Z0-9$_]+$/; + // check for invalid names + for (let i=0; i 200) { + // let nazi = this.getPosition(dateVal, lat, lng).azimuth; + const d = toDays(dateVal); + const c = sunCoords(d); + const H = siderealTime(d, lw) - c.ra; + const nazim = azimuthCalc(H, phi, c.dec); + + addval /= 2; + if (nazim < nazimuth) { + dateVal += addval; + } else { + dateVal -= addval; + } + } + return new Date(Math.floor(dateVal)); + }; + + // calculation for solar time based on https://www.pveducation.org/pvcdrom/properties-of-sunlight/solar-time + + /** + * Calculaes the solar time of the given date in the given latitude and UTC offset. + * @param {number|Date} dateValue Date object or timestamp for calculating solar time + * @param {number} lng longitude for calculating sun-time + * @param {number} utcOffset offset to the utc time + * @returns {Date} Returns the solar time of the given date in the given latitude and UTC offset. + */ + SunCalc.getSolarTime = function (dateValue, lng, utcOffset) { + // @ts-ignore + const date = new Date(dateValue); + // calculate the day of year + const start = new Date(date.getFullYear(), 0, 0); + const diff = (date.getTime() - start.getTime()) + ((start.getTimezoneOffset() - date.getTimezoneOffset()) * 60 * 1000); + const dayOfYear = Math.floor(diff / dayMs); + + const b = 360 / 365 * (dayOfYear - 81) * rad; + const equationOfTime = 9.87 * sin(2 * b) - 7.53 * cos(b) - 1.5 * sin(b); + const localSolarTimeMeridian = 15 * utcOffset; + const timeCorrection = equationOfTime + 4 * (lng - localSolarTimeMeridian); + const localSolarTime = date.getHours() + timeCorrection / 60 + date.getMinutes() / 60; + + const solarDate = new Date(0, 0); + solarDate.setMinutes(+localSolarTime * 60); + return solarDate; + }; + + // moon calculations, based on http://aa.quae.nl/en/reken/hemelpositie.html formulas + + /** + * calculate the geocentric ecliptic coordinates of the moon + * @param {number} d number of days + */ + function moonCoords(d) { + const L = rad * (218.316 + 13.176396 * d); // ecliptic longitude + const M = rad * (134.963 + 13.064993 * d); // mean anomaly + const F = rad * (93.272 + 13.229350 * d); // mean distance + const l = L + rad * 6.289 * sin(M); // longitude + const b = rad * 5.128 * sin(F); // latitude + const dt = 385001 - 20905 * cos(M); // distance to the moon in km + + return { + ra: rightAscension(l, b), + dec: declination(l, b), + dist: dt + }; + } + + /** + * calculates moon position for a given date and latitude/longitude + * @param {number|Date} dateValue Date object or timestamp for calculating moon-position + * @param {number} lat latitude for calculating moon-position + * @param {number} lng longitude for calculating moon-position + * @return {IMoonPosition} result object of moon-position + */ + SunCalc.getMoonPosition = function (dateValue, lat, lng) { + // console.log(`getMoonPosition dateValue=${dateValue} lat=${lat}, lng=${lng}`); + if (isNaN(lat)) { + throw new Error('latitude missing'); + } + if (isNaN(lng)) { + throw new Error('longitude missing'); + } + if (dateValue instanceof Date) { + dateValue = dateValue.valueOf(); + } + const lw = rad * -lng; + const phi = rad * lat; + const d = toDays(dateValue); + const c = moonCoords(d); + const H = siderealTime(d, lw) - c.ra; + let altitude = altitudeCalc(H, phi, c.dec); + altitude += astroRefraction(altitude); // altitude correction for refraction + + // formula 14.1 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998. + const pa = atan(sin(H), tan(phi) * cos(c.dec) - sin(c.dec) * cos(H)); + + const azimuth = azimuthCalc(H, phi, c.dec); + + return { + azimuth, + altitude, + azimuthDegrees: degr * azimuth, + altitudeDegrees: degr * altitude, + distance: c.dist, + parallacticAngle: pa, + parallacticAngleDegrees: degr * pa + }; + }; + + const fractionOfTheMoonCycle = SunCalc.moonCycles = [{ + from: 0, + to: 0.033863193308711, + id: 'newMoon', + emoji: '🌚', + code: ':new_moon_with_face:', + name: 'New Moon', + weight: 1, + css: 'wi-moon-new' + }, + { + from: 0.033863193308711, + to: 0.216136806691289, + id: 'waxingCrescentMoon', + emoji: '🌒', + code: ':waxing_crescent_moon:', + name: 'Waxing Crescent', + weight: 6.3825, + css: 'wi-moon-wax-cres' + }, + { + from: 0.216136806691289, + to: 0.283863193308711, + id: 'firstQuarterMoon', + emoji: '🌓', + code: ':first_quarter_moon:', + name: 'First Quarter', + weight: 1, + css: 'wi-moon-first-quart' + }, + { + from: 0.283863193308711, + to: 0.466136806691289, + id: 'waxingGibbousMoon', + emoji: '🌔', + code: ':waxing_gibbous_moon:', + name: 'Waxing Gibbous', + weight: 6.3825, + css: 'wi-moon-wax-gibb' + }, + { + from: 0.466136806691289, + to: 0.533863193308711, + id: 'fullMoon', + emoji: '🌝', + code: ':full_moon_with_face:', + name: 'Full Moon', + weight: 1, + css: 'wi-moon-full' + }, + { + from: 0.533863193308711, + to: 0.716136806691289, + id: 'waningGibbousMoon', + emoji: '🌖', + code: ':waning_gibbous_moon:', + name: 'Waning Gibbous', + weight: 6.3825, + css: 'wi-moon-wan-gibb' + }, + { + from: 0.716136806691289, + to: 0.783863193308711, + id: 'thirdQuarterMoon', + emoji: '🌗', + code: ':last_quarter_moon:', + name: 'third Quarter', + weight: 1, + css: 'wi-moon-third-quart' + }, + { + from: 0.783863193308711, + to: 0.966136806691289, + id: 'waningCrescentMoon', + emoji: '🌘', + code: ':waning_crescent_moon:', + name: 'Waning Crescent', + weight: 6.3825, + css: 'wi-moon-wan-cres' + }, + { + from: 0.966136806691289, + to: 1, + id: 'newMoon', + emoji: '🌚', + code: ':new_moon_with_face:', + name: 'New Moon', + weight: 1, + css: 'wi-moon-new' + }]; + + /** + * calculations for illumination parameters of the moon, + * based on http://idlastro.gsfc.nasa.gov/ftp/pro/astro/mphase.pro formulas and + * Chapter 48 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998. + * @param {number|Date} dateValue Date object or timestamp for calculating moon-illumination + * @return {IMoonIllumination} result object of moon-illumination + */ + SunCalc.getMoonIllumination = function (dateValue) { + // console.log(`getMoonIllumination dateValue=${dateValue}`); + if (dateValue instanceof Date) { + dateValue = dateValue.valueOf(); + } + const d = toDays(dateValue); + const s = sunCoords(d); + const m = moonCoords(d); + const sdist = 149598000; // distance from Earth to Sun in km + const phi = acos(sin(s.dec) * sin(m.dec) + cos(s.dec) * cos(m.dec) * cos(s.ra - m.ra)); + const inc = atan(sdist * sin(phi), m.dist - sdist * cos(phi)); + const angle = atan(cos(s.dec) * sin(s.ra - m.ra), sin(s.dec) * cos(m.dec) - + cos(s.dec) * sin(m.dec) * cos(s.ra - m.ra)); + const phaseValue = 0.5 + 0.5 * inc * (angle < 0 ? -1 : 1) / Math.PI; + + // calculates the difference in ms between the sirst fullMoon 2000 and given Date + const diffBase = dateValue - firstNewMoon2000; + // Calculate modulus to drop completed cycles + let cycleModMs = diffBase % lunarDaysMs; + // If negative number (date before new moon 2000) add lunarDaysMs + if ( cycleModMs < 0 ) { cycleModMs += lunarDaysMs; } + const nextNewMoon = (lunarDaysMs - cycleModMs) + dateValue; + let nextFullMoon = ((lunarDaysMs/2) - cycleModMs) + dateValue; + if (nextFullMoon < dateValue) { nextFullMoon += lunarDaysMs; } + const quater = (lunarDaysMs/4); + let nextFirstQuarter = (quater - cycleModMs) + dateValue; + if (nextFirstQuarter < dateValue) { nextFirstQuarter += lunarDaysMs; } + let nextThirdQuarter = (lunarDaysMs - quater - cycleModMs) + dateValue; + if (nextThirdQuarter < dateValue) { nextThirdQuarter += lunarDaysMs; } + // Calculate the fraction of the moon cycle + // const currentfrac = cycleModMs / lunarDaysMs; + const next = Math.min(nextNewMoon, nextFirstQuarter, nextFullMoon, nextThirdQuarter); + let phase; + + for (let index = 0; index < fractionOfTheMoonCycle.length; index++) { + const element = fractionOfTheMoonCycle[index]; + if ( (phaseValue >= element.from) && (phaseValue <= element.to) ) { + phase = element; + break; + } + } + + return { + fraction: (1 + cos(inc)) / 2, + // fraction2: cycleModMs / lunarDaysMs, + // @ts-ignore + phase, + phaseValue, + angle, + next : { + value: next, + date: (new Date(next)).toISOString(), + type: (next === nextNewMoon) ? 'newMoon' : ((next === nextFirstQuarter) ? 'firstQuarter' : ((next === nextFullMoon) ? 'fullMoon' : 'thirdQuarter')), + newMoon: { + value: nextNewMoon, + date: (new Date(nextNewMoon)).toISOString() + }, + fullMoon: { + value: nextFullMoon, + date: (new Date(nextFullMoon)).toISOString() + }, + firstQuarter: { + value: nextFirstQuarter, + date: (new Date(nextFirstQuarter)).toISOString() + }, + thirdQuarter: { + value: nextThirdQuarter, + date: (new Date(nextThirdQuarter)).toISOString() + } + } + }; + }; + + /** + * calculations moon position and illumination for a given date and latitude/longitude of the moon, + * @param {number|Date} dateValue Date object or timestamp for calculating moon-illumination + * @param {number} lat latitude for calculating moon-position + * @param {number} lng longitude for calculating moon-position + * @return {IMoonData} result object of moon-illumination + */ + SunCalc.getMoonData = function (dateValue, lat, lng) { + const pos = SunCalc.getMoonPosition(dateValue, lat, lng); + const illum = SunCalc.getMoonIllumination(dateValue); + return Object.assign({ + illumination : illum, + zenithAngle : illum.angle - pos.parallacticAngle + }, pos); + }; + + /** + * add hours to a date + * @param {number} dateValue timestamp to add hours + * @param {number} h - hours to add + * @returns {number} new timestamp with added hours + */ + function hoursLater(dateValue, h) { + return dateValue + h * dayMs / 24; + } + + /** + * calculations for moon rise/set times are based on http://www.stargazing.net/kepler/moonrise.html article + * @param {number|Date} dateValue Date object or timestamp for calculating moon-times + * @param {number} lat latitude for calculating moon-times + * @param {number} lng longitude for calculating moon-times + * @param {boolean} [inUTC] defines if the calculation should be in utc or local time (default is local) + * @return {IMoonTimes} result object of sunTime + */ + SunCalc.getMoonTimes = function (dateValue, lat, lng, inUTC, dateAsIs) { + if (isNaN(lat)) { + throw new Error('latitude missing'); + } + if (isNaN(lng)) { + throw new Error('longitude missing'); + } + let t + if (dateAsIs) { + t = dateValue + } else { + t = new Date(dateValue); + if (inUTC) { + t.setUTCHours(0, 0, 0, 0); + } else { + t.setHours(0, 0, 0, 0); + } + } + dateValue = t.valueOf(); + // console.log(`getMoonTimes lat=${lat} lng=${lng} dateValue=${dateValue} t=${t}`); + + const hc = 0.133 * rad; + let h0 = SunCalc.getMoonPosition(dateValue, lat, lng).altitude - hc; + let rise; let set; let ye; let d; let roots; let x1; let x2; let dx; + + // go in 2-hour chunks, each time seeing if a 3-point quadratic curve crosses zero (which means rise or set) + for (let i = 1; i <= 26; i += 2) { + const h1 = SunCalc.getMoonPosition(hoursLater(dateValue, i), lat, lng).altitude - hc; + const h2 = SunCalc.getMoonPosition(hoursLater(dateValue, i + 1), lat, lng).altitude - hc; + + const a = (h0 + h2) / 2 - h1; + const b = (h2 - h0) / 2; + const xe = -b / (2 * a); + ye = (a * xe + b) * xe + h1; + d = b * b - 4 * a * h1; + roots = 0; + + if (d >= 0) { + dx = Math.sqrt(d) / (Math.abs(a) * 2); + x1 = xe - dx; + x2 = xe + dx; + if (Math.abs(x1) <= 1) { + roots++; + } + + if (Math.abs(x2) <= 1) { + roots++; + } + + if (x1 < -1) { + x1 = x2; + } + } + + if (roots === 1) { + if (h0 < 0) { + rise = i + x1; + } else { + set = i + x1; + } + } else if (roots === 2) { + rise = i + (ye < 0 ? x2 : x1); + set = i + (ye < 0 ? x1 : x2); + } + + if (rise && set) { + break; + } + + h0 = h2; + } + + const result = {}; + if (rise) { + result.rise = new Date(hoursLater(dateValue, rise)); + } else { + result.rise = NaN; + } + + if (set) { + result.set = new Date(hoursLater(dateValue, set)); + } else { + result.set = NaN; + } + + if (!rise && !set) { + if (ye > 0) { + result.alwaysUp = true; + result.alwaysDown = false; + } else { + result.alwaysUp = false; + result.alwaysDown = true; + } + } else if (rise && set) { + result.alwaysUp = false; + result.alwaysDown = false; + result.highest = new Date(hoursLater(dateValue, Math.min(rise, set) + (Math.abs(set - rise) / 2))); + } else { + result.alwaysUp = false; + result.alwaysDown = false; + } + return result; + }; + + /** + * calc moon transit + * @param {number} rize timestamp for rise + * @param {number} set timestamp for set time + * @returns {Date} new moon transit + */ + function calcMoonTransit(rize, set) { + if (rize > set) { + return new Date(set + (rize - set) / 2); + } + return new Date(rize + (set - rize) / 2); + } + + /** + * calculated the moon transit + * @param {number|Date} rise rise time as Date object or timestamp for calculating moon-transit + * @param {number|Date} set set time as Date object or timestamp for calculating moon-transit + * @param {number} lat latitude for calculating moon-times + * @param {number} lng longitude for calculating moon-times + * @returns {{main: (Date|null), invert: (Date|null)}} + */ + SunCalc.moonTransit = function (rise, set, lat, lng) { + /** @type {Date|null} */ let main = null; + /** @type {Date|null} */ let invert = null; + const riseDate = new Date(rise); + const setDate = new Date(set); + const riseValue = riseDate.getTime(); + const setValue = setDate.getTime(); + const day = setDate.getDate(); + let tempTransitBefore; + let tempTransitAfter; + + if (rise && set) { + if (rise < set) { + main = calcMoonTransit(riseValue, setValue); + } else { + invert = calcMoonTransit(riseValue, setValue); + } + } + + if (rise) { + tempTransitAfter = calcMoonTransit(riseValue, SunCalc.getMoonTimes(new Date(riseDate).setDate(day + 1), lat, lng).set.valueOf()); + if (tempTransitAfter.getDate() === day) { + if (main) { + invert = tempTransitAfter; + } else { + main = tempTransitAfter; + } + } + } + + if (set) { + tempTransitBefore = calcMoonTransit(setValue, SunCalc.getMoonTimes(new Date(setDate).setDate(day - 1), lat, lng).rise.valueOf()); + if (tempTransitBefore.getDate() === day) { + main = tempTransitBefore; + } + } + return { + main, + invert + }; + }; + + // export as Node module / AMD module / browser variable + if (typeof exports === 'object' && typeof module !== 'undefined') { + module.exports = SunCalc; + // @ts-ignore + } else if (typeof define === 'function' && define.amd) { + // @ts-ignore + define(SunCalc); + } else { + // @ts-ignore + window.SunCalc = SunCalc; + } + +})(); diff --git a/tests/helpers/TestHelpers.ts b/tests/helpers/TestHelpers.ts index 44686cf..e6beb2f 100644 --- a/tests/helpers/TestHelpers.ts +++ b/tests/helpers/TestHelpers.ts @@ -1,50 +1,43 @@ +import { HomeAssistant } from 'custom-card-helpers' import { html, LitElement, TemplateResult } from 'lit' -import { customElement, state } from 'lit-element' - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type TTemplateResultFunction = (...args: any[]) => TemplateResult +import { customElement, state } from 'lit/decorators.js' @customElement('test-element') -export class TemplateResultTestHelper = Parameters> extends LitElement { - @state() - templateResultFunctionData?: U - - @state() - templateResultFunction?: T - - n = 5 - - constructor (templateResultFunction: T, templateResultFunctionData?: U) { - super() - - this.templateResultFunction = templateResultFunction - this.templateResultFunctionData = templateResultFunctionData +export class TemplateResultTestHelper extends LitElement { + public static async renderFunction (fun) { + const element = window.document.createElement('test-element') as TemplateResultTestHelper + element.templateResultFunction = fun + window.document.body.appendChild(element) + await element.updateComplete + return element.shadowRoot!.innerHTML } - render (): TemplateResult { - const data = this.templateResultFunctionData ?? [] - return this.templateResultFunction?.(...data) ?? html`No function assigned` + public static async renderElement (elementObject) { + return TemplateResultTestHelper.renderFunction(() => elementObject.render()) } -} -export class CustomSnapshotSerializer { - // eslint-disable-next-line no-use-before-define - static instance?: CustomSnapshotSerializer - - constructor () { - if (CustomSnapshotSerializer.instance) { - return CustomSnapshotSerializer.instance - } - - CustomSnapshotSerializer.instance = this - } - - test (snapshotContent: unknown): boolean { - return typeof snapshotContent === 'string' - } + @state() + templateResultFunction?: () => TemplateResult - print (snapshotContent: unknown): string { - const idRegex = /\$[\d]+\$/mg - return (snapshotContent as string).replace(idRegex, '') + render (): TemplateResult { + return this.templateResultFunction?.() ?? html`No function assigned` } } + +export const SaneHomeAssistant = { + config: { + latitude: 0, + longitude: 0, + elevation: 0, + time_zone: 'UTC' + }, + language: 'en', + locale: { + language: 'en', + time_format: 'language' + }, + themes: { + darkMode: true + }, + localize: (key) => key +} as unknown as HomeAssistant diff --git a/tests/mocks/HelperFunctions.ts b/tests/mocks/HelperFunctions.ts deleted file mode 100644 index 5e8a04f..0000000 --- a/tests/mocks/HelperFunctions.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { html, TemplateResult } from 'lit' - -export class HelperFunctions { - public static nothing (): TemplateResult { - return html`` - } - - public static renderFieldElement (_i18n: unknown, translationKey: string, value: Date | number | string | undefined): TemplateResult { - if (value === undefined) { - return HelperFunctions.nothing() - } - - const display = value instanceof Date ? value.getTime() : value.toString() - return html` -
- ${ translationKey } - ${ display } -
- ` - } - - public static isValidLanguage (language: string): boolean { - return language !== 'notSupportedLanguage' - } - - public static findSectionPosition (_msSinceSectionStart: number, _msSectionEnd: number, _section: number): number { - return 0 - } - - public static startOfDay (): Date { - return new Date(0) - } - - public static endOfDay (): Date { - return new Date(0) - } - - public static clamp (_min: number, _max: number, _value: number): number { - return 0 - } - - public static findSunProgress (_sunPosition: number, _startPosition: number, _endPosition: number): number { - return 0 - } -} diff --git a/tests/mocks/HorizonCardEditorContent.ts b/tests/mocks/HorizonCardEditorContent.ts deleted file mode 100644 index f84c869..0000000 --- a/tests/mocks/HorizonCardEditorContent.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { html, TemplateResult } from 'lit' - -export class HorizonCardEditorContent { - static onMock: jest.Mock - - public on (eventName: string, listener: () => void) { - HorizonCardEditorContent.onMock(eventName, listener) - } - - public render (): TemplateResult { - return html` -
- HORIZON CARD EDITOR CONTENT -
- ` - } -} diff --git a/tests/mocks/I18N.ts b/tests/mocks/I18N.ts index 675bf90..c513446 100644 --- a/tests/mocks/I18N.ts +++ b/tests/mocks/I18N.ts @@ -3,7 +3,15 @@ export class I18N { return `${date.getTime()}` } + public formatDecimal (num: number): string { + return num.toString() + } + public tr (translationKey: string): string { return translationKey } + + public localize (key: string): string { + return key + } } diff --git a/tests/mocks/SunCalc.ts b/tests/mocks/SunCalc.ts new file mode 100644 index 0000000..2f34636 --- /dev/null +++ b/tests/mocks/SunCalc.ts @@ -0,0 +1,60 @@ +// Mock SunCalc implementation that returns predictable (almost static) data +export default { + undefinedMoonTimes: false, + + getSunTimes (now) { + return { + civilDawn: this.time(now, '06:00:00'), + civilDusk: this.time(now, '19:00:00'), + nadir: this.time(now, '00:30:00', 1), + solarNoon: this.time(now, '12:30:00'), + sunriseStart: this.time(now, '06:30:00'), + sunsetEnd: this.time(now, '18:30:00') + } + }, + + getPosition () { + return { + azimuthDegrees: 180, + altitudeDegrees: 45 + } + }, + + getMoonData () { + return { + azimuthDegrees: 270, + altitudeDegrees: 90, + zenithAngle: Math.PI/2, + parallacticAngleDegrees: 10, + illumination: { + fraction: 1, + phase: { + id: 'fullMoon' + } + } + } + }, + + getMoonTimes (now) { + return { + rise: this.undefinedMoonTimes ? NaN : this.time(now, '13:00:00').value, + set: this.undefinedMoonTimes? NaN : this.time(now, '22:00:00').value + } + }, + + time (now, hours, plusDays=0) { + const parsed = new Date(`1970-01-01T${hours}Z`) + const result = new Date(now.getTime() + plusDays * 24 * 60 * 60 * 1000) + result.setUTCHours(parsed.getUTCHours()) + result.setUTCMinutes(parsed.getUTCMinutes()) + result.setUTCSeconds(parsed.getUTCSeconds()) + return { + value: result, + valid: true + } + }, + + setUndefinedMoonTimes (undefinedMoonTimes) { + this.undefinedMoonTimes = undefinedMoonTimes + } +} diff --git a/tests/unit/components/HorizonErrorContent.spec.ts b/tests/unit/components/HorizonErrorContent.spec.ts index d0e9a8d..05587aa 100644 --- a/tests/unit/components/HorizonErrorContent.spec.ts +++ b/tests/unit/components/HorizonErrorContent.spec.ts @@ -1,13 +1,13 @@ +import { NumberFormat, TimeFormat } from 'custom-card-helpers' + import { HorizonErrorContent } from '../../../src/components/HorizonErrorContent' import { EHorizonCardErrors } from '../../../src/types' import { I18N } from '../../../src/utils/I18N' -import { CustomSnapshotSerializer, TemplateResultTestHelper } from '../../helpers/TestHelpers' +import { TemplateResultTestHelper } from '../../helpers/TestHelpers' jest.mock('../../../src/utils/I18N', () => require('../../mocks/I18N')) -expect.addSnapshotSerializer(new CustomSnapshotSerializer()) - -describe('SunErrorContent', () => { +describe('HorizonErrorContent', () => { describe('render', () => { let consoleErrorSpy: jest.SpyInstance beforeAll(() => { @@ -22,32 +22,27 @@ describe('SunErrorContent', () => { consoleErrorSpy.mockRestore() }) + enum MockErrors { + MOCK_ERROR = 'MockError' + } + it('prints a console error message', () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation() - const config = { - type: 'horizon-card', - i18n: new I18N('es', undefined) - } - - const sunErrorContent = new HorizonErrorContent(config, EHorizonCardErrors.SunIntegrationNotFound) - sunErrorContent.render() - expect(consoleErrorSpy).toHaveBeenCalledWith('errors.SunIntegrationNotFound') + + const i18n = new I18N('en', 'UTC', TimeFormat.language, NumberFormat.language, (key) => key) + const horizonErrorContent = new HorizonErrorContent(MockErrors.MOCK_ERROR as unknown as EHorizonCardErrors, i18n) + horizonErrorContent.render() + + expect(consoleErrorSpy).toHaveBeenCalledWith('errors.MockError') }) it('returns a valid error template result', async () => { - // jest.spyOn(console, 'error').mockImplementation() - const config = { - type: 'horizon-card', - i18n: new I18N('es', undefined) - } - - const sunErrorContent = new HorizonErrorContent(config, EHorizonCardErrors.SunIntegrationNotFound) - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => sunErrorContent.render() - window.document.body.appendChild(element) - await element.updateComplete - - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() + const i18n = new I18N('en', 'UTC', TimeFormat.language, NumberFormat.language, (key) => key) + const horizonErrorContent = new HorizonErrorContent(MockErrors.MOCK_ERROR as unknown as EHorizonCardErrors, i18n) + + const html = await TemplateResultTestHelper.renderElement(horizonErrorContent) + + expect(html).toMatchSnapshot() }) }) }) diff --git a/tests/unit/components/__snapshots__/HorizonErrorContent.spec.ts.snap b/tests/unit/components/__snapshots__/HorizonErrorContent.spec.ts.snap index e26284b..0a28ed6 100644 --- a/tests/unit/components/__snapshots__/HorizonErrorContent.spec.ts.snap +++ b/tests/unit/components/__snapshots__/HorizonErrorContent.spec.ts.snap @@ -1,9 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`SunErrorContent render returns a valid error template result 1`] = ` - -
- errors.SunIntegrationNotFound -
- +exports[`HorizonErrorContent render returns a valid error template result 1`] = ` +
+ errors.MockError +
`; diff --git a/tests/unit/components/horizonCard/HorizonCard.spec.ts b/tests/unit/components/horizonCard/HorizonCard.spec.ts index 2985100..9b9e265 100644 --- a/tests/unit/components/horizonCard/HorizonCard.spec.ts +++ b/tests/unit/components/horizonCard/HorizonCard.spec.ts @@ -1,26 +1,39 @@ -import { HomeAssistant } from 'custom-card-helpers' +import { HomeAssistant, NumberFormat, TimeFormat } from 'custom-card-helpers' import { css, CSSResult } from 'lit' import { HorizonCard } from '../../../../src/components/horizonCard' import { Constants } from '../../../../src/constants' -import { EHorizonCardErrors, IHorizonCardConfig, THorizonCardData } from '../../../../src/types' -import { CustomSnapshotSerializer, TemplateResultTestHelper } from '../../../helpers/TestHelpers' +import { EHorizonCardErrors, IHorizonCardConfig, THorizonCardData, TMoonData, TSunTimes } from '../../../../src/types' +import { I18N } from '../../../../src/utils/I18N' +import { SaneHomeAssistant, TemplateResultTestHelper } from '../../../helpers/TestHelpers' +import { default as SunCalcMock } from '../../../mocks/SunCalc' jest.mock('../../../../src/components/HorizonErrorContent', () => require('../../../mocks/HorizonErrorContent')) jest.mock('../../../../src/components/horizonCard/HorizonCardContent', () => require('../../../mocks/HorizonCardContent')) -jest.mock('../../../../src/utils/I18N', () => require('../../../mocks/I18N')) +jest.mock('../../../../src/utils/I18N') jest.mock('../../../../src/cardStyles', () => css``) - -expect.addSnapshotSerializer(new CustomSnapshotSerializer()) +jest.mock('suncalc3', () => require('../../../mocks/SunCalc')) // JSDom doesn't include SVGPathElement class SVGPathElement { + x: number + y: number + + constructor (x=0, y=0) { + this.x = x + this.y = y + } + getPointAtLength () { return { - x: 0, - y: 0 + x: this.x, + y: this.y } } + + getTotalLength () { + return 500 + } } Object.defineProperty(window, 'SVGPathElement', { value: SVGPathElement }) @@ -28,6 +41,14 @@ Object.defineProperty(window, 'SVGPathElement', { value: SVGPathElement }) describe('HorizonCard', () => { let horizonCard: HorizonCard + beforeAll(() => { + jest.useFakeTimers() + }) + + afterAll(() => { + jest.useRealTimers() + }) + beforeEach(() => { horizonCard = new HorizonCard() horizonCard.attachShadow({ mode: 'open' }) @@ -35,60 +56,16 @@ describe('HorizonCard', () => { describe('set hass', () => { it('updates lastHass property', () => { - jest.spyOn(horizonCard as any, 'populateConfigFromHass').mockReturnValue(undefined) - jest.spyOn(horizonCard as any, 'processLastHass').mockReturnValue(undefined) expect(horizonCard['lastHass']).toBeUndefined() const expectedLastHass = {} as HomeAssistant horizonCard.hass = expectedLastHass expect(horizonCard['lastHass']).toEqual(expectedLastHass) }) - - it('calls populateConfigFromHass if it has not been rendered yet', () => { - const populateConfigFromHassSpy = jest.spyOn(horizonCard as any, 'populateConfigFromHass').mockReturnValue(undefined) - jest.spyOn(horizonCard as any, 'processLastHass').mockReturnValue(undefined) - horizonCard['hasRendered'] = false - - horizonCard.hass = {} as HomeAssistant - expect(populateConfigFromHassSpy).toHaveBeenCalledTimes(1) - }) - - it('does not call populateConfigFromHass if it has been rendered', () => { - const populateConfigFromHassSpy = jest.spyOn(horizonCard as any, 'populateConfigFromHass').mockReturnValue(undefined) - jest.spyOn(horizonCard as any, 'processLastHass').mockReturnValue(undefined) - horizonCard['hasRendered'] = true - - horizonCard.hass = {} as HomeAssistant - expect(populateConfigFromHassSpy).not.toHaveBeenCalled() - }) - - it('calls processLastHass if it has been rendered', () => { - jest.spyOn(horizonCard as any, 'populateConfigFromHass').mockReturnValue(undefined) - const processLastHassSpy = jest.spyOn(horizonCard as any, 'processLastHass').mockReturnValue(undefined) - horizonCard['hasRendered'] = true - - horizonCard.hass = {} as HomeAssistant - expect(processLastHassSpy).toHaveBeenCalledTimes(1) - }) }) - // Visual editor disabled because it's broken, see https://developers.home-assistant.io/blog/2022/02/18/paper-elements/ - // describe('getConfigElement', () => { - // it('creates and return a horizon card config element', () => { - // const expectedElement = document.createElement('div') - // const createElementSpy = jest.spyOn(document, 'createElement').mockReturnValueOnce(expectedElement) - // const result = HorizonCard.getConfigElement() - // - // expect(result).toEqual(expectedElement) - // expect(createElementSpy).toHaveBeenCalledTimes(1) - // expect(createElementSpy).toHaveBeenCalledWith(HorizonCardEditor.cardType) - // }) - // }) - describe('setConfig', () => { it('overrides old config with new values', () => { - jest.spyOn(horizonCard as any, 'populateConfigFromHass').mockReturnValue(undefined) - expect(horizonCard['config']).toEqual({ type: HorizonCard.cardType }) const config = { type: HorizonCard.cardType, title: 'Test' @@ -106,53 +83,45 @@ describe('HorizonCard', () => { expect(horizonCard['config'].title).toEqual(newConfig.title) }) - it('uses the provided component', () => { + it('throws an error if the provided language is not supported', () => { const config = { type: HorizonCard.cardType, - component: 'test' + language: 'notSupportedLanguage' } as IHorizonCardConfig - horizonCard.setConfig(config) - expect(horizonCard['config'].component).toEqual(config.component) + expect(() => horizonCard.setConfig(config)) + .toThrow(`${config.language} is not a supported language. Supported languages: ${Object.keys(Constants.LOCALIZATION_LANGUAGES)}`) }) - it('uses the default component when not provided', () => { - const config = { - type: HorizonCard.cardType + it('throws an error if only latitude or longitude is provided', () => { + const config1 = { + type: HorizonCard.cardType, + latitude: 10 } as IHorizonCardConfig - horizonCard.setConfig(config) - expect(horizonCard['config'].component).toEqual(Constants.DEFAULT_CONFIG.component) - }) - - it('throws an error if the provided language is not supported', () => { - const config = { + const config2 = { type: HorizonCard.cardType, - language: 'notSupportedLanguage' + longitude: 10 } as IHorizonCardConfig - let thrownError - try { - horizonCard.setConfig(config) - } catch (error) { - thrownError = (error as Error).message - } - - expect(thrownError).toEqual(`${config.language} is not a supported language. Supported languages: ${Object.keys(Constants.LOCALIZATION_LANGUAGES)}`) + expect(() => horizonCard.setConfig(config1)) + .toThrow('Latitude and longitude must be both set or unset') + expect(() => horizonCard.setConfig(config2)) + .toThrow('Latitude and longitude must be both set or unset') }) - const fields = ['sunrise', 'sunset', 'dawn', 'noon', 'dusk', 'azimuth', 'elevation'] + const fields = ['sunrise', 'sunset', 'dawn', 'noon', 'dusk', 'azimuth', 'elevation', 'moonrise', 'moonset', 'moon_phase'] for (const field of fields) { it(`uses the provided value for ${field}`, () => { const config = { type: HorizonCard.cardType, fields: { - [field]: 'test' + [field]: true } } as IHorizonCardConfig horizonCard.setConfig(config) - expect(horizonCard['config'].fields?.[field]).toEqual('test') + expect(horizonCard['config'].fields?.[field]).toEqual(true) }) it(`uses the default value for ${field} when not provided`, () => { @@ -161,422 +130,1049 @@ describe('HorizonCard', () => { } as IHorizonCardConfig horizonCard.setConfig(config) - expect(horizonCard['config'].fields?.[field]).toEqual(Constants.DEFAULT_CONFIG.fields?.[field]) + horizonCard.hass = SaneHomeAssistant + const expandedConfig = horizonCard['expandedConfig']() + expect(expandedConfig.fields?.[field]).toEqual(Constants.DEFAULT_CONFIG.fields?.[field]) }) } - it('calls populateConfigFromHass if lastHass has a value', () => { - const populateConfigFromHassSpy = jest.spyOn(horizonCard as any, 'populateConfigFromHass').mockReturnValue(undefined) - horizonCard['lastHass'] = {} as HomeAssistant + const sharedFields = ['azimuth', 'elevation'] + const individualPrefixes = ['sun', 'moon'] + for (const field of sharedFields) { + for (const prefix of individualPrefixes) { + const prefixedField = `${prefix}_${field}` + it(`uses the provided value for ${prefixedField} over provided value for ${field}`, () => { + const config = { + type: HorizonCard.cardType, + fields: { + [field]: false, + [prefixedField]: true + } + } as IHorizonCardConfig + + horizonCard.setConfig(config) + horizonCard.hass = SaneHomeAssistant + expect(horizonCard['config'].fields?.[prefixedField]).toEqual(true) + }) + + it(`uses the default value for ${field} when ${prefixedField} not provided`, () => { + const config = { + type: HorizonCard.cardType + } as IHorizonCardConfig + + horizonCard.setConfig(config) + horizonCard.hass = SaneHomeAssistant + const expandedConfig = horizonCard['expandedConfig']() + expect(expandedConfig.fields?.[prefixedField]).toEqual(Constants.DEFAULT_CONFIG.fields?.[field]) + }) + } + } + }) - const config = { - type: HorizonCard.cardType - } as IHorizonCardConfig + describe('render', () => { + it('renders nothing if hass has not been set', async () => { + const html = await TemplateResultTestHelper.renderElement(horizonCard) - horizonCard.setConfig(config) - expect(populateConfigFromHassSpy).toHaveBeenCalledTimes(1) + expect(html).toMatchSnapshot() }) - it('does not call populateConfigFromHass if lastHass has not a value', () => { - const populateConfigFromHassSpy = jest.spyOn(horizonCard as any, 'populateConfigFromHass').mockReturnValue(undefined) - delete (horizonCard as any)['lastHass'] + it('renders an error if error is present on data', async () => { + enum MockErrors { + MOCK_ERROR = 'MockError' + } + horizonCard.setConfig({} as IHorizonCardConfig) + horizonCard.hass = SaneHomeAssistant + horizonCard['error'] = MockErrors.MOCK_ERROR as unknown as EHorizonCardErrors + + const html = await TemplateResultTestHelper.renderElement(horizonCard) + + expect(html).toMatchSnapshot() + }) + it('renders the horizon card if no error is present on data', async () => { const config = { - type: HorizonCard.cardType + language: 'en' } as IHorizonCardConfig - horizonCard.setConfig(config) - expect(populateConfigFromHassSpy).not.toHaveBeenCalled() + horizonCard.hass = SaneHomeAssistant + + const html = await TemplateResultTestHelper.renderElement(horizonCard) + + expect(html).toMatchSnapshot() }) - }) - describe('render', () => { - it('renders an error if error is present on data', async () => { - if (!horizonCard['data']) { - horizonCard['data'] = {} as THorizonCardData + it('sets dark mode when it is set to true on the config', () => { + const config: IHorizonCardConfig = { + type: 'horizon-card', + dark_mode: true } + horizonCard.setConfig(config) + horizonCard.hass = SaneHomeAssistant - horizonCard['data'].error = EHorizonCardErrors.SunIntegrationNotFound - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => horizonCard.render() - window.document.body.appendChild(element) - await element.updateComplete + horizonCard.render() - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() + expect(horizonCard.classList).toContainEqual('horizon-card-dark') }) - it('renders the horizon card if no error is present on data', async () => { - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => horizonCard.render() - window.document.body.appendChild(element) - await element.updateComplete + it('does not set dark mode when it is set to false on the config', () => { + const config: IHorizonCardConfig = { + type: 'horizon-card', + dark_mode: false + } + horizonCard.setConfig(config) + horizonCard.hass = SaneHomeAssistant + + horizonCard.render() - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() + expect(horizonCard.classList).not.toContainEqual('horizon-card-dark') }) }) describe('updated', () => { - it('sets to true hasRendered if has not been rendered yet', () => { - jest.spyOn(horizonCard as any, 'processLastHass').mockReturnValue(undefined) - horizonCard['hasRendered'] = false + it('triggers calculation', async () => { + const path = new SVGPathElement() + jest.spyOn((horizonCard as any).shadowRoot, 'querySelector').mockReturnValue(path) + + horizonCard.setConfig({} as IHorizonCardConfig) + horizonCard.hass = SaneHomeAssistant + + const calculateStatePartialSpy = jest.spyOn(horizonCard as any, 'calculateStatePartial') + const calculateStateFinalSpy = jest.spyOn(horizonCard as any, 'calculateStateFinal') + + const setTimeoutSpy = jest.spyOn(window, 'setTimeout') + horizonCard['updated'](new Map()) - expect(horizonCard['hasRendered']).toEqual(true) - }) + expect(horizonCard['hasCalculated']).toEqual(true) + expect(calculateStatePartialSpy).toHaveBeenCalledTimes(1) + expect(calculateStateFinalSpy).not.toHaveBeenCalled() + expect(setTimeoutSpy).not.toHaveBeenCalled() + expect(horizonCard['data'].partial).toEqual(true) + + jest.clearAllMocks() - it('calls processLastHass if has not been rendered yet', () => { - const processLastHassSpy = jest.spyOn(horizonCard as any, 'processLastHass').mockReturnValue(undefined) - horizonCard['hasRendered'] = false + // Triggered again because of state change during the partial calculation horizonCard['updated'](new Map()) - expect(processLastHassSpy).toHaveBeenCalledTimes(1) + expect(horizonCard['hasCalculated']).toEqual(true) + expect(calculateStatePartialSpy).not.toHaveBeenCalled() + expect(calculateStateFinalSpy).toHaveBeenCalledTimes(1) + expect(setTimeoutSpy).toHaveBeenCalledTimes(1) + expect(horizonCard['data'].partial).toEqual(false) + + jest.clearAllMocks() + + // Refresh from timer triggers partial calculation + jest.runAllTimers() + expect(calculateStatePartialSpy).toHaveBeenCalledTimes(1) + expect(calculateStateFinalSpy).not.toHaveBeenCalled() + expect(setTimeoutSpy).not.toHaveBeenCalled() }) - it('does nothing more than call super.updated if has been already rendered', () => { - const processLastHassSpy = jest.spyOn(horizonCard as any, 'processLastHass').mockReturnValue(undefined) - horizonCard['hasRendered'] = true + it('does nothing if not configured yet', () => { + expect(horizonCard['hasCalculated']).toEqual(false) horizonCard['updated'](new Map()) - expect(processLastHassSpy).not.toHaveBeenCalledWith() + expect(horizonCard['hasCalculated']).toEqual(false) }) }) - describe('populateConfigFromHass', () => { + describe('disconnectedCallback', () => { + it('sets wasDisconnected', async () => { + expect(horizonCard['wasDisconnected']).toEqual(false) + horizonCard.disconnectedCallback() + expect(horizonCard['wasDisconnected']).toEqual(true) + }) + }) + + describe('expandedConfig', () => { it('keeps values from config when present', () => { const config = { type: HorizonCard.cardType, - darkMode: true, - language: 'es' + dark_mode: true, + language: 'es', + latitude: 10, + longitude: 20, + elevation: 100, + time_zone: 'Europe/Sofia' } as IHorizonCardConfig - horizonCard['lastHass'] = { + horizonCard.setConfig(config) + horizonCard.hass = { themes: { darkMode: false }, locale: { language: 'en' + }, + config: { + latitude: 30, + longitude: 40, + elevation: 200, + time_zone: 'Europe/Berlin' } } as any - horizonCard['config'] = config - horizonCard['populateConfigFromHass']() - expect(horizonCard['config'].darkMode).toEqual(config.darkMode) - expect(horizonCard['config'].language).toEqual(config.language) + const expandedCongfig = horizonCard['expandedConfig']() + expect(expandedCongfig.dark_mode).toEqual(config.dark_mode) + expect(expandedCongfig.language).toEqual(config.language) + expect(expandedCongfig.latitude).toEqual(config.latitude) + expect(expandedCongfig.longitude).toEqual(config.longitude) + expect(expandedCongfig.elevation).toEqual(config.elevation) + expect(expandedCongfig.time_zone).toEqual(config.time_zone) + expect(horizonCard['latitude']()).toEqual(config.latitude) + expect(horizonCard['longitude']()).toEqual(config.longitude) + expect(horizonCard['elevation']()).toEqual(config.elevation) + expect(horizonCard['timeZone']()).toEqual(config.time_zone) }) it('sets values from hass when not present in config', () => { - horizonCard['lastHass'] = { + horizonCard.setConfig({} as IHorizonCardConfig) + horizonCard.hass = { themes: { darkMode: false }, locale: { language: 'it' + }, + config: { + latitude: 30, + longitude: 40, + elevation: 200, + time_zone: 'Europe/Berlin' } } as any - horizonCard['populateConfigFromHass']() - expect(horizonCard['config'].darkMode).toEqual(false) - expect(horizonCard['config'].language).toEqual('it') + const expandedCongfig = horizonCard['expandedConfig']() + expect(expandedCongfig.dark_mode).toEqual(false) + expect(expandedCongfig.language).toEqual('it') + expect(expandedCongfig.latitude).toEqual(30) + expect(expandedCongfig.longitude).toEqual(40) + expect(expandedCongfig.elevation).toEqual(200) + expect(expandedCongfig.time_zone).toEqual('Europe/Berlin') + expect(horizonCard['latitude']()).toEqual(30) + expect(horizonCard['longitude']()).toEqual(40) + expect(horizonCard['elevation']()).toEqual(200) + expect(horizonCard['timeZone']()).toEqual('Europe/Berlin') }) - it('supports old versions of home assistant', () => { - horizonCard['lastHass'] = { - themes: { - darkMode: false - }, - language: 'it' - } as any + it('uses default values for fields when no user fields are configured', () => { + horizonCard.setConfig({} as IHorizonCardConfig) + horizonCard.hass = SaneHomeAssistant + + const expandedCongfig = horizonCard['expandedConfig']() + expect(expandedCongfig.fields?.sunrise).toEqual(true) + expect(expandedCongfig.fields?.sunset).toEqual(true) + expect(expandedCongfig.fields?.dawn).toEqual(true) + expect(expandedCongfig.fields?.noon).toEqual(true) + expect(expandedCongfig.fields?.dusk).toEqual(true) + expect(expandedCongfig.fields?.azimuth).toEqual(false) + expect(expandedCongfig.fields?.elevation).toEqual(false) + expect(expandedCongfig.fields?.moonrise).toEqual(false) + expect(expandedCongfig.fields?.moon_phase).toEqual(false) + expect(expandedCongfig.fields?.moonset).toEqual(false) + }) + + it('uses default values for fields and user fields config', () => { + horizonCard.setConfig({ + fields: { + azimuth: true + } + } as IHorizonCardConfig) + horizonCard.hass = SaneHomeAssistant + + const expandedCongfig = horizonCard['expandedConfig']() + expect(expandedCongfig.fields?.sunrise).toEqual(true) + expect(expandedCongfig.fields?.sunset).toEqual(true) + expect(expandedCongfig.fields?.dawn).toEqual(true) + expect(expandedCongfig.fields?.noon).toEqual(true) + expect(expandedCongfig.fields?.dusk).toEqual(true) + expect(expandedCongfig.fields?.azimuth).toEqual(true) + expect(expandedCongfig.fields?.elevation).toEqual(false) + expect(expandedCongfig.fields?.moonrise).toEqual(false) + expect(expandedCongfig.fields?.moon_phase).toEqual(false) + expect(expandedCongfig.fields?.moonset).toEqual(false) + }) + + it('uses default values for non-field config', () => { + horizonCard.setConfig({} as IHorizonCardConfig) + horizonCard.hass = SaneHomeAssistant - horizonCard['populateConfigFromHass']() - expect(horizonCard['config'].darkMode).toEqual(false) - expect(horizonCard['config'].language).toEqual('it') + const expandedCongfig = horizonCard['expandedConfig']() + expect(expandedCongfig['moon']).toEqual(true) + }) + + it('uses user provided values for non-field config', () => { + horizonCard.setConfig({ + moon: false + } as IHorizonCardConfig) + horizonCard.hass = SaneHomeAssistant + + const expandedCongfig = horizonCard['expandedConfig']() + expect(expandedCongfig['moon']).toEqual(false) }) }) - describe('processLastHass', () => { - it('does an early return if lastHass has no value', () => { - delete (horizonCard as any)['lastHass'] - const populateConfigFromHassSpy = jest.spyOn(horizonCard as any, 'populateConfigFromHass').mockReturnValue(undefined) - horizonCard['processLastHass']() + describe('i18n', () => { + const config = { + type: HorizonCard.cardType, + language: 'es', + time_zone: 'Europe/Sofia', + time_format: TimeFormat.language, + number_format: NumberFormat.language + } as IHorizonCardConfig + + it('uses local time zone', () => { + const hass = { + ...SaneHomeAssistant, + } + hass.locale['time_zone'] = 'local' + horizonCard.hass = hass - expect(populateConfigFromHassSpy).not.toHaveBeenCalled() + horizonCard['i18n'](config) + expect(I18N).toBeCalledWith('es', 'UTC', TimeFormat.language, NumberFormat.language, SaneHomeAssistant.localize) }) - it('calls populateConfigFromHass', () => { - (horizonCard as any)['lastHass'] = { states: {} } - const populateConfigFromHassSpy = jest.spyOn(horizonCard as any, 'populateConfigFromHass').mockReturnValue(undefined) - horizonCard['processLastHass']() + it('uses server time zone', () => { + const hass = { + ...SaneHomeAssistant, + } + hass.locale['time_zone'] = 'server' + horizonCard.hass = hass - expect(populateConfigFromHassSpy).toHaveBeenCalledTimes(1) + horizonCard['i18n'](config) + expect(I18N).toBeCalledWith('es', 'Europe/Sofia', TimeFormat.language, NumberFormat.language, SaneHomeAssistant.localize) }) - it('process the data from the component when found', () => { - (horizonCard as any)['lastHass'] = { - states: { - component: { - attributes: { - azimuth: 3, - elevation: 7 - } - } - } + it('uses server time zone on older HASS', () => { + horizonCard.hass = SaneHomeAssistant + + horizonCard['i18n'](config) + expect(I18N).toBeCalledWith('es', 'Europe/Sofia', TimeFormat.language, NumberFormat.language, SaneHomeAssistant.localize) + }) + }) + + describe('getCardSize', () => { + // Note: don't set hass for these tests, getCardSize() is called after setConfig() but before hass is set + it('compute size for default fields', () => { + horizonCard.setConfig({} as IHorizonCardConfig) + + expect(horizonCard.getCardSize()).toEqual(6) + }) + + const azimuthElevationFields = ['azimuth', 'sun_azimuth', 'moon_azimuth', 'elevation', 'sun_elevation', 'moon_elevation'] + for (const field of azimuthElevationFields) { + it(`compute size when field ${field} is added`, () => { + const fields = {} + fields[field] = true + horizonCard.setConfig({fields} as IHorizonCardConfig) + + expect(horizonCard.getCardSize()).toEqual(7) + }) + } + + it(`compute size when all azimuth/elevation fields are added`, () => { + const fields = {} + for (const field of azimuthElevationFields) { + fields[field] = true } + horizonCard.setConfig({fields} as IHorizonCardConfig) - const config = { - type: HorizonCard.cardType, - component: 'component' - } as IHorizonCardConfig + expect(horizonCard.getCardSize()).toEqual(7) + }) - horizonCard['config'] = config + const moonriseMoonsetFields = ['moonrise', 'moon_phase', 'moonset'] + for (const field of moonriseMoonsetFields) { + it(`compute size when field ${field} is added`, () => { + const fields = {} + fields[field] = true + horizonCard.setConfig({fields} as IHorizonCardConfig) - jest.spyOn(horizonCard as any, 'populateConfigFromHass').mockReturnValue(undefined) + expect(horizonCard.getCardSize()).toEqual(7) + }) + } - const times = { - dawn: new Date(0), - dusk: new Date(0), - noon: new Date(0), - sunrise: new Date(0), - sunset: new Date(0) + it(`compute size when all moonrise/moon_phase/moonset fields are added`, () => { + const fields = {} + for (const field of moonriseMoonsetFields) { + fields[field] = true } + horizonCard.setConfig({fields} as IHorizonCardConfig) + + expect(horizonCard.getCardSize()).toEqual(7) + }) - const sunInfo = { - sunrise: 0, - sunset: 0, - dawnProgressPercent: 0, - dayProgressPercent: 0, - duskProgressPercent: 0, - sunAboveHorizon: 0, - sunPercentOverHorizon: 100, - sunPosition: { x: 0, y: 0 } + it(`compute size when all fields are added`, () => { + const fields = {} + for (const field of azimuthElevationFields) { + fields[field] = true + } + for (const field of moonriseMoonsetFields) { + fields[field] = true } + horizonCard.setConfig({fields} as IHorizonCardConfig) - const readTimesSpy = jest.spyOn(horizonCard as any, 'readTimes').mockReturnValue(times) - const calculateSunInfoSpy = jest.spyOn(horizonCard as any, 'calculateSunInfo').mockReturnValue(sunInfo) + expect(horizonCard.getCardSize()).toEqual(8) + }) - horizonCard['processLastHass']() + it(`compute size when sunrise and sunset fields are removed`, () => { + horizonCard.setConfig({ + fields: { + sunrise: false, + sunset: false + } + } as IHorizonCardConfig) - expect(readTimesSpy).toHaveBeenCalledTimes(1) - expect(calculateSunInfoSpy).toHaveBeenCalledTimes(1) - expect(horizonCard['data'].times).toEqual(times) - expect(horizonCard['data'].sunInfo).toEqual(sunInfo) + expect(horizonCard.getCardSize()).toEqual(5) }) - it('sets an error if the component is not found', () => { - (horizonCard as any)['lastHass'] = { states: {} } - jest.spyOn(horizonCard as any, 'populateConfigFromHass').mockReturnValue(undefined) - horizonCard['processLastHass']() + it(`compute size when sunrise field is removed`, () => { + horizonCard.setConfig({ + fields: { + sunrise: false + } + } as IHorizonCardConfig) - expect(horizonCard['data'].error).toEqual(EHorizonCardErrors.SunIntegrationNotFound) + expect(horizonCard.getCardSize()).toEqual(6) }) - }) - describe('readTimes', () => { - it('returns dates for each time field', () => { - const normalizeSunEventTimeSpy = jest.spyOn(horizonCard as any, 'normalizeSunEventTime') - .mockReturnValue(new Date(0)) - const combineDateTimeSpy = jest.spyOn(horizonCard as any, 'combineDateTime') - .mockReturnValue(new Date(0)) - - const result = horizonCard['readTimes']({ - next_setting: 0, - next_dawn: 0, - next_dusk: 0, - next_noon: 0, - next_rising: 0 - }, new Date()) + it(`compute size when sunset field is removed`, () => { + horizonCard.setConfig({ + fields: { + sunset: false + } + } as IHorizonCardConfig) - expect(result).toEqual({ - dawn: new Date(0), - dusk: new Date(0), - noon: new Date(0), - sunrise: new Date(0), - sunset: new Date(0) - }) + expect(horizonCard.getCardSize()).toEqual(6) + }) + + it(`compute size when dusk, noon and dawn fields are removed`, () => { + horizonCard.setConfig({ + fields: { + dusk: false, + noon: false, + dawn: false + } + } as IHorizonCardConfig) - expect(normalizeSunEventTimeSpy).toHaveBeenCalledTimes(4) - expect(combineDateTimeSpy).toHaveBeenCalledTimes(1) + expect(horizonCard.getCardSize()).toEqual(5) }) - }) - describe('normalizeSunEventTime', () => { - it('normalizes the moment of a specific event according to next noon if within 24h', () => { - const eventTime = '2021-06-13T12:34:56.007' - const now = new Date('2021-06-13T13:40:23') - const noon = new Date('2021-06-12T23:45:56') - - const result = horizonCard['normalizeSunEventTime'](eventTime, now, noon) - - if (typeof result === 'object') { - expect(result.getFullYear()).toEqual(2021) - expect(result.getMonth()).toEqual(5) - expect(result.getDate()).toEqual(13) - expect(result.getHours()).toEqual(12) - expect(result.getMinutes()).toEqual(34) - expect(result.getSeconds()).toEqual(56) - expect(result.getMilliseconds()).toEqual(7) - } + it(`compute size when dusk field is removed`, () => { + horizonCard.setConfig({ + fields: { + dusk: false + } + } as IHorizonCardConfig) + + expect(horizonCard.getCardSize()).toEqual(6) + }) + + it(`compute size when noon field is removed`, () => { + horizonCard.setConfig({ + fields: { + noon: false + } + } as IHorizonCardConfig) + + expect(horizonCard.getCardSize()).toEqual(6) + }) + + it(`compute size when dawn field is removed`, () => { + horizonCard.setConfig({ + fields: { + dawn: false + } + } as IHorizonCardConfig) + + expect(horizonCard.getCardSize()).toEqual(6) + }) + + it(`compute size when all default fields are removed`, () => { + horizonCard.setConfig({ + fields: { + sunrise: false, + sunset: false, + dusk: false, + noon: false, + dawn: false + } + } as IHorizonCardConfig) + + expect(horizonCard.getCardSize()).toEqual(4) }) - it('normalizes the moment of a specific event and returns undefined if not within 24h of next noon', () => { - const eventTime = '2021-06-14T12:34:56.007' - const now = new Date('2021-06-13T13:40:23') - const noon = new Date('2021-06-12T23:45:56') - const result = horizonCard['normalizeSunEventTime'](eventTime, now, noon) + it(`compute size when a title is set`, () => { + horizonCard.setConfig({ + title: 'Fancy Card' + } as IHorizonCardConfig) - expect(result).toBeUndefined() + expect(horizonCard.getCardSize()).toEqual(7) }) }) - describe('combineDateTime', () => { - it('combines the date of one Date with the time of another Date', () => { - const date = new Date('2021-06-12T23:45:56') - const time = new Date('2023-03-18T12:34:56.007') + describe('refreshPeriod', () => { + it('uses default refresh period when not present in config', () => { + horizonCard.setConfig({} as IHorizonCardConfig) + horizonCard.hass = SaneHomeAssistant + expect(horizonCard['refreshPeriod']()).toEqual(Constants.DEFAULT_REFRESH_PERIOD) + }) + + it('uses refresh period from config', () => { + horizonCard.setConfig({ + refresh_period: 77 + } as IHorizonCardConfig) + horizonCard.hass = SaneHomeAssistant + expect(horizonCard['refreshPeriod']()).toEqual(77) + }) + }) - const result = horizonCard['combineDateTime'](date, time) + describe('now', () => { + it('uses true now when not present in config', () => { + jest.setSystemTime(new Date('2023-04-04T00:00:01Z')) + horizonCard.setConfig({} as IHorizonCardConfig) + horizonCard.hass = SaneHomeAssistant + expect(horizonCard['now']()).toEqual(new Date('2023-04-04T00:00:01Z')) + }) - expect(result.getFullYear()).toEqual(2021) - expect(result.getMonth()).toEqual(5) - expect(result.getDate()).toEqual(12) - expect(result.getHours()).toEqual(12) - expect(result.getMinutes()).toEqual(34) - expect(result.getSeconds()).toEqual(56) - expect(result.getMilliseconds()).toEqual(7) + it('uses now from config', () => { + horizonCard.setConfig({ + now: new Date('2023-04-04T00:00:02Z') + } as IHorizonCardConfig) + horizonCard.hass = SaneHomeAssistant + expect(horizonCard['now']()).toEqual(new Date('2023-04-04T00:00:02Z')) }) }) - describe('now and fixed now', () => { - beforeAll(() => { - jest.useFakeTimers() - jest.setSystemTime(new Date(2023, 2, 20, 12, 34, 56)) + describe('debugLevel', () => { + it('uses zero debug level when not present in config', () => { + horizonCard.setConfig({} as IHorizonCardConfig) + horizonCard.hass = SaneHomeAssistant + expect(horizonCard['debugLevel']()).toEqual(0) + }) + + it('uses debug level from config', () => { + horizonCard.setConfig({ + debug_level: 1 + } as IHorizonCardConfig) + horizonCard.hass = SaneHomeAssistant + expect(horizonCard['debugLevel']()).toEqual(1) + }) + + it('uses debug level from config', () => { + horizonCard.setConfig({ + debug_level: 1 + } as IHorizonCardConfig) + horizonCard.hass = SaneHomeAssistant + expect(horizonCard['debugLevel']()).toEqual(1) + }) + + it('does not print a console debug message when debug level is not configured', () => { + const consoleErrorSpy = jest.spyOn(console, 'debug').mockImplementation() + horizonCard.setConfig({} as IHorizonCardConfig) + horizonCard.hass = SaneHomeAssistant + + horizonCard['debug']('test1', 1) + horizonCard['debug'](() => 'test2', 2) + expect(consoleErrorSpy).not.toHaveBeenCalled() + }) + + it('prints a console debug message when debug level corresponds to message level', () => { + const consoleErrorSpy = jest.spyOn(console, 'debug').mockImplementation() + horizonCard.setConfig({ + debug_level: 2 + } as IHorizonCardConfig) + horizonCard.hass = SaneHomeAssistant + + consoleErrorSpy.mockClear() + horizonCard['debug']('test1', 1) + expect(consoleErrorSpy).toHaveBeenCalledWith('custom:horizon-card :: test1') + + consoleErrorSpy.mockClear() + horizonCard['debug'](() => 'test2', 2) + expect(consoleErrorSpy).toHaveBeenCalledWith('custom:horizon-card :: test2') + + consoleErrorSpy.mockClear() + horizonCard['debug']('test3', 3) + expect(consoleErrorSpy).not.toHaveBeenCalled() }) + }) - afterAll(() => { - jest.useRealTimers() + describe('readSunTimes', () => { + beforeEach(() => { + horizonCard.hass = SaneHomeAssistant + horizonCard.setConfig({} as IHorizonCardConfig) }) - it('uses actual time when no fixed offset', () => { - const result = horizonCard['now']() + it('returns dates for each time field when now is the same day', () => { + const now = new Date('2023-04-05T13:00:00Z') + jest.spyOn(horizonCard as any, 'now').mockReturnValue(now) + + const result = horizonCard['readSunTimes'](now, 0, 0, 0) - expect(result).toEqual(new Date()) + expect(result).toEqual({ + now: now, + dawn: new Date('2023-04-05T06:00:00Z'), + dusk: new Date('2023-04-05T19:00:00Z'), + midnight: new Date('2023-04-06T00:30:00Z'), + noon: new Date('2023-04-05T12:30:00Z'), + sunrise: new Date('2023-04-05T06:30:00Z'), + sunset: new Date('2023-04-05T18:30:00Z') + }) }) - it('uses date of noon plus set fixed offset from midnight', () => { - horizonCard['setFixedNow'](new Date('2023-03-18T07:35:15')) + it('returns dates for each time field when now is the next day but within 12 hours of the past noon', () => { + const now = new Date('2023-04-06T00:20:00Z') + jest.spyOn(horizonCard as any, 'now').mockReturnValue(now) - const result = horizonCard['now']() + const result = horizonCard['readSunTimes'](now, 0, 0, 0) - expect(result).toEqual(new Date('2023-03-18T07:35:15')) + expect(result).toEqual({ + now: now, + dawn: new Date('2023-04-05T06:00:00Z'), + dusk: new Date('2023-04-05T19:00:00Z'), + midnight: new Date('2023-04-06T00:30:00Z'), + noon: new Date('2023-04-05T12:30:00Z'), + sunrise: new Date('2023-04-05T06:30:00Z'), + sunset: new Date('2023-04-05T18:30:00Z') + }) }) }) - describe('calculateUsableSunrise', () => { + describe('convertSunCalcTimes', () => { + const sunCalcTimes = ['civilDawn', 'civilDusk', 'nadir', 'solarNoon', 'sunriseStart', 'sunsetEnd'] + const times = ['dawn', 'dusk', 'midnight', 'noon', 'sunrise', 'sunset'] + const rawSunTimes = {} + sunCalcTimes.forEach((name) => { + rawSunTimes[name] = { + value: new Date(0), + valid: true + } + }) + for (let i = 0; i < sunCalcTimes.length; i++) { + const sunCalcTime = sunCalcTimes[i] + const time = times[i] + it(`converts SunCalc '${sunCalcTime}' time to '${time} if valid`, () => { + jest.spyOn(horizonCard as any, 'now').mockReturnValue(new Date(1)) + const result = horizonCard['convertSunCalcTimes'](rawSunTimes) + expect(result[time]).toEqual(new Date(0)) + expect(result['now']).toEqual(new Date(1)) + }) + it(`converts SunCalc '${sunCalcTime}' time to '${time} if invalid`, () => { + jest.spyOn(horizonCard as any, 'now').mockReturnValue(new Date(1)) + const data = { + ...rawSunTimes, + [sunCalcTime]: { + ...rawSunTimes[sunCalcTime], + valid: false + } + } + const result = horizonCard['convertSunCalcTimes'](data) + expect(result[time]).toBeUndefined() + expect(result['now']).toEqual(new Date(1)) + }) + } + }) + + describe('sunriseForComputation', () => { it('returns actual time when usable', () => { - const dayStartMs = new Date('2023-03-18T00:00:00').getTime() - const elevation = -10 - const noon = new Date('2023-03-18T12:20:00') - const sunset = new Date('2023-03-18T06:30:00') - const sunrise = new Date('2023-03-18T19:40:00') + const noon = new Date('2023-03-18T12:20:00Z') + const sunrise = new Date('2023-03-18T06:30:00Z') - const result = horizonCard['calculateUsableSunrise'](dayStartMs, elevation, noon, sunset, sunrise) + const result = horizonCard['sunriseForComputation'](sunrise, noon, false) - expect(result).toEqual(new Date('2023-03-18T06:30:00').getTime()) + expect(result).toEqual(new Date('2023-03-18T06:30:00Z')) }) - it('returns day start when undefined above horizon', () => { - const dayStartMs = new Date('2023-03-18T00:00:00').getTime() - const elevation = 1 - const noon = new Date('2023-03-18T12:20:00') - const sunset = undefined - const sunrise = new Date('2023-03-18T19:40:00') + it('returns 12 hours before noon when undefined above horizon', () => { + const noon = new Date('2023-03-18T12:20:00Z') + const sunrise = undefined - const result = horizonCard['calculateUsableSunrise'](dayStartMs, elevation, noon, sunset, sunrise) + const result = horizonCard['sunriseForComputation'](sunrise, noon, false) - expect(result).toEqual(new Date('2023-03-18T00:00:00').getTime()) + expect(result).toEqual(new Date('2023-03-18T00:20:00Z')) }) - it('returns noon minus 1ms when undefined below horizon', () => { - const dayStartMs = new Date('2023-03-18T00:00:00').getTime() - const elevation = -1 - const noon = new Date('2023-03-18T12:20:00') - const sunset = undefined - const sunrise = new Date('2023-03-18T19:40:00') + it('returns noon when undefined below horizon', () => { + const noon = new Date('2023-03-18T12:20:00Z') + const sunrise = undefined + + const result = horizonCard['sunriseForComputation'](sunrise, noon, true) + + expect(result).toEqual(new Date('2023-03-18T12:20:00Z')) + }) + }) + + describe('calculateStatePartial and calculateStateFinal', () => { + const expectedPartial: THorizonCardData = { + partial: true, + latitude: 33, + longitude: 20, + sunData: { + azimuth: 180, + elevation: 45, + times: { + dawn: new Date('2023-04-05T06:00:00.000Z'), + dusk: new Date('2023-04-05T19:00:00.000Z'), + midnight: new Date('2023-04-06T00:30:00.000Z'), + noon: new Date('2023-04-05T12:30:00.000Z'), + now: new Date('2023-04-05T13:00:00.000Z'), + sunrise: new Date('2023-04-05T06:30:00.000Z'), + sunset: new Date('2023-04-05T18:30:00.000Z'), + }, + hueReduce: 0, + saturationReduce: 0, + lightnessReduce: 0 + }, + sunPosition: { + x: 80, + y: 100, + horizonY: 100, + offsetY: 0, + scaleY: 0.7875, + sunriseX: 80, + sunsetX: 80 + }, + moonData: { + zenithAngle: -90, + parallacticAngle: 10, + azimuth: 270, + elevation: 90, + fraction: 1, + phase: Constants.MOON_PHASES.fullMoon, + phaseRotation: -45, + times: { + moonrise: new Date('2023-04-05T13:00:00.000Z'), + moonset: new Date('2023-04-05T22:00:00.000Z'), + now: new Date('2023-04-05T13:00:00.000Z'), + }, + saturationReduce: 0, + lightnessReduce: 0 + }, + moonPosition: { + x: 403, + y: 14 + } + } + + beforeEach(() => { + const path = new SVGPathElement(80, 100) + jest.spyOn((horizonCard as any).shadowRoot, 'querySelector').mockReturnValue(path) - const result = horizonCard['calculateUsableSunrise'](dayStartMs, elevation, noon, sunset, sunrise) + jest.spyOn(horizonCard as any, 'now').mockReturnValue(new Date('2023-04-05T13:00:00Z')) + jest.spyOn(horizonCard as any, 'latitude').mockReturnValue(33) + jest.spyOn(horizonCard as any, 'longitude').mockReturnValue(20) + jest.spyOn(horizonCard as any, 'elevation').mockReturnValue(500) - expect(result).toEqual(new Date('2023-03-18T12:20:00').getTime() - 1) + horizonCard.hass = SaneHomeAssistant }) - it('returns day start when sunset is before midnight', () => { - const dayStartMs = new Date('2023-03-18T00:00:00').getTime() - const elevation = -10 - const noon = new Date('2023-03-18T12:20:00') - const sunset = new Date('2023-03-18T23:30:00') - const sunrise = new Date('2023-03-18T22:45:00') + it('calculateStatePartial', () => { + horizonCard.setConfig({ + southern_flip: false + } as IHorizonCardConfig) + horizonCard['calculateStatePartial']() + const result = horizonCard['data'] - const result = horizonCard['calculateUsableSunrise'](dayStartMs, elevation, noon, sunset, sunrise) + expect(result).toEqual(expectedPartial) + }) + + it('calculateStateFinal', () => { + horizonCard['data'] = expectedPartial + horizonCard['calculateStateFinal']() + const result = horizonCard['data'] - expect(result).toEqual(new Date('2023-03-18T00:00:00').getTime()) + const expectedFinal = { + ...expectedPartial, + partial: false, + sunPosition: { + ...expectedPartial['sunPosition'], + offsetY: -16 + } + } + expect(result).toEqual(expectedFinal) }) }) - describe('calculateUsableSunset', () => { - it('returns actual time when usable', () => { - const dayEndMs = new Date('2023-03-18T23:59:59.999').getTime() - const elevation = -10 - const noon = new Date('2023-03-18T12:20:00') - const sunset = new Date('2023-03-18T19:30:00') + describe('computeSunPosition', () => { + const mockPointOnCurve = (times) => { + // Very rough approximation of findPointOnCurve without the real curve inside a real SVGPathElement + return (time, noon) => { + if (time === noon) { + return {x: 275, y: 20} + } else if (time === times.sunrise) { + return {x: 135, y: 84} + } else if (time === times.sunset) { + return {x: 405, y: 84} + } else if (time === times.now) { + return {x: 300, y: 30} + } else if (time.getTime() === noon.getTime() - 12 * 60 * 60 * 1000) { + // mock sunrise for computation in white nights, 12 hours before solar noon + return {x: 5, y: 146} + } else { + throw new Error() + } + } + } + + it('computes the sun position when all times are available', () => { + const sunTimes = { + dawn: new Date('2023-04-05T06:00:00.000Z'), + dusk: new Date('2023-04-05T19:00:00.000Z'), + midnight: new Date('2023-04-06T00:30:00.000Z'), + noon: new Date('2023-04-05T12:30:00.000Z'), + now: new Date('2023-04-05T13:00:00.000Z'), + sunrise: new Date('2023-04-05T06:30:00.000Z'), + sunset: new Date('2023-04-05T18:30:00.000Z'), + } as TSunTimes + + jest.spyOn(horizonCard as any, 'findPointOnCurve').mockImplementation(mockPointOnCurve(sunTimes)) + + const expected = { + horizonY: 84, + offsetY: 0, + scaleY: 0.984375, + sunriseX: 135, + sunsetX: 405, + x: 300, + y: 30 + } - const result = horizonCard['calculateUsableSunset'](dayEndMs, elevation, noon, sunset) + const sunPosition = horizonCard['computeSunPosition'](sunTimes, false) + expect(sunPosition).toEqual(expected) - expect(result).toEqual(new Date('2023-03-18T19:30:00').getTime()) + const expectedWithScale = expected + const sunPositionWithScale = horizonCard['computeSunPosition'](sunTimes, false, expected.scaleY) + expect(sunPositionWithScale).toEqual(expectedWithScale) }) - it('returns day start when undefined above horizon', () => { - const dayEndMs = new Date('2023-03-18T23:59:59.999').getTime() - const elevation = 1 - const noon = new Date('2023-03-18T12:20:00') - const sunset = undefined + it('computes the sun position in white nights season and returns hidden sunset/sunrise', () => { + const sunTimes = { + midnight: new Date('2023-07-06T00:30:00.000Z'), + noon: new Date('2023-07-05T12:30:00.000Z'), + now: new Date('2023-07-05T13:00:00.000Z'), + } as TSunTimes + + jest.spyOn(horizonCard as any, 'findPointOnCurve').mockImplementation(mockPointOnCurve(sunTimes)) + + const expected = { + horizonY: 146, + offsetY: 0, + scaleY: 0.5, + sunriseX: -10, + sunsetX: -10, + x: 300, + y: 30 + } - const result = horizonCard['calculateUsableSunset'](dayEndMs, elevation, noon, sunset) + const sunPosition = horizonCard['computeSunPosition'](sunTimes, false) + expect(sunPosition).toEqual(expected) - expect(result).toEqual(new Date('2023-03-18T23:59:59.999').getTime()) + const expectedWithScale = { + ...expected, + offsetY: -62 + } + const sunPositionWithScale = horizonCard['computeSunPosition'](sunTimes, false, expected.scaleY) + expect(sunPositionWithScale).toEqual(expectedWithScale) }) - it('returns noon plus 1ms when undefined below horizon', () => { - const dayEndMs = new Date('2023-03-18T23:59:59.999').getTime() - const elevation = -1 - const noon = new Date('2023-03-18T12:20:00') - const sunset = undefined + it('computes the sun position in winter darkness season and returns hidden sunset/sunrise', () => { + const path = new SVGPathElement(80, 100) + jest.spyOn((horizonCard as any).shadowRoot, 'querySelector').mockReturnValue(path) + + const sunTimes = { + midnight: new Date('2023-12-06T00:30:00.000Z'), + noon: new Date('2023-12-05T12:30:00.000Z'), + now: new Date('2023-12-05T13:00:00.000Z'), + } as TSunTimes + + jest.spyOn(horizonCard as any, 'findPointOnCurve').mockImplementation(mockPointOnCurve(sunTimes)) + + const expected = { + horizonY: 20, + offsetY: 0, + scaleY: 0.5, + sunriseX: -10, + sunsetX: -10, + x: 300, + y: 30 + } - const result = horizonCard['calculateUsableSunset'](dayEndMs, elevation, noon, sunset) + const sunPosition = horizonCard['computeSunPosition'](sunTimes, true) + expect(sunPosition).toEqual(expected) - expect(result).toEqual(new Date('2023-03-18T12:20:00').getTime() + 1) + const expectedWithScale = { + ...expected, + offsetY: 64 + } + const sunPositionWithScale = horizonCard['computeSunPosition'](sunTimes, true, expected.scaleY) + expect(sunPositionWithScale).toEqual(expectedWithScale) }) }) - describe('calculateSunInfo', () => { - it('returns all sun info', () => { - const path = new SVGPathElement() - jest.spyOn((horizonCard as any).shadowRoot, 'querySelector').mockReturnValue(path) + describe('isWinterDarkness', () => { + it('returns true if north of the equator and month is between October and February', () => { + expect(horizonCard['isWinterDarkness'](1, new Date('2022-10-15T00:00:00Z'))).toEqual(true) + expect(horizonCard['isWinterDarkness'](1, new Date('2022-11-15T00:00:00Z'))).toEqual(true) + expect(horizonCard['isWinterDarkness'](1, new Date('2022-12-15T00:00:00Z'))).toEqual(true) + expect(horizonCard['isWinterDarkness'](1, new Date('2023-01-15T00:00:00Z'))).toEqual(true) + expect(horizonCard['isWinterDarkness'](1, new Date('2023-02-15T00:00:00Z'))).toEqual(true) + }) - const result = horizonCard['calculateSunInfo'](45, new Date(), { - dusk: new Date(0), - dawn: new Date(0), - noon: new Date(0), - sunrise: new Date(0), - sunset: new Date(0) - }) + it('returns false if north of the equator and month is between March and September', () => { + expect(horizonCard['isWinterDarkness'](1, new Date('2023-03-15T00:00:00Z'))).toEqual(false) + expect(horizonCard['isWinterDarkness'](1, new Date('2023-04-15T00:00:00Z'))).toEqual(false) + expect(horizonCard['isWinterDarkness'](1, new Date('2023-05-15T00:00:00Z'))).toEqual(false) + expect(horizonCard['isWinterDarkness'](1, new Date('2023-06-15T00:00:00Z'))).toEqual(false) + expect(horizonCard['isWinterDarkness'](1, new Date('2023-07-15T00:00:00Z'))).toEqual(false) + expect(horizonCard['isWinterDarkness'](1, new Date('2023-08-15T00:00:00Z'))).toEqual(false) + expect(horizonCard['isWinterDarkness'](1, new Date('2023-09-15T00:00:00Z'))).toEqual(false) + }) - expect(result).toEqual({ - dawnProgressPercent: 0, - dayProgressPercent: 0, - duskProgressPercent: 0, - sunAboveHorizon: true, - sunPercentOverHorizon: 100, - sunPosition: { - x: 0, - y: 0 - }, - sunrise: 0, - sunset: 0 - }) + it('returns false if south of the equator and month is between October and February', () => { + expect(horizonCard['isWinterDarkness'](-1, new Date('2022-10-15T00:00:00Z'))).toEqual(false) + expect(horizonCard['isWinterDarkness'](-1, new Date('2022-11-15T00:00:00Z'))).toEqual(false) + expect(horizonCard['isWinterDarkness'](-1, new Date('2022-12-15T00:00:00Z'))).toEqual(false) + expect(horizonCard['isWinterDarkness'](-1, new Date('2023-01-15T00:00:00Z'))).toEqual(false) + expect(horizonCard['isWinterDarkness'](-1, new Date('2023-02-15T00:00:00Z'))).toEqual(false) + }) + + it('returns true if south of the equator and month is between March and September', () => { + expect(horizonCard['isWinterDarkness'](-1, new Date('2023-03-15T00:00:00Z'))).toEqual(true) + expect(horizonCard['isWinterDarkness'](-1, new Date('2023-04-15T00:00:00Z'))).toEqual(true) + expect(horizonCard['isWinterDarkness'](-1, new Date('2023-05-15T00:00:00Z'))).toEqual(true) + expect(horizonCard['isWinterDarkness'](-1, new Date('2023-06-15T00:00:00Z'))).toEqual(true) + expect(horizonCard['isWinterDarkness'](-1, new Date('2023-07-15T00:00:00Z'))).toEqual(true) + expect(horizonCard['isWinterDarkness'](-1, new Date('2023-08-15T00:00:00Z'))).toEqual(true) + expect(horizonCard['isWinterDarkness'](-1, new Date('2023-09-15T00:00:00Z'))).toEqual(true) + }) + }) + + describe('computeMoonData', () => { + beforeEach(() => { + horizonCard.hass = SaneHomeAssistant + horizonCard.setConfig({} as IHorizonCardConfig) + }) + + it('returns moonrise and moonset times when available', () => { + const moonData = horizonCard['computeMoonData'](new Date('2023-04-05T14:00:00Z'), 40, 20) + expect(moonData.times.moonrise).toEqual(new Date('2023-04-05T13:00:00.000Z')) + expect(moonData.times.moonset).toEqual(new Date('2023-04-05T22:00:00.000Z')) + }) + + it('returns undefined moonrise and moonset when not available', () => { + SunCalcMock.setUndefinedMoonTimes(true) + try { + const moonData = horizonCard['computeMoonData'](new Date('2023-04-05T14:00:00Z'), 40, 20) + expect(moonData.times.moonrise).toBeUndefined() + expect(moonData.times.moonset).toBeUndefined() + } finally { + SunCalcMock.setUndefinedMoonTimes(false) + } + }) + + it('uses moon_phase_rotation from config', () => { + horizonCard.setConfig({ + moon_phase_rotation: 0 + } as IHorizonCardConfig) + const moonData = horizonCard['computeMoonData'](new Date('2023-04-05T14:00:00Z'), 40, 20) + expect(moonData.phaseRotation).toEqual(0) + }) + }) + + describe('computeMoonPosition', () => { + beforeEach(() => { + horizonCard.setConfig({ + southern_flip: false + } as IHorizonCardConfig) + }) + + it('moon at 0 azimuth and 0 elevation', () => { + const moonData = { + azimuth: 0, + elevation: 0 + } as TMoonData + const result = horizonCard['computeMoonPosition'](moonData) + expect(result).toEqual({x: 19, y: 84}) + }) + + it('moon at 0 azimuth and 0.1 elevation', () => { + const moonData = { + azimuth: 0, + elevation: 0.1 + } as TMoonData + const result = horizonCard['computeMoonPosition'](moonData) + expect(result.x).toEqual(19) + expect(result.y).toBeCloseTo(83.107, 2) + }) + + it('moon at 30 azimuth and 1 elevation', () => { + const moonData = { + azimuth: 30, + elevation: 1 + } as TMoonData + const result = horizonCard['computeMoonPosition'](moonData) + expect(result.x).toBeCloseTo(61.667) + expect(result.y).toBeCloseTo(76.586, 2) + }) + + it('moon at 120 azimuth and -1 elevation', () => { + const moonData = { + azimuth: 120, + elevation: -1 + } as TMoonData + const result = horizonCard['computeMoonPosition'](moonData) + expect(result.x).toBeCloseTo(189.667) + expect(result.y).toBeCloseTo(91.413, 2) + }) + + it('moon at 90 azimuth and 90 elevation', () => { + const moonData = { + azimuth: 90, + elevation: 90 + } as TMoonData + const result = horizonCard['computeMoonPosition'](moonData) + expect(result).toEqual({x: 147, y: 14}) + }) + + it('moon at 180 azimuth and 60 elevation', () => { + const moonData = { + azimuth: 180, + elevation: 60 + } as TMoonData + const result = horizonCard['computeMoonPosition'](moonData) + expect(result.x).toEqual(275) + expect(result.y).toBeCloseTo(21.215, 2) + }) + + it('moon at 270 azimuth and -60 elevation', () => { + const moonData = { + azimuth: 270, + elevation: -60 + } as TMoonData + const result = horizonCard['computeMoonPosition'](moonData) + expect(result.x).toEqual(403) + expect(result.y).toBeCloseTo(146.784, 2) + }) + + it('moon at nearly 360 azimuth and -90 elevation', () => { + const moonData = { + azimuth: 359.999, + elevation: -90 + } as TMoonData + const result = horizonCard['computeMoonPosition'](moonData) + expect(result.x).toBeCloseTo(531, 2) + expect(result.y).toEqual(154) + }) + + it('moon at 180 azimuth and 60 elevation with southern flip', () => { + horizonCard.setConfig({ + southern_flip: true + } as IHorizonCardConfig) + const moonData = { + azimuth: 180, + elevation: 60 + } as TMoonData + const result = horizonCard['computeMoonPosition'](moonData) + expect(result.x).toEqual(19) + expect(result.y).toBeCloseTo(21.215, 2) }) }) diff --git a/tests/unit/components/horizonCard/HorizonCardContent.spec.ts b/tests/unit/components/horizonCard/HorizonCardContent.spec.ts index 701f55b..765a7d9 100644 --- a/tests/unit/components/horizonCard/HorizonCardContent.spec.ts +++ b/tests/unit/components/horizonCard/HorizonCardContent.spec.ts @@ -1,98 +1,28 @@ -import { html } from 'lit' +import { NumberFormat, TimeFormat } from 'custom-card-helpers' import { HorizonCardContent } from '../../../../src/components/horizonCard' import { IHorizonCardConfig, THorizonCardData } from '../../../../src/types' -import { HelperFunctions } from '../../../../src/utils/HelperFunctions' -import { CustomSnapshotSerializer, TemplateResultTestHelper } from '../../../helpers/TestHelpers' +import { I18N } from '../../../../src/utils/I18N' +import { TemplateResultTestHelper } from '../../../helpers/TestHelpers' jest.mock('../../../../src/components/horizonCard/HorizonCardHeader', () => require('../../../mocks/HorizonCardHeader')) jest.mock('../../../../src/components/horizonCard/HorizonCardGraph', () => require('../../../mocks/HorizonCardGraph')) jest.mock('../../../../src/components/horizonCard/HorizonCardFooter', () => require('../../../mocks/HorizonCardFooter')) -expect.addSnapshotSerializer(new CustomSnapshotSerializer()) - describe('HorizonCardContent', () => { describe('render', () => { - beforeAll(() => { - jest.spyOn(HelperFunctions, 'nothing').mockImplementation(() => html``) - }) - - it('sets dark mode when it is set to true on the config', async () => { + it('renders the card content', async () => { const config: IHorizonCardConfig = { type: 'horizon-card', - darkMode: true + language: 'en' } - const horizonCardContent = new HorizonCardContent(config, {} as THorizonCardData) - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => horizonCardContent.render() - window.document.body.appendChild(element) - await element.updateComplete - - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() - }) - - it('does not set dark mode when it is set to false on the config', async () => { - const config: IHorizonCardConfig = { - type: 'horizon-card', - darkMode: false - } - - const horizonCardContent = new HorizonCardContent(config, {} as THorizonCardData) - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => horizonCardContent.render() - window.document.body.appendChild(element) - await element.updateComplete - - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() - }) - - it('renders the header when show header returns true', async () => { - const horizonCardContent = new HorizonCardContent({} as IHorizonCardConfig, {} as THorizonCardData) - jest.spyOn((horizonCardContent as unknown as { showHeader: () => boolean }), 'showHeader').mockImplementation(() => true) - - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => horizonCardContent.render() - window.document.body.appendChild(element) - await element.updateComplete - - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() - }) - - it('does not render the header when show header returns false', async () => { - const horizonCardContent = new HorizonCardContent({} as IHorizonCardConfig, {} as THorizonCardData) - jest.spyOn((horizonCardContent as unknown as { showHeader: () => boolean }), 'showHeader').mockImplementation(() => false) - - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => horizonCardContent.render() - window.document.body.appendChild(element) - await element.updateComplete - - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() - }) - - it('renders the footer when show footer returns true', async () => { - const horizonCardContent = new HorizonCardContent({} as IHorizonCardConfig, {} as THorizonCardData) - jest.spyOn((horizonCardContent as unknown as { showFooter: () => boolean }), 'showFooter').mockImplementation(() => true) - - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => horizonCardContent.render() - window.document.body.appendChild(element) - await element.updateComplete - - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() - }) - - it('does not render the footer when show footer returns false', async () => { - const horizonCardContent = new HorizonCardContent({} as IHorizonCardConfig, {} as THorizonCardData) - jest.spyOn((horizonCardContent as unknown as { showFooter: () => boolean }), 'showFooter').mockImplementation(() => false) + const i18n = new I18N('en', 'UTC', TimeFormat.language, NumberFormat.language, (key) => key) + const horizonCardContent = new HorizonCardContent(config, {} as THorizonCardData, i18n) - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => horizonCardContent.render() - window.document.body.appendChild(element) - await element.updateComplete + const html = await TemplateResultTestHelper.renderElement(horizonCardContent) - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() + expect(html).toMatchSnapshot() }) }) }) diff --git a/tests/unit/components/horizonCard/HorizonCardFooter.spec.ts b/tests/unit/components/horizonCard/HorizonCardFooter.spec.ts index 7a30287..145f5c2 100644 --- a/tests/unit/components/horizonCard/HorizonCardFooter.spec.ts +++ b/tests/unit/components/horizonCard/HorizonCardFooter.spec.ts @@ -1,72 +1,123 @@ -import { HorizonCardFooter } from '../../../../src/components/horizonCard/HorizonCardFooter' -import { IHorizonCardConfig, THorizonCardData } from '../../../../src/types' -import { CustomSnapshotSerializer, TemplateResultTestHelper } from '../../../helpers/TestHelpers' +import { NumberFormat, TimeFormat } from 'custom-card-helpers' -jest.mock('../../../../src/utils/HelperFunctions', () => require('../../../mocks/HelperFunctions')) +import { HorizonCardFooter } from '../../../../src/components/horizonCard' +import { IHorizonCardConfig, THorizonCardData } from '../../../../src/types' +import { I18N } from '../../../../src/utils/I18N' +import { TemplateResultTestHelper } from '../../../helpers/TestHelpers' -expect.addSnapshotSerializer(new CustomSnapshotSerializer()) +jest.mock('../../../../src/utils/I18N', () => require('../../../mocks/I18N')) describe('HorizonCardFooter', () => { - const dateFields = ['dawn', 'noon', 'dusk'] - const numberFields = ['azimuth', 'elevation'] - const getConfigForValue = (field: string) => ({ fields: { [field]: true } }) - const getDataForValue = (field: string) => { - if (dateFields.includes(field)) { - return { - times: { - [field]: new Date(0) - } - } + const dateFieldsSun = ['dawn', 'noon', 'dusk'] + const dateFieldsMoon = ['moonrise', 'moonset'] + const numberFields = [ + 'sun_azimuth', 'moon_azimuth', 'sun_elevation', 'moon_elevation', + ['sun_azimuth', 'moon_azimuth'], + ['sun_elevation', 'moon_elevation'] + ] + const allFields = [...dateFieldsSun, ...dateFieldsMoon, ...numberFields, 'moon_phase'] + + const falseFields = {} + allFields.forEach((field) => { + if (!(field instanceof Array)) { + falseFields[field as string] = false + } + }) + + const getConfigForValue = (field, config?) => { + config = config || { fields: { ...falseFields } } + config.fields[field] = true + return config + } + const getConfigForValues = (fields) => { + const config = { fields: { ...falseFields } } + fields.forEach((f) => getConfigForValue(f, config)) + return config + } + + const emptyData = () => ({ + sunData: { + times: {}, + }, + moonData: { + times: {} + } + }) + const getDataForValue = (field, data?) => { + data = data || emptyData() + + if (dateFieldsSun.includes(field)) { + data.sunData.times[field] = new Date(0) + } + + if (dateFieldsMoon.includes(field)) { + data.moonData.times[field] = new Date(0) + } + + if (field.startsWith('sun_')) { + data.sunData[field.substring('sun_'.length)] = 0 } - if (numberFields.includes(field)) { - return { - [field]: 0 + if (field.startsWith('moon_')) { + const name = field.substring('moon_'.length) + if (name === 'phase') { + data.moonData[name] = 'full_moon' + data.moonData['phaseRotation'] = 0 + } else { + data.moonData[name] = 0 } } - return + return data } + const getDataForValues = (fields) => { + const data = emptyData() + fields.forEach((f) => getDataForValue(f, data)) + return data + } + + const i18n = new I18N('en', 'UTC', TimeFormat.language, NumberFormat.language, (key) => key) describe('render', () => { - for (const field of [...dateFields, ...numberFields]) { + for (const field of allFields) { it(`should render the ${field} field when it is present in the config and data for it is provided`, async () => { - const config = getConfigForValue(field) as IHorizonCardConfig - const data = getDataForValue(field) as THorizonCardData + const config = field instanceof Array ? getConfigForValues(field) : getConfigForValue(field) + const data = field instanceof Array ? getDataForValues(field) : getDataForValue(field) + + const horizonCardFooter = new HorizonCardFooter(config, data, i18n) - const horizonCardFooter = new HorizonCardFooter(config, data) - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => horizonCardFooter.render() - window.document.body.appendChild(element) - await element.updateComplete + const html = await TemplateResultTestHelper.renderElement(horizonCardFooter) - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() + expect(html).toMatchSnapshot() }) - it(`should not render the ${field} field when it is present in the config but no data for it is provided`, async () => { - const config = getConfigForValue(field) as IHorizonCardConfig - const data = {} as THorizonCardData + it(`should render the ${field} field as '-' when it is present in the config but no data for it is provided`, async () => { + const config = field instanceof Array ? getConfigForValues(field) : getConfigForValue(field) + const data = { + sunData: { + times: {} + }, + moonData: { + times: {} + } + } as THorizonCardData - const horizonCardFooter = new HorizonCardFooter(config, data) - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => horizonCardFooter.render() - window.document.body.appendChild(element) - await element.updateComplete + const horizonCardFooter = new HorizonCardFooter(config, data, i18n) - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() + const html = await TemplateResultTestHelper.renderElement(horizonCardFooter) + + expect(html).toMatchSnapshot() }) it(`should not render the ${field} field when it is not present in the config but data for it is provided`, async () => { - const config = {} as IHorizonCardConfig - const data = getDataForValue(field) as THorizonCardData + const config = { fields: falseFields } as IHorizonCardConfig + const data = field instanceof Array ? getDataForValues(field) : getDataForValue(field) + + const horizonCardFooter = new HorizonCardFooter(config, data, i18n) - const horizonCardFooter = new HorizonCardFooter(config, data) - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => horizonCardFooter.render() - window.document.body.appendChild(element) - await element.updateComplete + const html = await TemplateResultTestHelper.renderElement(horizonCardFooter) - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() + expect(html).toMatchSnapshot() }) } }) diff --git a/tests/unit/components/horizonCard/HorizonCardGraph.spec.ts b/tests/unit/components/horizonCard/HorizonCardGraph.spec.ts index 230e85b..77a9a22 100644 --- a/tests/unit/components/horizonCard/HorizonCardGraph.spec.ts +++ b/tests/unit/components/horizonCard/HorizonCardGraph.spec.ts @@ -1,46 +1,88 @@ import { HorizonCardGraph } from '../../../../src/components/horizonCard' -import { THorizonCardData } from '../../../../src/types' -import { CustomSnapshotSerializer, TemplateResultTestHelper } from '../../../helpers/TestHelpers' - -expect.addSnapshotSerializer(new CustomSnapshotSerializer()) +import { Constants } from '../../../../src/constants' +import { IHorizonCardConfig, THorizonCardData } from '../../../../src/types' +import { TemplateResultTestHelper } from '../../../helpers/TestHelpers' describe('HorizonCardGraph', () => { describe('render', () => { - it(`should the graph with the data values when provided`, async () => { + it(`renders the graph with the data values when provided`, async () => { + const config = { + moon: true + } as IHorizonCardConfig + const data = { - sunInfo: { - dawnProgressPercent: 100, - dayProgressPercent: 100, - duskProgressPercent: 23, - sunAboveHorizon: false, - sunPercentOverHorizon: 0, - sunPosition: { - x: 50, - y: 50 - }, - sunrise: 200, - sunset: 400 + partial: false, + latitude: 40.5, + longitude: 16.7, + sunPosition: { + x: 50, + y: 50, + horizonY: 84, + sunriseX: 200, + sunsetX: 400, + scaleY: 1, + offsetY: 0, + }, + sunData: { + + }, + moonPosition: { + x: 100, + y: 25 + }, + moonData: { + azimuth: 90, + elevation: 45, + fraction: 1, + phase: Constants.MOON_PHASES.fullMoon, + zenithAngle: 0, + times: { + + } } } as THorizonCardData - const horizonCardGraph = new HorizonCardGraph(data) - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => horizonCardGraph.render() - window.document.body.appendChild(element) - await element.updateComplete + const horizonCardGraph = new HorizonCardGraph(config, data) + + const html = await TemplateResultTestHelper.renderElement(horizonCardGraph) - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() + expect(html).toMatchSnapshot() }) - it(`should the graph with the default values when data values are not provided`, async () => { - const data = {} as THorizonCardData - const horizonCardGraph = new HorizonCardGraph(data) - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => horizonCardGraph.render() - window.document.body.appendChild(element) - await element.updateComplete + it(`renders the graph with the default values when data values are not provided`, async () => { + const config = { + moon: true + } as IHorizonCardConfig - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() + const horizonCardGraph = new HorizonCardGraph(config, Constants.DEFAULT_CARD_DATA) + + const html = await TemplateResultTestHelper.renderElement(horizonCardGraph) + + expect(html).toMatchSnapshot() }) + + it(`does not render the moon when not configured so`, async () => { + const config = { + moon: false + } as IHorizonCardConfig + + const horizonCardGraph = new HorizonCardGraph(config, Constants.DEFAULT_CARD_DATA) + + const html = await TemplateResultTestHelper.renderElement(horizonCardGraph) + + expect(html).toMatchSnapshot() + }) + }) + + it(`renders the graph flipped horizontally when configured so`, async () => { + const config = { + southern_flip: true + } as IHorizonCardConfig + + const horizonCardGraph = new HorizonCardGraph(config, Constants.DEFAULT_CARD_DATA) + + const html = await TemplateResultTestHelper.renderElement(horizonCardGraph) + + expect(html).toMatchSnapshot() }) }) diff --git a/tests/unit/components/horizonCard/HorizonCardHeader.spec.ts b/tests/unit/components/horizonCard/HorizonCardHeader.spec.ts index 6cba8eb..48fcf13 100644 --- a/tests/unit/components/horizonCard/HorizonCardHeader.spec.ts +++ b/tests/unit/components/horizonCard/HorizonCardHeader.spec.ts @@ -1,48 +1,49 @@ -import { HorizonCardHeader } from '../../../../src/components/horizonCard' -import { IHorizonCardConfig, THorizonCardData, THorizonCardTimes } from '../../../../src/types' -import { CustomSnapshotSerializer, TemplateResultTestHelper } from '../../../helpers/TestHelpers' +import { NumberFormat, TimeFormat } from 'custom-card-helpers' -jest.mock('../../../../src/utils/HelperFunctions', () => require('../../../mocks/HelperFunctions')) +import { HorizonCardHeader } from '../../../../src/components/horizonCard' +import { IHorizonCardConfig, THorizonCardData, TSunData, TSunTimes } from '../../../../src/types' +import { I18N } from '../../../../src/utils/I18N' +import { TemplateResultTestHelper } from '../../../helpers/TestHelpers' -expect.addSnapshotSerializer(new CustomSnapshotSerializer()) +jest.mock('../../../../src/utils/I18N', () => require('../../../mocks/I18N')) describe('HorizonCardHeader', () => { + const i18n = new I18N('en', 'UTC', TimeFormat.language, NumberFormat.language, (key) => key) + describe('render', () => { it('renders the title if it is present in the configuration', async () => { const config: IHorizonCardConfig = { type: 'horizon-card', - title: 'test' + title: 'test', + fields: {} } const data = { - + sunData: {} } as THorizonCardData - const horizonCardHeader = new HorizonCardHeader(config, data) - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => horizonCardHeader.render() - window.document.body.appendChild(element) - await element.updateComplete + const horizonCardHeader = new HorizonCardHeader(config, data, i18n) - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() + const html = await TemplateResultTestHelper.renderElement(horizonCardHeader) + + expect(html).toMatchSnapshot() }) it('does not render the title if it is not present in the configuration', async () => { const config: IHorizonCardConfig = { - type: 'horizon-card' + type: 'horizon-card', + fields: {} } const data = { - + sunData: {} } as THorizonCardData - const horizonCardHeader = new HorizonCardHeader(config, data) - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => horizonCardHeader.render() - window.document.body.appendChild(element) - await element.updateComplete + const horizonCardHeader = new HorizonCardHeader(config, data, i18n) - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() + const html = await TemplateResultTestHelper.renderElement(horizonCardHeader) + + expect(html).toMatchSnapshot() }) it('renders the sunrise field when it is present in the data and it is activated on the config', async () => { @@ -54,18 +55,21 @@ describe('HorizonCardHeader', () => { } const data = { - times: { - sunrise: new Date(0) - } as THorizonCardTimes + sunData: { + times: { + now: new Date(0), + noon: new Date(0), + midnight: new Date(0), + sunrise: new Date(0) + } as TSunTimes + } as TSunData } as THorizonCardData - const horizonCardHeader = new HorizonCardHeader(config, data) - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => horizonCardHeader.render() - window.document.body.appendChild(element) - await element.updateComplete + const horizonCardHeader = new HorizonCardHeader(config, data, i18n) + + const html = await TemplateResultTestHelper.renderElement(horizonCardHeader) - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() + expect(html).toMatchSnapshot() }) it('does not render the sunrise field when it is present in the data but it is disabled on the config', async () => { @@ -77,18 +81,21 @@ describe('HorizonCardHeader', () => { } const data = { - times: { - sunrise: new Date(0) - } as THorizonCardTimes + sunData: { + times: { + now: new Date(0), + noon: new Date(0), + midnight: new Date(0), + sunrise: new Date(0) + } as TSunTimes + } as TSunData } as THorizonCardData - const horizonCardHeader = new HorizonCardHeader(config, data) - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => horizonCardHeader.render() - window.document.body.appendChild(element) - await element.updateComplete + const horizonCardHeader = new HorizonCardHeader(config, data, i18n) - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() + const html = await TemplateResultTestHelper.renderElement(horizonCardHeader) + + expect(html).toMatchSnapshot() }) it('does not render the sunrise field when it is not present in the data but it is activated on the config', async () => { @@ -99,15 +106,17 @@ describe('HorizonCardHeader', () => { } } - const data = {} as THorizonCardData + const data = { + sunData: { + times: {} + } + } as THorizonCardData + + const horizonCardHeader = new HorizonCardHeader(config, data, i18n) - const horizonCardHeader = new HorizonCardHeader(config, data) - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => horizonCardHeader.render() - window.document.body.appendChild(element) - await element.updateComplete + const html = await TemplateResultTestHelper.renderElement(horizonCardHeader) - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() + expect(html).toMatchSnapshot() }) it('does not render the sunrise field when it is not present in the data and it is disabled on the config', async () => { @@ -118,15 +127,17 @@ describe('HorizonCardHeader', () => { } } - const data = {} as THorizonCardData + const data = { + sunData: { + times: {} + } + } as THorizonCardData + + const horizonCardHeader = new HorizonCardHeader(config, data, i18n) - const horizonCardHeader = new HorizonCardHeader(config, data) - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => horizonCardHeader.render() - window.document.body.appendChild(element) - await element.updateComplete + const html = await TemplateResultTestHelper.renderElement(horizonCardHeader) - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() + expect(html).toMatchSnapshot() }) it('renders the sunset field when it is present in the data and it is activated on the config', async () => { @@ -138,18 +149,18 @@ describe('HorizonCardHeader', () => { } const data = { - times: { - sunset: new Date(0) - } as THorizonCardTimes + sunData: { + times: { + sunset: new Date(0) + } as TSunTimes + } as TSunData } as THorizonCardData - const horizonCardHeader = new HorizonCardHeader(config, data) - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => horizonCardHeader.render() - window.document.body.appendChild(element) - await element.updateComplete + const horizonCardHeader = new HorizonCardHeader(config, data, i18n) - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() + const html = await TemplateResultTestHelper.renderElement(horizonCardHeader) + + expect(html).toMatchSnapshot() }) it('does not render the sunset field when it is present in the data but it is disabled on the config', async () => { @@ -161,18 +172,21 @@ describe('HorizonCardHeader', () => { } const data = { - times: { - sunset: new Date(0) - } as THorizonCardTimes + sunData: { + times: { + now: new Date(0), + noon: new Date(0), + midnight: new Date(0), + sunrise: new Date(0) + } as TSunTimes + } as TSunData } as THorizonCardData - const horizonCardHeader = new HorizonCardHeader(config, data) - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => horizonCardHeader.render() - window.document.body.appendChild(element) - await element.updateComplete + const horizonCardHeader = new HorizonCardHeader(config, data, i18n) + + const html = await TemplateResultTestHelper.renderElement(horizonCardHeader) - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() + expect(html).toMatchSnapshot() }) it('does not render the sunset field when it is not present in the data but it is activated on the config', async () => { @@ -183,15 +197,17 @@ describe('HorizonCardHeader', () => { } } - const data = {} as THorizonCardData + const data = { + sunData: { + times: {} + } + } as THorizonCardData + + const horizonCardHeader = new HorizonCardHeader(config, data, i18n) - const horizonCardHeader = new HorizonCardHeader(config, data) - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => horizonCardHeader.render() - window.document.body.appendChild(element) - await element.updateComplete + const html = await TemplateResultTestHelper.renderElement(horizonCardHeader) - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() + expect(html).toMatchSnapshot() }) it('does not render the sunset field when it is not present in the data and it is disabled on the config', async () => { @@ -202,15 +218,17 @@ describe('HorizonCardHeader', () => { } } - const data = {} as THorizonCardData + const data = { + sunData: { + times: {} + } + } as THorizonCardData + + const horizonCardHeader = new HorizonCardHeader(config, data, i18n) - const horizonCardHeader = new HorizonCardHeader(config, data) - const element = window.document.createElement('test-element') as TemplateResultTestHelper - element.templateResultFunction = () => horizonCardHeader.render() - window.document.body.appendChild(element) - await element.updateComplete + const html = await TemplateResultTestHelper.renderElement(horizonCardHeader) - expect(element.shadowRoot!.innerHTML).toMatchSnapshot() + expect(html).toMatchSnapshot() }) }) }) diff --git a/tests/unit/components/horizonCard/__snapshots__/HorizonCard.spec.ts.snap b/tests/unit/components/horizonCard/__snapshots__/HorizonCard.spec.ts.snap index c2a8447..9b0d87e 100644 --- a/tests/unit/components/horizonCard/__snapshots__/HorizonCard.spec.ts.snap +++ b/tests/unit/components/horizonCard/__snapshots__/HorizonCard.spec.ts.snap @@ -1,19 +1,17 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`HorizonCard render renders an error if error is present on data 1`] = ` - -
- ERROR -
- +
+ ERROR +
`; +exports[`HorizonCard render renders nothing if hass has not been set 1`] = ``; + exports[`HorizonCard render renders the horizon card if no error is present on data 1`] = ` - - -
- HORIZON CARD CONTENT -
-
- + +
+ HORIZON CARD CONTENT +
+
`; diff --git a/tests/unit/components/horizonCard/__snapshots__/HorizonCardContent.spec.ts.snap b/tests/unit/components/horizonCard/__snapshots__/HorizonCardContent.spec.ts.snap index e754360..5a75ab6 100644 --- a/tests/unit/components/horizonCard/__snapshots__/HorizonCardContent.spec.ts.snap +++ b/tests/unit/components/horizonCard/__snapshots__/HorizonCardContent.spec.ts.snap @@ -1,137 +1,17 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`HorizonCardContent render does not render the footer when show footer returns false 1`] = ` - - -
- -
- HORIZON CARD HEADER -
- - -
- HORIZON CARD GRAPH -
- - -
-
- -`; - -exports[`HorizonCardContent render does not render the header when show header returns false 1`] = ` - - -
- - -
- HORIZON CARD GRAPH -
- - - - -
-
- -`; - -exports[`HorizonCardContent render does not set dark mode when it is set to false on the config 1`] = ` - - -
- -
- HORIZON CARD HEADER -
- - -
- HORIZON CARD GRAPH -
- - - - -
-
- -`; - -exports[`HorizonCardContent render renders the footer when show footer returns true 1`] = ` - - -
- -
- HORIZON CARD HEADER -
- - -
- HORIZON CARD GRAPH -
- - - - -
-
- -`; - -exports[`HorizonCardContent render renders the header when show header returns true 1`] = ` - - -
- -
- HORIZON CARD HEADER -
- - -
- HORIZON CARD GRAPH -
- - - - -
-
- -`; - -exports[`HorizonCardContent render sets dark mode when it is set to true on the config 1`] = ` - - -
- -
- HORIZON CARD HEADER -
- - -
- HORIZON CARD GRAPH -
- - - - -
-
- +exports[`HorizonCardContent render renders the card content 1`] = ` + +
+
+ HORIZON CARD HEADER +
+
+ HORIZON CARD GRAPH +
+ +
+
`; diff --git a/tests/unit/components/horizonCard/__snapshots__/HorizonCardFooter.spec.ts.snap b/tests/unit/components/horizonCard/__snapshots__/HorizonCardFooter.spec.ts.snap index c6ed685..123c5e9 100644 --- a/tests/unit/components/horizonCard/__snapshots__/HorizonCardFooter.spec.ts.snap +++ b/tests/unit/components/horizonCard/__snapshots__/HorizonCardFooter.spec.ts.snap @@ -1,281 +1,426 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`HorizonCardFooter render should not render the azimuth field when it is not present in the config but data for it is provided 1`] = ` - - - +exports[`HorizonCardFooter render should not render the dusk field when it is not present in the config but data for it is provided 1`] = ` + `; -exports[`HorizonCardFooter render should not render the azimuth field when it is present in the config but no data for it is provided 1`] = ` - - - +exports[`HorizonCardFooter render should not render the moon_elevation field when it is not present in the config but data for it is provided 1`] = ` + `; -exports[`HorizonCardFooter render should not render the dawn field when it is not present in the config but data for it is provided 1`] = ` - - - +exports[`HorizonCardFooter render should not render the moonrise field when it is not present in the config but data for it is provided 1`] = ` + +`; + +exports[`HorizonCardFooter render should not render the moonset field when it is not present in the config but data for it is provided 1`] = ` + +`; + +exports[`HorizonCardFooter render should not render the noon field when it is not present in the config but data for it is provided 1`] = ` + +`; + +exports[`HorizonCardFooter render should not render the sun_azimuth field when it is not present in the config but data for it is provided 1`] = ` + `; -exports[`HorizonCardFooter render should not render the dawn field when it is present in the config but no data for it is provided 1`] = ` - -