diff --git a/.env b/.env index 8e4c4d78..a202379a 100644 --- a/.env +++ b/.env @@ -1,3 +1,5 @@ +VITE_V3_API_HOST="https://map.geoportail.lu/" + # Proxy urls VITE_USE_PROXYURL=true VITE_PROXYURL_WMS="/ogcproxywms" @@ -9,6 +11,15 @@ VITE_GET_LEGENDS_URL="/legends/get_html" VITE_GET_METADATA_URL="/getMetadata" VITE_GET_INFO_SERVICE_URL="/getfeatureinfo" +# Urls for location info +VITE_FORAGE_URL="/getRapportForageVirtuel" +VITE_LIDAR_URL="https://lidar.geoportail.lu" +VITE_CYCLOMEDIA_URL="http://streetsmart.cyclomedia.com/streetsmart" +VITE_OBLIQUE_URL="https://oblique.geoportail.lu/publication/viewer" +VITE_SHORT_URL="/short/create" +VITE_QR_URL="/qr" +VITE_ADDRESS_URL="/geocode/reverse" + # Paths for symbols VITE_SYMBOL_ICONS_URL="/mymaps" VITE_SYMBOLS_URL="/mymaps/symbols" diff --git a/.env.development b/.env.development index d520915b..07678da4 100644 --- a/.env.development +++ b/.env.development @@ -1,3 +1,5 @@ +VITE_V3_API_HOST="http://localhost:8080/" + # Proxy urls VITE_USE_PROXYURL=true VITE_PROXYURL_WMS="https://migration.geoportail.lu/ogcproxywms" @@ -9,6 +11,15 @@ VITE_GET_LEGENDS_URL="https://migration.geoportail.lu/legends/get_html" VITE_GET_METADATA_URL="https://migration.geoportail.lu/getMetadata" VITE_GET_INFO_SERVICE_URL="https://migration.geoportail.lu/getfeatureinfo" +# Urls for location info +VITE_FORAGE_URL="http://localhost:8080/getRapportForageVirtuel" +VITE_LIDAR_URL="https://lidar.geoportail.lu" +VITE_CYCLOMEDIA_URL="http://streetsmart.cyclomedia.com/streetsmart" +VITE_OBLIQUE_URL="https://oblique.geoportail.lu/publication/viewer" +VITE_SHORT_URL="http://localhost:8080/short/create" +VITE_QR_URL="http://localhost:8080/qr" +VITE_ADDRESS_URL="http://localhost:8080/geocode/reverse" + # Paths for symbols VITE_SYMBOL_ICONS_URL="https://map.geoportail.lu/mymaps" # !!! use prod because of CORS VITE_SYMBOLS_URL="https://map.geoportail.lu/mymaps/symbols" # !!! use prod because of CORS diff --git a/.env.e2e b/.env.e2e index b5c76e50..636e126a 100644 --- a/.env.e2e +++ b/.env.e2e @@ -1,3 +1,5 @@ +VITE_V3_API_HOST="https://migration.geoportail.lu/" + # Proxy urls VITE_USE_PROXYURL=true VITE_PROXYURL_WMS="https://map.geoportail.lu/ogcproxywms" @@ -9,6 +11,15 @@ VITE_GET_LEGENDS_URL="https://migration.geoportail.lu/legends/get_html" VITE_GET_METADATA_URL="https://migration.geoportail.lu/getMetadata" VITE_GET_INFO_SERVICE_URL="https://migration.geoportail.lu/getfeatureinfo" +# Urls for location info +VITE_FORAGE_URL="https://migration.geoportail.lu/getRapportForageVirtuel" +VITE_LIDAR_URL="https://lidar.geoportail.lu" +VITE_CYCLOMEDIA_URL="http://streetsmart.cyclomedia.com/streetsmart" +VITE_OBLIQUE_URL="https://oblique.geoportail.lu/publication/viewer" +VITE_SHORT_URL="https://migration.geoportail.lu/short/create" +VITE_QR_URL="https://migration.geoportail.lu/qr" +VITE_ADDRESS_URL="https://migration.geoportail.lu/geocode/reverse" + # Paths for symbols VITE_SYMBOL_ICONS_URL="https://map.geoportail.lu/mymaps" # !!! use prod because of CORS VITE_SYMBOLS_URL="https://map.geoportail.lu/mymaps/symbols" # !!! use prod because of CORS diff --git a/.env.staging b/.env.staging index 071f1b9b..b1cb4531 100644 --- a/.env.staging +++ b/.env.staging @@ -1,3 +1,5 @@ +VITE_V3_API_HOST="https://migration.geoportail.lu/" + # Proxy urls VITE_USE_PROXYURL=true VITE_PROXYURL_WMS="https://migration.geoportail.lu/ogcproxywms" @@ -9,6 +11,15 @@ VITE_GET_LEGENDS_URL="https://migration.geoportail.lu/legends/get_html" VITE_GET_METADATA_URL="https://migration.geoportail.lu/getMetadata" VITE_GET_INFO_SERVICE_URL="https://migration.geoportail.lu/getfeatureinfo" +# Urls for location info +VITE_FORAGE_URL="/getRapportForageVirtuel" +VITE_LIDAR_URL="https://lidar.geoportail.lu" +VITE_CYCLOMEDIA_URL="http://streetsmart.cyclomedia.com/streetsmart" +VITE_OBLIQUE_URL="https://oblique.geoportail.lu/publication/viewer" +VITE_SHORT_URL="https://migration.geoportail.lu/short/create" +VITE_QR_URL="https://migration.geoportail.lu/qr" +VITE_ADDRESS_URL="https://migration.geoportail.lu/geocode/reverse" + # Paths for symbols VITE_SYMBOL_ICONS_URL="https://migration.geoportail.lu/mymaps" VITE_SYMBOLS_URL="https://migration.geoportail.lu/mymaps/symbols" diff --git a/cypress/e2e/draw/draw-feat-line.cy.ts b/cypress/e2e/draw/draw-feat-line.cy.ts index a5cbb7b3..1a09bca3 100644 --- a/cypress/e2e/draw/draw-feat-line.cy.ts +++ b/cypress/e2e/draw/draw-feat-line.cy.ts @@ -11,6 +11,29 @@ function testFeatItemMeasurements() { describe('Draw "Line"', () => { beforeEach(() => { + cy.intercept( + { + method: 'POST', + pathname: '/profile.json', + }, + req => + new Promise(r => { + const resp = new Response(req.body, { + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + }) + resp.formData().then(formData => { + const geom = formData.get('geom') + const coordinates = JSON.parse(geom as string).coordinates + if (coordinates[0][0] > 40000) { + r(req.body) + } else { + r({ + fixture: 'profile.json', + }) + } + }) + }) + ).as('profile-fixture') cy.visit('/') cy.get('button[data-cy="drawButton"]').click() cy.get('button[data-cy="drawLineButton"]').click() @@ -26,10 +49,12 @@ describe('Draw "Line"', () => { }) it('displays measurements for Line', () => { + cy.wait('@profile-fixture') testFeatItemMeasurements() }) it('displays the elevation profile for Line', () => { + cy.wait('@profile-fixture') cy.get('[data-cy="featItemProfileCumul"]').should( 'contain.text', 'Δ+964 m Δ-1105 m Δ-141 m' @@ -50,11 +75,9 @@ describe('Draw "Line"', () => { }) describe('When editing the line', () => { - beforeEach(() => { - cy.intercept('POST', '/profile.json', { fixture: 'profile.json' }) - }) it('refreshes the elevation profile for Line', () => { cy.dragVertexOnMap(320, 223, 305, 305) + cy.wait('@profile-fixture') cy.get('[data-cy="featItemProfileCumul"]').should($el => { const text = $el.text() const validValues = [ @@ -72,6 +95,7 @@ describe('Draw "Line"', () => { }) it('downloads the profile elevation', () => { + cy.wait('@profile-fixture') cy.get('[data-cy="featItemProfileCSV"]').click() const downloadPath = 'cypress/downloads/Ligne_1.csv' @@ -86,12 +110,15 @@ describe('Draw "Line"', () => { }) it('updates length measurement when editing geometry', () => { + cy.wait('@profile-fixture') cy.get('[data-cy="featItemLength"]').should('contain.text', '42.31 km') cy.dragVertexOnMap(320, 223, 305, 305) + cy.wait('@profile-fixture') cy.get('[data-cy="featItemLength"]').should('contain.text', '33.26 km') }) it('displays the possible actions for the feature', () => { + cy.wait('@profile-fixture') testFeatItem() }) }) @@ -99,12 +126,14 @@ describe('Draw "Line"', () => { describe('When clicking button dock', () => { it('displays the feature info in the map popup', () => { testFeatItemDocking() + cy.wait('@profile-fixture') testFeatItemMeasurements() }) }) describe('When clicking button dropdown menu', () => { it('displays the dropdown menu content for "Line"', () => { + cy.wait('@profile-fixture') cy.get('[data-cy="featMenuPopup"] > button').should('exist') cy.get('[data-cy="featMenuPopup"] > button').click() diff --git a/cypress/e2e/footer-bar.cy.ts b/cypress/e2e/footer-bar.cy.ts index 1496a958..70899ab0 100644 --- a/cypress/e2e/footer-bar.cy.ts +++ b/cypress/e2e/footer-bar.cy.ts @@ -31,7 +31,7 @@ describe('Footer bar', () => { cy.get('[data-cy="infoOpenClose"]').find('button').click() cy.get('[data-cy="infoPanel"]').should('exist') cy.get('button[data-cy="drawButton"]').click() - cy.get('[data-cy="infoPanel"]').should('not.exist') + cy.get('[data-cy="infoPanel"]').should('be.hidden') }) }) @@ -72,7 +72,7 @@ describe('Footer bar', () => { it('Other panels are closed', () => { cy.get('[data-cy="styleSelector"]').should('not.exist') cy.get('[data-cy="layerPanel"]').should('not.exist') - cy.get('[data-cy="infoPanel"]').should('not.exist') + cy.get('[data-cy="infoPanel"]').should('be.hidden') }) describe('When clicking on layers panel button', () => { @@ -94,7 +94,7 @@ describe('Footer bar', () => { }) it('Info panel is shown', () => { - cy.get('[data-cy="infoPanel"]').should('exist') + cy.get('[data-cy="infoPanel"]').should('be.visible') }) it('Other panels are closed', () => { @@ -108,7 +108,7 @@ describe('Footer bar', () => { }) it('closes the info panel', () => { - cy.get('[data-cy="infoPanel"]').should('not.exist') + cy.get('[data-cy="infoPanel"]').should('be.hidden') }) }) }) diff --git a/cypress/e2e/info/location-info.cy.ts b/cypress/e2e/info/location-info.cy.ts new file mode 100644 index 00000000..a4c07125 --- /dev/null +++ b/cypress/e2e/info/location-info.cy.ts @@ -0,0 +1,163 @@ +describe('Location Info', () => { + beforeEach(() => { + cy.intercept('POST', 'short/create', { + statusCode: 200, + body: { short_url: 'http://localhost:8080/s/uSxF' }, + }).as('shortUrl') + + cy.intercept( + { + method: 'GET', + pathname: '/geocode/reverse', + }, + req => { + const dist = Math.sqrt( + (parseFloat(req.query.easting.toString()) - 67887) ** 2 + + (parseFloat(req.query.northing.toString()) - 85410) ** 2 + ) + const categorizedDist = + dist > 5 ? (dist > 100 ? 1972.1284 : 394.2305) : 20.98493 + req.reply({ + statusCode: 200, + body: { + count: 1, + results: [ + { + id_caclr_locality: '37', + id_caclr_street: '1147', + id_caclr_bat: '213956', + street: 'Bergstr', + number: '18', + locality: 'Roodt/Eisch/Test', + commune: 'Habscht', + postal_code: '8398', + country: 'Luxembourg', + country_code: 'lu', + distance: categorizedDist, + contributor: 'ACT', + geom: { + type: 'Point', + coordinates: [req.query.easting, req.query.northing], + }, + geomlonlat: { + type: 'Point', + coordinates: [6.00041535, 49.697110053], + }, + }, + ], + }, + }) + } + ) + }) + + describe('Open location info on position', () => { + describe('Display basic feature info for multiple layers', () => { + beforeEach(() => { + cy.visit('/?zoom=8') + }) + it('should display coordinate and address information in the panel', () => { + cy.get('[data-cy="locationInfo"]').should('be.hidden') + cy.url().should('not.contain', 'crosshair=') + cy.window() + .its('olMap') + .then(function (olMap) { + const featureLayers = olMap + .getLayers() + .getArray() + .filter((l: any) => l.get('cyLayerType') === 'infoFeatureLayer') + const features = featureLayers + .map((l: any) => l.getSource().getFeatures()) + .flat() + cy.wrap(features.length).should('equal', 0) + }) + + cy.get('div.ol-viewport').rightclick(350, 300, { force: true }) + cy.get('[data-cy="locationInfo"]').should('be.visible') + cy.get('[data-cy="locationInfo"]').find('input').should('exist') + cy.get('[data-cy="locationInfo"]') + .find('input') + .invoke('val') + .should('contain', 'localhost:8080/s') + // 8 location infos (5 projections, elevation, address, distance) + cy.get('[data-cy="locationInfo"] > div > table > tbody > tr').should( + 'have.length', + 8 + ) + cy.get('[data-cy="locationInfo"] > div > table > tbody > tr') + .eq(6) + .find('td') + .should('contain.text', 'Roodt/Eisch/Test') + cy.get('[data-cy="locationInfo"] > div > table > tbody > tr') + .eq(7) + .find('td') + .should('contain.text', '1.97 km') + // check pointer + cy.window() + .its('olMap') + .then(function (olMap) { + const featureLayers = olMap + .getLayers() + .getArray() + .filter((l: any) => l.get('cyLayerType') === 'infoFeatureLayer') + const features = featureLayers + .map((l: any) => l.getSource().getFeatures()) + .flat() + cy.wrap(features.length).should('equal', 1) + }) + + cy.url().should('contain', 'crosshair=true') + }) + + it('streetview should integrate smoothly in the panel', () => { + cy.get('div.ol-viewport').rightclick(350, 300, { force: true }) + cy.get('[data-cy="streetviewOff"]').should('not.exist') + cy.get('[data-cy="streetviewOn"]').click() + cy.get('[data-cy="streetviewOff"]').should('exist') + cy.get('[data-cy="streetviewNoData"]').should('be.visible') + cy.get('[data-cy="streetviewNoData"]') + .find('span') + .should( + 'contain.text', + "Il n'y a pas de panorama Google disponible à cet endroit" + ) + cy.window() + .its('olMap') + .then(function (olMap) { + const featureLayers = olMap + .getLayers() + .getArray() + .filter((l: any) => l.get('cyLayerType') === 'svFeatureLayer') + const features = featureLayers + .map((l: any) => l.getSource().getFeatures()) + .flat() + cy.wrap(features.length).should('equal', 0) + }) + cy.get('div.ol-viewport').rightclick(350, 50, { force: true }) + cy.get('[data-cy="streetviewNoData"]').should('not.be.visible') + cy.get('[data-cy="streetviewLoading"]').should('not.be.visible') + cy.window() + .its('olMap') + .should(function (olMap) { + const featureLayers = olMap + .getLayers() + .getArray() + .filter((l: any) => l.get('cyLayerType') === 'svFeatureLayer') + const features = featureLayers + .map((l: any) => l.getSource().getFeatures()) + .flat() + expect(features.length).to.equal(3) + }) + cy.get('[data-cy="locationInfo"] > div > table > tbody > tr') + .last() + .find('td') + .should('contain.text', '20.98 m') + cy.get('div.ol-viewport').click(382, 82, { force: true }) + cy.get('[data-cy="locationInfo"] > div > table > tbody > tr') + .last() + .find('td') + .should(el => expect(el).to.contain.text('394.23 m')) + }) + }) + }) +}) diff --git a/cypress/e2e/legends/legends.cy.ts b/cypress/e2e/legends/legends.cy.ts index cf6452b4..0b124354 100644 --- a/cypress/e2e/legends/legends.cy.ts +++ b/cypress/e2e/legends/legends.cy.ts @@ -4,12 +4,17 @@ describe('Legends', () => { 'GET', '/legends/get_html?lang=fr&name=pcn_parcelles%3Ashow&id=359', { fixture: 'legends_parcelles.html' } - ) + ).as('parcel-fixture') cy.intercept( 'GET', '/legends/get_html?lang=fr&name=energie%3Apotentiel_solaire&id=1813', { fixture: 'legends_potentiel_solaire.html' } - ) + ).as('solaire-fixture') + cy.intercept( + 'GET', + '/legends/get_html?lang=fr&name=act%3Aroadmap_vt&id=556', + { fixture: 'legends_bg_roadmap.html' } + ).as('bg-roadmap-fixture') cy.visit('/') }) @@ -24,6 +29,7 @@ describe('Legends', () => { 'contain.text', "Aucune légende n'est disponible pour les couches sélectionnées." ) + cy.wait('@bg-roadmap-fixture') cy.get('[data-cy="legendBgLayer"]').should('exist') }) @@ -63,6 +69,8 @@ describe('Legends', () => { }) it('displays the legends for both layers', () => { + cy.wait('@solaire-fixture') + cy.wait('@parcel-fixture') cy.get('[data-cy="legendLayer"]').should('have.length', 2) }) }) @@ -84,7 +92,9 @@ describe('Legends', () => { }) it('displays the legends for both layers having legend', () => { - cy.get('[data-cy="legendLayer"]').should('have.length', 2) + cy.wait('@parcel-fixture') + cy.wait('@solaire-fixture') + cy.get('[data-cy="legendLayer"]').its('length').should('be.equal', 2) }) }) diff --git a/cypress/fixtures/legends_bg_roadmap.html b/cypress/fixtures/legends_bg_roadmap.html new file mode 100644 index 00000000..f79267ba --- /dev/null +++ b/cypress/fixtures/legends_bg_roadmap.html @@ -0,0 +1,16 @@ +

+ + + +

diff --git a/decs.d.ts b/decs.d.ts index 01d54d1b..c6fd9092 100644 --- a/decs.d.ts +++ b/decs.d.ts @@ -1,3 +1,4 @@ declare module '@camptocamp/ogc-client' declare module '@geoblocks/ol-maplibre-layer' +declare module 'google.maps' declare module 'file-saver' diff --git a/package-lock.json b/package-lock.json index 66364edf..caaaf236 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "@types/d3-selection": "^3.0.10", "@types/d3-shape": "^3.1.6", "@types/d3-transition": "^3.0.8", + "@types/google.maps": "^3.58.1", "@types/jsdom": "^20.0.1", "@types/luxon": "^3.3.1", "@types/node": "^18.11.9", @@ -3295,6 +3296,12 @@ "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "dev": true }, + "node_modules/@types/google.maps": { + "version": "3.58.1", + "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz", + "integrity": "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==", + "dev": true + }, "node_modules/@types/jsdom": { "version": "20.0.1", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", diff --git a/package.json b/package.json index 6e5d8b90..d4b683c5 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@types/d3-selection": "^3.0.10", "@types/d3-shape": "^3.1.6", "@types/d3-transition": "^3.0.8", + "@types/google.maps": "^3.58.1", "@types/jsdom": "^20.0.1", "@types/luxon": "^3.3.1", "@types/node": "^18.11.9", diff --git a/src/assets/images/streetview/arrow.png b/src/assets/images/streetview/arrow.png new file mode 100644 index 00000000..a9b96053 Binary files /dev/null and b/src/assets/images/streetview/arrow.png differ diff --git a/src/assets/images/streetview/direction_sv.png b/src/assets/images/streetview/direction_sv.png new file mode 100644 index 00000000..a7158107 Binary files /dev/null and b/src/assets/images/streetview/direction_sv.png differ diff --git a/src/assets/images/streetview/direction_sv_zl1.png b/src/assets/images/streetview/direction_sv_zl1.png new file mode 100644 index 00000000..00ad60f7 Binary files /dev/null and b/src/assets/images/streetview/direction_sv_zl1.png differ diff --git a/src/assets/images/streetview/direction_sv_zl1_p0.png b/src/assets/images/streetview/direction_sv_zl1_p0.png new file mode 100644 index 00000000..7fd5c85c Binary files /dev/null and b/src/assets/images/streetview/direction_sv_zl1_p0.png differ diff --git a/src/assets/images/streetview/direction_sv_zl1_p1.png b/src/assets/images/streetview/direction_sv_zl1_p1.png new file mode 100644 index 00000000..188697e0 Binary files /dev/null and b/src/assets/images/streetview/direction_sv_zl1_p1.png differ diff --git a/src/assets/images/streetview/direction_sv_zl1_p2.png b/src/assets/images/streetview/direction_sv_zl1_p2.png new file mode 100644 index 00000000..bd6f835a Binary files /dev/null and b/src/assets/images/streetview/direction_sv_zl1_p2.png differ diff --git a/src/assets/images/streetview/direction_sv_zl1_p3.png b/src/assets/images/streetview/direction_sv_zl1_p3.png new file mode 100644 index 00000000..4b568a41 Binary files /dev/null and b/src/assets/images/streetview/direction_sv_zl1_p3.png differ diff --git a/src/assets/images/streetview/direction_sv_zl2.png b/src/assets/images/streetview/direction_sv_zl2.png new file mode 100644 index 00000000..7aba1c28 Binary files /dev/null and b/src/assets/images/streetview/direction_sv_zl2.png differ diff --git a/src/assets/images/streetview/direction_sv_zl2_p0.png b/src/assets/images/streetview/direction_sv_zl2_p0.png new file mode 100644 index 00000000..dbe0acbe Binary files /dev/null and b/src/assets/images/streetview/direction_sv_zl2_p0.png differ diff --git a/src/assets/images/streetview/direction_sv_zl2_p1.png b/src/assets/images/streetview/direction_sv_zl2_p1.png new file mode 100644 index 00000000..edc011eb Binary files /dev/null and b/src/assets/images/streetview/direction_sv_zl2_p1.png differ diff --git a/src/assets/images/streetview/direction_sv_zl2_p2.png b/src/assets/images/streetview/direction_sv_zl2_p2.png new file mode 100644 index 00000000..345b1901 Binary files /dev/null and b/src/assets/images/streetview/direction_sv_zl2_p2.png differ diff --git a/src/assets/images/streetview/direction_sv_zl2_p3.png b/src/assets/images/streetview/direction_sv_zl2_p3.png new file mode 100644 index 00000000..9ab119e4 Binary files /dev/null and b/src/assets/images/streetview/direction_sv_zl2_p3.png differ diff --git a/src/assets/images/streetview/direction_sv_zl3.png b/src/assets/images/streetview/direction_sv_zl3.png new file mode 100644 index 00000000..7d4342ee Binary files /dev/null and b/src/assets/images/streetview/direction_sv_zl3.png differ diff --git a/src/assets/images/streetview/direction_sv_zl3_p0.png b/src/assets/images/streetview/direction_sv_zl3_p0.png new file mode 100644 index 00000000..864fd284 Binary files /dev/null and b/src/assets/images/streetview/direction_sv_zl3_p0.png differ diff --git a/src/assets/images/streetview/direction_sv_zl3_p1.png b/src/assets/images/streetview/direction_sv_zl3_p1.png new file mode 100644 index 00000000..d58d8144 Binary files /dev/null and b/src/assets/images/streetview/direction_sv_zl3_p1.png differ diff --git a/src/assets/images/streetview/direction_sv_zl3_p2.png b/src/assets/images/streetview/direction_sv_zl3_p2.png new file mode 100644 index 00000000..2a87ba18 Binary files /dev/null and b/src/assets/images/streetview/direction_sv_zl3_p2.png differ diff --git a/src/assets/images/streetview/direction_sv_zl3_p3.png b/src/assets/images/streetview/direction_sv_zl3_p3.png new file mode 100644 index 00000000..fb515835 Binary files /dev/null and b/src/assets/images/streetview/direction_sv_zl3_p3.png differ diff --git a/src/assets/images/streetview/direction_sv_zl4.png b/src/assets/images/streetview/direction_sv_zl4.png new file mode 100644 index 00000000..7ee9c73c Binary files /dev/null and b/src/assets/images/streetview/direction_sv_zl4.png differ diff --git a/src/assets/images/streetview/direction_sv_zl4_p0.png b/src/assets/images/streetview/direction_sv_zl4_p0.png new file mode 100644 index 00000000..f97d41e7 Binary files /dev/null and b/src/assets/images/streetview/direction_sv_zl4_p0.png differ diff --git a/src/assets/images/streetview/direction_sv_zl4_p1.png b/src/assets/images/streetview/direction_sv_zl4_p1.png new file mode 100644 index 00000000..cac7442b Binary files /dev/null and b/src/assets/images/streetview/direction_sv_zl4_p1.png differ diff --git a/src/assets/images/streetview/direction_sv_zl4_p2.png b/src/assets/images/streetview/direction_sv_zl4_p2.png new file mode 100644 index 00000000..bd2c8eb1 Binary files /dev/null and b/src/assets/images/streetview/direction_sv_zl4_p2.png differ diff --git a/src/assets/images/streetview/direction_sv_zl4_p3.png b/src/assets/images/streetview/direction_sv_zl4_p3.png new file mode 100644 index 00000000..c0e5c340 Binary files /dev/null and b/src/assets/images/streetview/direction_sv_zl4_p3.png differ diff --git a/src/assets/images/streetview/streetview_placeholder.png b/src/assets/images/streetview/streetview_placeholder.png new file mode 100644 index 00000000..0006b54a Binary files /dev/null and b/src/assets/images/streetview/streetview_placeholder.png differ diff --git a/src/assets/main.css b/src/assets/main.css index 40709ca5..1c7d3ffb 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -108,10 +108,17 @@ content: '\E902'; } - .lux-btn { + .lux-btn, + a.lux-btn { @apply cursor-pointer bg-white border text-primary py-[6px] px-[12px] hover:bg-primary hover:text-white leading-snug focus:bg-[#e6e6e6] focus:border-[#8c8c8c] focus:[color:var(--color-primary)] focus:lux-outlined; border: 1px solid var(--color-gray); } + .lux-btn:disabled { + @apply cursor-not-allowed opacity-65 shadow-none; + filter: alpha(opacity=65); + -webkit-box-shadow: none; + -moz-box-shadow: none; + } .lux-btn-primary { @apply lux-btn bg-primary text-white hover:bg-quaternary border-[1px] border-[color:var(--color-quaternary)]; @@ -234,6 +241,22 @@ @apply bg-transparent border-none gap-[1px] shadow-none m-0 py-0 float-right relative left-auto; } + .lux-loader { + @apply border-[20px] border-solid border-white rounded-full; + @apply border-t-[20px] border-t-[color:var(--color-primary)]; + @apply z-10 m-auto w-[200px] h-[200px]; + animation: lux-loader 4s linear infinite; + } + + @keyframes lux-loader { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + .lux-slider-line { @apply absolute h-full w-[4px] left-[50%] bg-primary ml-[-2px] cursor-ew-resize block top-0; } @@ -397,6 +420,11 @@ @apply w-full text-black bg-white border border-black p-1; } + .lux-info-table th, + .lux-info-table td { + @apply text-white text-left text-sm p-0; + } + .lux-tab { @apply text-secondary text-2xl pt-2 pb-1 px-3 mr-2 hover:text-white hover:bg-primary cursor-pointer text-center uppercase bg-tertiary; } diff --git a/src/bundle/lib.ts b/src/bundle/lib.ts index 0c91a8bc..63a5fd31 100644 --- a/src/bundle/lib.ts +++ b/src/bundle/lib.ts @@ -32,6 +32,7 @@ import FooterBar from '@/components/footer/footer-bar.vue' import ToolbarDraw from '@/components/footer/toolbar-draw.vue' import LayerPanel from '@/components/layer-panel/layer-panel.vue' import LegendsPanel from '@/components/legends/legends-panel.vue' +import LocationInfoPanel from '@/components/info/location-info.vue' import SliderComparator from '@/components/slider/slider-comparator.vue' import useBackgroundLayer from '@/composables/background-layer/background-layer.composable' import useLayers from '@/composables/layers/layers.composable' @@ -48,6 +49,7 @@ import { useProfileMeasuresv3Store } from './stores/profile-measures_v3.store' import { useProfileRoutingv3Store } from './stores/profile-routing_v3.store' import { useProfileInfosv3Store } from './stores/profile-infos_v3.store' import { useDrawStore } from '@/stores/draw.store' +import { useLocationInfoStore } from '@/stores/location-info.store' import { useStyleStore } from '@/stores/style.store' import { useThemeStore } from '@/stores/config.store' import { useUserManagerStore } from '@/stores/user-manager.store' @@ -152,6 +154,7 @@ export { ToolbarDraw, LayerPanel, LegendsPanel, + LocationInfoPanel, SliderComparator, proxyUrlHelper, styleUrlHelper, @@ -170,6 +173,7 @@ export { useProfileRoutingv3Store, useProfileInfosv3Store, useDrawStore, + useLocationInfoStore, useStyleStore, useThemeStore, useUserManagerStore, diff --git a/src/components/info/info-panel.vue b/src/components/info/info-panel.vue index 72c66a1b..aeafb532 100644 --- a/src/components/info/info-panel.vue +++ b/src/components/info/info-panel.vue @@ -2,20 +2,28 @@ import { useTranslation } from 'i18next-vue' import SidePanelLayout from '@/components/common/side-panel-layout.vue' import { useAppStore } from '@/stores/app.store' +import { useLocationInfoStore } from '@/stores/location-info.store' import { storeToRefs } from 'pinia' import { useFeatureInfoStore } from '@/stores/feature-info.store' +import useMap from '@/composables/map/map.composable' +import LocationInfo from './location-info.vue' import FeatureInfo from '@/components/info/feature-info.vue' -import { onUnmounted } from 'vue' +import { watch } from 'vue' const { t } = useTranslation() const appStore = useAppStore() +const { locationInfo } = storeToRefs(useLocationInfoStore()) +const map = useMap().olMap +const { infoOpen } = storeToRefs(appStore) const { clearContent } = useFeatureInfoStore() const { featureInfoPanelContent, isLoading } = storeToRefs( useFeatureInfoStore() ) -onUnmounted(() => { - clearContent() +watch(infoOpen, isOpen => { + if (!isOpen) { + clearContent() + } }) @@ -31,16 +39,26 @@ onUnmounted(() => { diff --git a/src/composables/info/feature-info.composable.ts b/src/composables/info/feature-info.composable.ts index bbae3994..f2aaae1a 100644 --- a/src/composables/info/feature-info.composable.ts +++ b/src/composables/info/feature-info.composable.ts @@ -22,6 +22,7 @@ import { DrawnFeature } from '@/services/draw/drawn-feature' import { Pixel } from 'ol/pixel' import { throttle } from '@/services/utils' import { useMapStore } from '@/stores/map.store' +import { useLocationInfoStore } from '@/stores/location-info.store' import { getFeatureInfoJson } from '@/services/api/api-feature-info.service' export default function useFeatureInfo() { @@ -35,6 +36,9 @@ export default function useFeatureInfo() { const { drawStateActive, editStateActive, drawnFeatures } = storeToRefs( useDrawStore() ) + const { locationInfo, isStreetviewActive } = storeToRefs( + useLocationInfoStore() + ) const { maxZoom } = storeToRefs(useMapStore()) const responses = ref([]) @@ -78,6 +82,8 @@ export default function useFeatureInfo() { // routingOpen || // lidarOpen || // appActivetool.value.isActive() || => corresponds to: measureActive, streetviewActive + evt.originalEvent.button === 2 || // right click + (isStreetviewActive.value && locationInfo.value) || drawStateActive.value || editStateActive.value || isLoading.value diff --git a/src/composables/info/location-info.composable.ts b/src/composables/info/location-info.composable.ts new file mode 100644 index 00000000..687182f6 --- /dev/null +++ b/src/composables/info/location-info.composable.ts @@ -0,0 +1,134 @@ +import { watch } from 'vue' +import { storeToRefs } from 'pinia' + +import useMap from '@/composables/map/map.composable' +import { listen } from 'ol/events' +import { Coordinate } from 'ol/coordinate' +import { MapBrowserEvent } from 'ol' +import { Feature } from 'ol' +import { Point } from 'ol/geom' +import VectorLayer from 'ol/layer/Vector' +import VectorSource from 'ol/source/Vector' +import StyleStyle from 'ol/style/Style' +import StyleCircle from 'ol/style/Circle' +import StyleFill from 'ol/style/Fill' +import StyleStroke from 'ol/style/Stroke' +import { useAppStore } from '@/stores/app.store' +import { useLocationInfoStore } from '@/stores/location-info.store' +import { useFeatureInfoStore } from '@/stores/feature-info.store' + +export const DEFAULT_INFO_ZINDEX = 1501 +export const INFO_FEATURE_LAYER_TYPE = 'infoFeatureLayer' + +export default function useLocationInfo() { + const map = useMap().getOlMap() + let holdTimeoutId: number | undefined = undefined + let startPixel: Coordinate | null = null + const { infoOpen } = storeToRefs(useAppStore()) + const { locationInfo, hidePointer } = storeToRefs(useLocationInfoStore()) + const { clearContent } = useFeatureInfoStore() + + const infoFeatureLayer = new VectorLayer({ + source: new VectorSource({ + features: [] as Feature[], + }), + zIndex: DEFAULT_INFO_ZINDEX, + }) + infoFeatureLayer.set('cyLayerType', INFO_FEATURE_LAYER_TYPE) + setInfoStyle(infoFeatureLayer) + map.addLayer(infoFeatureLayer) + + watch(infoOpen, open => { + if (!open) { + locationInfo.value = undefined + } + }) + + watch( + [locationInfo, hidePointer], + ([location, doHide]) => { + infoFeatureLayer.getSource()?.clear() + if (location && !doHide) { + infoOpen.value = true + const feature = new Feature(new Point(location)) + infoFeatureLayer.getSource()?.addFeature(feature) + } + }, + { immediate: true } + ) + + map + .getViewport() + .addEventListener('contextmenu', event => event.preventDefault()) + + listen(map, 'pointerdown', event => + handleClick(event as MapBrowserEvent) + ) + + function getClickCoordinate(event: MapBrowserEvent) { + return map.getEventCoordinate(event.originalEvent) + } + + function handleClick(event: MapBrowserEvent) { + startPixel = event.pixel + if (event.originalEvent.button === 2) { + // if right mouse click + locationInfo.value = getClickCoordinate(event) + clearContent() + } else if (event.originalEvent.pointerType === 'touch') { + window.clearTimeout(holdTimeoutId) + holdTimeoutId = window.setTimeout(() => { + locationInfo.value = getClickCoordinate(event) + clearContent() + }) + } + } + + listen(map, 'pointerup', event => { + if ( + startPixel && + (event as MapBrowserEvent).originalEvent.button === 0 + ) { + // if left mouse click + if (!hidePointer.value) { + locationInfo.value = undefined + } + } + window.clearTimeout(holdTimeoutId) + startPixel = null + }) + + listen(map, 'pointermove', event => { + if (startPixel !== null) { + const pixel = (event as MapBrowserEvent).pixel + const deltaX = Math.abs(startPixel[0] - pixel[0]) + const deltaY = Math.abs(startPixel[1] - pixel[1]) + if (deltaX + deltaY > 6) { + window.clearTimeout(holdTimeoutId) + startPixel = null + } + } + }) + + function setInfoStyle(layer: VectorLayer) { + const defaultFill = new StyleFill({ + color: [255, 255, 0, 0.6], + }) + const circleStroke = new StyleStroke({ + color: [255, 155, 55, 1], + width: 3, + }) + + const pointStyle = new StyleCircle({ + radius: 10, + fill: defaultFill, + stroke: circleStroke, + }) + + layer.setStyle([ + new StyleStyle({ + image: pointStyle, + }), + ]) + } +} diff --git a/src/composables/info/street-view.composable.ts b/src/composables/info/street-view.composable.ts new file mode 100644 index 00000000..bfde2167 --- /dev/null +++ b/src/composables/info/street-view.composable.ts @@ -0,0 +1,271 @@ +import { watch, nextTick, Ref } from 'vue' +import { storeToRefs } from 'pinia' +import useMap from '@/composables/map/map.composable' +import { useAppStore } from '@/stores/app.store' +import { useLocationInfoStore } from '@/stores/location-info.store' +import { MapBrowserEvent } from 'ol' +import BaseEvent from 'ol/events/Event' +import { Coordinate } from 'ol/coordinate' +import { Select } from 'ol/interaction' +import { FeatureLike } from 'ol/Feature' +import { fromLonLat, toLonLat } from 'ol/proj' +import { containsCoordinate } from 'ol/extent' +import { loadGoogleapis } from '@/services/info/street-view' +import { SvCompassFeature } from '@/services/info/sv-compass-feature' +import { SvDirectionFeature } from '@/services/info/sv-direction-feature' + +import {} from 'google.maps' + +import { Feature } from 'ol' +import { Point } from 'ol/geom' +import VectorLayer from 'ol/layer/Vector' +import VectorSource from 'ol/source/Vector' + +export const DEFAULT_INFO_ZINDEX = 1502 +export const SV_FEATURE_LAYER_TYPE = 'svFeatureLayer' + +const SV_RADIUS = 90 + +export default function useStreeView(streetViewDiv: Ref) { + const { infoOpen } = storeToRefs(useAppStore()) + const { + locationInfo, + hidePointer, + isStreetviewActive, + noDataAtLocation, + streetViewLoading, + panoPositionChanging, + svFeature, + } = storeToRefs(useLocationInfoStore()) + + const map = useMap().getOlMap() + + let googleMapService: typeof google.maps | null = null + let streetViewService: google.maps.StreetViewService | null = null + let panorama: google.maps.StreetViewPanorama | null = null + let panoramaLinksListener: google.maps.MapsEventListener | null = null + let panoramaPovListener: google.maps.MapsEventListener | null = null + const selectFeature = new Select({ + filter: (feature /*, layer*/) => feature instanceof SvDirectionFeature, + }) + selectFeature.on('select', handleNavigate) + + const svPoint = new Point([0, 0]) + const svCompassFeature = new SvCompassFeature() + svCompassFeature.setGeometry(svPoint) + + const svFeatureLayer = new VectorLayer({ + source: new VectorSource({ + features: [] as Feature[], + }), + // altitudeMode: 'clampToGround', + zIndex: DEFAULT_INFO_ZINDEX, + }) + svFeatureLayer.set('cyLayerType', SV_FEATURE_LAYER_TYPE) + map.addLayer(svFeatureLayer) + + function setLocation(loc: Coordinate | undefined) { + if (loc && panorama !== null && !panoPositionChanging.value) { + const lonlat = toLonLat(loc, map.getView().getProjection()) + streetViewService!.getPanorama( + { + location: { + lat: lonlat[1], + lng: lonlat[0], + }, + radius: SV_RADIUS, + }, + updatePanorama + ) + } + } + + function updatePanorama( + data: google.maps.StreetViewPanoramaData | null, + status: google.maps.StreetViewStatus + ) { + if (status === google.maps.StreetViewStatus.OK) { + noDataAtLocation.value = false + nextTick(() => { + panorama!.setPosition(data?.location?.latLng || null) + panorama!.setVisible(true) + }) + } else { + noDataAtLocation.value = true + streetViewLoading.value = false + svFeature.value = undefined + panorama!.setVisible(false) + } + } + + watch(infoOpen, open => { + if (open) { + if (isStreetviewActive.value && locationInfo.value) { + setSvFeatures(locationInfo.value) + } + } else { + svFeature.value = undefined + } + }) + + watch(locationInfo, loc => { + if (loc) { + if (isStreetviewActive.value && !panoPositionChanging.value) { + streetViewLoading.value = true + setLocation(loc) + } + } else { + svFeature.value = undefined + // force deactivate pointer style if it is still active from handleHover + map.getViewport().style.cursor = '' + } + }) + + watch([isStreetviewActive, streetViewDiv], async ([act, streetViewDiv]) => { + if (act && streetViewDiv) { + streetViewLoading.value = true + await loadGoogleapis() + if (window.hasOwnProperty('google')) { + // @ts-ignore + googleMapService = window.google.maps + } + // todo PIWIK + if (streetViewService === null) { + streetViewService = new googleMapService!.StreetViewService() + } + if (panorama === null) { + panorama = new googleMapService!.StreetViewPanorama(streetViewDiv, { + pov: { + heading: 0, + pitch: 0, + }, + visible: false, + zoom: 1, + }) + } + if (panoramaLinksListener === null) { + panoramaLinksListener = googleMapService!.event.addListener( + panorama, + 'links_changed', + () => handlePanoramaPositionChange(true) + ) + } + if (panoramaPovListener === null) { + panoramaPovListener = googleMapService!.event.addListener( + panorama, + 'pov_changed', + () => handlePanoramaPositionChange(false) + ) + } + setLocation(locationInfo.value) + } else { + if (panorama !== null) { + panorama.setVisible(false) + } + svFeature.value = undefined + if (panoramaPovListener) { + googleMapService!.event.removeListener(panoramaPovListener) + panoramaPovListener = null + } + if (panoramaLinksListener) { + googleMapService!.event.removeListener(panoramaLinksListener) + panoramaLinksListener = null + } + } + }) + + watch(svFeature, svf => { + svFeatureLayer.getSource()?.clear() + if (svf) { + svFeatureLayer.getSource()?.addFeature(svf.compass) + svf.directions.forEach((f: SvDirectionFeature) => + svFeatureLayer.getSource()?.addFeature(f) + ) + hidePointer.value = true + map.addInteraction(selectFeature) + map.on('pointermove', handleHover) + svFeatureLayer.once('postrender', () => { + streetViewLoading.value = false + }) + } else { + map.un('pointermove', handleHover) + map.removeInteraction(selectFeature) + hidePointer.value = false + } + }) + + function handleNavigate(evt: BaseEvent) { + if (evt.target.getFeatures().getLength() !== 0) { + const nextDirection = evt.target + .getFeatures() + .getArray()[0] as SvDirectionFeature + if (nextDirection.pano) { + evt.target.getFeatures().clear() + panorama!.setPano(nextDirection.pano) + } + } + } + + function handleHover(event: MapBrowserEvent) { + if (isStreetviewActive.value) { + const hit = map.forEachFeatureAtPixel( + event.pixel, + function (feature: FeatureLike) { + if (feature instanceof SvDirectionFeature) { + return true + // TODO: maybe show description (name of next SV node) on hover ?? + } + return false + } + ) + map.getViewport().style.cursor = hit ? 'pointer' : '' + } + } + + function setSvFeatures(loc: Coordinate) { + svPoint.setCoordinates(loc) + const pov = panorama!.getPov() + svCompassFeature.heading = pov.heading + svCompassFeature.zoom = Math.floor(panorama!.getZoom()) + svCompassFeature.pitch = pov.pitch + const dir = new SvDirectionFeature(svPoint, 3, '5', '5') + dir.heading + const navigationLinks = panorama!.getLinks() || [] + svFeature.value = { + compass: svCompassFeature, + directions: navigationLinks.map( + link => + new SvDirectionFeature( + svPoint, + link?.heading || 0, + link?.description || '', + link?.pano + ) + ), + } + } + + function handlePanoramaPositionChange(updateLocation: boolean) { + panoPositionChanging.value = true + const position = panorama!.getPosition() + if (position) { + const panoLonLat = [position.lng(), position.lat()] + const loc = fromLonLat(panoLonLat) + setSvFeatures(loc) + + if (updateLocation) { + locationInfo.value = loc + } + + if ( + locationInfo.value && + !containsCoordinate(map.getView().calculateExtent(map.getSize()), loc) + ) { + map.getView().setCenter(loc) + } + } + nextTick(() => { + panoPositionChanging.value = false + }) + } +} diff --git a/src/services/common/formatting.utils.spec.ts b/src/services/common/formatting.utils.spec.ts index fe137f11..2a4dea3e 100644 --- a/src/services/common/formatting.utils.spec.ts +++ b/src/services/common/formatting.utils.spec.ts @@ -5,6 +5,8 @@ import { formatLength, formatMeasure, } from './formatting.utils' +import { initProjections } from '@/services/projection.utils' +import { formatCoords } from '@/services/common/formatting.utils' vi.mock('i18next', () => ({ default: { t: vi.fn(key => key) }, @@ -138,4 +140,55 @@ describe('Formatting utils', () => { expect(val).toEqual('') }) }) + describe('#formatCoords', () => { + initProjections() + it('format basic lonlat coordinates', () => { + const formattedCoords = formatCoords( + [677840, 6390169], + 'EPSG:3857', + 'EPSG:4326' + ) + expect(formattedCoords).toEqual('6.08914 E | 49.674932 N') + }) + it('format DMS lonlat coordinates', () => { + const formattedCoords = formatCoords( + [677840, 6390169], + 'EPSG:3857', + 'EPSG:4326:DMS' + ) + expect(formattedCoords).toEqual('6° 5′ 20.9″ E | 49° 40′ 29.8″ N') + }) + it('format DMm lonlat coordinates', () => { + const formattedCoords = formatCoords( + [677840, 6390169], + 'EPSG:3857', + 'EPSG:4326:DMm' + ) + expect(formattedCoords).toEqual('6° 5.348419′ E | 49° 40.495935′ N') + }) + it('format basic lux coordinate', () => { + const formattedCoords = formatCoords( + [677840, 6390169], + 'EPSG:3857', + 'EPSG:2169' + ) + expect(formattedCoords).toEqual('74299 E | 82266 N') + }) + it('format UTM 31 coordinate', () => { + const formattedCoords = formatCoords( + [643596, 6390169], + 'EPSG:3857', + 'EPSG:3263*' + ) + expect(formattedCoords).toEqual('700672 | 5506204 (UTM31N)') + }) + it('format UTM 32 coordinate', () => { + const formattedCoords = formatCoords( + [677840, 6390169], + 'EPSG:3857', + 'EPSG:3263*' + ) + expect(formattedCoords).toEqual('289999 | 5506558 (UTM32N)') + }) + }) }) diff --git a/src/services/common/formatting.utils.ts b/src/services/common/formatting.utils.ts index ef0e46e6..652bf9e3 100644 --- a/src/services/common/formatting.utils.ts +++ b/src/services/common/formatting.utils.ts @@ -1,4 +1,9 @@ import i18next from 'i18next' +import { AddressResult } from '@/stores/location-info.store.model' +import { transform, toLonLat } from 'ol/proj' +import { Coordinate } from 'ol/coordinate' +import { Projection } from 'ol/proj' +import { PROJECTION_WGS84 } from '@/composables/map/map.composable' export type FormatMeasureType = 'elevation' | 'length' | 'area' @@ -60,7 +65,7 @@ export function formatElevation(value: number | string, digits = 0): string { * @param digits The digits to fixed * @returns The formatted value, or the original value if invalid number */ -export function formatLength(value: number, digits = 2): string { +export function formatLength(value: number | null, digits = 2): string { // null covers API errors or unavailable data (eg. elevation) if (value === null) { return i18next.t('N/A', { ns: 'client' }) @@ -90,3 +95,88 @@ export function formatArea(value: number, digits = 2): string { return '' } } + +/** + * Format an address from reverse address lookup service + * @param address AddressResult structure returned by the reverse search API + * @returns The formatted address as " , " + */ +export function formatAddress(address: AddressResult | undefined) { + if (address === undefined) { + return i18next.t('N/A', { ns: 'client' }) + } + return `${address.number}, ${address.street}, ${address.postal_code} ${address.locality}` +} + +/** + * Format coordinates in different CRS as an 'xxx E | yyy N' pair + * known targets: + * EPSG:2169, EPSG:4326, EPSG:3263* (UTM zones 31N and 32N) + * EPSG:4326:DMS (degree, minute, second), EPSG:4326:DMm (degree, minute.minute_decimals) + * @param coords The coordinate pair to be formatted + * @param fromCrs the CRS of the source coordinate pair + * @param format output format / CSR among: + * EPSG:2169, EPSG:4326, EPSG:3263* + * EPSG:4326:DMS, EPSG:4326:DMm + * @returns The formatted Coordinate as a string + */ +export function formatCoords( + coords: Coordinate, + fromCrs: Projection | string, + format: string +) { + const lonLanFormat = format.split(':')[2] + let toCrs = format + let utmZone = undefined + if (lonLanFormat !== undefined) { + toCrs = format.slice(0, format.lastIndexOf(':')) + } else if (format.endsWith('*')) { + const lonlat = toLonLat(coords, fromCrs) + utmZone = lonlat[0] <= 6 ? 'UTM31N' : 'UTM32N' + toCrs = format.replace('3*', utmZone.slice(3, 5)) + } + const projectedCoords = transform(coords, fromCrs, toCrs) + const isDegrees = toCrs === PROJECTION_WGS84 + const hemispheres = projectedCoords.map((coord: number, axis: number) => { + const axisNegative = (isDegrees ? normalizeDegrees(coord) : coord) < 0 + return axisNegative ? 'WS'[axis] : 'EN'[axis] + }) + const formattedCoords = projectedCoords.map( + (coord: number) => + ( + (isDegrees + ? formatDegrees(coord, lonLanFormat) + : Math.abs(Math.round(coord)).toString()) + ) + ) + if (utmZone !== undefined) { + return `${formattedCoords.join(' | ')} (${utmZone})` + } + return formattedCoords + .map((coord: string, axis: number) => `${coord} ${hemispheres[axis]}`) + .join(' | ') +} +export function normalizeDegrees(degrees: number) { + return ((degrees + 180) % 360) - 180 +} +export function formatDegrees(degrees: number, format: string) { + const normalizedDegrees = ((degrees + 180) % 360) - 180 + const absDegrees = Math.abs(normalizedDegrees) + if (format === undefined) { + return Math.round(absDegrees * 1e6) / 1e6 + } + const intDegrees = Math.floor(absDegrees) + const minutes = (absDegrees % 1) * 60 + // convert to degree, minute, decimals format + if (format === 'DMm') { + const roundedMinutes = Math.round(minutes * 1e6) / 1e6 + return `${intDegrees}\u00b0 ${roundedMinutes}\u2032` + } + if (format === 'DMS') { + // convert to degree, minute, second format + const intMinutes = Math.floor(minutes) + const seconds = Math.round((minutes % 1) * 600) / 10 + return `${intDegrees}\u00b0 ${intMinutes}\u2032 ${seconds}\u2033` + } + return 'N/A' +} diff --git a/src/services/info/location-info.ts b/src/services/info/location-info.ts new file mode 100644 index 00000000..181fd084 --- /dev/null +++ b/src/services/info/location-info.ts @@ -0,0 +1,73 @@ +import { urlStorage } from '@/services/state-persistor/storage/url-storage' +import { transform } from 'ol/proj' +import { Coordinate } from 'ol/coordinate' +import { Projection } from 'ol/proj' +import { containsCoordinate } from 'ol/extent' +import { PROJECTION_LUX } from '@/composables/map/map.composable' + +import { getElevation } from '@/components/draw/feature-measurements-helper' + +export const INFO_PROJECTIONS = { + 'EPSG:2169': 'Luref', + 'EPSG:4326': 'Lon/Lat WGS84', + 'EPSG:4326:DMS': 'Lon/Lat WGS84 DMS', + 'EPSG:4326:DMm': 'Lon/Lat WGS84 DM', + 'EPSG:3263*': 'WGS84 UTM', +} + +export const LIDAR_EXTENT = [46602, 53725, 106944, 141219] +export const LIDAR_EXTENT_SRS = 'EPSG:2169' + +export async function queryInfos(location: Coordinate, fromCrs: Projection) { + const clickCoordinateLuref = transform(location, fromCrs, PROJECTION_LUX) + const [shortUrl, elevation, address] = ( + await Promise.allSettled([ + createShortUrl(location), + getElevation(location), + getNearestAddress(clickCoordinateLuref), + ]) + ).map(r => (r.status === 'fulfilled' ? r.value : undefined)) + return { + shortUrl, + elevation, + address, + clickCoordinateLuref, + isInBoxOfLidar: isInBoxOfLidar(clickCoordinateLuref), + } +} + +function isInBoxOfLidar(clickCoordinateLuref: Coordinate) { + let testCoordinate = clickCoordinateLuref + if (PROJECTION_LUX !== LIDAR_EXTENT_SRS) { + testCoordinate = transform( + clickCoordinateLuref, + PROJECTION_LUX, + LIDAR_EXTENT_SRS + ) + } + return containsCoordinate(LIDAR_EXTENT, testCoordinate) +} + +export async function createShortUrl(optCoordinate: Coordinate | undefined) { + const shortUrl = (await urlStorage.getShortUrl(optCoordinate)).short_url + // workaround for incorrect route settings in migration + // TODO: remove when migration route settings are fixed + return shortUrl.replace( + 'http://g-o.lu/migration/', + import.meta.env.VITE_V3_API_HOST + 's/' + ) +} + +export function getQRUrl(shortUrl: string | undefined) { + return shortUrl && `${import.meta.env.VITE_QR_URL}?url=${shortUrl}` +} + +export async function getNearestAddress(coords: Coordinate) { + const resp = await fetch( + `${import.meta.env.VITE_ADDRESS_URL}?easting=${coords[0]}&northing=${ + coords[1] + }` + ) + const json = await resp.json() + return json.results[0] +} diff --git a/src/services/info/street-view.ts b/src/services/info/street-view.ts new file mode 100644 index 00000000..fdbfc787 --- /dev/null +++ b/src/services/info/street-view.ts @@ -0,0 +1,15 @@ +export async function loadGoogleapis() { + return await new Promise((resolve /*, reject*/) => { + if (!window.hasOwnProperty('google')) { + const script = document.createElement('script') + script.src = + 'https://maps.googleapis.com/maps/api/js?v=3&key=AIzaSyCObzX7dJqeGm5Wv2VwS4JzNyEtLsOgWX8' + script.onload = function () { + resolve('loaded') + } + document.getElementsByTagName('head')[0].appendChild(script) + } else { + resolve('loaded') + } + }) +} diff --git a/src/services/info/sv-compass-feature.ts b/src/services/info/sv-compass-feature.ts new file mode 100644 index 00000000..496ab664 --- /dev/null +++ b/src/services/info/sv-compass-feature.ts @@ -0,0 +1,44 @@ +import { Feature } from 'ol' +import { FeatureLike } from 'ol/Feature' +import { Style, Icon } from 'ol/style' +import directionUrls from '@/services/info/sv-direction-urls' + +export class SvCompassFeature extends Feature { + zoom: number + heading: number + pitch: number + + getStyleFunction() { + return function (feature: FeatureLike): Style[] { + let curZoom = (feature as SvCompassFeature).zoom + if (curZoom < 1) { + curZoom = 1 + } else if (curZoom > 4) { + curZoom = 4 + } + const curPitch = Math.abs((feature as SvCompassFeature).pitch) + let pitch = 0 + if (curPitch >= 0 && curPitch <= 23) { + pitch = 0 + } else if (curPitch > 23 && curPitch <= 45) { + pitch = 1 + } + if (curPitch > 45 && curPitch <= 68) { + pitch = 2 + } + if (curPitch > 68) { + pitch = 3 + } + const directionArrowKey: string = `direction_sv_zl${curZoom}_p${pitch}` + + return [ + new Style({ + image: new Icon({ + src: directionUrls.get(directionArrowKey), + rotation: ((feature as SvCompassFeature).heading * Math.PI) / 180, + }), + }), + ] + } + } +} diff --git a/src/services/info/sv-direction-feature.ts b/src/services/info/sv-direction-feature.ts new file mode 100644 index 00000000..fead4c9e --- /dev/null +++ b/src/services/info/sv-direction-feature.ts @@ -0,0 +1,39 @@ +import { Feature } from 'ol' +import { FeatureLike } from 'ol/Feature' +import { Geometry } from 'ol/geom' +import { Style, Icon } from 'ol/style' +import arrowUrl from '@/assets/images/streetview/arrow.png' + +export class SvDirectionFeature extends Feature { + // By putting the properties below as public into the constructor, they are both declared + // and initialized in the constructor + // heading: number + // description: string + // pano: string + + constructor( + geometry: Geometry, + public heading: number, + public description: string, + public pano: string | null | undefined + ) { + super(geometry) + this.setStyle(this.createStyleFunction()) + } + + private createStyleFunction() { + return function (feature: FeatureLike) { + return [ + new Style({ + image: new Icon({ + anchor: [0.5, 50], + anchorXUnits: 'fraction', + anchorYUnits: 'pixels', + src: arrowUrl, + rotation: ((feature as SvDirectionFeature).heading * Math.PI) / 180, + }), + }), + ] + } + } +} diff --git a/src/services/info/sv-direction-urls.ts b/src/services/info/sv-direction-urls.ts new file mode 100644 index 00000000..d1cd1f60 --- /dev/null +++ b/src/services/info/sv-direction-urls.ts @@ -0,0 +1,53 @@ +// generated by +// z_seq_1_to_4 = [...Array(5).keys()].slice(1) +// p_seq_0_to_4 = [...Array(5).keys()] +// keys = z_seq_1_to_4.flatMap(z=>p_seq_0_to_4.map(p=> `direction_sv_zl${z}${p<4?'_p' + p:''}`)) +// console.log(keys.map(k => `import ${k} from '@/assets/images/streetview/${k}.png'`).join('\n')) + +import direction_sv_zl1_p0 from '@/assets/images/streetview/direction_sv_zl1_p0.png' +import direction_sv_zl1_p1 from '@/assets/images/streetview/direction_sv_zl1_p1.png' +import direction_sv_zl1_p2 from '@/assets/images/streetview/direction_sv_zl1_p2.png' +import direction_sv_zl1_p3 from '@/assets/images/streetview/direction_sv_zl1_p3.png' +import direction_sv_zl1 from '@/assets/images/streetview/direction_sv_zl1.png' +import direction_sv_zl2_p0 from '@/assets/images/streetview/direction_sv_zl2_p0.png' +import direction_sv_zl2_p1 from '@/assets/images/streetview/direction_sv_zl2_p1.png' +import direction_sv_zl2_p2 from '@/assets/images/streetview/direction_sv_zl2_p2.png' +import direction_sv_zl2_p3 from '@/assets/images/streetview/direction_sv_zl2_p3.png' +import direction_sv_zl2 from '@/assets/images/streetview/direction_sv_zl2.png' +import direction_sv_zl3_p0 from '@/assets/images/streetview/direction_sv_zl3_p0.png' +import direction_sv_zl3_p1 from '@/assets/images/streetview/direction_sv_zl3_p1.png' +import direction_sv_zl3_p2 from '@/assets/images/streetview/direction_sv_zl3_p2.png' +import direction_sv_zl3_p3 from '@/assets/images/streetview/direction_sv_zl3_p3.png' +import direction_sv_zl3 from '@/assets/images/streetview/direction_sv_zl3.png' +import direction_sv_zl4_p0 from '@/assets/images/streetview/direction_sv_zl4_p0.png' +import direction_sv_zl4_p1 from '@/assets/images/streetview/direction_sv_zl4_p1.png' +import direction_sv_zl4_p2 from '@/assets/images/streetview/direction_sv_zl4_p2.png' +import direction_sv_zl4_p3 from '@/assets/images/streetview/direction_sv_zl4_p3.png' +import direction_sv_zl4 from '@/assets/images/streetview/direction_sv_zl4.png' + +// console.log(keys.join(',\n')) + +const direction_dict = { + direction_sv_zl1_p0, + direction_sv_zl1_p1, + direction_sv_zl1_p2, + direction_sv_zl1_p3, + direction_sv_zl1, + direction_sv_zl2_p0, + direction_sv_zl2_p1, + direction_sv_zl2_p2, + direction_sv_zl2_p3, + direction_sv_zl2, + direction_sv_zl3_p0, + direction_sv_zl3_p1, + direction_sv_zl3_p2, + direction_sv_zl3_p3, + direction_sv_zl3, + direction_sv_zl4_p0, + direction_sv_zl4_p1, + direction_sv_zl4_p2, + direction_sv_zl4_p3, + direction_sv_zl4, +} + +export default new Map(Object.entries(direction_dict)) diff --git a/src/services/state-persistor/state-persistor-location-info.ts b/src/services/state-persistor/state-persistor-location-info.ts new file mode 100644 index 00000000..943c366a --- /dev/null +++ b/src/services/state-persistor/state-persistor-location-info.ts @@ -0,0 +1,56 @@ +import { watch, watchEffect, WatchStopHandle } from 'vue' +import { storeToRefs } from 'pinia' +import { + SP_KEY_CROSSHAIR, + SP_KEY_X, + SP_KEY_Y, + StatePersistorService, +} from './state-persistor.model' +import { storageHelper } from './storage/storage.helper' +import useMap from '@/composables/map/map.composable' +import { useLocationInfoStore } from '@/stores/location-info.store' + +class StatePersistorLocationInfo implements StatePersistorService { + bootstrap() { + this.restore() + let stop: WatchStopHandle + // eslint-disable-next-line prefer-const + stop = watchEffect(() => { + this.persist() + stop && stop() // test if exists, for HMR support + }) + } + + persist() { + const { locationInfo } = storeToRefs(useLocationInfoStore()) + + watch( + locationInfo, + value => { + if (value === undefined) { + storageHelper.removeItem(SP_KEY_CROSSHAIR) + } else { + storageHelper.setValue(SP_KEY_CROSSHAIR, true) + storageHelper.setValue(SP_KEY_X, value[0]) + storageHelper.setValue(SP_KEY_Y, value[1]) + } + }, + { immediate: true } + ) + } + + restore() { + const { locationInfo } = storeToRefs(useLocationInfoStore()) + const map = useMap().getOlMap() + + const location = storageHelper.getValue(SP_KEY_CROSSHAIR) + // This represents the behaviour in V3, where restoring from permalink + // always displays the info of the map center + // This is not a very logical behaviour ?? + if (location && location !== 'false') { + locationInfo.value = map.getView().getCenter() + } + } +} + +export const statePersistorLocationInfo = new StatePersistorLocationInfo() diff --git a/src/services/state-persistor/state-persistor.model.ts b/src/services/state-persistor/state-persistor.model.ts index 7452d81a..2ede555f 100644 --- a/src/services/state-persistor/state-persistor.model.ts +++ b/src/services/state-persistor/state-persistor.model.ts @@ -24,6 +24,7 @@ export const SP_KEY_ROTATION = 'rotation' export const SP_KEY_SRS = 'SRS' // TODO: export const SP_KEY_X = 'X' export const SP_KEY_Y = 'Y' +export const SP_KEY_CROSSHAIR = 'crosshair' export const SP_KEY_SERIAL = 'serial' export const SP_KEY_SERIAL_LAYERS = 'serialLayer' export const SP_KEYS_STYLE = [ diff --git a/src/services/state-persistor/storage/url-storage.ts b/src/services/state-persistor/storage/url-storage.ts index 9bdc3b85..996a70d3 100644 --- a/src/services/state-persistor/storage/url-storage.ts +++ b/src/services/state-persistor/storage/url-storage.ts @@ -1,3 +1,11 @@ +import { + SP_KEY_LOCALFORAGE, + SP_KEY_APPLOGIN, + SP_KEY_IPV6, + SP_KEY_EMBEDDED_SERVER, + SP_KEY_EMBEDDED_SERVER_PROTOCOL, +} from '@/services/state-persistor/state-persistor.model' + export class UrlStorage implements Storage { private snappedUrl: URL @@ -18,6 +26,52 @@ export class UrlStorage implements Storage { throw new Error('Method key() not implemented. ' + index) } + getStrippedUrl(optCoordinate: number[] | undefined) { + // stripped by embedded app parameters + const url = new URL(window.location.toString()) + const params = new URLSearchParams(url.search) + + if (optCoordinate !== undefined) { + params.set('X', Math.round(optCoordinate[0]).toString()) + params.set('Y', Math.round(optCoordinate[1]).toString()) + } + params.delete(SP_KEY_LOCALFORAGE) + params.delete(SP_KEY_APPLOGIN) + params.delete(SP_KEY_IPV6) + params.delete(SP_KEY_EMBEDDED_SERVER) + params.delete(SP_KEY_EMBEDDED_SERVER_PROTOCOL) + + url.search = params.toString() + + return url.toString() + } + + async getShortUrl(optCoordinate: number[] | undefined) { + const strippedUrl = this.getStrippedUrl(optCoordinate) + // convert github pages and vite ports localhost 4173 or 5173 + // to hosts accepted by the shortURL entrypoint + // TODO: remove when there is a v4 API available + .replace( + /https:\/\/geoportail-luxembourg.github.io\/luxembourg-geoportail\/.+\//, + import.meta.env.VITE_V3_API_HOST + ) + .replace(/http:\/\/localhost:[45]173\//, import.meta.env.VITE_V3_API_HOST) + + const data = new URLSearchParams() + + data.set('url', strippedUrl) + + const response = await fetch(import.meta.env.VITE_SHORT_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: data.toString(), + }) + + return await response.json() + } + getSnappedUrl() { return this.snappedUrl } diff --git a/src/services/utils.ts b/src/services/utils.ts index 209b6e1d..e2c075eb 100644 --- a/src/services/utils.ts +++ b/src/services/utils.ts @@ -146,12 +146,28 @@ export function sanitizeFilename(filename: string) { return filename.replace(/\s+/g, '_').replace(/[^a-z0-9\-_]/gi, '') || '_' } +export async function downloadUrl( + url: string | URL | Request, + filename: string +) { + const response = await fetch(url) + if (!response.ok) { + throw new Error() + } + const blob = await response.blob() + downloadBlob(filename, blob) +} + export function downloadFile( filename: string, content: BlobPart, contentType = 'text/plain' ) { const blob = new Blob([content], { type: contentType }) + downloadBlob(filename, blob) +} + +export function downloadBlob(filename: string, blob: Blob) { const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url diff --git a/src/stores/location-info.store.model.ts b/src/stores/location-info.store.model.ts new file mode 100644 index 00000000..1b26e3dc --- /dev/null +++ b/src/stores/location-info.store.model.ts @@ -0,0 +1,16 @@ +import { SvCompassFeature } from '@/services/info/sv-compass-feature' +import { SvDirectionFeature } from '@/services/info/sv-direction-feature' + +export interface SvFeature { + compass: SvCompassFeature + directions: SvDirectionFeature[] +} + +export interface AddressResult { + formattedAddress: string + distance: number + number: number + street: string + postal_code: number + locality: string +} diff --git a/src/stores/location-info.store.ts b/src/stores/location-info.store.ts new file mode 100644 index 00000000..467622a0 --- /dev/null +++ b/src/stores/location-info.store.ts @@ -0,0 +1,31 @@ +import { defineStore, acceptHMRUpdate } from 'pinia' +import { ref, Ref } from 'vue' +import { SvFeature } from '@/stores/location-info.store.model' +import { Coordinate } from 'ol/coordinate' +import { Feature } from 'ol' + +export const useLocationInfoStore = defineStore('info', () => { + const locationInfo: Ref = ref(undefined) + const hidePointer: Ref = ref(false) + const isStreetviewActive: Ref = ref(false) + const noDataAtLocation = ref(true) + const streetViewLoading: Ref = ref(false) + const panoPositionChanging: Ref = ref(false) + const svFeature: Ref = ref(undefined) + const routingFeatureTemp: Ref = ref(undefined) + + return { + locationInfo, + hidePointer, + isStreetviewActive, + noDataAtLocation, + streetViewLoading, + panoPositionChanging, + svFeature, + routingFeatureTemp, + } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useLocationInfoStore, import.meta.hot)) +} diff --git a/tailwind.config.cjs b/tailwind.config.cjs index 37d9fe8f..501013b8 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -170,6 +170,8 @@ module.exports = { measure: '"\\e021"', print: '"\\e02f"', share: '"\\e02a"', + streetview: + 'url("/src/assets/images/streetview/streetview_placeholder.png")', download: 'url("/src/assets/images/palette.svg")', upload: "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 512'%3E%3C!--! Font Awesome Pro 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --%3E%3Cpath style='fill:white;' d='M144 480C64.5 480 0 415.5 0 336c0-62.8 40.2-116.2 96.2-135.9c-.1-2.7-.2-5.4-.2-8.1c0-88.4 71.6-160 160-160c59.3 0 111 32.2 138.7 80.2C409.9 102 428.3 96 448 96c53 0 96 43 96 96c0 12.2-2.3 23.8-6.4 34.6C596 238.4 640 290.1 640 352c0 70.7-57.3 128-128 128H144zm79-217c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l39-39V392c0 13.3 10.7 24 24 24s24-10.7 24-24V257.9l39 39c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-80-80c-9.4-9.4-24.6-9.4-33.9 0l-80 80z'/%3E%3C/svg%3E\")",