diff --git a/CHANGELOG.md b/CHANGELOG.md index de785d7eb..0c82462d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Added ### Changed +- Values for "Height", "Angle", "Pitch", and coordinates are now input fields. Users can adjust values using arrow keys. +- The range for height has been increased to 700'000m. - Config is loaded from the frontend at runtime now. diff --git a/ui/locales/app.de.json b/ui/locales/app.de.json index a07593fd7..6d2be36a6 100644 --- a/ui/locales/app.de.json +++ b/ui/locales/app.de.json @@ -9,8 +9,10 @@ "cam_lock_info_pitch": "Im Moment kann im 3D Viewer nur der Neigungswinkel verändert werden.", "camera_position_angle_label": "Azimut", "camera_position_coordinates_label": "Koordinaten", + "camera_position_coordinates_input_error": "Die eingegebenen Koordinaten sind ungültig.", "camera_position_coordinates_system_label": "Koordinatensystem", - "camera_position_height_label": "Höhe(m. ü. OKT)", + "camera_position_height_label": "Höhe", + "camera_position_height_unit": "(m. ü. OKT)", "camera_position_pitch_label": "Neigungswinkel", "cancel": "Abbrechen", "contact_mailto_text": "Kontakt", diff --git a/ui/locales/app.en.json b/ui/locales/app.en.json index ea47951a9..5ad4cc796 100644 --- a/ui/locales/app.en.json +++ b/ui/locales/app.en.json @@ -7,10 +7,12 @@ "cam_lock_info_elevation": "In the 3D viewer currently you can only change the height.", "cam_lock_info_move": "In the 3D viewer currently you can only change the coordinates.", "cam_lock_info_pitch": "In the 3D viewer currently you can only change the pitch.", - "camera_position_angle_label": "Angle (N±)", + "camera_position_angle_label": "Angle(N±)", "camera_position_coordinates_label": "Coordinates", + "camera_position_coordinates_input_error": "The entered coordinates are invalid.", "camera_position_coordinates_system_label": "Coordinate system", - "camera_position_height_label": "Height(m AGL)", + "camera_position_height_label": "Height", + "camera_position_height_unit": "(m AGL)", "camera_position_pitch_label": "Pitch", "cancel": "Cancel", "contact_mailto_text": "Contact", diff --git a/ui/locales/app.fr.json b/ui/locales/app.fr.json index 334878fcd..acaf7fae2 100644 --- a/ui/locales/app.fr.json +++ b/ui/locales/app.fr.json @@ -9,8 +9,10 @@ "cam_lock_info_pitch": "Dans le viewer 3D, seul l'angle peut être modifié actuellement.", "camera_position_angle_label": "Angle", "camera_position_coordinates_label": "Coordonnées", + "camera_position_coordinates_input_error": "Les coordonnées saisies sont invalides.", "camera_position_coordinates_system_label": "Système de coordonnées", - "camera_position_height_label": "Altitude(m AGL)", + "camera_position_height_label": "Altitude", + "camera_position_height_unit": "(m AGL)", "camera_position_pitch_label": "Inclinaison", "cancel": "Annuler", "contact_mailto_text": "Contact", diff --git a/ui/locales/app.it.json b/ui/locales/app.it.json index 919ee83e2..85c97cb3c 100644 --- a/ui/locales/app.it.json +++ b/ui/locales/app.it.json @@ -9,8 +9,10 @@ "cam_lock_info_pitch": "Nel visualizzatore 3D si può solo cambiare l'angolo attualmente", "camera_position_angle_label": "Angolo", "camera_position_coordinates_label": "Coordinate", + "camera_position_coordinates_input_error": "Le coordinate inserite non sono valide.", "camera_position_coordinates_system_label": "Sistema di coordinate", - "camera_position_height_label": "Altitudine(m AGL)", + "camera_position_height_label": "Altitudine", + "camera_position_height_unit": "(m AGL)", "camera_position_pitch_label": "Inclinazione", "cancel": "Annullare", "contact_mailto_text": "Contatto", diff --git a/ui/src/constants.ts b/ui/src/constants.ts index 1a53b221e..b596fa679 100644 --- a/ui/src/constants.ts +++ b/ui/src/constants.ts @@ -2,9 +2,11 @@ import {Cartesian3, Color, ColorBlendMode, Math as CMath, Rectangle, ShadowMode, export {LayerType, DEFAULT_LAYER_OPACITY} from './layertree'; -export const SWITZERLAND_BOUNDS = [5.140242, 45.398181, 11.47757, 48.230651]; -export const SWITZERLAND_RECTANGLE = Rectangle.fromDegrees(...SWITZERLAND_BOUNDS); +export const SWITZERLAND_BOUNDS_WGS84 = [4.54249, 44.61921, 12.67250, 48.45365]; +export const SWITZERLAND_BOUNDS_LV95 = [2370000, 945000, 2987000, 1380000]; + +export const SWITZERLAND_RECTANGLE = Rectangle.fromDegrees(...SWITZERLAND_BOUNDS_WGS84); export const MINIMAP_EXTENT = [5.910642046, 45.191912227, 10.554524194, 48.04750923]; diff --git a/ui/src/elements/ngm-cam-configuration.ts b/ui/src/elements/ngm-cam-configuration.ts index eee9b0bb1..bdfe0e764 100644 --- a/ui/src/elements/ngm-cam-configuration.ts +++ b/ui/src/elements/ngm-cam-configuration.ts @@ -5,33 +5,38 @@ import {customElement, property, state} from 'lit/decorators.js'; import draggable from './draggable'; import i18next from 'i18next'; import type {Interactable} from '@interactjs/types'; -import {Event, Scene, Viewer} from 'cesium'; import { + Event, + Scene, + Viewer, Cartesian2, + Cartesian3, KeyboardEventModifier, Math as CesiumMath, Matrix4, ScreenSpaceEventHandler, - ScreenSpaceEventType + ScreenSpaceEventType, + Ellipsoid } from 'cesium'; -import {formatCartographicAs2DLv95, radToDeg} from '../projection'; +import {formatCartographicAs2DLv95, lv95ToDegrees, radToDeg} from '../projection'; import {styleMap} from 'lit/directives/style-map.js'; import {classMap} from 'lit/directives/class-map.js'; import './ngm-cam-coordinates'; import NavToolsStore from '../store/navTools'; import {dragArea} from './helperElements'; import './ngm-minimap'; -import {unsafeHTML} from 'lit/directives/unsafe-html.js'; +import {CoordinateWithCrs} from './ngm-cam-coordinates'; export type LockType = '' | 'elevation' | 'angle' | 'pitch' | 'move'; - +export const ABSOLUTE_ELEVATION_MIN = 30000; +export const ABSOLUTE_ELEVATION_MAX = 700000; /* * Convert cartographic height (between -30'000m and +300'000) to input value (between 0 and 1) * The input value between 0 to 0.5 is mapped to the height between -30'000m and 0m * The input between 0.5 and 1 is mapped to the height between 0m and +300'000m */ export function heightToValue(height: number): number { - const m = 0.5 / (height < 0 ? 30000 : 300000); + const m = 0.5 / (height < 0 ? ABSOLUTE_ELEVATION_MIN : ABSOLUTE_ELEVATION_MAX); return m * height + 0.5; } @@ -42,9 +47,9 @@ export function heightToValue(height: number): number { */ export function valueToHeight(value: number): number { if (value < 0.5) { - return (30000 / 0.5) * value - 30000; + return (ABSOLUTE_ELEVATION_MIN / 0.5) * value - ABSOLUTE_ELEVATION_MIN; } else { - return (300000 / 0.5) * value - 300000; + return (ABSOLUTE_ELEVATION_MAX / 0.5) * value - ABSOLUTE_ELEVATION_MAX; } } @@ -69,29 +74,50 @@ export class NgmCamConfiguration extends LitElementI18n { @state() accessor lockType: LockType = ''; // always use the 'de-CH' locale to always have the simple tick as thousands separator - private integerFormat = new Intl.NumberFormat('de-CH', { + private readonly integerFormat = new Intl.NumberFormat('de-CH', { maximumFractionDigits: 1 }); + private timeout: null | NodeJS.Timeout = null; private handler: ScreenSpaceEventHandler | undefined; private lockMove = false; - private lockMoveStartPosition: Cartesian2 = new Cartesian2(); - private lockMovePosition: Cartesian2 = new Cartesian2(); + private readonly lockMoveStartPosition: Cartesian2 = new Cartesian2(); + private readonly lockMovePosition: Cartesian2 = new Cartesian2(); private removeOnTick: Event.RemoveCallback | undefined; - private configurations = [ + private readonly configurations = [ { - label: () => i18next.t('camera_position_height_label'), + label: () => html`${i18next.t('camera_position_height_label')}${i18next.t('camera_position_height_unit')}`, iconClass: () => classMap({'ngm-cam-h-icon': true, 'ngm-active-icon': this.lockType === 'elevation'}), minValue: 0, maxValue: 1, - step: 'any', + step: 0.1 / (ABSOLUTE_ELEVATION_MAX + ABSOLUTE_ELEVATION_MIN), + minInputValue: -ABSOLUTE_ELEVATION_MIN, + maxInputValue: ABSOLUTE_ELEVATION_MAX, + inputStep: 0.1, style: () => this.getSliderStyle(heightToValue(this.elevation), 0, 1), getValue: () => heightToValue(this.elevation), + getInputValue: () => this.elevation.toFixed(1), getValueLabel: () => `${this.integerFormat.format(this.elevation)} m`, - onChange: evt => this.updateHeight(valueToHeight(Number(evt.target.value))), + onSliderChange: (evt) => this.updateHeight(valueToHeight(Number(evt.target.value))), + onInputChange: (evt) => { + if (this.timeout) { + clearTimeout(this.timeout); + } + let value = Number(evt.target.value); + if (value < -ABSOLUTE_ELEVATION_MIN) { + value = -ABSOLUTE_ELEVATION_MIN; + } + if (value > ABSOLUTE_ELEVATION_MAX) { + value = ABSOLUTE_ELEVATION_MAX; + } + this.timeout = setTimeout(() => { + this.updateHeight(value); + this.timeout = null; + }, 300); + }, lock: () => this.toggleLock('elevation') }, { - label: () => i18next.t('camera_position_angle_label'), + label: () => html`${i18next.t('camera_position_angle_label')}(°)`, iconClass: () => classMap({'ngm-cam-d-icon': true, 'ngm-active-icon': this.lockType === 'angle'}), minValue: 0, maxValue: 359, @@ -99,11 +125,11 @@ export class NgmCamConfiguration extends LitElementI18n { style: () => this.getSliderStyle(this.heading, 0, 359, true), getValue: () => this.heading, getValueLabel: () => `${this.integerFormat.format(this.heading)}°`, - onChange: (evt) => this.updateAngle(Number(evt.target.value)), + onSliderChange: (evt) => this.updateAngle(Number(evt.target.value)), lock: () => this.toggleLock('angle') }, { - label: () => i18next.t('camera_position_pitch_label'), + label: () => html`${i18next.t('camera_position_pitch_label')}(°)`, iconClass: () => classMap({'ngm-cam-t-icon': true, 'ngm-active-icon': this.lockType === 'pitch'}), minValue: -90, maxValue: 90, @@ -111,7 +137,7 @@ export class NgmCamConfiguration extends LitElementI18n { style: () => this.getSliderStyle(this.pitch, -90, 90), getValue: () => this.pitch, getValueLabel: () => `${this.integerFormat.format(this.pitch)}°`, - onChange: (evt) => this.updatePitch(Number(evt.target.value)), + onSliderChange: (evt) => this.updatePitch(Number(evt.target.value)), lock: () => this.toggleLock('pitch') }, ]; @@ -130,10 +156,11 @@ export class NgmCamConfiguration extends LitElementI18n { super.disconnectedCallback(); } + updated(changedProperties: PropertyValues) { if (this.viewer && !this.unlistenPostRender) { this.scene = this.viewer.scene; - this.handler = new ScreenSpaceEventHandler(this.viewer!.canvas); + this.handler = new ScreenSpaceEventHandler(this.viewer.canvas); this.unlistenPostRender = this.scene.postRender.addEventListener(() => this.updateFromCamera()); this.updateFromCamera(); } @@ -145,7 +172,7 @@ export class NgmCamConfiguration extends LitElementI18n { updateFromCamera() { const camera = this.scene!.camera; const pc = camera.positionCartographic; - const altitude = this.scene!.globe.getHeight(pc) || 0; + const altitude = this.scene!.globe.getHeight(pc) ?? 0; this.elevation = pc.height - altitude; this.pitch = Math.round(CesiumMath.toDegrees(camera.pitch)); let heading = Math.round(CesiumMath.toDegrees(camera.heading)); @@ -160,7 +187,7 @@ export class NgmCamConfiguration extends LitElementI18n { } updateHeight(value: number) { - const altitude = this.scene!.globe.getHeight(this.scene!.camera.positionCartographic) || 0; + const altitude = this.scene!.globe.getHeight(this.scene!.camera.positionCartographic) ?? 0; NavToolsStore.setCameraHeight(value + altitude); } @@ -184,6 +211,12 @@ export class NgmCamConfiguration extends LitElementI18n { }); } + updateCoordinates(event: CustomEvent) { + const detail = event.detail; + const coordinates = detail.crs === 'lv95' ? lv95ToDegrees([detail.long, detail.lat]) : [detail.long, detail.lat]; + this.scene?.camera.setView({destination: Cartesian3.fromDegrees(coordinates[0], coordinates[1], Ellipsoid.WGS84.cartesianToCartographic(this.scene.camera.position).height)}); + } + getSliderStyle(value: number, minValue: number, maxValue: number, oneDirection = false) { if (oneDirection) { return { @@ -292,21 +325,30 @@ export class NgmCamConfiguration extends LitElementI18n { - ${unsafeHTML(c.label())} - ${c.getValueLabel()} + ${c.label()} + + @input=${c.onSliderChange} + @keydown="${(e) => (e.key === 'ArrowLeft' || e.key === 'ArrowRight') && e.stopPropagation()}" + /> `)} this.toggleLock('move')}> - + ${dragArea} diff --git a/ui/src/elements/ngm-cam-coordinates.ts b/ui/src/elements/ngm-cam-coordinates.ts index 6c19c062a..518ecc2a7 100644 --- a/ui/src/elements/ngm-cam-coordinates.ts +++ b/ui/src/elements/ngm-cam-coordinates.ts @@ -6,7 +6,49 @@ import {html} from 'lit'; import {customElement, property, query, state} from 'lit/decorators.js'; import 'fomantic-ui-css/components/transition.js'; import 'fomantic-ui-css/components/dropdown.js'; +import {showSnackbarError} from '../notifications'; +import {SWITZERLAND_BOUNDS_LV95, SWITZERLAND_BOUNDS_WGS84} from '../constants'; +type CoordinateRange = { + minValue: number; + maxValue: number; +}; +type ValidCrs = 'lv95' | 'wgs84' +type MinMaxCoordinateValues = { + [key in ValidCrs]: { + long: CoordinateRange; + lat: CoordinateRange; + }; +}; + +const MIN_MAX_COORDINATE_VALUES: MinMaxCoordinateValues = { + lv95: { + long: { + minValue: SWITZERLAND_BOUNDS_LV95[0], + maxValue: SWITZERLAND_BOUNDS_LV95[2], + }, + lat: { + minValue: SWITZERLAND_BOUNDS_LV95[1], + maxValue: SWITZERLAND_BOUNDS_LV95[3], + }, + }, + wgs84: { + long: { + minValue: SWITZERLAND_BOUNDS_WGS84[0], + maxValue: SWITZERLAND_BOUNDS_WGS84[2], + }, + lat: { + minValue: SWITZERLAND_BOUNDS_WGS84[1], + maxValue: SWITZERLAND_BOUNDS_WGS84[3], + }, + } +}; + +export interface CoordinateWithCrs { + long: number, + lat: number, + crs: ValidCrs, +} @customElement('ngm-cam-coordinates') export class NgmCamCoordinates extends LitElementI18n { @@ -14,11 +56,14 @@ export class NgmCamCoordinates extends LitElementI18n { accessor coordinates = {}; @state() - accessor key = 'lv95'; + accessor key: ValidCrs = 'lv95'; @query('.dropdown') accessor dropdown; + @query('.ngm-coords-input') + accessor coordsInput; + createRenderRoot() { return this; } @@ -28,6 +73,29 @@ export class NgmCamCoordinates extends LitElementI18n { super.updated(changedProperties); } + updateCoordinates() { + const coords = this.coordsInput.value.replace(/['`’´]/g, '').split(', ').map(c => parseFloat(c)); + const text = i18next.t('camera_position_coordinates_input_error'); + if (isNaN(coords[0]) || isNaN(coords[1])) { + showSnackbarError(text); + return; + } + if (coords[0] < MIN_MAX_COORDINATE_VALUES[this.key].long.minValue || coords[0] > MIN_MAX_COORDINATE_VALUES[this.key].long.maxValue) { + showSnackbarError(text); + return; + } + if (coords[1] < MIN_MAX_COORDINATE_VALUES[this.key].lat.minValue || coords[1] > MIN_MAX_COORDINATE_VALUES[this.key].lat.maxValue) { + showSnackbarError(text); + return; + } + this.dispatchEvent(new CustomEvent('coordinates-changed', { + detail: { + long: coords[0], + lat: coords[1], + crs: this.key, + }})); + } + render() { if (!this.coordinates || !this.coordinates[this.key]) { return ''; @@ -47,7 +115,12 @@ export class NgmCamCoordinates extends LitElementI18n { ${i18next.t('camera_position_coordinates_label')} - ${c[0]}, ${c[1]} + e.key === 'Enter' && this.updateCoordinates()}" + /> `; } diff --git a/ui/src/style/ngm-cam-configuration.css b/ui/src/style/ngm-cam-configuration.css index 196cf54ae..596b346bb 100644 --- a/ui/src/style/ngm-cam-configuration.css +++ b/ui/src/style/ngm-cam-configuration.css @@ -45,6 +45,14 @@ ngm-cam-configuration { width: 100%; } +.ngm-cam-conf-number-input { + border: none; + padding: 0; + width: 50%; + background-color: unset; + justify-items: end +} + .ngm-cam-coord { display: flex; flex-direction: column; @@ -67,6 +75,11 @@ ngm-cam-configuration { color: #222529; } +.ngm-cam-coord .ngm-coords-input { + background-color: unset; + border: none; +} + .ngm-cam-coord .ui.dropdown .menu { border-radius: 0; box-shadow: 0 3px 4px #00000029; diff --git a/ui/src/test/ngm-cam-configuration.test.ts b/ui/src/test/ngm-cam-configuration.test.ts index 7bfa6304d..f4277bf7a 100644 --- a/ui/src/test/ngm-cam-configuration.test.ts +++ b/ui/src/test/ngm-cam-configuration.test.ts @@ -1,26 +1,31 @@ -import assert from 'assert'; -import {round} from './utils'; - -import {heightToValue, valueToHeight} from '../elements/ngm-cam-configuration'; - -describe('ngm-cam-configuration', () => { - describe('heightToValue', () => { - it('should return value from height', () => { - assert.equal(round(heightToValue(300000)), 1); - assert.equal(round(heightToValue(150000)), 0.75); - assert.equal(round(heightToValue(0)), 0.5); - assert.equal(round(heightToValue(-18000)), 0.2); - assert.equal(round(heightToValue(-30000)), 0); - }); - }); - - describe('valueToHeight', () => { - it('should return height from value', () => { - assert.equal(round(valueToHeight(1)), 300000); - assert.equal(round(valueToHeight(0.75)), 150000); - assert.equal(round(valueToHeight(0.5)), 0); - assert.equal(round(valueToHeight(0.2)), -18000); - assert.equal(round(valueToHeight(0)), -30000); - }); - }); -}); +import assert from 'assert'; +import {round} from './utils'; + +import { + ABSOLUTE_ELEVATION_MAX, + ABSOLUTE_ELEVATION_MIN, + heightToValue, + valueToHeight +} from '../elements/ngm-cam-configuration'; + +describe('ngm-cam-configuration', () => { + describe('heightToValue', () => { + it('should return value from height', () => { + assert.equal(round(heightToValue(ABSOLUTE_ELEVATION_MAX)), 1); + assert.equal(round(heightToValue(ABSOLUTE_ELEVATION_MAX / 2)), 0.75); + assert.equal(round(heightToValue(0)), 0.5); + assert.equal(round(heightToValue(-ABSOLUTE_ELEVATION_MIN * 0.6)), 0.2); + assert.equal(round(heightToValue(-ABSOLUTE_ELEVATION_MIN)), 0); + }); + }); + + describe('valueToHeight', () => { + it('should return height from value', () => { + assert.equal(round(valueToHeight(1)), ABSOLUTE_ELEVATION_MAX); + assert.equal(round(valueToHeight(0.75)), ABSOLUTE_ELEVATION_MAX / 2); + assert.equal(round(valueToHeight(0.5)), 0); + assert.equal(round(valueToHeight(0.2)), -ABSOLUTE_ELEVATION_MIN * 0.6); + assert.equal(round(valueToHeight(0)), -ABSOLUTE_ELEVATION_MIN); + }); + }); +});