From 312976974a2f9e80a3d45b6bca763efe7c8d7a91 Mon Sep 17 00:00:00 2001 From: Viet Ngoc <96962827+ngocjohn@users.noreply.github.com> Date: Thu, 14 Nov 2024 15:02:06 +0100 Subject: [PATCH] Feat: Add search function to get coordinates. (#41) * feat: add search function to get coordinates * refactor: Improve MoonHorizon component with resize observer for card width measurement --- src/components/moon-data.ts | 26 +++-- src/components/moon-editor-search.ts | 141 +++++++++++++++++++++++++++ src/css/editor.css | 48 +++++++++ src/editor.ts | 81 ++++++++++----- src/lunar-phase-card.ts | 4 +- src/types.ts | 11 ++- src/utils/helpers.ts | 33 ++++++- 7 files changed, 306 insertions(+), 38 deletions(-) create mode 100644 src/components/moon-editor-search.ts diff --git a/src/components/moon-data.ts b/src/components/moon-data.ts index 1c9aab3..94d5831 100644 --- a/src/components/moon-data.ts +++ b/src/components/moon-data.ts @@ -12,16 +12,31 @@ import { Moon } from '../utils/moon'; export class LunarBaseData extends LitElement { @state() moon!: Moon; @state() swiper: Swiper | null = null; + @state() moonData?: MoonData; static get styles(): CSSResultGroup { return [mainStyles, unsafeCSS(swiperStyleCss)]; } - protected firstUpdated(changedProps: PropertyValues): void { + protected async firstUpdated(changedProps: PropertyValues): Promise { super.firstUpdated(changedProps); + console.time('moon'); if (this.moon) { - this.initSwiper(); + this._validateMoonData(); } + this.initSwiper(); + } + + private _validateMoonData() { + const replacer = (key: string, value: any) => { + if (['direction', 'position'].includes(key)) { + return undefined; + } + return value; + }; + const moonData = JSON.parse(JSON.stringify(this.moon.moonData, replacer)); + this.moonData = moonData; + console.timeEnd('moon'); } protected shouldUpdate(_changedProperties: PropertyValues): boolean { @@ -55,11 +70,8 @@ export class LunarBaseData extends LitElement { protected render(): TemplateResult { // const newMoonData = this.baseMoonData; - const baseMoonData = this.moon.moonData; - const newMoonData: MoonData = { ...baseMoonData }; - delete newMoonData.direction; - delete newMoonData.position; - const chunkedData = this._chunkObject(newMoonData, 5); + const baseMoonData = (this.moonData as MoonData) || {}; + const chunkedData = this._chunkObject(baseMoonData, 5); const dataContainer = Object.keys(chunkedData).map((key) => { return html`
diff --git a/src/components/moon-editor-search.ts b/src/components/moon-editor-search.ts new file mode 100644 index 0000000..df0552a --- /dev/null +++ b/src/components/moon-editor-search.ts @@ -0,0 +1,141 @@ +import { mdiClose, mdiMagnify } from '@mdi/js'; +import { LitElement, TemplateResult, CSSResultGroup, html, nothing, css } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +import { SearchResults } from '../types'; +import { getCoordinates } from '../utils/helpers'; + +// styles +import editorStyles from '../css/editor.css'; +import { LunarPhaseCardEditor } from '../editor'; + +const ALERT_DURATION = 5 * 1000; // 5 seconds + +@customElement('moon-editor-search') +export class MoonEditorSearch extends LitElement { + @property({ attribute: false }) _editor!: LunarPhaseCardEditor; + @state() _searchValue: string = ''; + @state() _searchResults: SearchResults[] = []; + @state() _searchResultsVisible = false; + @state() _toastDissmissed = false; + + static get styles(): CSSResultGroup { + return [editorStyles]; + } + + render(): TemplateResult { + const searchInput = html` + this._handleSearchInput(ev)} + @blur=${() => this._searchLocation()} + > + + ${this._searchResultsVisible + ? html`` + : html``} + `; + + const infoSuccess = html` `; + + return html`
+
${searchInput}
+ ${infoSuccess} ${this._renderSearchResults()} +
`; + } + + private _handleAlertDismiss(ev: Event): void { + const alert = ev.target as HTMLElement; + alert.style.display = 'none'; + this._toastDissmissed = true; + } + + private _renderSearchResults(): TemplateResult | typeof nothing { + if (!this._searchResultsVisible || this._searchResults.length === 0) { + return html`${!this._toastDissmissed + ? html` this._handleAlertDismiss(ev)} + > + You can get the latitude and longitude from the search with query like "London, UK".` + : nothing}`; + } + const results = this._searchResults.map((result) => { + return html`
  • this._handleSearchResult(result)}> + ${result.display_name} +
  • `; + }); + + return html` `; + } + + private _handleSearchResult(result: SearchResults): void { + console.log('search result', result); + const { lat, lon, display_name } = result; + const event = new CustomEvent('location-update', { + detail: { + latitude: lat, + longitude: lon, + }, + bubbles: true, + composed: true, + }); + + this.dispatchEvent(event); + this._clearSearch(); + const message = `${display_name} [${lat}, ${lon}]`; + this._handleSettingsSuccess(message); + } + + private _handleSettingsSuccess(message: string): void { + const alert = this.shadowRoot?.getElementById('success-alert') as HTMLElement; + if (alert) { + alert.innerHTML = message; + alert.style.display = 'block'; + setTimeout(() => { + alert.style.display = 'none'; + }, ALERT_DURATION); + } + } + + private _handleSearchInput(ev: Event): void { + ev.stopPropagation(); + const target = ev.target as HTMLInputElement; + this._searchValue = target.value; + } + + private _clearSearch(): void { + console.log('clear search'); + this._searchValue = ''; + this._searchResults = []; + this._searchResultsVisible = false; + } + + private async _searchLocation(): Promise { + console.log('search location', this._searchValue); + const searchValue = this._searchValue; + if (!searchValue || searchValue === '') { + return; + } + this._toastDissmissed = true; + const results = await getCoordinates(searchValue); + if (results) { + this._searchResults = results; + this._searchResultsVisible = true; + } + } +} diff --git a/src/css/editor.css b/src/css/editor.css index a4c036a..68f17ac 100644 --- a/src/css/editor.css +++ b/src/css/editor.css @@ -33,6 +33,12 @@ font-size: 12px; } +.search-button { + display: flex; + align-items: center; + justify-content: flex-end; +} + .switches { display: flex; flex-wrap: wrap; @@ -97,6 +103,48 @@ ha-select { cursor: pointer; } +.search-form { + display: flex; + gap: 8px; + align-items: center; + width: 100%; +} + +ul.search-results { + list-style-type: none; + padding-left: 0; + padding-right: 0; + margin-top: 0; + margin-bottom: 0; + max-height: 500px; + overflow-y: auto; + background-color: var(--divider-color); +} + +li.search-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px; + cursor: pointer; + border-bottom: 1px solid var(--divider-color); + min-height: 40px; +} + +li.search-item:nth-child(odd) { + background-color: var(--secondary-background-color); +} + +li.search-item:hover { + background-color: var(--divider-color); +} + +li.search-item:last-child { + border-bottom: none; +} + + + .location-item { display: flex; flex-direction: column; diff --git a/src/editor.ts b/src/editor.ts index 48a61ad..9969476 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -1,7 +1,6 @@ /* @typescript-eslint/no-explicit-any */ import { LitElement, html, TemplateResult, css, CSSResultGroup, PropertyValues } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; -import { unsafeHTML } from 'lit/directives/unsafe-html.js'; // Custom card helpers import { fireEvent, LovelaceCardEditor } from 'custom-card-helpers'; @@ -21,15 +20,20 @@ import { generateConfig } from './utils/ha-helper'; import { compareConfig, getAddressFromOpenStreet } from './utils/helpers'; import { loadHaComponents, stickyPreview, _saveConfig } from './utils/loader'; +// Components +import './components/moon-editor-search'; +import { mdiMagnify } from '@mdi/js'; + @customElement('lunar-phase-card-editor') export class LunarPhaseCardEditor extends LitElement implements LovelaceCardEditor { @property({ attribute: false }) public hass!: HomeAssistant; - @state() private _config!: LunarPhaseCardConfig; + @state() _config!: LunarPhaseCardConfig; @state() private _activeTabIndex?: number; @state() _activeGraphEditor = false; @state() private _location!: LocationAddress; + @state() private _searchLocation: boolean = false; public async setConfig(config: LunarPhaseCardConfig): Promise { this._config = { ...config }; @@ -52,6 +56,7 @@ export class LunarPhaseCardEditor extends LitElement implements LovelaceCardEdit protected async firstUpdated(changedProps: PropertyValues): Promise { super.firstUpdated(changedProps); + console.log('First updated'); await new Promise((resolve) => setTimeout(resolve, 0)); this._handleFirstConfig(this._config); this.getLocation(); @@ -80,6 +85,7 @@ export class LunarPhaseCardEditor extends LitElement implements LovelaceCardEdit this._config = newConfig; fireEvent(this, 'config-changed', { config: this._config }); await _saveConfig(cardId, this._config); + console.log('Config is valid'); } } private get selectedLanguage(): string { @@ -95,12 +101,11 @@ export class LunarPhaseCardEditor extends LitElement implements LovelaceCardEdit } private getLocation = () => { - this.updateComplete.then(() => { + this.updateComplete.then(async () => { const { latitude, longitude } = this._config; if (latitude && longitude) { - getAddressFromOpenStreet(latitude, longitude).then((location) => { - this._location = location; - }); + const location = await getAddressFromOpenStreet(latitude, longitude); + this._location = location; } }); }; @@ -172,31 +177,52 @@ export class LunarPhaseCardEditor extends LitElement implements LovelaceCardEdit > `; - const contentWrapp = html` -
    ${radios} ${southern}
    - ${this._renderLocation()} -
    - ${this._config?.use_default - ? this._renderUseDefault() - : this._config?.use_custom - ? this._renderCustomLatLong() - : this._config?.use_entity - ? this._renderEntityPicker() - : ''} -
    - `; + const searchWrapper = html` this._handleLocationChange(ev)} + >`; + + const contentWrapp = html`${this._renderLocation()} + ${this._searchLocation + ? html`
    ${searchWrapper}
    ` + : html` +
    + ${this._config?.use_default + ? this._renderUseDefault() + : this._config?.use_custom + ? this._renderCustomLatLong() + : this._config?.use_entity + ? this._renderEntityPicker() + : ''} +
    + +
    ${radios} ${southern}
    + `}`; return this.contentTemplate('baseConfig', 'baseConfig', 'mdi:cog', contentWrapp); } private _renderLocation(): TemplateResult { const location = this._location || { country: '', city: '' }; + + const markerStyle = `color: var(--secondary-text-color); margin-right: 0.5rem;`; + const headerStyle = `border: none; min-height: auto;`; + + const locationHeader = html` +
    +
    ${location.city}
    + ${location.country} +
    + (this._searchLocation = !this._searchLocation)} + >`; + return html` -
    -
    -
    ${location.country} ${unsafeHTML(`•`)} ${location.city}
    -
    - +
    + ${this._searchLocation + ? html` (this._searchLocation = !this._searchLocation)}>Back ` + : locationHeader}
    `; } @@ -646,6 +672,13 @@ export class LunarPhaseCardEditor extends LitElement implements LovelaceCardEdit /* ----------------------------- HANDLER METHODS ---------------------------- */ + private _handleLocationChange(ev: CustomEvent): void { + const { latitude, longitude } = ev.detail; + this._config = { ...this._config, latitude, longitude }; + fireEvent(this, 'config-changed', { config: this._config }); + this.getLocation(); + } + private _handleValueChange(event: any): void { event.stopPropagation(); const ev = event as CustomEvent; diff --git a/src/lunar-phase-card.ts b/src/lunar-phase-card.ts index 4a67d29..c8692c0 100644 --- a/src/lunar-phase-card.ts +++ b/src/lunar-phase-card.ts @@ -1,4 +1,4 @@ -import { LovelaceCardEditor, formatDate, FrontendLocaleData, TimeFormat, fireEvent } from 'custom-card-helpers'; +import { LovelaceCardEditor, formatDate, FrontendLocaleData, TimeFormat } from 'custom-card-helpers'; import { LitElement, html, TemplateResult, PropertyValues, CSSResultGroup, nothing } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; @@ -9,7 +9,7 @@ import { HomeAssistantExtended as HomeAssistant, LunarPhaseCardConfig, defaultCo // Helpers import { BLUE_BG, PageType, MoonState, ICON } from './const'; import { localize } from './localize/localize'; -import { deepMerge, generateConfig } from './utils/ha-helper'; +import { generateConfig } from './utils/ha-helper'; import { getDefaultConfig } from './utils/helpers'; import { isEditorMode } from './utils/loader'; diff --git a/src/types.ts b/src/types.ts index 218ee3a..52631c6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -148,8 +148,8 @@ export interface MoonData { altitudeDegrees: MoonDataItem; nextFullMoon: MoonDataItem; nextNewMoon: MoonDataItem; - direction?: MoonDataItem; - position?: MoonDataItem; + direction: MoonDataItem; + position: MoonDataItem; } export type ChartColors = { @@ -165,3 +165,10 @@ export type LocationAddress = { country: string; city: string; }; + +export type SearchResults = { + display_name: string; + name: string; + lat: number; + lon: number; +}; diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 3eb2429..ad7aa28 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,6 +1,6 @@ import { FrontendLocaleData, TimeFormat, HomeAssistant, LovelaceCardConfig } from 'custom-card-helpers'; -import { LocationAddress, LunarPhaseCardConfig } from '../types'; +import { LocationAddress, LunarPhaseCardConfig, SearchResults } from '../types'; import { MOON_IMAGES } from './moon-pic'; export function formatMoonTime(dateString: string): string { @@ -263,7 +263,7 @@ export function compareConfig(refObj: any, configObj: any): boolean { export async function getAddressFromOpenStreet(lat: number, lon: number): Promise { console.log('getAddressFromOpenStreet', lat, lon); - const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=jsonv2`; + const url = `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${lat}&lon=${lon}`; try { const response = await fetch(url); const data = await response.json(); @@ -277,10 +277,37 @@ export async function getAddressFromOpenStreet(lat: number, lon: number): Promis console.log('Address fetched from OpenStreetMap:', address); return address; } else { - throw new Error('Failed to fetch address OpenStreetMap'); + // throw new Error('Failed to fetch address OpenStreetMap'); + return { city: '', country: '' }; } } catch (error) { console.log('Error fetching address from OpenStreetMap:', error); return { city: '', country: '' }; } } + +export async function getCoordinates(query: string): Promise { + const url = `https://nominatim.openstreetmap.org/search?q=${query}&format=jsonv2&limit=5`; + + try { + const response = await fetch(url); + const data = await response.json(); + console.log('getCoordinates', data); + if (response.ok) { + const results = data.map((result: any) => ({ + display_name: result.display_name, + name: result.display_name, + lat: parseFloat(result.lat), + lon: parseFloat(result.lon), + })); + console.log('Coordinates fetched:', results); + return results; + } else { + // throw new Error('Failed to fetch coordinates'); + return []; + } + } catch (error) { + console.log('Error fetching coordinates:', error); + return []; + } +}