From 3f6f76d6a3b56fbcb395b2988c5e06cdaacd269f Mon Sep 17 00:00:00 2001 From: Daniel von Atzigen Date: Wed, 4 Dec 2024 07:19:09 +0100 Subject: [PATCH] Restructure catalog sidebar --- .idea/inspectionProfiles/Project_Default.xml | 1 + ui/locales/app.de.json | 1 - ui/locales/app.en.json | 1 - ui/locales/app.fr.json | 1 - ui/locales/app.it.json | 1 - ui/src/components/core/core-button.ts | 44 +++ ui/src/components/core/core-icon.ts | 49 ++++ ui/src/components/core/index.ts | 2 + ui/src/components/layers/layers-catalog.ts | 217 ++++++++++++++ ui/src/components/layers/layers-display.ts | 274 ++++++++++++++++++ .../navigation/navigation-layer-panel.ts | 151 ++++++++++ .../navigation/navigation-panel-header.ts | 51 ++++ .../components/navigation/navigation-panel.ts | 32 ++ ui/src/elements/ngm-side-bar.ts | 224 +++++--------- ui/src/index.ts | 1 + ui/src/layers/ngm-catalog.ts | 77 ----- ui/src/layers/ngm-layers-item.ts | 52 +++- ui/src/layers/ngm-layers.ts | 7 +- ui/src/style/index.css | 2 + ui/src/style/layers.css | 6 +- ui/src/style/ngm-side-bar.css | 10 +- ui/src/styles/index.css | 24 ++ ui/src/styles/theme.ts | 26 ++ ui/src/typings/styles.d.ts | 4 + ui/webpack.config.js | 16 +- 25 files changed, 1007 insertions(+), 267 deletions(-) create mode 100644 ui/src/components/core/core-button.ts create mode 100644 ui/src/components/core/core-icon.ts create mode 100644 ui/src/components/core/index.ts create mode 100644 ui/src/components/layers/layers-catalog.ts create mode 100644 ui/src/components/layers/layers-display.ts create mode 100644 ui/src/components/navigation/navigation-layer-panel.ts create mode 100644 ui/src/components/navigation/navigation-panel-header.ts create mode 100644 ui/src/components/navigation/navigation-panel.ts delete mode 100644 ui/src/layers/ngm-catalog.ts create mode 100644 ui/src/styles/index.css create mode 100644 ui/src/styles/theme.ts create mode 100644 ui/src/typings/styles.d.ts diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 03d9549ea..52ff3626e 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -1,6 +1,7 @@ \ No newline at end of file diff --git a/ui/locales/app.de.json b/ui/locales/app.de.json index 6d2be36a6..8976af170 100644 --- a/ui/locales/app.de.json +++ b/ui/locales/app.de.json @@ -62,7 +62,6 @@ "dtd_cant_upload_kml_error": "Fehler beim Hochladen von KML", "dtd_change_order_label": "Anordnen", "dtd_clamp_to_ground": "Auf Terrain legen", - "dtd_configure_data_btn": "Angezeigte Daten konfigurieren", "dtd_displayed_data_label": "Angezeigte Daten", "dtd_download_hint": "Herunterladen / Legende", "dtd_empty_map_label": "Kein Hintergrund", diff --git a/ui/locales/app.en.json b/ui/locales/app.en.json index 5ad4cc796..e7a72090d 100644 --- a/ui/locales/app.en.json +++ b/ui/locales/app.en.json @@ -62,7 +62,6 @@ "dtd_cant_upload_kml_error": "Error while uploading KML", "dtd_change_order_label": "Change order", "dtd_clamp_to_ground": "Clamp to terrain", - "dtd_configure_data_btn": "Configure data displayed", "dtd_displayed_data_label": "Data displayed", "dtd_download_hint": "Download / Legend", "dtd_empty_map_label": "No background", diff --git a/ui/locales/app.fr.json b/ui/locales/app.fr.json index acaf7fae2..182704e46 100644 --- a/ui/locales/app.fr.json +++ b/ui/locales/app.fr.json @@ -62,7 +62,6 @@ "dtd_cant_upload_kml_error": "Erreur lors du téléchargement de KML", "dtd_change_order_label": "Réarranger", "dtd_clamp_to_ground": "Fixation au terrain", - "dtd_configure_data_btn": "Configurer les données affichées", "dtd_displayed_data_label": "Données affichées", "dtd_download_hint": "Télécharger / Légende", "dtd_empty_map_label": "Sans arrière-plan", diff --git a/ui/locales/app.it.json b/ui/locales/app.it.json index 85c97cb3c..5f6ef7bfc 100644 --- a/ui/locales/app.it.json +++ b/ui/locales/app.it.json @@ -62,7 +62,6 @@ "dtd_cant_upload_kml_error": "Errore nel download di KML", "dtd_change_order_label": "Riordinare", "dtd_clamp_to_ground": "Morsetto al terreno", - "dtd_configure_data_btn": "Configurare i dati visualizzati ", "dtd_displayed_data_label": "Dati mostrati", "dtd_download_hint": "Scaricare / Scala", "dtd_empty_map_label": "Senza sfondo", diff --git a/ui/src/components/core/core-button.ts b/ui/src/components/core/core-button.ts new file mode 100644 index 000000000..5c6a0f365 --- /dev/null +++ b/ui/src/components/core/core-button.ts @@ -0,0 +1,44 @@ +import {css, html, LitElement} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; + +@customElement('ngm-core-button') +export class CoreButton extends LitElement { + @property({reflect: true}) + accessor variant: Variant = 'default'; + + @property({type: Boolean, attribute: 'active', reflect: true}) + accessor isActive: boolean = false; + + readonly render = () => html` + + `; + + static readonly styles = css` + button { + font-family: var(--font); + font-size: 14px; + } + + :host([variant='text']) button { + color: var(--color-highlight--darker); + + border: none; + background-color: transparent; + cursor: pointer; + } + + :host([variant='text'][active]) button { + color: var(--color-action); + } + + :host([variant='text']) button:hover { + color: var(--color-action--light); + } + `; +} + +export type Variant = + | 'default' + | 'text' diff --git a/ui/src/components/core/core-icon.ts b/ui/src/components/core/core-icon.ts new file mode 100644 index 000000000..840407d6d --- /dev/null +++ b/ui/src/components/core/core-icon.ts @@ -0,0 +1,49 @@ +import {css, html, LitElement} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; + +@customElement('ngm-core-icon') +export class CoreIcon extends LitElement { + @property() + accessor icon: IconName | null = null; + + @property({type: Boolean, attribute: 'interactive'}) + accessor isInteractive: boolean = false; + + readonly render = () => html``; + + static readonly styles = css` + :host { + display: inline-block; + + --size: var(--icon-size--normal); + width: var(--size); + height: var(--size); + background-color: var(--color-bg-contrast); + + mask: var(--mask, none) no-repeat center; + -webkit-mask: var(--mask, none) no-repeat center; + + /* Hide element if no valid icon has been specified. */ + visibility: hidden; + } + + :host([interactive]:hover) { + cursor: pointer; + background-color: var(--color-action); + } + + :host([icon='close']) { + visibility: initial; + --mask: url('images/i_close.svg'); + } + + :host([icon='dropdown']) { + visibility: initial; + --mask: url('images/i_menu-1.svg') + } + `; +} + +export type IconName = + | 'close' + | 'dropdown' diff --git a/ui/src/components/core/index.ts b/ui/src/components/core/index.ts new file mode 100644 index 000000000..5446187a9 --- /dev/null +++ b/ui/src/components/core/index.ts @@ -0,0 +1,2 @@ +import './core-button'; +import './core-icon'; diff --git a/ui/src/components/layers/layers-catalog.ts b/ui/src/components/layers/layers-catalog.ts new file mode 100644 index 000000000..732b450de --- /dev/null +++ b/ui/src/components/layers/layers-catalog.ts @@ -0,0 +1,217 @@ +import {css, html, TemplateResult, unsafeCSS} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import {LitElementI18n} from '../../i18n.js'; +import i18next from 'i18next'; +import auth from '../../store/auth'; +import type {LayerTreeNode} from '../../layertree'; +import $ from 'jquery'; + + +import fomanticTransitionCss from 'fomantic-ui-css/components/transition.css'; +import fomanticAccordionCss from 'fomantic-ui-css/components/accordion.css'; +import 'fomantic-ui-css/components/transition.js'; +import {LayerEvent} from './layers-display'; + + +@customElement('ngm-layers-catalog') +export class NgmLayersCatalog extends LitElementI18n { + @property({type: Array}) + accessor layers: LayerTreeNode[] = []; + + @state() + accessor userGroups: string[] = []; + + constructor() { + super(); + auth.user.subscribe((user) => { + this.userGroups = user?.['cognito:groups'] ?? []; + }); + } + + firstUpdated(): void { + const {shadowRoot} = this; + if (shadowRoot == null) { + return; + } + $(shadowRoot.querySelectorAll(':host > .ui.accordion')).accordion({ + duration: 150, + }); + } + + getCategoryOrLayerTemplate(node: LayerTreeNode, level: string): TemplateResult { + if (node.children) { + return this.getCategoryTemplate(node, level); + } + return this.getLayerTemplate(node); + } + + getCategoryTemplate(category: LayerTreeNode, level: string): TemplateResult { + // if it is a restricted layer, the user must be logged in to see it + const content = category.children?.filter( + node => !(node.restricted && (!node.restricted.some(g => this.userGroups.includes(g)))) + ).map(node => this.getCategoryOrLayerTemplate(node, 'second-level')); + + if (!content?.length) return html``; + + return html` +
+
+ + +
+
+ ${content} +
+
+ `; + } + + getLayerTemplate(layer: LayerTreeNode): TemplateResult { + return html` +
{ + this.dispatchEvent(new CustomEvent('layer-click', { + composed: true, + bubbles: true, + detail: { + layer + } + }) satisfies LayerEvent); + }}> + + + +
+ `; + } + + render() { + return html`${this.layers.map(node => this.getCategoryOrLayerTemplate(node, 'first-level'))}`; + } + + static readonly styles = css` + ${unsafeCSS(fomanticTransitionCss)} + ${unsafeCSS(fomanticAccordionCss)} + + :host, :host * { + box-sizing: border-box; + } + + .category.ui.accordion { + margin-top: 0; + } + + .category.ui.accordion > .title ~ .content, + .category.ui.accordion .category.ui.accordion > .title ~ .content + { + padding-top: 0; + } + + .category > .title { + display: flex; + align-items: center; + cursor: pointer; + } + + .category > .title.active > label { + color: var(--color-action); + } + + .category > .title:hover > label { + color: var(--color-action--light); + } + + .category > .title > ngm-core-icon { + background-color: var(--color-highlight--darker); + } + + .category > .title:hover > ngm-core-icon { + background-color: var(--color-action--light); + } + + .category > .title.first-level { + font-weight: 700; + } + + .category > .title > label { + cursor: pointer; + font-size: 14px; + margin-left: 10px; + } + + .category > .title.active > ngm-core-icon { + transform: rotate(90deg); + } + + .ngm-checkbox { + display: flex; + align-items: center; + margin-bottom: 12px; + cursor: pointer; + } + + .ngm-checkbox:hover { + color: var(--color-action--light); + } + + .ngm-checkbox.active { + color: var(--color-action); + } + + .ngm-checkbox > input { + display: none; + } + + .ngm-checkbox:hover > .ngm-checkbox-icon { + border-color: var(--color-action--light); + } + + .ngm-checkbox > .ngm-checkbox-icon { + display: inline-block; + position: relative; + width: 19px; + height: 18px; + border-radius: 2px; + border: 2px solid var(--ngm-interaction); + transition: all 0.5s ease; + } + + .ngm-checkbox > label { + margin-left: 10px; + cursor: pointer; + } + + .ngm-checkbox.active > .ngm-checkbox-icon { + border-color: var(--color-action); + } + + .ngm-checkbox input:checked + .ngm-checkbox-icon { + background-color: var(--color-action); + } + + .ngm-checkbox.active:hover > .ngm-checkbox-icon { + background-color: var(--color-action--light); + } + + .ngm-checkbox > .ngm-checkbox-icon::before { + box-sizing: content-box; + content: ""; + top: -2px; + left: 3px; + width: 6px; + height: 12px; + display: none; + position: absolute; + transform: rotate(45deg); + transition: all 0.5s ease; + border-right: 2px solid #fff; + border-bottom: 2px solid #fff; + } + + .ngm-checkbox input:checked + .ngm-checkbox-icon::before { + display: block; + } + `; +} diff --git a/ui/src/components/layers/layers-display.ts b/ui/src/components/layers/layers-display.ts new file mode 100644 index 000000000..a281a54da --- /dev/null +++ b/ui/src/components/layers/layers-display.ts @@ -0,0 +1,274 @@ +import {customElement, property, state} from 'lit/decorators.js'; +import {LitElementI18n} from '../../i18n.js'; +import {css, html} from 'lit'; +import i18next from 'i18next'; +import {CustomDataSource, Viewer} from 'cesium'; +import {PropertyValues} from '@lit/reactive-element'; +import LayersActions from '../../layers/LayersActions'; +import {DEFAULT_LAYER_OPACITY, LayerConfig, LayerTreeNode} from '../../layertree'; +import '../../layers/ngm-layers'; +import '../../layers/ngm-layers-sort'; +import MainStore from '../../store/main'; +import {Subscription} from 'rxjs'; +import {classMap} from 'lit/directives/class-map.js'; +import {query} from 'lit/decorators.js'; +import {parseKml, renderWithDelay} from '../../cesiumutils'; + +@customElement('ngm-layers-display') +export class NgmLayersDisplay extends LitElementI18n { + @property({type: Array}) + accessor layers: LayerTreeNode[] = [] + + @state() + private accessor isReordering = false + + @state() + private accessor viewer: Viewer | null = null + + @state() + private accessor actions: LayersActions | null = null; + + private readonly subscription = new Subscription(); + + @query('.ngm-side-bar-panel > .ngm-toast-placeholder') + accessor toastPlaceholder; + + @state() + private accessor globeQueueLength = 0 + + constructor() { + super(); + + this.subscription.add(MainStore.viewer.subscribe((viewer) => { + this.viewer = viewer; + this.initializeViewer(); + })); + + this.handleLayerRemoval = this.handleLayerRemoval.bind(this); + this.handleReordering = this.handleReordering.bind(this); + this.toggleReordering = this.toggleReordering.bind(this); + this.handleLayerUpdate = this.handleLayerUpdate.bind(this); + this.handleKmlUpload = this.handleKmlUpload.bind(this); + } + + private initializeViewer(): void { + if (this.viewer == null) { + return; + } + this.subscription.add(this.viewer.scene.globe.tileLoadProgressEvent.addEventListener((queueLength) => { + this.globeQueueLength = queueLength; + })); + } + + readonly render2 = () => html` +
+ + ${this.isReordering ? i18next.t('dtd_finish_ordering_label') : i18next.t('dtd_change_order_label')} + +
+
+ ${this.isReordering ? this.renderSortableLayers() : this.renderLayers()} +
+ `; + + readonly render = () => html` +
+ +
+
+ ${this.isReordering ? i18next.t('dtd_finish_ordering_label') : i18next.t('dtd_change_order_label')} +
+ ${this.isReordering + ? this.renderSortableLayers() + : this.renderLayers()} +
${i18next.t('dtd_user_content_label')}
+ + + +
+ ${i18next.t('dtd_background_map_label')} +
+ ${this.globeQueueLength} +
+
+ +
+
+
+ `; + + private readonly renderLayers = () => html` + + + `; + + private readonly renderSortableLayers = () => html` + + + `; + + updated(changedProperties: PropertyValues): void { + if (changedProperties.has('viewer' as keyof NgmLayersDisplay)) { + this.actions = this.viewer == null ? null : new LayersActions(this.viewer); + } + } + + disconnectedCallback(): void { + this.subscription.unsubscribe(); + } + + private toggleReordering(): void { + this.isReordering = !this.isReordering; + } + + private async handleReordering(e: LayersReorderEvent): Promise { + const {actions} = this; + if (actions == null) { + return; + } + await actions.reorderLayers(e.detail); + if (!this.isReordering) { + this.updateLayers(e.detail); + } + } + + private async handleLayerRemoval(e: LayerRemovalEvent): Promise { + const newLayers = [...this.layers]; + newLayers.splice(e.detail.idx, 1); + this.updateLayers(newLayers); + this.removeLayer(e.detail.config); + } + + private async handleLayerUpdate(e: LayerChangeEvent): Promise { + this.updateLayer(e.detail); + } + + // TODO Cleanup/Refactor this function. + // As of now, this function remains unchanged to before the navigation-catalog refactoring. + private async handleKmlUpload(file: File, clampToGround: boolean): Promise { + if (this.viewer == null) { + return; + } + + const dataSource = new CustomDataSource(); + const name = await parseKml(this.viewer, file, dataSource, clampToGround); + const layer = `${name.replace(' ', '_')}_${Date.now()}`; + + // name used as id for datasource + dataSource.name = layer; + MainStore.addUploadedKmlName(dataSource.name); + await this.viewer.dataSources.add(dataSource); + await renderWithDelay(this.viewer); + + // done like this to have correct rerender of component + const dataSourcePromise = Promise.resolve(dataSource); + const config: LayerConfig = { + load() { return dataSourcePromise; }, + label: name, + layer, + promise: dataSourcePromise, + opacity: DEFAULT_LAYER_OPACITY, + notSaveToPermalink: true, + ownKml: true, + opacityDisabled: true + }; + this.clickLayer(config); + await this.viewer.zoomTo(dataSource); + this.requestUpdate(); + } + + private updateLayers(layers: LayerTreeNode[]): void { + this.dispatchEvent(new CustomEvent('layers-update', { + detail: { + layers, + }, + }) satisfies LayersUpdateEvent); + } + + private updateLayer(layer: LayerConfig): void { + this.dispatchEvent(new CustomEvent('layer-update', { + detail: { + layer, + }, + }) satisfies LayerEvent); + } + + private removeLayer(layer: LayerConfig): void { + this.dispatchEvent(new CustomEvent('layer-removal', { + detail: { + layer, + }, + }) satisfies LayerEvent); + } + + private clickLayer(layer: LayerConfig): void { + this.dispatchEvent(new CustomEvent('layer-click', { + bubbles: true, + composed: true, + detail: { + layer, + }, + }) satisfies LayerEvent); + } + + private openIonModal(): void { + this.dispatchEvent(new CustomEvent('openIonModal', { + bubbles: true, + composed: true, + })); + } + + // TODO Make all children of this component use the Shadow DOM so we can remove this. + createRenderRoot() { + return this; + } + + static readonly styles = css` + ngm-layers-display .actions { + display: flex; + justify-content: flex-end; + padding-top: 9px; + } + + ngm-layers-display * { + box-sizing: border-box; + } + `; +} + +export type LayersUpdateEvent = CustomEvent<{ + layers: LayerTreeNode[] +}> + +export type LayerEvent = CustomEvent<{ + layer: LayerConfig | LayerTreeNode +}> + +type LayerRemovalEvent = CustomEvent<{ + idx: number + config: LayerConfig +}> + +type LayersReorderEvent = CustomEvent +type LayerChangeEvent = CustomEvent diff --git a/ui/src/components/navigation/navigation-layer-panel.ts b/ui/src/components/navigation/navigation-layer-panel.ts new file mode 100644 index 000000000..315b634ba --- /dev/null +++ b/ui/src/components/navigation/navigation-layer-panel.ts @@ -0,0 +1,151 @@ +import {LitElementI18n} from '../../i18n'; +import {css, html, unsafeCSS} from 'lit'; +import i18next from 'i18next'; +import {customElement, property} from 'lit/decorators.js'; +import {LayerConfig} from '../../layertree'; +import './navigation-panel'; +import './navigation-panel-header'; +import '../layers/layers-catalog'; +import '../layers/layers-display'; +import {LayerEvent, LayersUpdateEvent} from '../layers/layers-display'; + + +@customElement('ngm-navigation-layer-panel') +export class NavigationLayerPanel extends LitElementI18n { + @property() + public accessor layers: LayerConfig[] | null = null + + @property() + public accessor displayLayers: LayerConfig[] | null = null + + // @state() + // private accessor activeTab = Tab.Catalog + + constructor() { + super(); + + this.close = this.close.bind(this); + this.handleDisplayLayersUpdate = this.handleDisplayLayersUpdate.bind(this); + this.handleDisplayLayerUpdate = this.handleDisplayLayerUpdate.bind(this); + this.handleDisplayLayerRemoval = this.handleDisplayLayerRemoval.bind(this); + } + + readonly render = () => html` + + +
+ + ${i18next.t('lyr_geocatalog_label')} + + ${this.renderCatalog()} +
+
+ + ${i18next.t('dtd_displayed_data_label')} + + ${this.renderDisplay()} +
+
+ `; + + private readonly renderCatalog = () => html` + + `; + + private readonly renderDisplay = () => html` + + `; + + connectedCallback(): void { + super.connectedCallback(); + this.setAttribute('role', 'complementary'); + } + + private close(): void { + this.dispatchEvent(new CustomEvent('close')); + } + + private handleDisplayLayersUpdate(e: LayersUpdateEvent): void { + this.dispatchEvent(new CustomEvent('display-layers-update', { + detail: e.detail, + }) satisfies LayersUpdateEvent); + } + + private handleDisplayLayerUpdate(e: LayerEvent): void { + this.dispatchEvent(new CustomEvent('display-layer-update', { + detail: e.detail, + }) satisfies LayerEvent); + } + + private handleDisplayLayerRemoval(e: LayerEvent): void { + this.dispatchEvent(new CustomEvent('display-layer-removal', { + detail: e.detail, + }) satisfies LayerEvent); + } + + private handleDisplayLayerClick(e: LayerEvent): void { + this.dispatchEvent(new CustomEvent('display-layer-click', { + detail: e.detail, + }) satisfies LayerEvent); + } + + // TODO Make all children of this component use the Shadow DOM so we can remove this. + createRenderRoot() { + return this; + } + + static readonly styles = css` + ngm-navigation-layer-panel * { + box-sizing: border-box; + } + + ngm-navigation-layer-panel ngm-navigation-panel { + --display-height: 500px; + --catalog-height: calc(var(--panel-height) - var(--display-height) - 34px); + + display: grid; + grid-template-columns: 1fr; + grid-template-rows: repeat(2, 1fr); + max-height: calc(100vh - var(--ngm-header-height)); + padding: 0; + } + + ngm-navigation-layer-panel ngm-navigation-panel > section { + --section-height: calc(var(--panel-height) / 2); + + position: relative; + max-height: var(--section-height); + background-color: var(--color-bg--dark); + } + + + ngm-navigation-layer-panel ngm-navigation-panel > section > * { + max-width: calc(100vw); + padding: 0 var(--panel-padding); + } + + ngm-navigation-layer-panel ngm-layers-catalog, + ngm-navigation-layer-panel ngm-layers-display { + display: block; + height: calc(var(--section-height) - 34px); + max-height: calc(var(--section-height) - 34px); + overflow-y: auto; + } + `; +} + +// enum Tab { +// Catalog, +// Data, +// } diff --git a/ui/src/components/navigation/navigation-panel-header.ts b/ui/src/components/navigation/navigation-panel-header.ts new file mode 100644 index 000000000..b53cdcdca --- /dev/null +++ b/ui/src/components/navigation/navigation-panel-header.ts @@ -0,0 +1,51 @@ +import {LitElementI18n} from '../../i18n'; +import {css, html, nothing} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; +import '../core'; + +@customElement('ngm-navigation-panel-header') +export class NavigationPanelHeader extends LitElementI18n { + @property({type: Boolean, attribute: 'closeable'}) + accessor isCloseable: boolean = false; + + constructor() { + super(); + + this.close = this.close.bind(this); + } + + readonly render = () => html` + + ${this.isCloseable ? html` + + ` : nothing} + `; + + connectedCallback(): void { + super.connectedCallback(); + this.setAttribute('role', 'heading'); + } + + private close(): void { + this.dispatchEvent(new CustomEvent('close')); + } + + static readonly styles = css` + :host { + box-sizing: border-box; + border-bottom: 2px solid #DFE2E6; + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 700; + height: 34px; + padding: 4px 0 2px 0; + color: var(--color-bg-contrast--light); + } + `; +} + diff --git a/ui/src/components/navigation/navigation-panel.ts b/ui/src/components/navigation/navigation-panel.ts new file mode 100644 index 000000000..54aa3027a --- /dev/null +++ b/ui/src/components/navigation/navigation-panel.ts @@ -0,0 +1,32 @@ +import {LitElementI18n} from '../../i18n'; +import {css, html} from 'lit'; +import {customElement} from 'lit/decorators.js'; +import '../core'; + +@customElement('ngm-navigation-panel') +export class NavigationPanel extends LitElementI18n { + readonly render = () => html``; + + static readonly styles = css` + :host { + --panel-height: calc(100vh - var(--ngm-header-height)); + + box-sizing: border-box; + width: 530px; + max-width: 100vw; + height: var(--panel-height); + max-height: var(--panel-height); + max-width: calc(100vw); + padding: 0 var(--panel-padding); + + display: flex; + flex-direction: column; + overflow-y: auto; + box-shadow: 4px 0 4px #00000029; + z-index: 5; + + background-color: var(--color-bg--dark); + } + `; +} + diff --git a/ui/src/elements/ngm-side-bar.ts b/ui/src/elements/ngm-side-bar.ts index 2fc3952a9..dc93b9d93 100644 --- a/ui/src/elements/ngm-side-bar.ts +++ b/ui/src/elements/ngm-side-bar.ts @@ -3,8 +3,8 @@ import {LitElementI18n} from '../i18n.js'; import '../toolbox/ngm-toolbox'; import '../layers/ngm-layers'; import '../layers/ngm-layers-sort'; -import '../layers/ngm-catalog'; import './dashboard/ngm-dashboard'; +import '../components/navigation/navigation-layer-panel'; import LayersActions from '../layers/LayersActions'; import {DEFAULT_LAYER_OPACITY, LayerType} from '../constants'; import defaultLayerTree, {LayerConfig} from '../layertree'; @@ -17,7 +17,7 @@ import { getSliceParam, getZoomToPosition, setCesiumToolbarParam, - syncLayersParam + syncLayersParam, } from '../permalink'; import {createCesiumObject} from '../layers/helpers'; import i18next from 'i18next'; @@ -46,7 +46,7 @@ import type QueryManager from '../query/QueryManager'; import DashboardStore from '../store/dashboard'; import {getAssets} from '../api-ion'; -import {parseKml, renderWithDelay} from '../cesiumutils'; +import {LayerEvent, LayersUpdateEvent} from '../components/layers/layers-display'; type SearchLayer = { layer: string @@ -56,6 +56,12 @@ type SearchLayer = { dataSourceName?: string } +interface LayerClickEvent { + detail: { + layer: string + } +} + @customElement('ngm-side-bar') export class SideBar extends LitElementI18n { @property({type: Object}) @@ -68,25 +74,20 @@ export class SideBar extends LitElementI18n { accessor catalogLayers: LayerConfig[] | undefined; @state() accessor activeLayers: LayerConfig[] = []; + + // TODO change this back to `null` @state() accessor activePanel: string | null = null; @state() accessor showHeader = false; @state() - accessor globeQueueLength_ = 0; - @state() accessor mobileShowAll = false; @state() accessor hideDataDisplayed = false; @state() - accessor layerOrderChangeActive = false; - @state() accessor debugToolsActive = getCesiumToolbarParam(); @query('.ngm-side-bar-panel > .ngm-toast-placeholder') accessor toastPlaceholder; - @query('ngm-catalog') - accessor catalogElement; - private viewer: Viewer | null = null; private layerActions: LayersActions | undefined; private zoomedToPosition = false; private accordionInited = false; @@ -94,10 +95,18 @@ export class SideBar extends LitElementI18n { private shareDownListener = evt => { if (!evt.composedPath().includes(this)) this.activePanel = null; }; + private viewer: Viewer | null = null; constructor() { super(); - MainStore.viewer.subscribe(viewer => this.viewer = viewer); + + this.handleDisplayLayersUpdate = this.handleDisplayLayersUpdate.bind(this); + this.handleDisplayLayerUpdate = this.handleDisplayLayerUpdate.bind(this); + this.handleDisplayLayerRemoval = this.handleDisplayLayerRemoval.bind(this); + + MainStore.viewer.subscribe((viewer) => { + this.viewer = viewer; + }); auth.user.subscribe((user) => { if (!user && this.activeLayers) { @@ -115,7 +124,7 @@ export class SideBar extends LitElementI18n { this.activeLayers.forEach(layer => this.removeLayerWithoutSync(layer)); } await this.syncActiveLayers(); - this.catalogElement.requestUpdate(); + this.requestUpdate(); MainStore.nextLayersRemove(); }); @@ -184,15 +193,6 @@ export class SideBar extends LitElementI18n { @click=${() => this.togglePanel('settings')}>
`; - const dataMobileHeader = html` -
this.hideDataDisplayed = true} - class="ngm-data-catalog-label ${classMap({active: this.hideDataDisplayed})}"> - ${i18next.t('lyr_geocatalog_label')} -
-
this.hideDataDisplayed = false} - class="ngm-data-catalog-label ${classMap({active: !this.hideDataDisplayed})}"> - ${i18next.t('dtd_displayed_data_label')} -
`; return html`
@@ -232,38 +232,34 @@ export class SideBar extends LitElementI18n {
this.activePanel = ''} - @layerclick=${evt => this.onCatalogLayerClicked(evt.detail.layer)} + @close=${() => this.activePanel = null} + @layerclick=${(e: LayerClickEvent) => this.onCatalogLayerClicked(e.detail.layer)} > -
-
- ${this.mobileView ? dataMobileHeader : i18next.t('lyr_geocatalog_label')} -
this.activePanel = ''}>
-
-
this.hideDataDisplayed = !this.hideDataDisplayed}> - ${i18next.t('dtd_configure_data_btn')} -
- this.onCatalogLayerClicked(evt.detail.layer)}> - -
+ this.onCatalogLayerClicked(e.detail.layer)} + @display-layers-update="${this.handleDisplayLayersUpdate}" + @display-layer-update="${this.handleDisplayLayerUpdate}" + @display-layer-removal="${this.handleDisplayLayerRemoval}" + >
this.activePanel = 'tools'} - @close=${() => this.activePanel = ''}> + @close=${() => this.activePanel = null}>
${i18next.t('lsb_settings')} -
this.activePanel = ''}>
+
this.activePanel = null}>
@@ -280,53 +276,6 @@ export class SideBar extends LitElementI18n {
-
-
- ${this.mobileView ? dataMobileHeader : i18next.t('dtd_displayed_data_label')} -
this.mobileView ? this.activePanel = '' : this.hideDataDisplayed = true}>
-
-
-
-
- ${this.layerOrderChangeActive ? i18next.t('dtd_finish_ordering_label') : i18next.t('dtd_change_order_label')} -
- ${this.layerOrderChangeActive ? - html` - this.onLayersOrderChange(evt.detail)}> - ` : - html` - this.onRemoveDisplayedLayer(evt)} - @layerChanged=${evt => this.onLayerChanged(evt)}> - ` - } -
${i18next.t('dtd_user_content_label')}
- this.onKmlUpload(file, clampToGround)}> - - -
- ${i18next.t('dtd_background_map_label')} -
- ${this.globeQueueLength_} -
-
- -
-
-
`; } @@ -341,7 +290,7 @@ export class SideBar extends LitElementI18n { return; } this.activePanel = panelName; - if (this.activePanel === 'data' && !this.mobileView) this.hideDataDisplayed = false; + // if (this.activePanel === 'data' && !this.mobileView) this.hideDataDisplayed = false; } async syncActiveLayers() { @@ -434,9 +383,6 @@ export class SideBar extends LitElementI18n { this.catalogLayers = [...defaultLayerTree]; await this.syncActiveLayers(); } - this.viewer.scene.globe.tileLoadProgressEvent.addEventListener(queueLength => { - this.globeQueueLength_ = queueLength; - }); } // hide share panel on any action outside side bar if (!this.shareListenerAdded && this.activePanel === 'share') { @@ -505,18 +451,6 @@ export class SideBar extends LitElementI18n { this.viewer!.scene.requestRender(); } - onLayerChanged(evt) { - this.queryManager!.hideObjectInformation(); - const catalogLayers = this.catalogLayers ? this.catalogLayers : []; - this.catalogLayers = [...catalogLayers]; - this.activeLayers = [...this.activeLayers]; - syncLayersParam(this.activeLayers); - if (evt.detail) { - this.maybeShowVisibilityHint(evt.detail); - } - this.requestUpdate(); - } - maybeShowVisibilityHint(config: LayerConfig) { if (this.displayUndergroundHint && config.visible @@ -527,28 +461,6 @@ export class SideBar extends LitElementI18n { } } - async onRemoveDisplayedLayer(evt) { - const {config, idx} = evt.detail; - this.activeLayers.splice(idx, 1); - await this.removeLayer(config); - } - - async removeLayerWithoutSync(config: LayerConfig) { - if (config.setVisibility) { - config.setVisibility(false); - } else { - const c = await config.promise; - if (c instanceof CustomDataSource || c instanceof GeoJsonDataSource) { - this.viewer!.dataSources.getByName(c.name)[0].show = false; - } - } - config.visible = false; - config.displayed = false; - if (config.remove) { - config.remove(); - } - } - async removeLayer(config: LayerConfig) { await this.removeLayerWithoutSync(config); this.viewer!.scene.requestRender(); @@ -694,44 +606,40 @@ export class SideBar extends LitElementI18n { return layer.promise; } - toggleLayerOrderChange() { - this.layerOrderChangeActive = !this.layerOrderChangeActive; + private handleDisplayLayersUpdate(e: LayersUpdateEvent): void { + this.activeLayers = e.detail.layers; } - async onLayersOrderChange(layers: LayerConfig[]) { - await this.layerActions!.reorderLayers(layers); - // update activeLayers only when ordering finished - if (!this.layerOrderChangeActive) { - this.activeLayers = [...layers]; + private handleDisplayLayerUpdate(e: LayerEvent): void { + this.queryManager!.hideObjectInformation(); + const catalogLayers = this.catalogLayers ? this.catalogLayers : []; + this.catalogLayers = [...catalogLayers]; + this.activeLayers = [...this.activeLayers]; + syncLayersParam(this.activeLayers); + if (e.detail) { + this.maybeShowVisibilityHint(e.detail.layer); } - this.dispatchEvent(new CustomEvent('layerChanged')); + this.requestUpdate(); } - async onKmlUpload(file: File, clampToGround: boolean) { - if (!this.viewer) return; - const dataSource = new CustomDataSource(); - const name = await parseKml(this.viewer, file, dataSource, clampToGround); - const layer = `${name.replace(' ', '_')}_${Date.now()}`; - // name used as id for datasource - dataSource.name = layer; - MainStore.addUploadedKmlName(dataSource.name); - await this.viewer.dataSources.add(dataSource); - await renderWithDelay(this.viewer); - // done like this to have correct rerender of component - const promise = Promise.resolve(dataSource); - const config: LayerConfig = { - load() { return promise; }, - label: name, - layer, - promise: promise, - opacity: DEFAULT_LAYER_OPACITY, - notSaveToPermalink: true, - ownKml: true, - opacityDisabled: true - }; - await this.onCatalogLayerClicked(config); - this.viewer.zoomTo(dataSource); - this.requestUpdate(); + private async handleDisplayLayerRemoval(e: LayerEvent): Promise { + await this.removeLayer(e.detail.layer); + } + + private async removeLayerWithoutSync(layer: LayerConfig): Promise { + if (layer.setVisibility) { + layer.setVisibility(false); + } else { + const c = await layer.promise; + if (c instanceof CustomDataSource || c instanceof GeoJsonDataSource) { + this.viewer!.dataSources.getByName(c.name)[0].show = false; + } + } + layer.visible = false; + layer.displayed = false; + if (layer.remove) { + layer.remove(); + } } toggleDebugTools(event) { diff --git a/ui/src/index.ts b/ui/src/index.ts index 636ff825b..ae2307bc9 100644 --- a/ui/src/index.ts +++ b/ui/src/index.ts @@ -2,6 +2,7 @@ import './jquery.polyfill'; import './style/index.css'; import {ReactiveElement} from 'lit'; + // Detect issues following lit2 migration ReactiveElement.enableWarning?.('migration'); diff --git a/ui/src/layers/ngm-catalog.ts b/ui/src/layers/ngm-catalog.ts deleted file mode 100644 index 94d126eb4..000000000 --- a/ui/src/layers/ngm-catalog.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type {TemplateResult} from 'lit'; -import {html} from 'lit'; -import {customElement, property, state} from 'lit/decorators.js'; -import {LitElementI18n} from '../i18n.js'; -import i18next from 'i18next'; -import auth from '../store/auth'; -import type {LayerTreeNode} from '../layertree'; - -@customElement('ngm-catalog') -export class Catalog extends LitElementI18n { - @property({type: Array}) - accessor layers: LayerTreeNode[] = []; - @state() - accessor userGroups: string[] = []; - - constructor() { - super(); - auth.user.subscribe((user) => { - this.userGroups = user?.['cognito:groups'] ?? []; - }); - } - - getCategoryOrLayerTemplate(node: LayerTreeNode, level: string): TemplateResult { - if (node.children) { - return this.getCategoryTemplate(node, level); - } - return this.getLayerTemplate(node); - } - - getCategoryTemplate(category: LayerTreeNode, level: string): TemplateResult { - // if it is a restricted layer, the user must be logged in to see it - const content = category.children?.filter( - node => !(node.restricted && (!node.restricted.some(g => this.userGroups.includes(g)))) - ).map(node => this.getCategoryOrLayerTemplate(node, 'second-level')); - - if (!content?.length) return html``; - - return html` -
-
-
- -
-
- ${content} -
-
- `; - } - - getLayerTemplate(layer: LayerTreeNode): TemplateResult { - return html` -
{ - this.dispatchEvent(new CustomEvent('layerclick', { - detail: { - layer - } - })); - }}> - - - -
- `; - } - - render() { - return html`${this.layers.map(node => this.getCategoryOrLayerTemplate(node, 'first-level'))}`; - } - - createRenderRoot() { - return this; - } -} diff --git a/ui/src/layers/ngm-layers-item.ts b/ui/src/layers/ngm-layers-item.ts index e23fe9cbd..02c423b05 100644 --- a/ui/src/layers/ngm-layers-item.ts +++ b/ui/src/layers/ngm-layers-item.ts @@ -1,5 +1,5 @@ import i18next from 'i18next'; -import {html} from 'lit'; +import {css, html, unsafeCSS} from 'lit'; import {customElement, property, query, state} from 'lit/decorators.js'; import {LitElementI18n} from '../i18n.js'; import {classMap} from 'lit-html/directives/class-map.js'; @@ -10,6 +10,15 @@ import {styleMap} from 'lit/directives/style-map.js'; import {Sortable} from 'sortablejs'; import type LayersAction from './LayersActions'; import {debounce} from '../utils'; +import {PropertyValues} from '@lit/reactive-element'; + +import iconsCss from '../style/icons.css'; +import layersCss from '../style/layers.css'; +import sliderCss from '../style/ngm-slider.css'; + +import fomanticTransitionCss from 'fomantic-ui-css/components/transition.css'; +import fomanticDropdownCss from 'fomantic-ui-css/components/dropdown.css'; +import 'fomantic-ui-css/components/transition.js'; const GEOCAT_LANG_CODE = { 'de': 'ger', @@ -41,11 +50,17 @@ export class NgmLayersItem extends LitElementI18n { private toggleItemSelection = () => this.movable ? Sortable.utils.select(this) : Sortable.utils.deselect(this); private debouncedOpacityChange = debounce(() => this.changeOpacity(), 250, true); - firstUpdated() { - $(this.querySelector('.ui.dropdown')!).dropdown(); + firstUpdated(): void { + if (this.shadowRoot != null) { + $(this.shadowRoot.querySelectorAll('.ui.dropdown')).dropdown({ + on: 'mouseup', + collapseOnActionable: false, + }); + } } - updated(changedProps) { + updated(changedProps: PropertyValues): void { + super.updated(changedProps); if (changedProps.has('changeOrderActive')) { this.updateMovableState(); } @@ -54,7 +69,6 @@ export class NgmLayersItem extends LitElementI18n { this.requestUpdate(); }); } - super.updated(changedProps); } connectedCallback() { @@ -127,6 +141,7 @@ export class NgmLayersItem extends LitElementI18n { showLayerLegend(config: LayerConfig) { this.dispatchEvent(new CustomEvent('showLayerLegend', { + composed: true, bubbles: true, detail: { config @@ -136,6 +151,7 @@ export class NgmLayersItem extends LitElementI18n { showWmtsDatePicker(config: LayerConfig) { this.dispatchEvent(new CustomEvent('showWmtsDatePicker', { + composed: true, bubbles: true, detail: { config @@ -145,6 +161,7 @@ export class NgmLayersItem extends LitElementI18n { showVoxelFilter(config: LayerConfig) { this.dispatchEvent(new CustomEvent('showVoxelFilter', { + composed: true, bubbles: true, detail: { config @@ -166,9 +183,9 @@ export class NgmLayersItem extends LitElementI18n { ${i18next.t('dtd_legend')} ` : ''} ${this.config?.geocatId ? html` - Geocat.ch ` : ''} @@ -273,10 +290,6 @@ export class NgmLayersItem extends LitElementI18n { `; } - createRenderRoot() { - return this; - } - cloneNode(deep) { const node = super.cloneNode(deep) as NgmLayersItem; node.config = this.config; @@ -285,4 +298,19 @@ export class NgmLayersItem extends LitElementI18n { node.clone = true; return node; } + + static readonly styles = css` + ${unsafeCSS(fomanticTransitionCss)} + ${unsafeCSS(fomanticDropdownCss)} + ${unsafeCSS(iconsCss)} + ${unsafeCSS(layersCss.replaceAll('ngm-layers-item', ':host'))} + ${unsafeCSS(sliderCss)} + + .ui.dropdown .menu > .item { + font-size: 14px; + text-decoration: none; + padding: 10px 16px; + min-height: unset; + } + `; } diff --git a/ui/src/layers/ngm-layers.ts b/ui/src/layers/ngm-layers.ts index daa3e811b..3de0a48fa 100644 --- a/ui/src/layers/ngm-layers.ts +++ b/ui/src/layers/ngm-layers.ts @@ -4,13 +4,14 @@ import {LitElementI18n} from '../i18n.js'; import './ngm-layers-item'; import DashboardStore from '../store/dashboard'; import {LayerConfig} from '../layertree'; +import type LayersAction from './LayersActions'; @customElement('ngm-layers') export default class NgmLayers extends LitElementI18n { @property({type: Array}) accessor layers: LayerConfig[] = []; @property({type: Object}) - accessor actions: any; + accessor actions: LayersAction | null = null; updated(changedProperties: PropertyValues) { if (changedProperties.has('layers')) { @@ -52,8 +53,4 @@ export default class NgmLayers extends LitElementI18n { const reverse = [...this.layers].reverse(); return html`${reverse.map((c, idx) => this.createLayerTemplate(c, idx, len))}`; } - - createRenderRoot() { - return this; - } } diff --git a/ui/src/style/index.css b/ui/src/style/index.css index bfeb7e982..eb4baf4b7 100644 --- a/ui/src/style/index.css +++ b/ui/src/style/index.css @@ -64,6 +64,8 @@ @import 'ngm-ion-modal.css'; @import 'ngm-info-table.css'; +@import '../styles/index.css'; + * { scrollbar-color: light; scrollbar-width: thin; diff --git a/ui/src/style/layers.css b/ui/src/style/layers.css index 8ab73406d..1f5481a02 100644 --- a/ui/src/style/layers.css +++ b/ui/src/style/layers.css @@ -50,7 +50,7 @@ ngm-layers-item, .ngm-base-layer { flex-direction: row; } -ngm-layers-item, .ngm-base-layer > div:first-child { +.ngm-base-layer > div:first-child { padding: 0 11px; } @@ -141,11 +141,11 @@ ngm-layers-item .ui.dropdown .menu .item:hover { background-color: var(--ngm-hover); } -.ngm-extension-panel .ui.header { +.ngm-extension-panel .ui.header, ngm-navigation-panel .ui.header { margin-top: 7px; } -.ngm-extension-panel .ui.divider { +.ngm-extension-panel .ui.divider, ngm-navigation-panel .ui.divider { margin-top: 15px; margin-bottom: 8px; border-top: 1px solid #DFE2E6 !important; diff --git a/ui/src/style/ngm-side-bar.css b/ui/src/style/ngm-side-bar.css index c048e708d..c360956b5 100644 --- a/ui/src/style/ngm-side-bar.css +++ b/ui/src/style/ngm-side-bar.css @@ -1,5 +1,7 @@ ngm-side-bar { display: flex; + z-index: 5; + max-width: 100vw; } .ngm-menu, @@ -65,9 +67,9 @@ ngm-side-bar { width: 250px; height: calc(100vh - var(--ngm-header-height)); max-width: 1028px; - background-color: #F1F3F5; + background-color: var(--color-bg--dark); box-shadow: 4px 0 4px #00000029; - padding: 10px; + padding: var(--panel-padding); position: absolute; margin-left: var(--ngm-left-side-bar-width); z-index: 5; @@ -76,6 +78,10 @@ ngm-side-bar { overflow-y: auto; } +ngm-navigation-catalog .ngm-side-bar-panel { + margin-left: 0; +} + .inner-toolbar-settings { display: flex; flex-direction: column; diff --git a/ui/src/styles/index.css b/ui/src/styles/index.css new file mode 100644 index 000000000..75bc137a3 --- /dev/null +++ b/ui/src/styles/index.css @@ -0,0 +1,24 @@ + +:root { + --color-bg: #FFF; + --color-bg--dark: #F1F3F5; + + --color-bg-contrast: #000; + --color-bg-contrast--light: #212529; + --color-bg-contrast--lighter: #656363; + + --color-highlight: #C5F6FA; + --color-highlight--dark: #66D9E8; + --color-highlight--darker: #0B7285; + + --color-action: #a83526; + --color-action--light: #FF0000; + + --font: 'Inter', sans-serif; + font-family: var(--font); + font-size: 16px; + + --panel-padding: 10px; + + --icon-size--normal: 24px; +} diff --git a/ui/src/styles/theme.ts b/ui/src/styles/theme.ts new file mode 100644 index 000000000..8748caf81 --- /dev/null +++ b/ui/src/styles/theme.ts @@ -0,0 +1,26 @@ +import {css, CSSResult} from 'lit'; + +type Breakpoint = + | 'xs' + | 'sm' + | 'md' + | 'lg' + | 'xl' + | 'xxl' + +const breakpoints: Record = { + xs: 0, + sm: 576, + md: 768, + lg: 992, + xl: 1200, + xxl: 1400, +}; + +export const upFrom = (breakpoint: Breakpoint): CSSResult => { + const min = breakpoints[breakpoint]; + if (min === 0) { + throw new Error(`can't target breakpoint '${breakpoint}' with media query as it starts at zero pixels`); + } + return css`@media (min-width: ${min}px)`; +}; diff --git a/ui/src/typings/styles.d.ts b/ui/src/typings/styles.d.ts new file mode 100644 index 000000000..5bf18d724 --- /dev/null +++ b/ui/src/typings/styles.d.ts @@ -0,0 +1,4 @@ +declare module '*.css' { + const styles: string; + export default styles; +} diff --git a/ui/webpack.config.js b/ui/webpack.config.js index ff483f9a6..e5b97cbcc 100644 --- a/ui/webpack.config.js +++ b/ui/webpack.config.js @@ -73,15 +73,19 @@ const config = { type: 'asset', }, { - test: /\.css$/i, - issuer: /\.(?:js|ts)$/, - use: ['style-loader', 'css-loader'], + test: /\/style\/index.css$/i, + //use: [isDev ? "style-loader" : MiniCssExtractPlugin.loader, 'css-loader'], + use: [MiniCssExtractPlugin.loader, 'css-loader'], }, { test: /\.css$/i, - issuer: {not: [/\.(?:js|ts)$/]}, - //use: [isDev ? "style-loader" : MiniCssExtractPlugin.loader, 'css-loader'], - use: [MiniCssExtractPlugin.loader, 'css-loader'], + exclude: /\/style\/index.css$/i, + use: [{ + loader: 'css-loader', + options: { + exportType: 'string', + } + }], }, { test: /\.ts$/,