From a2c56b7d4f251e7b2d08965ad92e34beeb3fc909 Mon Sep 17 00:00:00 2001 From: Noenandre <5470915+noenandre@users.noreply.github.com> Date: Fri, 6 May 2022 00:50:19 +0200 Subject: [PATCH 01/30] Initial work for new street level photo provider "Vegbilder" from the Norwegian Public Roads Administration. --- css/60_photos.css | 22 +- data/core.yaml | 3 + modules/renderer/background.js | 3 +- modules/renderer/photos.js | 2 +- modules/services/index.js | 3 + modules/services/vegbilder.js | 609 +++++++++++++++++++++++++++++++++ modules/svg/index.js | 1 + modules/svg/layers.js | 2 + modules/svg/vegbilder.js | 324 ++++++++++++++++++ modules/ui/photoviewer.js | 1 + 10 files changed, 966 insertions(+), 4 deletions(-) create mode 100644 modules/services/vegbilder.js create mode 100644 modules/svg/vegbilder.js diff --git a/css/60_photos.css b/css/60_photos.css index d3f2f1f96..96ce413a2 100644 --- a/css/60_photos.css +++ b/css/60_photos.css @@ -185,6 +185,18 @@ stroke-opacity: 0.85; /* bump opacity - only one per road */ } +/* Vegbilder Image Layer */ +.layer-vegbilder { + pointer-events: none; +} +.layer-vegbilder .viewfield-group * { + fill: #ed9300; +} +.layer-vegbilder .sequence { + stroke: #ed9300; + stroke-opacity: 0.85; /* bump opacity - only one per road */ +} + /* Mapillary Image Layer */ .layer-mapillary { @@ -273,7 +285,8 @@ } } -.ms-wrapper .pnlm-compass.pnlm-control { +.ms-wrapper .pnlm-compass.pnlm-control, +.vegbilder-wrapper .pnlm-compass.pnlm-control { width: 26px; height: 26px; left: 4px; @@ -341,12 +354,17 @@ label.streetside-hires { } } -.kartaview-image-wrap { +.kartaview-image-wrap, +.vegbilder-image-wrap { width: 100%; height: 100%; transform-origin:0 0; } +.vegbilder-wrapper { + position: relative; + background-color: #000; +} /* photo-controls (step forward, back, rotate) */ .photo-controls-wrap { diff --git a/data/core.yaml b/data/core.yaml index ebe5892e4..3fe99c40a 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -1358,6 +1358,9 @@ en: report: Report a privacy concern with this image view_on_bing: "View on Bing Maps" hires: "High resolution" + vegbilder: + title: "Vegbilder" + tooltip: "Street-level photos from the Norwegian Public Roads Administration" mapillary_images: tooltip: "Street-level photos from Mapillary" mapillary_map_features: diff --git a/modules/renderer/background.js b/modules/renderer/background.js index e6ee5d051..9cdae67ca 100644 --- a/modules/renderer/background.js +++ b/modules/renderer/background.js @@ -251,7 +251,8 @@ export function rendererBackground(context) { mapillary: 'Mapillary Images', 'mapillary-map-features': 'Mapillary Map Features', 'mapillary-signs': 'Mapillary Signs', - kartaview: 'KartaView Images' + kartaview: 'KartaView Images', + vegbilder: 'Norwegian road administration images' }; for (let layerID in photoOverlayLayers) { diff --git a/modules/renderer/photos.js b/modules/renderer/photos.js index ee2705b84..a4fa95d96 100644 --- a/modules/renderer/photos.js +++ b/modules/renderer/photos.js @@ -7,7 +7,7 @@ import { utilQsString, utilStringQs } from '../util'; export function rendererPhotos(context) { var dispatch = d3_dispatch('change'); - var _layerIDs = ['streetside', 'mapillary', 'mapillary-map-features', 'mapillary-signs', 'kartaview']; + var _layerIDs = ['streetside', 'mapillary', 'mapillary-map-features', 'mapillary-signs', 'kartaview', 'vegbilder']; var _allPhotoTypes = ['flat', 'panoramic']; var _shownPhotoTypes = _allPhotoTypes.slice(); // shallow copy var _dateFilters = ['fromDate', 'toDate']; diff --git a/modules/services/index.js b/modules/services/index.js index 6b1784da7..9d1494e02 100644 --- a/modules/services/index.js +++ b/modules/services/index.js @@ -6,6 +6,7 @@ import serviceMapRules from './maprules'; import serviceNominatim from './nominatim'; import serviceNsi from './nsi'; import serviceKartaview from './kartaview'; +import serviceVegbilder from './vegbilder'; import serviceOsm from './osm'; import serviceOsmWikibase from './osm_wikibase'; import serviceStreetside from './streetside'; @@ -23,6 +24,7 @@ export let services = { mapillary: serviceMapillary, nsi: serviceNsi, kartaview: serviceKartaview, + vegbilder: serviceVegbilder, osm: serviceOsm, osmWikibase: serviceOsmWikibase, maprules: serviceMapRules, @@ -42,6 +44,7 @@ export { serviceNominatim, serviceNsi, serviceKartaview, + serviceVegbilder, serviceOsm, serviceOsmWikibase, serviceStreetside, diff --git a/modules/services/vegbilder.js b/modules/services/vegbilder.js new file mode 100644 index 000000000..2e9f69246 --- /dev/null +++ b/modules/services/vegbilder.js @@ -0,0 +1,609 @@ +import { json as d3_json } from 'd3-fetch'; +import { dispatch as d3_dispatch } from 'd3-dispatch'; +import { select as d3_select } from 'd3-selection'; +import { zoom as d3_zoom, zoomIdentity as d3_zoomIdentity } from 'd3-zoom'; +import { utilQsString, utilTiler, utilRebind, utilArrayUnion, utilStringQs, utilSetTransform} from '../util'; +import { geoExtent, geoScaleToZoom } from '../geo'; +import RBush from 'rbush'; + +const owsEndpoint = 'https://www.vegvesen.no/kart/ogc/vegbilder_1_0/ows?'; +const pannellumViewerCSS = 'pannellum-streetside/pannellum.css'; +const pannellumViewerJS = 'pannellum-streetside/pannellum.js'; +const tileZoom = 14; +const tiler = utilTiler().zoomExtent([tileZoom, tileZoom]).skipNullIsland(true); +const dispatch = d3_dispatch('loadedImages', 'viewerChanged'); + +let imgZoom = d3_zoom() + .extent([[0, 0], [320, 240]]) + .translateExtent([[0, 0], [320, 240]]) + .scaleExtent([1, 15]); +let _sceneOptions = { + showFullscreenCtrl: false, + autoLoad: true, + compass: true, + yaw: 0, + type: 'equirectangular', +}; +let _vegbilderCache; +let _loadViewerPromise; +let _pannellumViewer; + + +function abortRequest(controller) { + controller.abort(); +} + +/** +* loadTiles() wraps the process of generating tiles and then fetching image points for each tile. +*/ +function loadTiles(which, url, projection, margin) { + const tiles = tiler.margin(margin).getTiles(projection); + + // abort inflight requests that are no longer needed + const cache = _vegbilderCache[which]; + Object.keys(cache.inflight).forEach(k => { + const wanted = tiles.find(tile => k.indexOf(tile.id + ',') === 0); + if (!wanted) { + abortRequest(cache.inflight[k]); + delete cache.inflight[k]; + } + }); + + tiles.forEach(tile => loadNextTilePage(which, url, tile)); +} + + +/** +* loadNextTilePage() load data for the next tile page in line. +*/ +function loadNextTilePage(which, url, tile) { + const cache = _vegbilderCache[which]; + const bbox = tile.extent.bbox(); + const years = [2019, 2020, 2021]; + const typenames = years.map(year => `vegbilder_1_0:Vegbilder_360_${year}`); + const id = tile.id; + if (cache.loaded[id] || cache.inflight[id]) return; + + + const params = { + service: 'WFS', + request: 'GetFeature', + version: '2.0.0', + typenames: typenames[2], + bbox: [bbox.minY, bbox.minX, bbox.maxY, bbox.maxX].join(','), + outputFormat: 'json' + }; + + const controller = new AbortController(); + cache.inflight[id] = controller; + + const options = { + method: 'GET', + signal: controller.signal, +}; + + let urlForRequest = url + utilQsString(params); + + d3_json(urlForRequest, options) + .then(featureCollection => { + cache.loaded[id] = true; + delete cache.inflight[id]; + + if (featureCollection.features.length === 0) { return; } + + const features = featureCollection.features.map(feature => { + const loc = feature.geometry.coordinates; + const key = feature.id; + const properties = feature.properties; + const { + RETNING: ca, + TIDSPUNKT: captured_at, + URL: image_path, + BILDETYPE: image_type, + METER: metering, + FELTKODE: lane_kode + } = properties; + const sequence_reference = roadReference(properties); + const d = { + loc, + key, + ca, + image_path, + metering, + sequence_reference, + captured_at: new Date(captured_at), + is_pano: image_type === '360' + }; + + cache.points.set(key, d); + + const lane_number = Number(lane_kode.match(/^[0-9]+/)[0]); + const direction = lane_number % 2 === 0; + let sequence = _vegbilderCache.sequences.get(sequence_reference); + + if (!sequence) { sequence = { direction, images: [], geometry: {type: 'LineString', coordinates: [] }}; } + _vegbilderCache.sequences.set(sequence_reference, sequence); + + sequence.images.push(d); + + return { + minX: loc[0], minY: loc[1], maxX: loc[0], maxY: loc[1], data: d + }; + }); + + cache.rtree.load(features); + + if (which === 'images') { + dispatch.call('loadedImages'); + } + + }) + .catch(() => { + cache.loaded[id] = true; + delete cache.inflight[id]; + }); +} + + +function OrderSequences() { + for (let [_, sequence] of _vegbilderCache.sequences) { + const {images, direction, geometry} = sequence; + if (direction) { + images.sort((a, b) => b.metering - a.metering); + } else { + images.sort((a, b) => a.metering - b.metering); + } + geometry.coordinates = images.map(d => d.loc); + } +} + +function roadReference(properties) { + let { + FYLKENUMMER: county_number, + VEGKATEGORI: road_class, + VEGSTATUS: road_status, + VEGNUMMER: road_number, + STREKNING: section, + DELSTREKNING: subsection, + HP: parcel, + KRYSSDEL: junction_part, + SIDEANLEGGSDEL: services_part, + ANKERPUNKT: anker_point, + FELTKODE: lane, + AAR: year, + BILDETYPE: image_type + } = properties; + + if (image_type === undefined) { image_type = 'Planar'; } + + let reference = `${year} ${image_type}`; + + if (year >= 2020) { + reference = `${reference}:${road_class}${road_status}${road_number} S${section}D${subsection}`; + if (junction_part) { + reference = `${reference} M${anker_point} KD${junction_part}`; + } else if (services_part) { + reference = `${reference} M${anker_point} SD${services_part}`; + } + } else { + reference = `${reference} ${county_number}${road_class}${road_status}${road_number} HP${parcel}`; + } + + reference = `${reference} F${lane}`; + return reference; +} + +function partitionViewport(projection) { + let z = geoScaleToZoom(projection.scale()); + let z2 = (Math.ceil(z * 2) / 2) + 2.5; // round to next 0.5 and add 2.5 + let tiler = utilTiler().zoomExtent([z2, z2]); + + return tiler.getTiles(projection) + .map(tile => tile.extent); +} + +function searchLimited(limit, projection, rtree) { + limit = limit || 5; + + return partitionViewport(projection) + .reduce((result, extent) => { + let found = rtree.search(extent.bbox()) + .slice(0, limit) + .map(d => d.data); + + return (found.length ? result.concat(found) : result); + }, []); +} + + +export default { + + init: function () { + if (!_vegbilderCache) { + this.reset(); + } + + this.event = utilRebind(this, dispatch, 'on'); + }, + + reset: function () { + if (_vegbilderCache) { + Object.values(_vegbilderCache.images.inflight).forEach(abortRequest); + } + + _vegbilderCache = { + images: { inflight: {}, loaded: {}, rtree: new RBush(), points: new Map()}, + sequences: new Map() + }; + }, + + + images: function (projection) { + const limit = 5; + return searchLimited(limit, projection, _vegbilderCache.images.rtree); + }, + + + sequences: function (projection) { + OrderSequences(); + const viewport = projection.clipExtent(); + const min = [viewport[0][0], viewport[1][1]]; + const max = [viewport[1][0], viewport[0][1]]; + const bbox = geoExtent(projection.invert(min), projection.invert(max)).bbox(); + let seen = new Set(); + let line_strings = []; + + for (let d of _vegbilderCache.images.rtree.search(bbox)) { + const key = d.data.sequence_reference; + if (!seen.has(key)) { + seen.add(key); + let sequence = _vegbilderCache.sequences.get(key); + let geometry = { + type: 'LineString', + coordinates: sequence.geometry.coordinates, + properties: {key} + }; + line_strings.push(geometry); + } + } + return line_strings; + }, + + + cachedImage: function (key) { + return _vegbilderCache.images.points.get(key); + }, + + + getSequenceKeyForImage: function (d) { + return d && d.sequence_reference; + }, + + + loadImages: function (projection) { + const margin = 1; + loadTiles('images', owsEndpoint, projection, margin); + }, + + viewer: function() { + return _pannellumViewer; + }, + + initViewer: function () { + if (!window.pannellum) return; + if (_pannellumViewer) return; + + _currScene += 1; + const sceneID = _currScene.toString(); + const options = { + 'default': { firstScene: sceneID }, + scenes: {} + }; + options.scenes[sceneID] = _sceneOptions; + + _pannellumViewer = window.pannellum.viewer('ideditor-viewer-streetside', options); + }, + + ensureViewerLoaded: function(context) { + + if (_loadViewerPromise) return _loadViewerPromise; + + const wrap = context.container().select('.photoviewer') + .selectAll('.vegbilder-wrapper') + .data([0]); + + const wrapEnter = wrap.enter() + .append('div') + .attr('class', 'photo-wrapper vegbilder-wrapper') + .classed('hide', true); + + wrapEnter + .append('div') + .attr('class', 'photo-attribution fillD'); + + const controlsEnter = wrapEnter + .append('div') + .attr('class', 'photo-controls-wrap') + .append('div') + .attr('class', 'photo-controls'); + + controlsEnter + .append('button') + .on('click.back', step(-1)) + .text('◄'); + + controlsEnter + .append('button') + .on('click.forward', step(1)) + .text('►'); + + wrapEnter + .append('div') + .attr('class', 'vegbilder-image-wrap'); + + + context.ui().photoviewer.on('resize.vegbilder', dimensions => { + if (_pannellumViewer) { + _pannellumViewer.resize(); + } else { + imgZoom = d3_zoom() + .extent([[0, 0], dimensions]) + .translateExtent([[0, 0], dimensions]) + .scaleExtent([1, 15]) + .on('zoom', zoomPan); + } + }); + + + _loadViewerPromise = new Promise((resolve, reject) => { + + let loadedCount = 0; + function loaded() { + loadedCount += 1; + // wait until both files are loaded + if (loadedCount === 2) resolve(); + } + + const head = d3_select('head'); + + // load streetside pannellum viewer css + head.selectAll('#ideditor-streetside-viewercss') + .data([0]) + .enter() + .append('link') + .attr('id', 'ideditor-streetside-viewercss') + .attr('rel', 'stylesheet') + .attr('crossorigin', 'anonymous') + .attr('href', context.asset(pannellumViewerCSS)) + .on('load.serviceStreetside', loaded) + .on('error.serviceStreetside', function() { + reject(); + }); + + // load streetside pannellum viewer js + head.selectAll('#ideditor-streetside-viewerjs') + .data([0]) + .enter() + .append('script') + .attr('id', 'ideditor-streetside-viewerjs') + .attr('crossorigin', 'anonymous') + .attr('src', context.asset(pannellumViewerJS)) + .on('load.serviceStreetside', loaded) + .on('error.serviceStreetside', function() { + reject(); + }); + }) + .catch(function() { + _loadViewerPromise = null; + }); + + const that = this; + + return _loadViewerPromise; + + function zoomPan(d3_event) { + const t = d3_event.transform; + context.container().select('.photoviewer .vegbilder-image-wrap') + .call(utilSetTransform, t.x, t.y, t.k); + } + + function step(stepBy) { + return () => { + const viewer = context.container().select('.photoviewer'); + const selected = viewer.empty() ? undefined : viewer.datum(); + if (!selected) return; + + const sequence = _vegbilderCache.sequences.get(that.getSequenceKeyForImage(selected)); + const nextIndex = sequence.images.indexOf(selected) + stepBy; + const nextImage = sequence.images[nextIndex]; + + if (!nextImage) return; + // TODO jump to a spatial and temporal close sequence when reaching the start or end. + that.selectImage(context, nextImage.key); + }; + } + + }, + + selectImage: function(context, key) { + const d = this.cachedImage(key); + this.updateUrlImage(key); + + const viewer = context.container().select('.photoviewer'); + if (!viewer.empty()) viewer.datum(d); + + this.setStyles(context, null, true); + + context.container().selectAll('.icon-sign') + .classed('currentView', false); + + if (!d) return this; + + const wrap = context.container().select('.photoviewer .vegbilder-wrapper'); + const imageWrap = wrap.selectAll('.vegbilder-image-wrap'); + const attribution = wrap.selectAll('.photo-attribution').text(''); + + wrap + .transition() + .duration(100) + .call(imgZoom.transform, d3_zoomIdentity); + + imageWrap + .selectAll('.vegbilder-image') + .remove(); + + if (!d.is_pano) { + imageWrap + .append('img') + .attr('class', 'vegbilder-image') + .attr('src', d.image_path); + } else { + imageWrap + .append('div') + .attr('class', 'vegbilder-panorama') + .attr('id', 'vegbilder-panorama') + .on(); + + _sceneOptions.panorama = d.image_path; + _sceneOptions.northOffset = d.ca; + _pannellumViewer = window.pannellum.viewer('vegbilder-panorama', _sceneOptions); + _pannellumViewer + .on('mousedown', () => { + d3_select(window) + .on('mousemove', () => { + dispatch.call('viewerChanged'); + }); + }) + .on('animatefinished', () => { + d3_select(window) + .on('mousemove', null); + dispatch.call('viewerChanged'); + }); + } + + if (d.captured_at) { + attribution + .append('span') + .attr('class', 'year') + .text(d.captured_at.getFullYear()); + } + + attribution + .append('a') + .attr('target', '_blank') + .attr('href', 'https://vegvesen.no') + .text('Norwegian Public Roads Administration'); + + return this; + }, + + showViewer: function (context) { + const viewer = context.container().select('.photoviewer') + .classed('hide', false); + + const isHidden = viewer.selectAll('.photo-wrapper.vegbilder-wrapper.hide').size(); + + if (isHidden) { + viewer + .selectAll('.photo-wrapper:not(.vegbilder-wrapper)') + .classed('hide', true); + + viewer + .selectAll('.photo-wrapper.vegbilder-wrapper') + .classed('hide', false); + } + return this; + }, + + hideViewer: function(context) { + this.updateUrlImage(null); + + const viewer = context.container().select('.photoviewer'); + if (!viewer.empty()) viewer.datum(null); + + viewer + .classed('hide', true) + .selectAll('.photo-wrapper') + .classed('hide', true); + + context.container().selectAll('.viewfield-group, .sequence, .icon-sign') + .classed('currentView', false); + + return this.setStyles(context, null, true); +}, + + + // Updates the currently highlighted sequence and selected bubble. + // Reset is only necessary when interacting with the viewport because + // this implicitly changes the currently selected bubble/sequence + setStyles: function (context, hovered, reset) { + if (reset) { // reset all layers + context.container().selectAll('.viewfield-group') + .classed('highlighted', false) + .classed('hovered', false) + .classed('currentView', false); + + context.container().selectAll('.sequence') + .classed('highlighted', false) + .classed('currentView', false); + } + + const hoveredImageKey = hovered && hovered.key; + const hoveredSequenceKey = this.getSequenceKeyForImage(hovered); + const hoveredSequence = hoveredSequenceKey && _vegbilderCache.sequences.get(hoveredSequenceKey); + const hoveredImageKeys = (hoveredSequence && hoveredSequence.images.map(d => d.key)) || []; + + const viewer = context.container().select('.photoviewer'); + const selected = viewer.empty() ? undefined : viewer.datum(); + const selectedImageKey = selected && selected.key; + const selectedSequenceKey = this.getSequenceKeyForImage(selected); + const selectedSequence = selectedSequenceKey && _vegbilderCache.sequences.get(selectedSequenceKey); + const selectedImageKeys = (selectedSequence && selectedSequence.images.map(d => d.key)) || []; + + // highlight sibling viewfields on either the selected or the hovered sequences + const highlightedImageKeys = utilArrayUnion(hoveredImageKeys, selectedImageKeys); + + context.container().selectAll('.layer-vegbilder .viewfield-group') + .classed('highlighted', d => highlightedImageKeys.indexOf(d.key) !== -1) + .classed('hovered', d => d.key === hoveredImageKey) + .classed('currentView', d => d.key === selectedImageKey); + + context.container().selectAll('.layer-vegbilder .sequence') + .classed('highlighted', d => d.properties.key === hoveredSequenceKey) + .classed('currentView', d => d.properties.key === selectedSequenceKey); + + // update viewfields if needed + context.container().selectAll('.layer-vegbilder .viewfield-group .viewfield') + .attr('d', viewfieldPath); + + function viewfieldPath() { + const d = this.parentNode.__data__; + if (d.is_pano && d.key !== selectedImageKey) { + return 'M 8,13 m -10,0 a 10,10 0 1,0 20,0 a 10,10 0 1,0 -20,0'; + } else { + return 'M 6,9 C 8,8.4 8,8.4 10,9 L 16,-2 C 12,-5 4,-5 0,-2 z'; + } + } + + return this; + }, + + + updateUrlImage: function (key) { + if (!window.mocha) { + const hash = utilStringQs(window.location.hash); + if (key) { + hash.photo = 'vegbilder/' + key; + } else { + delete hash.photo; + } + window.location.replace('#' + utilQsString(hash, true)); + } + }, + + + cache: function () { + return _vegbilderCache; + } + +}; diff --git a/modules/svg/index.js b/modules/svg/index.js index e2c94c316..733a12e9b 100644 --- a/modules/svg/index.js +++ b/modules/svg/index.js @@ -22,6 +22,7 @@ export { svgPoints } from './points.js'; export { svgRelationMemberTags } from './helpers.js'; export { svgSegmentWay } from './helpers.js'; export { svgStreetside } from './streetside.js'; +export { svgVegbilder } from './vegbilder'; export { svgTagClasses } from './tag_classes.js'; export { svgTagPattern } from './tag_pattern.js'; export { svgTouch } from './touch.js'; diff --git a/modules/svg/layers.js b/modules/svg/layers.js index 169805c4c..56c7015f5 100644 --- a/modules/svg/layers.js +++ b/modules/svg/layers.js @@ -8,6 +8,7 @@ import { svgKeepRight } from './keepRight'; import { svgImproveOSM } from './improveOSM'; import { svgOsmose } from './osmose'; import { svgStreetside } from './streetside'; +import { svgVegbilder} from './vegbilder'; import { svgMapillaryImages } from './mapillary_images'; import { svgMapillaryPosition } from './mapillary_position'; import { svgMapillarySigns } from './mapillary_signs'; @@ -36,6 +37,7 @@ export function svgLayers(projection, context) { { id: 'mapillary-map-features', layer: svgMapillaryMapFeatures(projection, context, dispatch) }, { id: 'mapillary-signs', layer: svgMapillarySigns(projection, context, dispatch) }, { id: 'kartaview', layer: svgKartaviewImages(projection, context, dispatch) }, + { id: 'vegbilder', layer: svgVegbilder(projection, context, dispatch) }, { id: 'debug', layer: svgDebug(projection, context, dispatch) }, { id: 'geolocate', layer: svgGeolocate(projection, context, dispatch) }, { id: 'touch', layer: svgTouch(projection, context, dispatch) } diff --git a/modules/svg/vegbilder.js b/modules/svg/vegbilder.js new file mode 100644 index 000000000..52c6cf60b --- /dev/null +++ b/modules/svg/vegbilder.js @@ -0,0 +1,324 @@ +import _throttle from 'lodash-es/throttle'; +import { select as d3_select } from 'd3-selection'; +import { svgPath, svgPointTransform } from './helpers'; +import { services } from '../services'; + + +export function svgVegbilder(projection, context, dispatch) { + const throttledRedraw = _throttle(() => dispatch.call('change'), 1000); + const minZoom = 14; + const minMarkerZoom = 16; + const minViewfieldZoom = 18; + let layer = d3_select(null); + let _viewerYaw = 0; + let _selectedSequence = null; + let _vegbilder; + + /** + * init(). + */ + function init() { + if (svgVegbilder.initialized) return; // run once + svgVegbilder.enabled = false; + svgVegbilder.initialized = true; + } + + /** + * getService(). + */ + function getService() { + if (services.vegbilder && !_vegbilder) { + _vegbilder = services.vegbilder; + _vegbilder.event + .on('viewerChanged.svgVegbilder', viewerChanged) + .on('loadedImages.svgVegbilder', throttledRedraw); + } else if (!services.vegbilder && _vegbilder) { + _vegbilder = null; + } + + return _vegbilder; + } + + /** + * showLayer(). + */ + function showLayer() { + let service = getService(); + if (!service) return; + + editOn(); + + layer + .style('opacity', 0) + .transition() + .duration(250) + .style('opacity', 1) + .on('end', () => dispatch.call('change')); + } + + /** + * hideLayer(). + */ + function hideLayer() { + throttledRedraw.cancel(); + + layer + .transition() + .duration(250) + .style('opacity', 0) + .on('end', editOff); + } + + /** + * editOn(). + */ + function editOn() { + layer.style('display', 'block'); + } + + /** + * editOff(). + */ + function editOff() { + layer.selectAll('.viewfield-group').remove(); + layer.style('display', 'none'); + } + + /** + * click() Handles 'bubble' point click event. + */ + function click(d3_event, d) { + const service = getService(); + if (!service) return; + + // try to preserve the viewer rotation when staying on the same sequence + _selectedSequence = d.sequence_reference; + + service + .ensureViewerLoaded(context) + .then(() => { + service + .selectImage(context, d.key) + .showViewer(context); + }); + + context.map().centerEase(d.loc); + } + + /** + * mouseover(). + */ + function mouseover(d3_event, d) { + const service = getService(); + if (service) service.setStyles(context, d); + } + + /** + * mouseout(). + */ + function mouseout() { + const service = getService(); + if (service) service.setStyles(context, null); + } + + /** + * transform(). + */ + function transform(d) { + let t = svgPointTransform(projection)(d); + const rot = d.ca + _viewerYaw; + if (rot) { + t += ' rotate(' + Math.floor(rot) + ',0,0)'; + } + return t; + } + + + function viewerChanged() { + const service = getService(); + if (!service) return; + + const viewer = service.viewer(); + if (!viewer) return; + + // update viewfield rotation + _viewerYaw = viewer.getYaw(); + + // avoid updating if the map is currently transformed + // e.g. during drags or easing. + if (context.map().isTransformed()) return; + + layer.selectAll('.viewfield-group.currentView') + .attr('transform', transform); + } + + function filterSequences(sequences) { + + } + + /** + * update(). + */ + function update() { + const viewer = context.container().select('.photoviewer'); + const selected = viewer.empty() ? undefined : viewer.datum(); + const z = ~~context.map().zoom(); + const showMarkers = (z >= minMarkerZoom); + const showViewfields = (z >= minViewfieldZoom); + const service = getService(); + + let sequences = []; + let images = []; + + sequences = (service ? service.sequences(projection) : []); + images = (service && showMarkers ? service.images(projection) : []); + + let traces = layer.selectAll('.sequences').selectAll('.sequence') + .data(sequences, d => d.properties.key); + + // exit + traces.exit() + .remove(); + + // enter/update + traces = traces.enter() + .append('path') + .attr('class', 'sequence') + .merge(traces) + .attr('d', svgPath(projection).geojson); + + + const groups = layer.selectAll('.markers').selectAll('.viewfield-group') + .data(images, (d) => d.key); + + // exit + groups.exit() + .remove(); + + // enter + const groupsEnter = groups.enter() + .append('g') + .attr('class', 'viewfield-group') + .on('mouseenter', mouseover) + .on('mouseleave', mouseout) + .on('click', click); + + groupsEnter + .append('g') + .attr('class', 'viewfield-scale'); + + // update + const markers = groups + .merge(groupsEnter) + .sort((a, b) => { + return (a === selected) ? 1 + : (b === selected) ? -1 + : b.loc[1] - a.loc[1]; + }) + .attr('transform', transform) + .select('.viewfield-scale'); + + + markers.selectAll('circle') + .data([0]) + .enter() + .append('circle') + .attr('dx', '0') + .attr('dy', '0') + .attr('r', '6'); + + const viewfields = markers.selectAll('.viewfield') + .data(showViewfields ? [0] : []); + + viewfields.exit() + .remove(); + + // viewfields may or may not be drawn... + // but if they are, draw below the circles + viewfields.enter() + .insert('path', 'circle') + .attr('class', 'viewfield') + .attr('transform', 'scale(1.5,1.5),translate(-8, -13)') + .attr('d', viewfieldPath); + + function viewfieldPath() { + const d = this.parentNode.__data__; + if (d.is_pano) { + return 'M 8,13 m -10,0 a 10,10 0 1,0 20,0 a 10,10 0 1,0 -20,0'; + } else { + return 'M 6,9 C 8,8.4 8,8.4 10,9 L 16,-2 C 12,-5 4,-5 0,-2 z'; + } + } + } + + /** + * drawImages() + * drawImages is the method that is returned (and that runs) every time 'svgStreetside()' is called. + * 'svgStreetside()' is called from index.js + */ + function drawImages(selection) { + const enabled = svgVegbilder.enabled; + const service = getService(); + + layer = selection.selectAll('.layer-vegbilder') + .data(service ? [0] : []); + + layer.exit() + .remove(); + + const layerEnter = layer.enter() + .append('g') + .attr('class', 'layer-vegbilder') + .style('display', enabled ? 'block' : 'none'); + + layerEnter + .append('g') + .attr('class', 'sequences'); + + layerEnter + .append('g') + .attr('class', 'markers'); + + layer = layerEnter + .merge(layer); + + if (enabled) { + if (service && ~~context.map().zoom() >= minZoom) { + editOn(); + update(); + service.loadImages(projection); + } else { + editOff(); + } + } + } + + + /** + * drawImages.enabled(). + */ + drawImages.enabled = function (_) { + if (!arguments.length) return svgVegbilder.enabled; + svgVegbilder.enabled = _; + if (svgVegbilder.enabled) { + showLayer(); + context.photos().on('change.vegbilder', update); + } else { + hideLayer(); + context.photos().on('change.vegbilder', null); + } + dispatch.call('change'); + return this; + }; + + /** + * drawImages.supported(). + */ + drawImages.supported = function () { + return !!getService(); + }; + + init(); + + return drawImages; +} diff --git a/modules/ui/photoviewer.js b/modules/ui/photoviewer.js index 51b92b7be..61c221611 100644 --- a/modules/ui/photoviewer.js +++ b/modules/ui/photoviewer.js @@ -24,6 +24,7 @@ export function uiPhotoviewer(context) { if (services.streetside) { services.streetside.hideViewer(context); } if (services.mapillary) { services.mapillary.hideViewer(context); } if (services.kartaview) { services.kartaview.hideViewer(context); } + if (services.vegbilder) { services.vegbilder.hideViewer(context); } }) .append('div') .call(svgIcon('#iD-icon-close')); From 55223387169efdc25acc6de69232f6d50c9b3f90 Mon Sep 17 00:00:00 2001 From: Noenandre <5470915+noenandre@users.noreply.github.com> Date: Tue, 14 Feb 2023 12:25:43 +0100 Subject: [PATCH 02/30] Rename pannellum-streetside dist folder to pannellum, since Pannellum isn't used by just Bing Streetside anymore. TODO: Pannellum is loaded twice. --- modules/services/streetside.js | 4 ++-- modules/services/vegbilder.js | 22 +++++++++++----------- package.json | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/modules/services/streetside.js b/modules/services/streetside.js index b9ba34f2d..563b8791f 100644 --- a/modules/services/streetside.js +++ b/modules/services/streetside.js @@ -20,8 +20,8 @@ import { utilArrayUnion, utilQsString, utilRebind, utilStringQs, utilTiler, util const bubbleApi = 'https://dev.virtualearth.net/mapcontrol/HumanScaleServices/GetBubbles.ashx?'; const streetsideImagesApi = 'https://t.ssl.ak.tiles.virtualearth.net/tiles/'; const bubbleAppKey = 'AuftgJsO0Xs8Ts4M1xZUQJQXJNsvmh3IV8DkNieCiy3tCwCUMq76-WpkrBtNAuEm'; -const pannellumViewerCSS = 'pannellum-streetside/pannellum.css'; -const pannellumViewerJS = 'pannellum-streetside/pannellum.js'; +const pannellumViewerCSS = 'pannellum/pannellum.css'; +const pannellumViewerJS = 'pannellum/pannellum.js'; const maxResults = 2000; const tileZoom = 16.5; const tiler = utilTiler().zoomExtent([tileZoom, tileZoom]).skipNullIsland(true); diff --git a/modules/services/vegbilder.js b/modules/services/vegbilder.js index 2e9f69246..cd3c36cde 100644 --- a/modules/services/vegbilder.js +++ b/modules/services/vegbilder.js @@ -7,8 +7,8 @@ import { geoExtent, geoScaleToZoom } from '../geo'; import RBush from 'rbush'; const owsEndpoint = 'https://www.vegvesen.no/kart/ogc/vegbilder_1_0/ows?'; -const pannellumViewerCSS = 'pannellum-streetside/pannellum.css'; -const pannellumViewerJS = 'pannellum-streetside/pannellum.js'; +const pannellumViewerCSS = 'pannellum/pannellum.css'; +const pannellumViewerJS = 'pannellum/pannellum.js'; const tileZoom = 14; const tiler = utilTiler().zoomExtent([tileZoom, tileZoom]).skipNullIsland(true); const dispatch = d3_dispatch('loadedImages', 'viewerChanged'); @@ -367,33 +367,33 @@ export default { const head = d3_select('head'); // load streetside pannellum viewer css - head.selectAll('#ideditor-streetside-viewercss') + head.selectAll('#ideditor-vegbilder-viewercss') .data([0]) .enter() .append('link') - .attr('id', 'ideditor-streetside-viewercss') + .attr('id', 'ideditor-vegbilder-viewercss') .attr('rel', 'stylesheet') .attr('crossorigin', 'anonymous') .attr('href', context.asset(pannellumViewerCSS)) - .on('load.serviceStreetside', loaded) - .on('error.serviceStreetside', function() { + .on('load.serviceVegbilder', loaded) + .on('error.serviceVegbilder', function() { reject(); }); // load streetside pannellum viewer js - head.selectAll('#ideditor-streetside-viewerjs') + head.selectAll('#ideditor-vegbilder-viewerjs') .data([0]) .enter() .append('script') - .attr('id', 'ideditor-streetside-viewerjs') + .attr('id', 'ideditor-vegbilder-viewerjs') .attr('crossorigin', 'anonymous') .attr('src', context.asset(pannellumViewerJS)) - .on('load.serviceStreetside', loaded) - .on('error.serviceStreetside', function() { + .on('load.serviceVegbilder', loaded) + .on('error.serviceVegbilder', function() { reject(); }); }) - .catch(function() { + .catch(() => { _loadViewerPromise = null; }); diff --git a/package.json b/package.json index 7f0742372..6d4995f6a 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "clean": "shx rm -f dist/esbuild.json dist/*.js dist/*.map dist/*.css dist/img/*.svg", "dist": "run-p dist:**", "dist:mapillary": "shx mkdir -p dist/mapillary-js && shx cp -R node_modules/mapillary-js/dist/* dist/mapillary-js/", - "dist:pannellum": "shx mkdir -p dist/pannellum-streetside && shx cp -R node_modules/pannellum/build/* dist/pannellum-streetside/", + "dist:pannellum": "shx mkdir -p dist/pannellum && shx cp -R node_modules/pannellum/build/* dist/pannellum/", "dist:min": "node config/esbuild.config.min.mjs", "dist:svg:iD": "svg-sprite --symbol --symbol-dest . --shape-id-generator \"iD-%s\" --symbol-sprite dist/img/iD-sprite.svg \"svg/iD-sprite/**/*.svg\"", "dist:svg:community": "svg-sprite --symbol --symbol-dest . --shape-id-generator \"community-%s\" --symbol-sprite dist/img/community-sprite.svg node_modules/osm-community-index/dist/img/*.svg", From 990cab58560fe1925508131d437713071621728f Mon Sep 17 00:00:00 2001 From: Noenandre <5470915+noenandre@users.noreply.github.com> Date: Tue, 14 Feb 2023 12:40:34 +0100 Subject: [PATCH 03/30] Fetch available WFS image layers from OWS server. Use maps in cache, instead of objects. --- modules/services/vegbilder.js | 245 +++++++++++++++++++++------------- modules/svg/vegbilder.js | 4 +- 2 files changed, 152 insertions(+), 97 deletions(-) diff --git a/modules/services/vegbilder.js b/modules/services/vegbilder.js index cd3c36cde..d704ec88b 100644 --- a/modules/services/vegbilder.js +++ b/modules/services/vegbilder.js @@ -1,4 +1,4 @@ -import { json as d3_json } from 'd3-fetch'; +import { json as d3_json, xml as d3_xml} from 'd3-fetch'; import { dispatch as d3_dispatch } from 'd3-dispatch'; import { select as d3_select } from 'd3-selection'; import { zoom as d3_zoom, zoomIdentity as d3_zoomIdentity } from 'd3-zoom'; @@ -27,126 +27,174 @@ let _sceneOptions = { let _vegbilderCache; let _loadViewerPromise; let _pannellumViewer; +let _availableLayers; function abortRequest(controller) { controller.abort(); } -/** -* loadTiles() wraps the process of generating tiles and then fetching image points for each tile. -*/ -function loadTiles(which, url, projection, margin) { - const tiles = tiler.margin(margin).getTiles(projection); +async function fetchAvailableLayers() { + const params = { + service: 'WFS', + request: 'GetCapabilities', + version: '2.0.0', + }; - // abort inflight requests that are no longer needed - const cache = _vegbilderCache[which]; - Object.keys(cache.inflight).forEach(k => { - const wanted = tiles.find(tile => k.indexOf(tile.id + ',') === 0); - if (!wanted) { - abortRequest(cache.inflight[k]); - delete cache.inflight[k]; + const urlForRequest = owsEndpoint + utilQsString(params); + const repsonse = await d3_xml(urlForRequest); + const xPathSelector = '/wfs:WFS_Capabilities/wfs:FeatureTypeList/wfs:FeatureType/wfs:Name'; + const regexMatcher = /^vegbilder_1_0:Vegbilder(?_360)?_(?\d{4})$/; + const NSResolver = repsonse.createNSResolver(repsonse); + const l = repsonse.evaluate( + xPathSelector, + repsonse, + NSResolver, + XPathResult.ANY_TYPE + ); + let node; + _availableLayers = []; + while (node = l.iterateNext()) { + let match = node.textContent?.match(regexMatcher); + if (match) { + _availableLayers.push({ + name: match[0], + is_sphere: !!match.groups?.image_type, + year: parseInt(match.groups?.year) + }); } - }); - - tiles.forEach(tile => loadNextTilePage(which, url, tile)); + } } +function filterAvailableLayers(photos) { + const fromDate = photos.fromDate(); + const toDate = photos.toDate(); + const fromYear = fromDate ? new Date(fromDate).getFullYear() : 2016; + const toYear = toDate ? new Date(toDate).getFullYear() : null; + const showsFlat = photos.showsFlat(); + const showsPano = photos.showsPanoramic(); + return _availableLayers.filter(layerInfo => ( + (layerInfo.year >= fromYear) && + (!toYear || (layerInfo.year <= toYear)) && + ((!layerInfo.is_sphere && showsFlat) || (layerInfo.is_sphere && showsPano)) + )); +} + +function loadWFSLayers(projection, margin, layers) { + for (let {name} of layers) { + loadWFSLayer(name, projection, margin); + } +} + +function loadWFSLayer(layername, projection, margin) { + const tiles = tiler.margin(margin).getTiles(projection); + let cache = _vegbilderCache.wfslayers.get(layername); + + if (!cache) { + cache = {loaded: new Map(), inflight: new Map()}; + _vegbilderCache.wfslayers.set(layername, cache); + } + + // abort inflight requests that are no longer needed + for (let key of cache.inflight.keys()) { + const wanted = tiles.find(tile => key.indexOf(tile.id + ',') === 0); + if (!wanted) { + abortRequest(cache.inflight.get(key)); + cache.inflight.delete(key); + } + } + + for (let tile of tiles) { + loadTile(cache, layername, tile); + } +} /** * loadNextTilePage() load data for the next tile page in line. */ -function loadNextTilePage(which, url, tile) { - const cache = _vegbilderCache[which]; +async function loadTile(cache, layername, tile) { const bbox = tile.extent.bbox(); - const years = [2019, 2020, 2021]; - const typenames = years.map(year => `vegbilder_1_0:Vegbilder_360_${year}`); - const id = tile.id; - if (cache.loaded[id] || cache.inflight[id]) return; - + const tileid = tile.id; + if (cache.loaded.get(tileid) || cache.inflight.get(tileid)) return; const params = { service: 'WFS', request: 'GetFeature', version: '2.0.0', - typenames: typenames[2], + typenames: layername, bbox: [bbox.minY, bbox.minX, bbox.maxY, bbox.maxX].join(','), outputFormat: 'json' }; const controller = new AbortController(); - cache.inflight[id] = controller; + cache.inflight.set(tileid, controller); const options = { method: 'GET', signal: controller.signal, -}; + }; - let urlForRequest = url + utilQsString(params); + const urlForRequest = owsEndpoint + utilQsString(params); - d3_json(urlForRequest, options) - .then(featureCollection => { - cache.loaded[id] = true; - delete cache.inflight[id]; + try { + var featureCollection = await d3_json(urlForRequest, options); + } catch { + cache.loaded.set(tileid, false); + return; + } finally { + cache.inflight.delete(tileid); + } - if (featureCollection.features.length === 0) { return; } + cache.loaded.set(tileid, true); - const features = featureCollection.features.map(feature => { - const loc = feature.geometry.coordinates; - const key = feature.id; - const properties = feature.properties; - const { - RETNING: ca, - TIDSPUNKT: captured_at, - URL: image_path, - BILDETYPE: image_type, - METER: metering, - FELTKODE: lane_kode - } = properties; - const sequence_reference = roadReference(properties); - const d = { - loc, - key, - ca, - image_path, - metering, - sequence_reference, - captured_at: new Date(captured_at), - is_pano: image_type === '360' - }; + if (featureCollection.features.length === 0) { return; } - cache.points.set(key, d); + const features = featureCollection.features.map(feature => { + const loc = feature.geometry.coordinates; + const key = feature.id; + const properties = feature.properties; + const { + RETNING: ca, + TIDSPUNKT: captured_at, + URL: image_path, + BILDETYPE: image_type, + METER: metering, + FELTKODE: lane_kode + } = properties; + const sequence_reference = roadReference(properties); + const d = { + loc, + key, + ca, + image_path, + metering, + sequence_reference, + captured_at: new Date(captured_at), + is_sphere: image_type === '360' + }; - const lane_number = Number(lane_kode.match(/^[0-9]+/)[0]); - const direction = lane_number % 2 === 0; - let sequence = _vegbilderCache.sequences.get(sequence_reference); + _vegbilderCache.points.set(key, d); - if (!sequence) { sequence = { direction, images: [], geometry: {type: 'LineString', coordinates: [] }}; } - _vegbilderCache.sequences.set(sequence_reference, sequence); + const lane_number = parseInt(lane_kode.match(/^[0-9]+/)[0]); + const direction = lane_number % 2 === 0; + let sequence = _vegbilderCache.sequences.get(sequence_reference); - sequence.images.push(d); + if (!sequence) { sequence = { direction, images: [], geometry: {type: 'LineString', coordinates: [] }}; } + _vegbilderCache.sequences.set(sequence_reference, sequence); - return { - minX: loc[0], minY: loc[1], maxX: loc[0], maxY: loc[1], data: d - }; - }); + sequence.images.push(d); - cache.rtree.load(features); + return { + minX: loc[0], minY: loc[1], maxX: loc[0], maxY: loc[1], data: d + }; + }); - if (which === 'images') { - dispatch.call('loadedImages'); - } - - }) - .catch(() => { - cache.loaded[id] = true; - delete cache.inflight[id]; - }); + _vegbilderCache.rtree.load(features); + dispatch.call('loadedImages'); } - function OrderSequences() { - for (let [_, sequence] of _vegbilderCache.sequences) { + for (let [, sequence] of _vegbilderCache.sequences) { const {images, direction, geometry} = sequence; if (direction) { images.sort((a, b) => b.metering - a.metering); @@ -218,29 +266,35 @@ function searchLimited(limit, projection, rtree) { export default { - init: function () { + init: async function () { if (!_vegbilderCache) { - this.reset(); + await this.reset(); } this.event = utilRebind(this, dispatch, 'on'); }, - reset: function () { + reset: async function () { if (_vegbilderCache) { - Object.values(_vegbilderCache.images.inflight).forEach(abortRequest); + for (let layer of _vegbilderCache.wfslayers.values()) { + for (let tile of layer.values()) {abortRequest(tile);} + } } _vegbilderCache = { - images: { inflight: {}, loaded: {}, rtree: new RBush(), points: new Map()}, + wfslayers: new Map(), + rtree: new RBush(), + points: new Map(), sequences: new Map() }; + + await fetchAvailableLayers(); }, images: function (projection) { const limit = 5; - return searchLimited(limit, projection, _vegbilderCache.images.rtree); + return searchLimited(limit, projection, _vegbilderCache.rtree); }, @@ -253,7 +307,7 @@ export default { let seen = new Set(); let line_strings = []; - for (let d of _vegbilderCache.images.rtree.search(bbox)) { + for (let d of _vegbilderCache.rtree.search(bbox)) { const key = d.data.sequence_reference; if (!seen.has(key)) { seen.add(key); @@ -271,7 +325,7 @@ export default { cachedImage: function (key) { - return _vegbilderCache.images.points.get(key); + return _vegbilderCache.points.get(key); }, @@ -280,16 +334,17 @@ export default { }, - loadImages: function (projection) { + loadImages: function (projection, photos) { const margin = 1; - loadTiles('images', owsEndpoint, projection, margin); + const layers = filterAvailableLayers(photos); + loadWFSLayers(projection, margin, layers); }, viewer: function() { return _pannellumViewer; }, - initViewer: function () { + initViewerpannellumViewer: function () { if (!window.pannellum) return; if (_pannellumViewer) return; @@ -301,7 +356,7 @@ export default { }; options.scenes[sceneID] = _sceneOptions; - _pannellumViewer = window.pannellum.viewer('ideditor-viewer-streetside', options); + _pannellumViewer = window.pannellum.viewer('ideditor-viewer-vegbilder', options); }, ensureViewerLoaded: function(context) { @@ -354,7 +409,6 @@ export default { } }); - _loadViewerPromise = new Promise((resolve, reject) => { let loadedCount = 0; @@ -422,7 +476,6 @@ export default { that.selectImage(context, nextImage.key); }; } - }, selectImage: function(context, key) { @@ -452,7 +505,7 @@ export default { .selectAll('.vegbilder-image') .remove(); - if (!d.is_pano) { + if (!d.is_sphere) { imageWrap .append('img') .attr('class', 'vegbilder-image') @@ -466,7 +519,7 @@ export default { _sceneOptions.panorama = d.image_path; _sceneOptions.northOffset = d.ca; - _pannellumViewer = window.pannellum.viewer('vegbilder-panorama', _sceneOptions); + _pannellumViewer = window.pannellum.viewer('vegbilder-imagesphere', _sceneOptions); _pannellumViewer .on('mousedown', () => { d3_select(window) @@ -494,6 +547,8 @@ export default { .attr('href', 'https://vegvesen.no') .text('Norwegian Public Roads Administration'); + this.showViewer(context); + return this; }, @@ -578,7 +633,7 @@ export default { function viewfieldPath() { const d = this.parentNode.__data__; - if (d.is_pano && d.key !== selectedImageKey) { + if (d.is_sphere && d.key !== selectedImageKey) { return 'M 8,13 m -10,0 a 10,10 0 1,0 20,0 a 10,10 0 1,0 -20,0'; } else { return 'M 6,9 C 8,8.4 8,8.4 10,9 L 16,-2 C 12,-5 4,-5 0,-2 z'; diff --git a/modules/svg/vegbilder.js b/modules/svg/vegbilder.js index 52c6cf60b..09f143187 100644 --- a/modules/svg/vegbilder.js +++ b/modules/svg/vegbilder.js @@ -243,7 +243,7 @@ export function svgVegbilder(projection, context, dispatch) { function viewfieldPath() { const d = this.parentNode.__data__; - if (d.is_pano) { + if (d.is_sphere) { return 'M 8,13 m -10,0 a 10,10 0 1,0 20,0 a 10,10 0 1,0 -20,0'; } else { return 'M 6,9 C 8,8.4 8,8.4 10,9 L 16,-2 C 12,-5 4,-5 0,-2 z'; @@ -286,7 +286,7 @@ export function svgVegbilder(projection, context, dispatch) { if (service && ~~context.map().zoom() >= minZoom) { editOn(); update(); - service.loadImages(projection); + service.loadImages(projection, context.photos()); } else { editOff(); } From 98975e064bc12efdc28f47d6091525661def65f4 Mon Sep 17 00:00:00 2001 From: Noenandre <5470915+noenandre@users.noreply.github.com> Date: Tue, 14 Feb 2023 14:31:35 +0100 Subject: [PATCH 04/30] Implemented image filtering. --- modules/renderer/photos.js | 4 ++-- modules/services/vegbilder.js | 24 ++++++++++----------- modules/svg/vegbilder.js | 39 ++++++++++++++++++++++++++++++----- 3 files changed, 48 insertions(+), 19 deletions(-) diff --git a/modules/renderer/photos.js b/modules/renderer/photos.js index a4fa95d96..51b7f1f22 100644 --- a/modules/renderer/photos.js +++ b/modules/renderer/photos.js @@ -119,12 +119,12 @@ export function rendererPhotos(context) { } photos.shouldFilterByDate = function() { - return showsLayer('mapillary') || showsLayer('kartaview') || showsLayer('streetside'); + return showsLayer('mapillary') || showsLayer('kartaview') || showsLayer('streetside') || showsLayer('vegbilder'); }; photos.shouldFilterByPhotoType = function() { return showsLayer('mapillary') || - (showsLayer('streetside') && showsLayer('kartaview')); + (showsLayer('streetside') && showsLayer('kartaview')) || showsLayer('vegbilder'); }; photos.shouldFilterByUsername = function() { diff --git a/modules/services/vegbilder.js b/modules/services/vegbilder.js index d704ec88b..87af17aad 100644 --- a/modules/services/vegbilder.js +++ b/modules/services/vegbilder.js @@ -66,13 +66,13 @@ async function fetchAvailableLayers() { } } -function filterAvailableLayers(photos) { - const fromDate = photos.fromDate(); - const toDate = photos.toDate(); - const fromYear = fromDate ? new Date(fromDate).getFullYear() : 2016; - const toYear = toDate ? new Date(toDate).getFullYear() : null; - const showsFlat = photos.showsFlat(); - const showsPano = photos.showsPanoramic(); +function filterAvailableLayers(photoContex) { + const fromDateString = photoContex.fromDate(); + const toDateString = photoContex.toDate(); + const fromYear = fromDateString ? new Date(fromDateString).getFullYear() : 2016; + const toYear = toDateString ? new Date(toDateString).getFullYear() : null; + const showsFlat = photoContex.showsFlat(); + const showsPano = photoContex.showsPanoramic(); return _availableLayers.filter(layerInfo => ( (layerInfo.year >= fromYear) && (!toYear || (layerInfo.year <= toYear)) && @@ -193,7 +193,7 @@ async function loadTile(cache, layername, tile) { dispatch.call('loadedImages'); } -function OrderSequences() { +function orderSequences() { for (let [, sequence] of _vegbilderCache.sequences) { const {images, direction, geometry} = sequence; if (direction) { @@ -299,7 +299,7 @@ export default { sequences: function (projection) { - OrderSequences(); + orderSequences(); const viewport = projection.clipExtent(); const min = [viewport[0][0], viewport[1][1]]; const max = [viewport[1][0], viewport[0][1]]; @@ -334,9 +334,9 @@ export default { }, - loadImages: function (projection, photos) { + loadImages: function (projection, photosContext) { const margin = 1; - const layers = filterAvailableLayers(photos); + const layers = filterAvailableLayers(photosContext); loadWFSLayers(projection, margin, layers); }, @@ -344,7 +344,7 @@ export default { return _pannellumViewer; }, - initViewerpannellumViewer: function () { + initPannellumViewer: function () { if (!window.pannellum) return; if (_pannellumViewer) return; diff --git a/modules/svg/vegbilder.js b/modules/svg/vegbilder.js index 09f143187..dff6152ef 100644 --- a/modules/svg/vegbilder.js +++ b/modules/svg/vegbilder.js @@ -152,8 +152,32 @@ export function svgVegbilder(projection, context, dispatch) { .attr('transform', transform); } - function filterSequences(sequences) { + function filterImages(images) { + const photoContext = context.photos(); + const fromDateString = photoContext.fromDate(); + const toDateString = photoContext.toDate(); + const showsFlat = photoContext.showsFlat(); + const showsPano = photoContext.showsPanoramic(); + if (fromDateString) { + const fromDate = new Date(fromDateString); + images = images.filter(image => image.captured_at.getTime() >= fromDate.getTime()); + } + + if (toDateString) { + const toDate = new Date(toDateString); + images = images.filter(image => image.captured_at.getTime() <= toDate.getTime()); + } + + if (!showsPano) { + images = images.filter(image => !image.is_sphere); + } + + if (!showsFlat) { + images = images.filter(image => image.is_sphere); + } + + return images; } /** @@ -166,12 +190,17 @@ export function svgVegbilder(projection, context, dispatch) { const showMarkers = (z >= minMarkerZoom); const showViewfields = (z >= minViewfieldZoom); const service = getService(); - let sequences = []; let images = []; - sequences = (service ? service.sequences(projection) : []); - images = (service && showMarkers ? service.images(projection) : []); + if (service) { + // The WFS-layer for that year or image type may not be loaded after a filter is changed + service.loadImages(projection, context.photos()); + + sequences = service.sequences(projection); + images = showMarkers ? service.images(projection) : []; + images = filterImages(images); + } let traces = layer.selectAll('.sequences').selectAll('.sequence') .data(sequences, d => d.properties.key); @@ -181,7 +210,7 @@ export function svgVegbilder(projection, context, dispatch) { .remove(); // enter/update - traces = traces.enter() + traces.enter() .append('path') .attr('class', 'sequence') .merge(traces) From b8eb110a8f49d7d3f220c5caef498e7c483e9321 Mon Sep 17 00:00:00 2001 From: Noenandre <5470915+noenandre@users.noreply.github.com> Date: Tue, 21 Feb 2023 23:22:47 +0100 Subject: [PATCH 05/30] Order sequences by time, primarily, and not linear reference. --- modules/services/vegbilder.js | 180 +++++++++++++++++++++------------- modules/svg/vegbilder.js | 2 +- 2 files changed, 114 insertions(+), 68 deletions(-) diff --git a/modules/services/vegbilder.js b/modules/services/vegbilder.js index 87af17aad..4eefff496 100644 --- a/modules/services/vegbilder.js +++ b/modules/services/vegbilder.js @@ -12,6 +12,10 @@ const pannellumViewerJS = 'pannellum/pannellum.js'; const tileZoom = 14; const tiler = utilTiler().zoomExtent([tileZoom, tileZoom]).skipNullIsland(true); const dispatch = d3_dispatch('loadedImages', 'viewerChanged'); +const directionEnum = Object.freeze({ + forward: Symbol(0), + backward: Symbol(1) +}); let imgZoom = d3_zoom() .extent([[0, 0], [320, 240]]) @@ -81,12 +85,13 @@ function filterAvailableLayers(photoContex) { } function loadWFSLayers(projection, margin, layers) { - for (let {name} of layers) { - loadWFSLayer(name, projection, margin); - } + Promise.all(layers.map( + ({name}) => loadWFSLayer(name, projection, margin) + )) + .then(orderSequences); } -function loadWFSLayer(layername, projection, margin) { +async function loadWFSLayer(layername, projection, margin) { const tiles = tiler.margin(margin).getTiles(projection); let cache = _vegbilderCache.wfslayers.get(layername); @@ -97,16 +102,16 @@ function loadWFSLayer(layername, projection, margin) { // abort inflight requests that are no longer needed for (let key of cache.inflight.keys()) { - const wanted = tiles.find(tile => key.indexOf(tile.id + ',') === 0); + const wanted = tiles.some(tile => key === tile.id); if (!wanted) { abortRequest(cache.inflight.get(key)); cache.inflight.delete(key); } } - for (let tile of tiles) { - loadTile(cache, layername, tile); - } + await Promise.all(tiles.map( + tile => loadTile(cache, layername, tile) + )); } /** @@ -115,7 +120,7 @@ function loadWFSLayer(layername, projection, margin) { async function loadTile(cache, layername, tile) { const bbox = tile.extent.bbox(); const tileid = tile.id; - if (cache.loaded.get(tileid) || cache.inflight.get(tileid)) return; + if (cache.loaded.has(tileid) || cache.inflight.has(tileid)) return; const params = { service: 'WFS', @@ -136,8 +141,9 @@ async function loadTile(cache, layername, tile) { const urlForRequest = owsEndpoint + utilQsString(params); + let featureCollection; try { - var featureCollection = await d3_json(urlForRequest, options); + featureCollection = await d3_json(urlForRequest, options); } catch { cache.loaded.set(tileid, false); return; @@ -159,31 +165,26 @@ async function loadTile(cache, layername, tile) { URL: image_path, BILDETYPE: image_type, METER: metering, - FELTKODE: lane_kode + FELTKODE: lane_code } = properties; - const sequence_reference = roadReference(properties); + const lane_number = parseInt(lane_code.match(/^[0-9]+/)[0]); + const direction = lane_number % 2 === 0 ? directionEnum.backward : directionEnum.forward; const d = { loc, key, ca, image_path, + layername, + road_reference: roadReference(properties), metering, - sequence_reference, + lane_code, + direction, captured_at: new Date(captured_at), is_sphere: image_type === '360' }; _vegbilderCache.points.set(key, d); - const lane_number = parseInt(lane_kode.match(/^[0-9]+/)[0]); - const direction = lane_number % 2 === 0; - let sequence = _vegbilderCache.sequences.get(sequence_reference); - - if (!sequence) { sequence = { direction, images: [], geometry: {type: 'LineString', coordinates: [] }}; } - _vegbilderCache.sequences.set(sequence_reference, sequence); - - sequence.images.push(d); - return { minX: loc[0], minY: loc[1], maxX: loc[0], maxY: loc[1], data: d }; @@ -194,17 +195,69 @@ async function loadTile(cache, layername, tile) { } function orderSequences() { - for (let [, sequence] of _vegbilderCache.sequences) { - const {images, direction, geometry} = sequence; - if (direction) { - images.sort((a, b) => b.metering - a.metering); + const {points} = _vegbilderCache; + if (points.size === 0) return; + + const imageSequences = []; + + const grouped = Array.from(points.values()).reduce((mapping, image) => { + let key = `${image.layername} ${image.road_reference}`; + if (mapping.has(key)) { + mapping.get(key).push(image); } else { - images.sort((a, b) => a.metering - b.metering); + mapping.set(key, [image]); } - geometry.coordinates = images.map(d => d.loc); + return mapping; + }, new Map() + ); + for (const imageGroup of grouped.values()) { + imageGroup.sort((a, b) => { + if (a.captured_at.valueOf() > b.captured_at.valueOf()) { + return 1; + } else if (a.captured_at.valueOf() < b.captured_at.valueOf()) { + return -1; + } else { + const {direction} = a; + if (direction === directionEnum.forward) { + return a.metering - b.metering; + } else { + return b.metering - a.metering; + } + } + }); + let lastImage = imageGroup[0]; + let imageSequence = []; + for (const image of imageGroup) { + if ( + image.direction === lastImage.direction && + image.captured_at.valueOf() - lastImage.captured_at.valueOf() <= 20000 + ) { + imageSequence.push(image); + } else { + imageSequences.push(imageSequence); + imageSequence = [image]; + } + lastImage = image; + } + imageSequences.push(imageSequence); } + + _vegbilderCache.sequences = imageSequences.map(images => { + const seqence = { + images, + key: images[0].key, + geometry : { + type : 'LineString', + coordinates : images.map(image => image.loc) + }}; + for (const image of images) { + _vegbilderCache.image_sequence_map.set(image.key, seqence); + } + return seqence; + }); } + function roadReference(properties) { let { FYLKENUMMER: county_number, @@ -217,27 +270,22 @@ function roadReference(properties) { KRYSSDEL: junction_part, SIDEANLEGGSDEL: services_part, ANKERPUNKT: anker_point, - FELTKODE: lane, AAR: year, - BILDETYPE: image_type } = properties; - if (image_type === undefined) { image_type = 'Planar'; } - - let reference = `${year} ${image_type}`; + let reference; if (year >= 2020) { - reference = `${reference}:${road_class}${road_status}${road_number} S${section}D${subsection}`; + reference = `${road_class}${road_status}${road_number} S${section}D${subsection}`; if (junction_part) { reference = `${reference} M${anker_point} KD${junction_part}`; } else if (services_part) { reference = `${reference} M${anker_point} SD${services_part}`; } } else { - reference = `${reference} ${county_number}${road_class}${road_status}${road_number} HP${parcel}`; + reference = `${county_number}${road_class}${road_status}${road_number} HP${parcel}`; } - reference = `${reference} F${lane}`; return reference; } @@ -277,7 +325,7 @@ export default { reset: async function () { if (_vegbilderCache) { for (let layer of _vegbilderCache.wfslayers.values()) { - for (let tile of layer.values()) {abortRequest(tile);} + for (let tile of layer.values()) { abortRequest(tile); } } } @@ -285,7 +333,8 @@ export default { wfslayers: new Map(), rtree: new RBush(), points: new Map(), - sequences: new Map() + sequences: new Map(), + image_sequence_map: new Map() }; await fetchAvailableLayers(); @@ -299,7 +348,6 @@ export default { sequences: function (projection) { - orderSequences(); const viewport = projection.clipExtent(); const min = [viewport[0][0], viewport[1][1]]; const max = [viewport[1][0], viewport[0][1]]; @@ -307,19 +355,19 @@ export default { let seen = new Set(); let line_strings = []; - for (let d of _vegbilderCache.rtree.search(bbox)) { - const key = d.data.sequence_reference; - if (!seen.has(key)) { - seen.add(key); - let sequence = _vegbilderCache.sequences.get(key); - let geometry = { - type: 'LineString', - coordinates: sequence.geometry.coordinates, - properties: {key} - }; - line_strings.push(geometry); - } - } + for (let {data} of _vegbilderCache.rtree.search(bbox)) { + const sequence = _vegbilderCache.image_sequence_map.get(data.key); + if (!sequence) continue; + const {key, geometry} = sequence; + if (seen.has(key)) continue; + seen.add(key); + let line = { + type: 'LineString', + coordinates: geometry.coordinates, + key + }; + line_strings.push(line); + } return line_strings; }, @@ -328,11 +376,9 @@ export default { return _vegbilderCache.points.get(key); }, - - getSequenceKeyForImage: function (d) { - return d && d.sequence_reference; - }, - + getSequenceForImage: function (image) { + return _vegbilderCache.image_sequence_map.get(image?.key); + }, loadImages: function (projection, photosContext) { const margin = 1; @@ -603,17 +649,17 @@ export default { .classed('currentView', false); } - const hoveredImageKey = hovered && hovered.key; - const hoveredSequenceKey = this.getSequenceKeyForImage(hovered); - const hoveredSequence = hoveredSequenceKey && _vegbilderCache.sequences.get(hoveredSequenceKey); - const hoveredImageKeys = (hoveredSequence && hoveredSequence.images.map(d => d.key)) || []; + const hoveredImageKey = hovered?.key; + const hoveredSequence = this.getSequenceForImage(hovered); + const hoveredSequenceKey = hoveredSequence?.key; + const hoveredImageKeys = (hoveredSequence?.images.map(d => d.key)) || []; const viewer = context.container().select('.photoviewer'); const selected = viewer.empty() ? undefined : viewer.datum(); - const selectedImageKey = selected && selected.key; - const selectedSequenceKey = this.getSequenceKeyForImage(selected); - const selectedSequence = selectedSequenceKey && _vegbilderCache.sequences.get(selectedSequenceKey); - const selectedImageKeys = (selectedSequence && selectedSequence.images.map(d => d.key)) || []; + const selectedImageKey = selected?.key; + const selectedSequence = this.getSequenceForImage(selected); + const selectedSequenceKey = selectedSequence?.key; + const selectedImageKeys = (selectedSequence?.images.map(d => d.key)) || []; // highlight sibling viewfields on either the selected or the hovered sequences const highlightedImageKeys = utilArrayUnion(hoveredImageKeys, selectedImageKeys); @@ -624,8 +670,8 @@ export default { .classed('currentView', d => d.key === selectedImageKey); context.container().selectAll('.layer-vegbilder .sequence') - .classed('highlighted', d => d.properties.key === hoveredSequenceKey) - .classed('currentView', d => d.properties.key === selectedSequenceKey); + .classed('highlighted', d => d.key === hoveredSequenceKey) + .classed('currentView', d => d.key === selectedSequenceKey); // update viewfields if needed context.container().selectAll('.layer-vegbilder .viewfield-group .viewfield') diff --git a/modules/svg/vegbilder.js b/modules/svg/vegbilder.js index dff6152ef..48feea3c0 100644 --- a/modules/svg/vegbilder.js +++ b/modules/svg/vegbilder.js @@ -203,7 +203,7 @@ export function svgVegbilder(projection, context, dispatch) { } let traces = layer.selectAll('.sequences').selectAll('.sequence') - .data(sequences, d => d.properties.key); + .data(sequences, d => d.key); // exit traces.exit() From dbf8b9cec645e8cfcf49aa696dbd3e2467261c3c Mon Sep 17 00:00:00 2001 From: Noenandre <5470915+noenandre@users.noreply.github.com> Date: Wed, 22 Feb 2023 14:33:38 +0100 Subject: [PATCH 06/30] Estimate image direction (azimuth) if missing. --- modules/services/vegbilder.js | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/modules/services/vegbilder.js b/modules/services/vegbilder.js index 4eefff496..bc3267bbb 100644 --- a/modules/services/vegbilder.js +++ b/modules/services/vegbilder.js @@ -2,8 +2,9 @@ import { json as d3_json, xml as d3_xml} from 'd3-fetch'; import { dispatch as d3_dispatch } from 'd3-dispatch'; import { select as d3_select } from 'd3-selection'; import { zoom as d3_zoom, zoomIdentity as d3_zoomIdentity } from 'd3-zoom'; +import { pairs as d3_pairs } from 'd3-array'; import { utilQsString, utilTiler, utilRebind, utilArrayUnion, utilStringQs, utilSetTransform} from '../util'; -import { geoExtent, geoScaleToZoom } from '../geo'; +import {geoExtent, geoScaleToZoom, geoVecAngle} from '../geo'; import RBush from 'rbush'; const owsEndpoint = 'https://www.vegvesen.no/kart/ogc/vegbilder_1_0/ows?'; @@ -88,7 +89,7 @@ function loadWFSLayers(projection, margin, layers) { Promise.all(layers.map( ({name}) => loadWFSLayer(name, projection, margin) )) - .then(orderSequences); + .then(() => orderSequences(projection)); } async function loadWFSLayer(layername, projection, margin) { @@ -194,7 +195,7 @@ async function loadTile(cache, layername, tile) { dispatch.call('loadedImages'); } -function orderSequences() { +function orderSequences(projection) { const {points} = _vegbilderCache; if (points.size === 0) return; @@ -225,9 +226,17 @@ function orderSequences() { } } }); - let lastImage = imageGroup[0]; - let imageSequence = []; - for (const image of imageGroup) { + let imageSequence = [imageGroup[0]]; + for (const [lastImage, image] of d3_pairs(imageGroup)) { + if (lastImage.ca === null) { + let b = projection(lastImage.loc); + let a = projection(image.loc); + let angle = geoVecAngle(a, b); + angle *= (180 / Math.PI); + angle -= 90; + angle = angle >= 0 ? angle : angle + 360; + lastImage.ca = angle; + } if ( image.direction === lastImage.direction && image.captured_at.valueOf() - lastImage.captured_at.valueOf() <= 20000 @@ -237,7 +246,6 @@ function orderSequences() { imageSequences.push(imageSequence); imageSequence = [image]; } - lastImage = image; } imageSequences.push(imageSequence); } @@ -249,7 +257,7 @@ function orderSequences() { geometry : { type : 'LineString', coordinates : images.map(image => image.loc) - }}; + }}; for (const image of images) { _vegbilderCache.image_sequence_map.set(image.key, seqence); } @@ -513,7 +521,7 @@ export default { const selected = viewer.empty() ? undefined : viewer.datum(); if (!selected) return; - const sequence = _vegbilderCache.sequences.get(that.getSequenceKeyForImage(selected)); + const sequence = that.getSequenceForImage(selected); const nextIndex = sequence.images.indexOf(selected) + stepBy; const nextImage = sequence.images[nextIndex]; From 75dba4b6c7392ad3bf8f438e01c8f7bb0ab65a69 Mon Sep 17 00:00:00 2001 From: Noenandre <5470915+noenandre@users.noreply.github.com> Date: Wed, 22 Feb 2023 21:30:55 +0100 Subject: [PATCH 07/30] Filter sequences too. --- modules/services/vegbilder.js | 9 ++++---- modules/svg/vegbilder.js | 40 +++++++++++++++++++++++++++++++---- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/modules/services/vegbilder.js b/modules/services/vegbilder.js index bc3267bbb..1de0fbccf 100644 --- a/modules/services/vegbilder.js +++ b/modules/services/vegbilder.js @@ -3,7 +3,7 @@ import { dispatch as d3_dispatch } from 'd3-dispatch'; import { select as d3_select } from 'd3-selection'; import { zoom as d3_zoom, zoomIdentity as d3_zoomIdentity } from 'd3-zoom'; import { pairs as d3_pairs } from 'd3-array'; -import { utilQsString, utilTiler, utilRebind, utilArrayUnion, utilStringQs, utilSetTransform} from '../util'; +import { utilQsString, utilTiler, utilRebind, utilArrayUnion, utilStringQs} from '../util'; import {geoExtent, geoScaleToZoom, geoVecAngle} from '../geo'; import RBush from 'rbush'; @@ -315,7 +315,7 @@ function searchLimited(limit, projection, rtree) { .slice(0, limit) .map(d => d.data); - return (found.length ? result.concat(found) : result); + return result.concat(found); }, []); } @@ -366,13 +366,14 @@ export default { for (let {data} of _vegbilderCache.rtree.search(bbox)) { const sequence = _vegbilderCache.image_sequence_map.get(data.key); if (!sequence) continue; - const {key, geometry} = sequence; + const {key, geometry, images} = sequence; if (seen.has(key)) continue; seen.add(key); let line = { type: 'LineString', coordinates: geometry.coordinates, - key + key, + images }; line_strings.push(line); } diff --git a/modules/svg/vegbilder.js b/modules/svg/vegbilder.js index 48feea3c0..9794cb6ad 100644 --- a/modules/svg/vegbilder.js +++ b/modules/svg/vegbilder.js @@ -124,9 +124,12 @@ export function svgVegbilder(projection, context, dispatch) { /** * transform(). */ - function transform(d) { + function transform(d, selected) { let t = svgPointTransform(projection)(d); - const rot = d.ca + _viewerYaw; + let rot = d.ca; + if (d === selected) { + rot += _viewerYaw; + } if (rot) { t += ' rotate(' + Math.floor(rot) + ',0,0)'; } @@ -149,7 +152,7 @@ export function svgVegbilder(projection, context, dispatch) { if (context.map().isTransformed()) return; layer.selectAll('.viewfield-group.currentView') - .attr('transform', transform); + .attr('transform', (d) => transform(d, d)); } function filterImages(images) { @@ -180,6 +183,34 @@ export function svgVegbilder(projection, context, dispatch) { return images; } + function filterSequences(sequences) { + const photoContext = context.photos(); + const fromDateString = photoContext.fromDate(); + const toDateString = photoContext.toDate(); + const showsFlat = photoContext.showsFlat(); + const showsPano = photoContext.showsPanoramic(); + + if (fromDateString) { + const fromDate = new Date(fromDateString); + sequences = sequences.filter(({images}) => images[0].captured_at.getTime() >= fromDate.getTime()); + } + + if (toDateString) { + const toDate = new Date(toDateString); + sequences = sequences.filter(({images}) => images[-1].captured_at.getTime() <= toDate.getTime()); + } + + if (!showsPano) { + sequences = sequences.filter(({images}) => !images[0].is_sphere); + } + + if (!showsFlat) { + sequences = sequences.filter(({images}) => images[0].is_sphere); + } + + return sequences; + } + /** * update(). */ @@ -200,6 +231,7 @@ export function svgVegbilder(projection, context, dispatch) { sequences = service.sequences(projection); images = showMarkers ? service.images(projection) : []; images = filterImages(images); + sequences = filterSequences(sequences); } let traces = layer.selectAll('.sequences').selectAll('.sequence') @@ -244,7 +276,7 @@ export function svgVegbilder(projection, context, dispatch) { : (b === selected) ? -1 : b.loc[1] - a.loc[1]; }) - .attr('transform', transform) + .attr('transform', (d) => transform(d, selected)) .select('.viewfield-scale'); From 58cceb03c2e6e15c7168a8cc9e4c51da623e8a40 Mon Sep 17 00:00:00 2001 From: Noenandre <5470915+noenandre@users.noreply.github.com> Date: Fri, 3 Mar 2023 01:23:31 +0100 Subject: [PATCH 08/30] Split concern for panoramic and flat photos to own module. --- modules/services/pannellum_photo.js | 162 ++++++++++++++++++++ modules/services/plane_photo.js | 82 ++++++++++ modules/services/vegbilder.js | 222 +++++++--------------------- modules/svg/vegbilder.js | 12 +- 4 files changed, 296 insertions(+), 182 deletions(-) create mode 100644 modules/services/pannellum_photo.js create mode 100644 modules/services/plane_photo.js diff --git a/modules/services/pannellum_photo.js b/modules/services/pannellum_photo.js new file mode 100644 index 000000000..f28b34753 --- /dev/null +++ b/modules/services/pannellum_photo.js @@ -0,0 +1,162 @@ +import { select as d3_select } from 'd3-selection'; +import { dispatch as d3_dispatch } from 'd3-dispatch'; +import { utilRebind } from '../util'; + + +const pannellumViewerCSS = 'pannellum/pannellum.css'; +const pannellumViewerJS = 'pannellum/pannellum.js'; +const dispatch = d3_dispatch('viewerChanged'); + +let _currScenes = []; +let _pannellumViewer; + +export default { + + init: async function(context, selection) { + + selection + .append('div') + .attr('class', 'photo-frame pannellum-frame') + .attr('id', 'ideditor-pannellum-viewer') + .classed('hide', true); + + if (!window.pannellum) { + await this.loadPannellum(context); + } + + const options = { + 'default': { firstScene: '' }, + scenes: {} + }; + + _pannellumViewer = window.pannellum.viewer('ideditor-pannellum-viewer', options); + + _pannellumViewer + .on('mousedown', () => { + d3_select(window) + .on('pointermove.pannellum mousemove.pannellum', () => { + dispatch.call('viewerChanged'); + }); + }) + .on('mouseup', () => { + d3_select(window) + .on('pointermove.pannellum mousemove.pannellum', null); + }) + .on('animatefinished', () => { + dispatch.call('viewerChanged'); + }); + + context.ui().photoviewer.on('resize.pannellum', () => { + _pannellumViewer.resize(); + }); + + this.event = utilRebind(this, dispatch, 'on'); + + return this; + }, + + loadPannellum: function(context) { + return new Promise((resolve, reject) => { + + let loadedCount = 0; + function loaded() { + loadedCount += 1; + // wait until both files are loaded + if (loadedCount === 2) resolve(); + } + + const head = d3_select('head'); + + // load pannellum viewer css + head.selectAll('#ideditor-pannellum-viewercss') + .data([0]) + .enter() + .append('link') + .attr('id', 'ideditor-pannellum-viewercss') + .attr('rel', 'stylesheet') + .attr('crossorigin', 'anonymous') + .attr('href', context.asset(pannellumViewerCSS)) + .on('load.pannellum', loaded) + .on('error.pannellum', () => { + reject(); + }); + + // load streetside pannellum viewer js + head.selectAll('#ideditor-pannellum-viewerjs') + .data([0]) + .enter() + .append('script') + .attr('id', 'ideditor-pannellum-viewerjs') + .attr('crossorigin', 'anonymous') + .attr('src', context.asset(pannellumViewerJS)) + .on('load.pannellum', loaded) + .on('error.pannellum', () => { + reject(); + }); + }); + }, + + showPhotoFrame: function (context) { + const isHidden = context.selectAll('.photo-frame.pannellum-frame.hide').size(); + + if (isHidden) { + context + .selectAll('.photo-frame:not(.pannellum-frame)') + .classed('hide', true); + + context + .selectAll('.photo-frame.pannellum-frame') + .classed('hide', false); + } + + return this; + }, + + hidePhotoFrame: function (viewerContext) { + viewerContext + .select('photo-frame.pannellum-frame') + .classed('hide', false); + + return this; + }, + + selectPhoto: function (data, keepOrientation) { + const {key} = data; + if ( !(key in _currScenes) ) { + let newSceneOptions = { + showFullscreenCtrl: false, + autoLoad: true, + compass: true, + yaw: 0, + type: 'equirectangular', + preview: data.preview_path, + panorama: data.image_path, + northOffset: data.ca + }; + + _currScenes.push(key); + _pannellumViewer.addScene(key, newSceneOptions); + } + + let yaw = 0; + let pitch = 0; + + if (keepOrientation) { + yaw = this.getYaw(); + pitch = _pannellumViewer.getPitch(); + } + _pannellumViewer.loadScene(key, pitch, yaw); + + if (_currScenes.length > 3) { + const old_key = _currScenes.shift(); + _pannellumViewer.removeScene(old_key); + } + + return this; + }, + + getYaw: function() { + return _pannellumViewer.getYaw(); + } + +}; diff --git a/modules/services/plane_photo.js b/modules/services/plane_photo.js new file mode 100644 index 000000000..275a011d9 --- /dev/null +++ b/modules/services/plane_photo.js @@ -0,0 +1,82 @@ +import { dispatch as d3_dispatch } from 'd3-dispatch'; +import { zoom as d3_zoom } from 'd3-zoom'; +import { utilSetTransform, utilRebind } from '../util'; + +const dispatch = d3_dispatch('viewerChanged'); + +let _photo; +let imgZoom; + +export default { + + init: async function(context, selection) { + imgZoom = d3_zoom() + .extent([[0, 0], [320, 240]]) + //.translateExtent(?) + .scaleExtent([1, 15]) + .on('zoom', this.zoomPan); + + const wrapper = selection + .append('div') + .attr('class', 'photo-frame plane-frame') + .call(imgZoom) + .classed('hide', true); + + _photo = wrapper + .append('img') + .attr('class', 'plane-photo'); + + this.event = utilRebind(this, dispatch, 'on'); + + context.ui().photoviewer.on('resize.plane', (dimensions) => { + imgZoom = d3_zoom() + .extent([[0, 0], dimensions]) + //.translateExtent(?) + .scaleExtent([1, 15]) + .on('zoom', this.zoomPan); + }); + + await Promise.resolve(); + + return this; + }, + + showPhotoFrame: function (context) { + const isHidden = context.selectAll('.photo-frame.plane-frame.hide').size(); + + if (isHidden) { + context + .selectAll('.photo-frame:not(.plane-frame)') + .classed('hide', true); + + context + .selectAll('.photo-frame.plane-frame') + .classed('hide', false); + } + + return this; + }, + + hidePhotoFrame: function (context) { + context + .select('photo-frame.plane-frame') + .classed('hide', false); + + return this; + }, + + selectPhoto: function (data) { + _photo.attr('src', data.image_path); + return this; + }, + + zoomPan: function (d3_event) { + let t = d3_event.transform; + _photo.call(utilSetTransform, t.x, t.y, t.k); + }, + + getYaw: function() { + return 0; + } + +}; diff --git a/modules/services/vegbilder.js b/modules/services/vegbilder.js index 1de0fbccf..cf5581357 100644 --- a/modules/services/vegbilder.js +++ b/modules/services/vegbilder.js @@ -1,15 +1,13 @@ import { json as d3_json, xml as d3_xml} from 'd3-fetch'; import { dispatch as d3_dispatch } from 'd3-dispatch'; -import { select as d3_select } from 'd3-selection'; -import { zoom as d3_zoom, zoomIdentity as d3_zoomIdentity } from 'd3-zoom'; import { pairs as d3_pairs } from 'd3-array'; import { utilQsString, utilTiler, utilRebind, utilArrayUnion, utilStringQs} from '../util'; import {geoExtent, geoScaleToZoom, geoVecAngle} from '../geo'; +import pannellumPhotoFrame from './pannellum_photo'; +import planePhotoFrame from './plane_photo'; import RBush from 'rbush'; const owsEndpoint = 'https://www.vegvesen.no/kart/ogc/vegbilder_1_0/ows?'; -const pannellumViewerCSS = 'pannellum/pannellum.css'; -const pannellumViewerJS = 'pannellum/pannellum.js'; const tileZoom = 14; const tiler = utilTiler().zoomExtent([tileZoom, tileZoom]).skipNullIsland(true); const dispatch = d3_dispatch('loadedImages', 'viewerChanged'); @@ -18,23 +16,13 @@ const directionEnum = Object.freeze({ backward: Symbol(1) }); -let imgZoom = d3_zoom() - .extent([[0, 0], [320, 240]]) - .translateExtent([[0, 0], [320, 240]]) - .scaleExtent([1, 15]); -let _sceneOptions = { - showFullscreenCtrl: false, - autoLoad: true, - compass: true, - yaw: 0, - type: 'equirectangular', -}; -let _vegbilderCache; +let _planeFrame; +let _pannellumFrame; +let _currentFrame; let _loadViewerPromise; -let _pannellumViewer; +let _vegbilderCache; let _availableLayers; - function abortRequest(controller) { controller.abort(); } @@ -59,13 +47,13 @@ async function fetchAvailableLayers() { ); let node; _availableLayers = []; - while (node = l.iterateNext()) { + while ( (node = l.iterateNext()) !== null ) { let match = node.textContent?.match(regexMatcher); if (match) { _availableLayers.push({ name: match[0], is_sphere: !!match.groups?.image_type, - year: parseInt(match.groups?.year) + year: parseInt(match.groups?.year, 10) }); } } @@ -86,14 +74,14 @@ function filterAvailableLayers(photoContex) { } function loadWFSLayers(projection, margin, layers) { + const tiles = tiler.margin(margin).getTiles(projection); Promise.all(layers.map( - ({name}) => loadWFSLayer(name, projection, margin) + ({name}) => loadWFSLayer(name, tiles) )) .then(() => orderSequences(projection)); } -async function loadWFSLayer(layername, projection, margin) { - const tiles = tiler.margin(margin).getTiles(projection); +async function loadWFSLayer(layername, tiles) { let cache = _vegbilderCache.wfslayers.get(layername); if (!cache) { @@ -164,17 +152,19 @@ async function loadTile(cache, layername, tile) { RETNING: ca, TIDSPUNKT: captured_at, URL: image_path, + URLPREVIEW : preview_path, BILDETYPE: image_type, METER: metering, FELTKODE: lane_code } = properties; - const lane_number = parseInt(lane_code.match(/^[0-9]+/)[0]); + const lane_number = parseInt(lane_code.match(/^[0-9]+/)[0], 10); const direction = lane_number % 2 === 0 ? directionEnum.backward : directionEnum.forward; - const d = { + const data = { loc, key, ca, image_path, + preview_path, layername, road_reference: roadReference(properties), metering, @@ -184,10 +174,10 @@ async function loadTile(cache, layername, tile) { is_sphere: image_type === '360' }; - _vegbilderCache.points.set(key, d); + _vegbilderCache.points.set(key, data); return { - minX: loc[0], minY: loc[1], maxX: loc[0], maxY: loc[1], data: d + minX: loc[0], minY: loc[1], maxX: loc[0], maxY: loc[1], data }; }); @@ -395,29 +385,30 @@ export default { loadWFSLayers(projection, margin, layers); }, - viewer: function() { - return _pannellumViewer; - }, - - initPannellumViewer: function () { - if (!window.pannellum) return; - if (_pannellumViewer) return; - - _currScene += 1; - const sceneID = _currScene.toString(); - const options = { - 'default': { firstScene: sceneID }, - scenes: {} - }; - options.scenes[sceneID] = _sceneOptions; - - _pannellumViewer = window.pannellum.viewer('ideditor-viewer-vegbilder', options); + photoFrame: function() { + return _currentFrame; }, ensureViewerLoaded: function(context) { if (_loadViewerPromise) return _loadViewerPromise; + const step = (stepBy) => () => { + const viewer = context.container().select('.photoviewer'); + const selected = viewer.empty() ? undefined : viewer.datum(); + if (!selected) return; + + const sequence = this.getSequenceForImage(selected); + const nextIndex = sequence.images.indexOf(selected) + stepBy; + const nextImage = sequence.images[nextIndex]; + const nextKey = nextImage.key; + + if (!nextKey) return; + + context.map().centerEase(nextImage.loc); + this.selectImage(context, nextKey, true); + }; + const wrap = context.container().select('.photoviewer') .selectAll('.vegbilder-wrapper') .data([0]); @@ -447,148 +438,32 @@ export default { .on('click.forward', step(1)) .text('►'); - wrapEnter - .append('div') - .attr('class', 'vegbilder-image-wrap'); - - - context.ui().photoviewer.on('resize.vegbilder', dimensions => { - if (_pannellumViewer) { - _pannellumViewer.resize(); - } else { - imgZoom = d3_zoom() - .extent([[0, 0], dimensions]) - .translateExtent([[0, 0], dimensions]) - .scaleExtent([1, 15]) - .on('zoom', zoomPan); - } + _loadViewerPromise = Promise.all([ + pannellumPhotoFrame.init(context, wrapEnter), + planePhotoFrame.init(context, wrapEnter) + ]).then(([pannellumPhotoFrame, planePhotoFrame]) => { + _pannellumFrame = pannellumPhotoFrame; + _planeFrame = planePhotoFrame; + _pannellumFrame.event.on('viewerChanged', () => dispatch.call('viewerChanged')); }); - _loadViewerPromise = new Promise((resolve, reject) => { - - let loadedCount = 0; - function loaded() { - loadedCount += 1; - // wait until both files are loaded - if (loadedCount === 2) resolve(); - } - - const head = d3_select('head'); - - // load streetside pannellum viewer css - head.selectAll('#ideditor-vegbilder-viewercss') - .data([0]) - .enter() - .append('link') - .attr('id', 'ideditor-vegbilder-viewercss') - .attr('rel', 'stylesheet') - .attr('crossorigin', 'anonymous') - .attr('href', context.asset(pannellumViewerCSS)) - .on('load.serviceVegbilder', loaded) - .on('error.serviceVegbilder', function() { - reject(); - }); - - // load streetside pannellum viewer js - head.selectAll('#ideditor-vegbilder-viewerjs') - .data([0]) - .enter() - .append('script') - .attr('id', 'ideditor-vegbilder-viewerjs') - .attr('crossorigin', 'anonymous') - .attr('src', context.asset(pannellumViewerJS)) - .on('load.serviceVegbilder', loaded) - .on('error.serviceVegbilder', function() { - reject(); - }); - }) - .catch(() => { - _loadViewerPromise = null; - }); - - const that = this; - return _loadViewerPromise; - - function zoomPan(d3_event) { - const t = d3_event.transform; - context.container().select('.photoviewer .vegbilder-image-wrap') - .call(utilSetTransform, t.x, t.y, t.k); - } - - function step(stepBy) { - return () => { - const viewer = context.container().select('.photoviewer'); - const selected = viewer.empty() ? undefined : viewer.datum(); - if (!selected) return; - - const sequence = that.getSequenceForImage(selected); - const nextIndex = sequence.images.indexOf(selected) + stepBy; - const nextImage = sequence.images[nextIndex]; - - if (!nextImage) return; - // TODO jump to a spatial and temporal close sequence when reaching the start or end. - that.selectImage(context, nextImage.key); - }; - } }, - selectImage: function(context, key) { + selectImage: function(context, key, keepOrientation) { const d = this.cachedImage(key); this.updateUrlImage(key); const viewer = context.container().select('.photoviewer'); - if (!viewer.empty()) viewer.datum(d); + if (!viewer.empty()) { viewer.datum(d); } this.setStyles(context, null, true); - context.container().selectAll('.icon-sign') - .classed('currentView', false); - if (!d) return this; const wrap = context.container().select('.photoviewer .vegbilder-wrapper'); - const imageWrap = wrap.selectAll('.vegbilder-image-wrap'); const attribution = wrap.selectAll('.photo-attribution').text(''); - wrap - .transition() - .duration(100) - .call(imgZoom.transform, d3_zoomIdentity); - - imageWrap - .selectAll('.vegbilder-image') - .remove(); - - if (!d.is_sphere) { - imageWrap - .append('img') - .attr('class', 'vegbilder-image') - .attr('src', d.image_path); - } else { - imageWrap - .append('div') - .attr('class', 'vegbilder-panorama') - .attr('id', 'vegbilder-panorama') - .on(); - - _sceneOptions.panorama = d.image_path; - _sceneOptions.northOffset = d.ca; - _pannellumViewer = window.pannellum.viewer('vegbilder-imagesphere', _sceneOptions); - _pannellumViewer - .on('mousedown', () => { - d3_select(window) - .on('mousemove', () => { - dispatch.call('viewerChanged'); - }); - }) - .on('animatefinished', () => { - d3_select(window) - .on('mousemove', null); - dispatch.call('viewerChanged'); - }); - } - if (d.captured_at) { attribution .append('span') @@ -602,7 +477,11 @@ export default { .attr('href', 'https://vegvesen.no') .text('Norwegian Public Roads Administration'); - this.showViewer(context); + _currentFrame = d.is_sphere? _pannellumFrame : _planeFrame; + + _currentFrame + .selectPhoto(d, keepOrientation) + .showPhotoFrame(wrap); return this; }, @@ -636,7 +515,7 @@ export default { .selectAll('.photo-wrapper') .classed('hide', true); - context.container().selectAll('.viewfield-group, .sequence, .icon-sign') + context.container().selectAll('.viewfield-group, .sequence') .classed('currentView', false); return this.setStyles(context, null, true); @@ -698,7 +577,6 @@ export default { return this; }, - updateUrlImage: function (key) { if (!window.mocha) { const hash = utilStringQs(window.location.hash); diff --git a/modules/svg/vegbilder.js b/modules/svg/vegbilder.js index 9794cb6ad..8b1cc9a2e 100644 --- a/modules/svg/vegbilder.js +++ b/modules/svg/vegbilder.js @@ -11,7 +11,6 @@ export function svgVegbilder(projection, context, dispatch) { const minViewfieldZoom = 18; let layer = d3_select(null); let _viewerYaw = 0; - let _selectedSequence = null; let _vegbilder; /** @@ -84,16 +83,10 @@ export function svgVegbilder(projection, context, dispatch) { layer.style('display', 'none'); } - /** - * click() Handles 'bubble' point click event. - */ function click(d3_event, d) { const service = getService(); if (!service) return; - // try to preserve the viewer rotation when staying on the same sequence - _selectedSequence = d.sequence_reference; - service .ensureViewerLoaded(context) .then(() => { @@ -141,11 +134,10 @@ export function svgVegbilder(projection, context, dispatch) { const service = getService(); if (!service) return; - const viewer = service.viewer(); - if (!viewer) return; + const frame = service.photoFrame(); // update viewfield rotation - _viewerYaw = viewer.getYaw(); + _viewerYaw = frame.getYaw(); // avoid updating if the map is currently transformed // e.g. during drags or easing. From ab2985d98544375decf8e6a1fd993b9640db1db7 Mon Sep 17 00:00:00 2001 From: Noenandre <5470915+noenandre@users.noreply.github.com> Date: Fri, 3 Mar 2023 12:36:29 +0100 Subject: [PATCH 09/30] Fix zoom for plane photos. TODO: set translateExtent correctly. --- css/60_photos.css | 2 +- modules/services/plane_photo.js | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/css/60_photos.css b/css/60_photos.css index 96ce413a2..b359bc30b 100644 --- a/css/60_photos.css +++ b/css/60_photos.css @@ -355,7 +355,7 @@ label.streetside-hires { } .kartaview-image-wrap, -.vegbilder-image-wrap { +.plane-photo { width: 100%; height: 100%; transform-origin:0 0; diff --git a/modules/services/plane_photo.js b/modules/services/plane_photo.js index 275a011d9..9449926ec 100644 --- a/modules/services/plane_photo.js +++ b/modules/services/plane_photo.js @@ -1,5 +1,5 @@ import { dispatch as d3_dispatch } from 'd3-dispatch'; -import { zoom as d3_zoom } from 'd3-zoom'; +import { zoom as d3_zoom, zoomIdentity as d3_zoomIdentity } from 'd3-zoom'; import { utilSetTransform, utilRebind } from '../util'; const dispatch = d3_dispatch('viewerChanged'); @@ -57,6 +57,7 @@ export default { return this; }, + hidePhotoFrame: function (context) { context .select('photo-frame.plane-frame') @@ -65,8 +66,11 @@ export default { return this; }, - selectPhoto: function (data) { + selectPhoto: function (data, keepOrientation) { _photo.attr('src', data.image_path); + if (!keepOrientation) { + _photo.call(imgZoom.transform, d3_zoomIdentity); + } return this; }, From 9a7f473f20fbd1e41096a519708a6f59e4af9299 Mon Sep 17 00:00:00 2001 From: Noenandre <5470915+noenandre@users.noreply.github.com> Date: Fri, 3 Mar 2023 12:39:34 +0100 Subject: [PATCH 10/30] Removed a variable, renamed a property. --- modules/services/vegbilder.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/modules/services/vegbilder.js b/modules/services/vegbilder.js index cf5581357..350dc384e 100644 --- a/modules/services/vegbilder.js +++ b/modules/services/vegbilder.js @@ -249,7 +249,7 @@ function orderSequences(projection) { coordinates : images.map(image => image.loc) }}; for (const image of images) { - _vegbilderCache.image_sequence_map.set(image.key, seqence); + _vegbilderCache.image2sequence_map.set(image.key, seqence); } return seqence; }); @@ -332,7 +332,7 @@ export default { rtree: new RBush(), points: new Map(), sequences: new Map(), - image_sequence_map: new Map() + image2sequence_map: new Map() }; await fetchAvailableLayers(); @@ -354,7 +354,7 @@ export default { let line_strings = []; for (let {data} of _vegbilderCache.rtree.search(bbox)) { - const sequence = _vegbilderCache.image_sequence_map.get(data.key); + const sequence = _vegbilderCache.image2sequence_map.get(data.key); if (!sequence) continue; const {key, geometry, images} = sequence; if (seen.has(key)) continue; @@ -376,7 +376,7 @@ export default { }, getSequenceForImage: function (image) { - return _vegbilderCache.image_sequence_map.get(image?.key); + return _vegbilderCache.image2sequence_map.get(image?.key); }, loadImages: function (projection, photosContext) { @@ -401,12 +401,11 @@ export default { const sequence = this.getSequenceForImage(selected); const nextIndex = sequence.images.indexOf(selected) + stepBy; const nextImage = sequence.images[nextIndex]; - const nextKey = nextImage.key; - if (!nextKey) return; + if (!nextImage) return; context.map().centerEase(nextImage.loc); - this.selectImage(context, nextKey, true); + this.selectImage(context, nextImage.key, true); }; const wrap = context.container().select('.photoviewer') From 255c80d34113290e517bc6563d1827de76cdf008 Mon Sep 17 00:00:00 2001 From: Noenandre <5470915+noenandre@users.noreply.github.com> Date: Fri, 3 Mar 2023 16:43:54 +0100 Subject: [PATCH 11/30] Test for init and reset methods on service. Bugfix for reset. Adjusted expectation of number of svg layers. --- modules/services/vegbilder.js | 2 +- test/spec/services/vegbilder.js | 48 +++++++++++++++++++++++++++++++++ test/spec/spec_helpers.js | 43 +++++++++++++++++++++++++++++ test/spec/svg/layers.js | 9 ++++--- 4 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 test/spec/services/vegbilder.js diff --git a/modules/services/vegbilder.js b/modules/services/vegbilder.js index 350dc384e..36d0b52fb 100644 --- a/modules/services/vegbilder.js +++ b/modules/services/vegbilder.js @@ -323,7 +323,7 @@ export default { reset: async function () { if (_vegbilderCache) { for (let layer of _vegbilderCache.wfslayers.values()) { - for (let tile of layer.values()) { abortRequest(tile); } + for (let tile of layer.inflight.values()) { abortRequest(tile); } } } diff --git a/test/spec/services/vegbilder.js b/test/spec/services/vegbilder.js new file mode 100644 index 000000000..dd4f98f32 --- /dev/null +++ b/test/spec/services/vegbilder.js @@ -0,0 +1,48 @@ +describe('iD.serviceVegbilder', function() { + const dimensions = [64, 64]; + let context, vegbilder; + + before(function() { + iD.services.vegbilder = iD.serviceVegbilder; + }); + + after(function() { + delete iD.services.vegbilder; + }); + + beforeEach(async function() { + context = iD.coreContext().assetPath('../dist/').init(); + context.projection + .scale(iD.geoZoomToScale(14)) + .translate([-116508, 0]) // 10,0 + .clipExtent([[0,0], dimensions]); + + vegbilder = iD.services.vegbilder; + await vegbilder.reset(); + }); + + afterEach(function() { + fetchMock.reset(); + }); + + describe('#init', function() { + it('Initializes cache one time', function() { + const cache = vegbilder.cache(); + expect(cache).to.have.property('wfslayers'); + expect(cache).to.have.property('rtree'); + expect(cache).to.have.property('sequences'); + + vegbilder.init(); + const cache2 = vegbilder.cache(); + expect(cache).to.equal(cache2); + }); + }); + + describe('#reset', function() { + it('resets cache', function() { + vegbilder.cache().foo = 'bar'; + vegbilder.reset(); + expect(vegbilder.cache()).to.not.have.property('foo'); + }); + }); +}); diff --git a/test/spec/spec_helpers.js b/test/spec/spec_helpers.js index bb5626822..56929a0a7 100644 --- a/test/spec/spec_helpers.js +++ b/test/spec/spec_helpers.js @@ -137,5 +137,48 @@ const capabilities = ` fetchMock.sticky('https://www.openstreetmap.org/api/capabilities', capabilities, {sticky: true}); fetchMock.sticky('http://www.openstreetmap.org/api/capabilities', capabilities, {sticky: true}); +const vegbilderOwsCapabilities = ` + + + Mock OGC + WFS + 2.0.0 + + + + vegbilder_1_0:Vegbilder_2020 + Vegbilder_2020 + Testlayer + urn:ogc:def:crs:EPSG::4326 + urn:ogc:def:crs:EPSG::3857 + + + not_matched_layer:Vegbilder_2020 + Vegbilder_2020_4 + Not matched layer + urn:ogc:def:crs:EPSG::4326 + urn:ogc:def:crs:EPSG::3857 + + + +`; + +fetchMock.sticky({ + url: 'https://www.vegvesen.no/kart/ogc/vegbilder_1_0/ows', + query: { + service: 'WFS', + request: 'GetCapabilities' + } + }, vegbilderOwsCapabilities, {sticky: true}); fetchMock.config.fallbackToNetwork = true; fetchMock.config.overwriteRoutes = false; diff --git a/test/spec/svg/layers.js b/test/spec/svg/layers.js index 2c54936d7..d92f96ffa 100644 --- a/test/spec/svg/layers.js +++ b/test/spec/svg/layers.js @@ -26,7 +26,7 @@ describe('iD.svgLayers', function () { it('creates default data layers', function () { container.call(iD.svgLayers(projection, context)); var nodes = container.selectAll('svg .data-layer').nodes(); - expect(nodes.length).to.eql(15); + expect(nodes.length).to.eql(16); expect(d3.select(nodes[0]).classed('osm')).to.be.true; expect(d3.select(nodes[1]).classed('notes')).to.be.true; expect(d3.select(nodes[2]).classed('data')).to.be.true; @@ -39,9 +39,10 @@ describe('iD.svgLayers', function () { expect(d3.select(nodes[9]).classed('mapillary-map-features')).to.be.true; expect(d3.select(nodes[10]).classed('mapillary-signs')).to.be.true; expect(d3.select(nodes[11]).classed('kartaview')).to.be.true; - expect(d3.select(nodes[12]).classed('debug')).to.be.true; - expect(d3.select(nodes[13]).classed('geolocate')).to.be.true; - expect(d3.select(nodes[14]).classed('touch')).to.be.true; + expect(d3.select(nodes[12]).classed('vegbilder')).to.be.true; + expect(d3.select(nodes[13]).classed('debug')).to.be.true; + expect(d3.select(nodes[14]).classed('geolocate')).to.be.true; + expect(d3.select(nodes[15]).classed('touch')).to.be.true; }); }); From 15f4438fad92b3222a05ebca4695535911674c87 Mon Sep 17 00:00:00 2001 From: Noenandre <5470915+noenandre@users.noreply.github.com> Date: Fri, 3 Mar 2023 19:24:33 +0100 Subject: [PATCH 12/30] More info and localization in attribution bar. --- css/60_photos.css | 12 ++++++++++-- data/core.yaml | 2 ++ modules/services/vegbilder.js | 19 ++++++++++++++++--- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/css/60_photos.css b/css/60_photos.css index b359bc30b..d508e6767 100644 --- a/css/60_photos.css +++ b/css/60_photos.css @@ -354,13 +354,21 @@ label.streetside-hires { } } -.kartaview-image-wrap, -.plane-photo { +.kartaview-image-wrap { width: 100%; height: 100%; transform-origin:0 0; } +.plane-frame { + width: 100%; + height: 100%; +} + +.plane-frame > .plane-photo{ + transform-origin:0 0; +} + .vegbilder-wrapper { position: relative; background-color: #000; diff --git a/data/core.yaml b/data/core.yaml index 3fe99c40a..334a8b244 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -1361,6 +1361,8 @@ en: vegbilder: title: "Vegbilder" tooltip: "Street-level photos from the Norwegian Public Roads Administration" + publisher: "Norwegian Public Roads Administration" + view_on: "View it on Vegbilder" mapillary_images: tooltip: "Street-level photos from Mapillary" mapillary_map_features: diff --git a/modules/services/vegbilder.js b/modules/services/vegbilder.js index 36d0b52fb..2d04bdaf4 100644 --- a/modules/services/vegbilder.js +++ b/modules/services/vegbilder.js @@ -1,6 +1,7 @@ import { json as d3_json, xml as d3_xml} from 'd3-fetch'; import { dispatch as d3_dispatch } from 'd3-dispatch'; import { pairs as d3_pairs } from 'd3-array'; +import { t, localizer } from '../core/localizer'; import { utilQsString, utilTiler, utilRebind, utilArrayUnion, utilStringQs} from '../util'; import {geoExtent, geoScaleToZoom, geoVecAngle} from '../geo'; import pannellumPhotoFrame from './pannellum_photo'; @@ -287,6 +288,12 @@ function roadReference(properties) { return reference; } +function localeTimestamp(date) { + const options = { day: '2-digit', month: '2-digit', year: 'numeric', + hour: 'numeric', minute: 'numeric', second: 'numeric' }; + return date.toLocaleString(localizer.localeCode(), options); +} + function partitionViewport(projection) { let z = geoScaleToZoom(projection.scale()); let z2 = (Math.ceil(z * 2) / 2) + 2.5; // round to next 0.5 and add 2.5 @@ -466,15 +473,21 @@ export default { if (d.captured_at) { attribution .append('span') - .attr('class', 'year') - .text(d.captured_at.getFullYear()); + .attr('class', 'captured_at') + .text(localeTimestamp(d.captured_at)); } attribution .append('a') .attr('target', '_blank') .attr('href', 'https://vegvesen.no') - .text('Norwegian Public Roads Administration'); + .call(t.append('vegbilder.publisher')); + + attribution + .append('a') + .attr('target', '_blank') + .attr('href', `https://vegbilder.atlas.vegvesen.no/?year=${d.captured_at.getFullYear()}&lat=${d.loc[1]}&lng=${d.loc[0]}&view=image&imageId=${d.key}`) + .call(t.append('vegbilder.view_on')); _currentFrame = d.is_sphere? _pannellumFrame : _planeFrame; From c824fe19375451288070ef0e4e024de2960a0726 Mon Sep 17 00:00:00 2001 From: Noenandre <5470915+noenandre@users.noreply.github.com> Date: Sat, 4 Mar 2023 14:04:02 +0100 Subject: [PATCH 13/30] Create sequences for each (wfs)layer instead of for all points at once. Rearrange cache. --- modules/services/vegbilder.js | 40 ++++++++++++++++----------------- modules/svg/vegbilder.js | 2 +- test/spec/services/vegbilder.js | 2 +- 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/modules/services/vegbilder.js b/modules/services/vegbilder.js index 2d04bdaf4..752d626f1 100644 --- a/modules/services/vegbilder.js +++ b/modules/services/vegbilder.js @@ -76,17 +76,20 @@ function filterAvailableLayers(photoContex) { function loadWFSLayers(projection, margin, layers) { const tiles = tiler.margin(margin).getTiles(projection); - Promise.all(layers.map( - ({name}) => loadWFSLayer(name, tiles) - )) - .then(() => orderSequences(projection)); + for (const {name} of layers) { + loadWFSLayer(projection, name, tiles); + } } -async function loadWFSLayer(layername, tiles) { +function loadWFSLayer(projection, layername, tiles) { let cache = _vegbilderCache.wfslayers.get(layername); if (!cache) { - cache = {loaded: new Map(), inflight: new Map()}; + cache = { + loaded: new Map(), + inflight: new Map(), + points: new Map(), + sequences: []}; _vegbilderCache.wfslayers.set(layername, cache); } @@ -99,9 +102,9 @@ async function loadWFSLayer(layername, tiles) { } } - await Promise.all(tiles.map( + Promise.all(tiles.map( tile => loadTile(cache, layername, tile) - )); + )).then(() => orderSequences(projection, cache)); } /** @@ -166,7 +169,6 @@ async function loadTile(cache, layername, tile) { ca, image_path, preview_path, - layername, road_reference: roadReference(properties), metering, lane_code, @@ -175,7 +177,7 @@ async function loadTile(cache, layername, tile) { is_sphere: image_type === '360' }; - _vegbilderCache.points.set(key, data); + cache.points.set(key, data); return { minX: loc[0], minY: loc[1], maxX: loc[0], maxY: loc[1], data @@ -186,14 +188,12 @@ async function loadTile(cache, layername, tile) { dispatch.call('loadedImages'); } -function orderSequences(projection) { - const {points} = _vegbilderCache; - if (points.size === 0) return; - +function orderSequences(projection, cache) { const imageSequences = []; + const {points} = cache; const grouped = Array.from(points.values()).reduce((mapping, image) => { - let key = `${image.layername} ${image.road_reference}`; + let key = image.road_reference; if (mapping.has(key)) { mapping.get(key).push(image); } else { @@ -241,7 +241,7 @@ function orderSequences(projection) { imageSequences.push(imageSequence); } - _vegbilderCache.sequences = imageSequences.map(images => { + cache.sequences = imageSequences.map(images => { const seqence = { images, key: images[0].key, @@ -256,7 +256,6 @@ function orderSequences(projection) { }); } - function roadReference(properties) { let { FYLKENUMMER: county_number, @@ -337,8 +336,6 @@ export default { _vegbilderCache = { wfslayers: new Map(), rtree: new RBush(), - points: new Map(), - sequences: new Map(), image2sequence_map: new Map() }; @@ -377,9 +374,10 @@ export default { return line_strings; }, - cachedImage: function (key) { - return _vegbilderCache.points.get(key); + for (const {points} of _vegbilderCache.wfslayers.values()) { + if (points.has(key)) return points.get(key); + } }, getSequenceForImage: function (image) { diff --git a/modules/svg/vegbilder.js b/modules/svg/vegbilder.js index 8b1cc9a2e..98d53fe14 100644 --- a/modules/svg/vegbilder.js +++ b/modules/svg/vegbilder.js @@ -189,7 +189,7 @@ export function svgVegbilder(projection, context, dispatch) { if (toDateString) { const toDate = new Date(toDateString); - sequences = sequences.filter(({images}) => images[-1].captured_at.getTime() <= toDate.getTime()); + sequences = sequences.filter(({images}) => images[images.length - 1].captured_at.getTime() <= toDate.getTime()); } if (!showsPano) { diff --git a/test/spec/services/vegbilder.js b/test/spec/services/vegbilder.js index dd4f98f32..2839d2d22 100644 --- a/test/spec/services/vegbilder.js +++ b/test/spec/services/vegbilder.js @@ -30,7 +30,7 @@ describe('iD.serviceVegbilder', function() { const cache = vegbilder.cache(); expect(cache).to.have.property('wfslayers'); expect(cache).to.have.property('rtree'); - expect(cache).to.have.property('sequences'); + expect(cache).to.have.property('image2sequence_map'); vegbilder.init(); const cache2 = vegbilder.cache(); From fcbdee0aed655ff837c3c250790c01353e93eb4b Mon Sep 17 00:00:00 2001 From: Noenandre <5470915+noenandre@users.noreply.github.com> Date: Mon, 6 Mar 2023 13:29:44 +0100 Subject: [PATCH 14/30] Attempt to fix plane photoviewer. --- css/60_photos.css | 15 ++++++++++----- modules/services/plane_photo.js | 21 ++++++++++++--------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/css/60_photos.css b/css/60_photos.css index d508e6767..fce2e7ff2 100644 --- a/css/60_photos.css +++ b/css/60_photos.css @@ -68,8 +68,7 @@ } -.photo-wrapper, -.photo-wrapper img { +.photo-wrapper { width: 100%; height: 100%; overflow: hidden; @@ -361,12 +360,18 @@ label.streetside-hires { } .plane-frame { - width: 100%; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; height: 100%; + width: 100%; } -.plane-frame > .plane-photo{ - transform-origin:0 0; +.plane-frame > img.plane-photo{ + width: min-content; + height: 100%; + transform-origin: 0 0; } .vegbilder-wrapper { diff --git a/modules/services/plane_photo.js b/modules/services/plane_photo.js index 9449926ec..2e11cdb47 100644 --- a/modules/services/plane_photo.js +++ b/modules/services/plane_photo.js @@ -10,30 +10,33 @@ let imgZoom; export default { init: async function(context, selection) { + this.event = utilRebind(this, dispatch, 'on'); + imgZoom = d3_zoom() .extent([[0, 0], [320, 240]]) - //.translateExtent(?) + .translateExtent([[0, 0], [320, 240]]) .scaleExtent([1, 15]) .on('zoom', this.zoomPan); const wrapper = selection .append('div') .attr('class', 'photo-frame plane-frame') - .call(imgZoom) .classed('hide', true); _photo = wrapper .append('img') .attr('class', 'plane-photo'); - this.event = utilRebind(this, dispatch, 'on'); - - context.ui().photoviewer.on('resize.plane', (dimensions) => { + context.ui().photoviewer.on('resize.plane', () => { + const {width: wrapperWidth} = wrapper.node().getBoundingClientRect(); + const {width, height} = _photo.node().getBoundingClientRect(); + const widthOverflow = width - wrapperWidth; imgZoom = d3_zoom() - .extent([[0, 0], dimensions]) - //.translateExtent(?) - .scaleExtent([1, 15]) - .on('zoom', this.zoomPan); + .extent([[widthOverflow / 2, 0], [wrapperWidth + widthOverflow / 2, height]]) + .translateExtent([[0, 0], [width, height]]) + .scaleExtent([1, 15]) + .on('zoom', this.zoomPan); + wrapper.call(imgZoom); }); await Promise.resolve(); From e33279bb4a1df755e56b68fd6a9ad66399218842 Mon Sep 17 00:00:00 2001 From: Noenandre <5470915+noenandre@users.noreply.github.com> Date: Thu, 9 Mar 2023 16:38:02 +0100 Subject: [PATCH 15/30] Create a new zoom behaviour when the image size potentially change. --- css/60_photos.css | 24 ++++++++----- modules/services/plane_photo.js | 60 ++++++++++++++++++++++----------- 2 files changed, 55 insertions(+), 29 deletions(-) diff --git a/css/60_photos.css b/css/60_photos.css index fce2e7ff2..663a210f3 100644 --- a/css/60_photos.css +++ b/css/60_photos.css @@ -72,7 +72,6 @@ width: 100%; height: 100%; overflow: hidden; - object-fit: cover; } .photo-wrapper .photo-attribution { @@ -344,6 +343,13 @@ label.streetside-hires { background-repeat: no-repeat; } +.kartaview-wrapper img { + width: 100%; + height: 100%; + overflow: hidden; + object-fit: cover; +} + .kartaview-wrapper .photo-attribution a:active { color: #20c4ff; } @@ -359,7 +365,12 @@ label.streetside-hires { transform-origin:0 0; } -.plane-frame { +.vegbilder-wrapper { + position: relative; + background-color: #000; +} + +.vegbilder-wrapper .plane-frame { display: flex; align-items: center; justify-content: center; @@ -368,17 +379,12 @@ label.streetside-hires { width: 100%; } -.plane-frame > img.plane-photo{ - width: min-content; +.vegbilder-wrapper .plane-frame > img.plane-photo{ + width: auto; height: 100%; transform-origin: 0 0; } -.vegbilder-wrapper { - position: relative; - background-color: #000; -} - /* photo-controls (step forward, back, rotate) */ .photo-controls-wrap { text-align: center; diff --git a/modules/services/plane_photo.js b/modules/services/plane_photo.js index 2e11cdb47..8a5b246fc 100644 --- a/modules/services/plane_photo.js +++ b/modules/services/plane_photo.js @@ -5,8 +5,36 @@ import { utilSetTransform, utilRebind } from '../util'; const dispatch = d3_dispatch('viewerChanged'); let _photo; +let _wrapper; let imgZoom; +function zoomPan (d3_event) { + let t = d3_event.transform; + _photo.call(utilSetTransform, t.x, t.y, t.k); + } + +function zoomBeahvior () { + const {width: wrapperWidth, height: wrapperHeight} = _wrapper.node().getBoundingClientRect(); + const {naturalHeight, naturalWidth} = _photo.node(); + const intrinsicRatio = naturalWidth / naturalHeight; + const widthOverflow = wrapperHeight * intrinsicRatio - wrapperWidth; + return d3_zoom() + .extent([[widthOverflow / 2, 0], [wrapperWidth + widthOverflow / 2, wrapperHeight]]) + .translateExtent([[0, 0], [wrapperWidth + widthOverflow, wrapperHeight]]) + .scaleExtent([1, 15]) + .on('zoom', zoomPan); +} + +function loadImage (selection, path) { + return new Promise((resolve) => { + selection.attr('src', path); + selection.on('load', () => { + resolve(selection); + }); + }); +} + + export default { init: async function(context, selection) { @@ -18,25 +46,18 @@ export default { .scaleExtent([1, 15]) .on('zoom', this.zoomPan); - const wrapper = selection + _wrapper = selection .append('div') .attr('class', 'photo-frame plane-frame') .classed('hide', true); - _photo = wrapper + _photo = _wrapper .append('img') .attr('class', 'plane-photo'); context.ui().photoviewer.on('resize.plane', () => { - const {width: wrapperWidth} = wrapper.node().getBoundingClientRect(); - const {width, height} = _photo.node().getBoundingClientRect(); - const widthOverflow = width - wrapperWidth; - imgZoom = d3_zoom() - .extent([[widthOverflow / 2, 0], [wrapperWidth + widthOverflow / 2, height]]) - .translateExtent([[0, 0], [width, height]]) - .scaleExtent([1, 15]) - .on('zoom', this.zoomPan); - wrapper.call(imgZoom); + imgZoom = zoomBeahvior(); + _wrapper.call(imgZoom); }); await Promise.resolve(); @@ -70,18 +91,17 @@ export default { }, selectPhoto: function (data, keepOrientation) { - _photo.attr('src', data.image_path); - if (!keepOrientation) { - _photo.call(imgZoom.transform, d3_zoomIdentity); - } + loadImage(_photo, data.image_path) + .then(() => { + if (!keepOrientation) { + imgZoom = zoomBeahvior(); + _wrapper.call(imgZoom); + _wrapper.call(imgZoom.transform, d3_zoomIdentity); + } + }); return this; }, - zoomPan: function (d3_event) { - let t = d3_event.transform; - _photo.call(utilSetTransform, t.x, t.y, t.k); - }, - getYaw: function() { return 0; } From 2d74e4fde580d8775c1ecd1c3227ad3adc1621d3 Mon Sep 17 00:00:00 2001 From: Noenandre <5470915+noenandre@users.noreply.github.com> Date: Thu, 9 Mar 2023 16:39:06 +0100 Subject: [PATCH 16/30] Small bugfix. --- modules/services/vegbilder.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/services/vegbilder.js b/modules/services/vegbilder.js index 752d626f1..ecfe3361a 100644 --- a/modules/services/vegbilder.js +++ b/modules/services/vegbilder.js @@ -113,7 +113,7 @@ function loadWFSLayer(projection, layername, tiles) { async function loadTile(cache, layername, tile) { const bbox = tile.extent.bbox(); const tileid = tile.id; - if (cache.loaded.has(tileid) || cache.inflight.has(tileid)) return; + if ((cache.loaded.get(tileid) === true) || cache.inflight.has(tileid)) return; const params = { service: 'WFS', From 4c6f3621f2710500f077c2799d3cba6c2b56356b Mon Sep 17 00:00:00 2001 From: Noenandre <5470915+noenandre@users.noreply.github.com> Date: Thu, 9 Mar 2023 16:45:49 +0100 Subject: [PATCH 17/30] Use last calculated missing heading for null vectors. --- modules/services/vegbilder.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/modules/services/vegbilder.js b/modules/services/vegbilder.js index ecfe3361a..2abbe2ce2 100644 --- a/modules/services/vegbilder.js +++ b/modules/services/vegbilder.js @@ -3,7 +3,7 @@ import { dispatch as d3_dispatch } from 'd3-dispatch'; import { pairs as d3_pairs } from 'd3-array'; import { t, localizer } from '../core/localizer'; import { utilQsString, utilTiler, utilRebind, utilArrayUnion, utilStringQs} from '../util'; -import {geoExtent, geoScaleToZoom, geoVecAngle} from '../geo'; +import {geoExtent, geoScaleToZoom, geoVecAngle, geoVecEqual} from '../geo'; import pannellumPhotoFrame from './pannellum_photo'; import planePhotoFrame from './plane_photo'; import RBush from 'rbush'; @@ -218,14 +218,17 @@ function orderSequences(projection, cache) { } }); let imageSequence = [imageGroup[0]]; + let angle = null; for (const [lastImage, image] of d3_pairs(imageGroup)) { if (lastImage.ca === null) { let b = projection(lastImage.loc); let a = projection(image.loc); - let angle = geoVecAngle(a, b); - angle *= (180 / Math.PI); - angle -= 90; - angle = angle >= 0 ? angle : angle + 360; + if (!geoVecEqual(a, b)) { + angle = geoVecAngle(a, b); + angle *= (180 / Math.PI); + angle -= 90; + angle = angle >= 0 ? angle : angle + 360; + } lastImage.ca = angle; } if ( From a2a1fdc58a6db651a964a07b1868d2500e113f0b Mon Sep 17 00:00:00 2001 From: Noenandre <5470915+noenandre@users.noreply.github.com> Date: Wed, 15 Mar 2023 18:13:50 +0100 Subject: [PATCH 18/30] Tests for service/vegbilder is as comprehensive as similar providers. --- modules/services/vegbilder.js | 8 +- modules/svg/vegbilder.js | 4 +- test/spec/services/vegbilder.js | 253 +++++++++++++++++++++++++++++++- 3 files changed, 258 insertions(+), 7 deletions(-) diff --git a/modules/services/vegbilder.js b/modules/services/vegbilder.js index 2abbe2ce2..2ad8c84d7 100644 --- a/modules/services/vegbilder.js +++ b/modules/services/vegbilder.js @@ -387,10 +387,10 @@ export default { return _vegbilderCache.image2sequence_map.get(image?.key); }, - loadImages: function (projection, photosContext) { - const margin = 1; - const layers = filterAvailableLayers(photosContext); - loadWFSLayers(projection, margin, layers); + loadImages: function (context, margin) { + margin ??= 1; + const layers = filterAvailableLayers(context.photos()); + loadWFSLayers(context.projection, margin, layers); }, photoFrame: function() { diff --git a/modules/svg/vegbilder.js b/modules/svg/vegbilder.js index 98d53fe14..2a608dd51 100644 --- a/modules/svg/vegbilder.js +++ b/modules/svg/vegbilder.js @@ -218,7 +218,7 @@ export function svgVegbilder(projection, context, dispatch) { if (service) { // The WFS-layer for that year or image type may not be loaded after a filter is changed - service.loadImages(projection, context.photos()); + service.loadImages(context); sequences = service.sequences(projection); images = showMarkers ? service.images(projection) : []; @@ -339,7 +339,7 @@ export function svgVegbilder(projection, context, dispatch) { if (service && ~~context.map().zoom() >= minZoom) { editOn(); update(); - service.loadImages(projection, context.photos()); + service.loadImages(context); } else { editOff(); } diff --git a/test/spec/services/vegbilder.js b/test/spec/services/vegbilder.js index 2839d2d22..4f1657067 100644 --- a/test/spec/services/vegbilder.js +++ b/test/spec/services/vegbilder.js @@ -1,7 +1,110 @@ describe('iD.serviceVegbilder', function() { const dimensions = [64, 64]; + const testImages = [{ + loc: [5.7, 58.90001], + key: 'Vegbilder_2021.2021-01-01T11.11.11.000000_EV00001_S001D1_m00001', + ca: 90, + image_path: 'https://s3vegbilder.atlas.vegvesen.no/foo/bar1.jpg', + road_reference: 'EV1 S1D1', + metering: 1, + lane_code: '1K', + captured_at: new Date('2021-01-01T11.11.11Z'), + is_sphere: false + }, { + loc: [5.7, 58.90002], + key: 'Vegbilder_2021.2021-01-01T11.11.12.000000_EV00001_S001D1_m00002', + ca: 90, + image_path: 'https://s3vegbilder.atlas.vegvesen.no/foo/bar2.jpg', + road_reference: 'EV1 S1D1', + metering: 2, + lane_code: '1K', + captured_at: new Date('2021-01-01T11.11.12Z'), + is_sphere: false + }, { + loc: [5.7, 59.90003], + key: 'Vegbilder_2021.2021-01-01T11.11.13.000000_EV00001_S002D1_m00003', + ca: 90, + image_path: 'https://s3vegbilder.atlas.vegvesen.no/foo/bar3.jpg', + road_reference: 'EV1 S2D1', + metering: 3, + lane_code: '1K', + captured_at: new Date('2021-01-01T11.11.13Z'), + is_sphere: false + }]; + const stacedImages = [{ + loc: [5.7, 58.9], + key: 'Vegbilder_2021.2021-01-01T11.11.11.000000_EV00001_S001D1_m00001', + ca: 90, + image_path: 'https://s3vegbilder.atlas.vegvesen.no/foo/bar1.jpg', + road_reference: 'EV1 S1D1', + metering: 1, + lane_code: '1K', + captured_at: new Date('2021-01-01T11.11.11Z'), + is_sphere: false + }, { + loc: [5.7, 58.9], + key: 'Vegbilder_2021.2021-01-01T11.11.12.000000_EV00001_S001D1_m00002', + ca: 90, + image_path: 'https://s3vegbilder.atlas.vegvesen.no/foo/bar2.jpg', + road_reference: 'EV1 S1D1', + metering: 2, + lane_code: '1K', + captured_at: new Date('2021-01-01T11.11.12Z'), + is_sphere: false + }, { + loc: [5.7, 58.9], + key: 'Vegbilder_2021.2021-01-01T11.11.13.000000_EV00001_S001D1_m00003', + ca: 90, + image_path: 'https://s3vegbilder.atlas.vegvesen.no/foo/bar3.jpg', + road_reference: 'EV1 S1D1', + metering: 3, + lane_code: '1K', + captured_at: new Date('2021-01-01T11.11.13Z'), + is_sphere: false + }, { + loc: [5.7, 58.9], + key: 'Vegbilder_2021.2021-01-01T11.11.14.000000_EV00001_S001D1_m00004', + ca: 90, + image_path: 'https://s3vegbilder.atlas.vegvesen.no/foo/bar4.jpg', + road_reference: 'EV1 S1D1', + metering: 4, + lane_code: '1K', + captured_at: new Date('2021-01-01T11.11.14Z'), + is_sphere: false + }, { + loc: [5.7, 58.9], + key: 'Vegbilder_2021.2021-01-01T11.11.15.000000_EV00001_S001D1_m00005', + ca: 90, + image_path: 'https://s3vegbilder.atlas.vegvesen.no/foo/bar5.jpg', + road_reference: 'EV1 S1D1', + metering: 5, + lane_code: '1K', + captured_at: new Date('2021-01-01T11.11.15Z'), + is_sphere: false + }, { + loc: [5.7, 58.9], + key: 'Vegbilder_2021.2021-01-01T11.11.16.000000_EV00001_S001D1_m00006', + ca: 90, + image_path: 'https://s3vegbilder.atlas.vegvesen.no/foo/bar6.jpg', + road_reference: 'EV1 S1D1', + metering: 6, + lane_code: '1K', + captured_at: new Date('2021-01-01T11.11.16Z'), + is_sphere: false + }]; + let context, vegbilder; + function asFeature(images) { + return images.map(image => ({ + minX: image.loc[0], + minY: image.loc[1], + maxX: image.loc[0], + maxY: image.loc[1], + data: image + })); + } + before(function() { iD.services.vegbilder = iD.serviceVegbilder; }); @@ -12,13 +115,15 @@ describe('iD.serviceVegbilder', function() { beforeEach(async function() { context = iD.coreContext().assetPath('../dist/').init(); + // bbox maxX: 5.705423355102539 maxY: 58.900168239328906 minX: 5.699930191040039 minY: 58.8973307343531 context.projection .scale(iD.geoZoomToScale(14)) - .translate([-116508, 0]) // 10,0 + .translate([-66409, 853915]) .clipExtent([[0,0], dimensions]); vegbilder = iD.services.vegbilder; await vegbilder.reset(); + fetchMock.reset(); }); afterEach(function() { @@ -45,4 +150,150 @@ describe('iD.serviceVegbilder', function() { expect(vegbilder.cache()).to.not.have.property('foo'); }); }); + + describe('loadImages', function () { + it('fires loadedImages when images are loaded', function() { + const respons = { + 'type': 'FeatureCollection', + 'features': [ + { + 'type': 'Feature', + 'id': 'Vegbilder_2021.2021-05-05T08.42.47.315227_EV00039_S100D1_m14966_KD1_m00319', + 'geometry': {'type': 'Point', 'coordinates': [5.686, 58.901]}, + 'properties':{ + 'BILDETYPE':'Planar','AAR':2021,'TIDSPUNKT':'2021-05-05T06:42:47Z','FYLKENUMMER':11,'VEGKATEGORI':'E','VEGSTATUS':'V', + 'VEGNUMMER':39,'STREKNING':100,'HP':null,'DELSTREKNING':1,'ANKERPUNKT':14966,'KRYSSDEL':1,'SIDEANLEGGSDEL':null, + 'METER':319.0,'FELTKODE':'1K','REFLINKID':null,'REFLINKPOSISJON':null,'RETNING':176.2, + 'URL':'https://s3vegbilder.atlas.vegvesen.no/foo/bar1.jpg' + } + }, { + 'type': 'Feature', + 'id': 'Vegbilder_2021.2021-05-05T08.42.47.627214_EV00039_S100D1_m14966_KD1_m00320', + 'geometry': {'type': 'Point', 'coordinates': [5.687, 58.902]}, + 'properties': { + 'BILDETYPE':'Planar','AAR':2021,'TIDSPUNKT':'2021-05-05T06:42:47Z','FYLKENUMMER':11,'VEGKATEGORI':'E','VEGSTATUS':'V', + 'VEGNUMMER':39,'STREKNING':100,'HP':null,'DELSTREKNING':1,'ANKERPUNKT':14966,'KRYSSDEL':1,'SIDEANLEGGSDEL':null, + 'METER':320.0,'FELTKODE':'1K','REFLINKID':null,'REFLINKPOSISJON':null,'RETNING':178.5, + 'URL':'https://s3vegbilder.atlas.vegvesen.no/foo/bar2.jpg' + } + }, { + 'type': 'Feature', + 'id': 'Vegbilder_2021.2021-05-05T08.42.47.627214_EV00039_S100D1_m14966_KD1_m00321', + 'geometry': {'type':'Point','coordinates':[5.688, 58.903]}, + 'properties': { + 'BILDETYPE':'Planar','AAR':2021,'TIDSPUNKT':'2021-05-05T06:42:47Z','FYLKENUMMER':11,'VEGKATEGORI':'E','VEGSTATUS':'V', + 'VEGNUMMER':39,'STREKNING':100,'HP':null,'DELSTREKNING':1,'ANKERPUNKT':14966,'KRYSSDEL':1,'SIDEANLEGGSDEL':null, + 'METER':321.0,'FELTKODE':'1K','REFLINKID':null,'REFLINKPOSISJON':null,'RETNING':178.5, + 'URL':'https://s3vegbilder.atlas.vegvesen.no/foo/bar3.jpg' + } + } + ], + 'totalFeatures': 3 + }; + + fetchMock.mock({ + url: 'https://www.vegvesen.no/kart/ogc/vegbilder_1_0/ows', + query: { + service: 'WFS', + request: 'GetFeature' + } + }, respons); + + return new Promise((resolve) => { + vegbilder.on('loadedImages', () => { + expect(fetchMock.calls().length).to.eql(1); + resolve(); + }); + + vegbilder.loadImages(context, 0); + }); + }); + + it('does not load images around null island', async function() { + const respons = { + 'type': 'FeatureCollection', + 'features': [ + { + 'type': 'Feature', + 'id': 'Vegbilder_2021.2021-01-01T11.11.11.000000_EV00001_S001D1_m00001', + 'geometry': {'type': 'Point', 'coordinates': [0.0, 0.0]}, + 'properties':{ + 'BILDETYPE':'Planar','AAR':2021,'TIDSPUNKT':'2021-01-01T11.11.11Z','FYLKENUMMER':1,'VEGKATEGORI':'E','VEGSTATUS':'V', + 'VEGNUMMER':1,'STREKNING':1,'HP':null,'DELSTREKNING':1,'ANKERPUNKT':null,'KRYSSDEL':null,'SIDEANLEGGSDEL':null, + 'METER':1.0,'FELTKODE':'1K','REFLINKID':null,'REFLINKPOSISJON':null,'RETNING':null, + 'URL':'https://s3vegbilder.atlas.vegvesen.no/foo/bar.jpg' + } + } + ], + 'totalFeatures': 1 + }; + + fetchMock.mock({ + url: 'https://www.vegvesen.no/kart/ogc/vegbilder_1_0/ows', + query: { + service: 'WFS', + request: 'GetFeature' + } + }, respons); + + context.projection.translate([0, 0]); + + const spy = sinon.spy(); + vegbilder.on('loadedImages', spy); + vegbilder.loadImages(context, 0); + + await new Promise((resolve) => { window.setTimeout(resolve, 200); }); + + expect(spy).to.have.been.not.called; + expect(fetchMock.calls().length).to.eql(0); + }); + }); + + describe('#images', function() { + it('returns images in the visible map area', function() { + const features = asFeature(testImages); + + vegbilder.cache().rtree.load(features); + const result = vegbilder.images(context.projection); + + expect(result).to.deep.eql(testImages.slice(0, 2)); + }); + + it('limits results no more than 5 stacked images in one spot', function() { + const features = asFeature(stacedImages); + + vegbilder.cache().rtree.load(features); + const result = vegbilder.images(context.projection); + expect(features).to.have.length.of.at.least(6); + expect(result).to.have.length.of.at.most(5); + }); + }); + describe('#sequences', function() { + it('returns sequence linestrings in the visible map area', function() { + const features = asFeature(testImages); + const cache = vegbilder.cache(); + + cache.rtree.load(features); + + const sequence = { + images: testImages, + key: '1', + geometry : { + type : 'LineString', + coordinates : testImages.map(image => image.loc) + }}; + + for (const image of testImages) { + cache.image2sequence_map.set(image.key, sequence); + } + + const result = vegbilder.sequences(context.projection); + expect(result).to.deep.eql([{ + type: 'LineString', + coordinates: [[5.7, 58.90001], [5.7, 58.90002], [5.7, 59.90003]], + key: '1', + images: testImages + }]); + }); + }); }); From c7c130dae1fa214cfeb5243004fd55e7b9b52961 Mon Sep 17 00:00:00 2001 From: Noenandre <5470915+noenandre@users.noreply.github.com> Date: Wed, 15 Mar 2023 18:15:32 +0100 Subject: [PATCH 19/30] Use nullish coalescing operator where it makes sense. --- modules/services/vegbilder.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/services/vegbilder.js b/modules/services/vegbilder.js index 2ad8c84d7..1e9bed1a8 100644 --- a/modules/services/vegbilder.js +++ b/modules/services/vegbilder.js @@ -306,7 +306,7 @@ function partitionViewport(projection) { } function searchLimited(limit, projection, rtree) { - limit = limit || 5; + limit ??= 5; return partitionViewport(projection) .reduce((result, extent) => { @@ -553,14 +553,14 @@ export default { const hoveredImageKey = hovered?.key; const hoveredSequence = this.getSequenceForImage(hovered); const hoveredSequenceKey = hoveredSequence?.key; - const hoveredImageKeys = (hoveredSequence?.images.map(d => d.key)) || []; + const hoveredImageKeys = hoveredSequence?.images.map(d => d.key) ?? []; const viewer = context.container().select('.photoviewer'); const selected = viewer.empty() ? undefined : viewer.datum(); const selectedImageKey = selected?.key; const selectedSequence = this.getSequenceForImage(selected); const selectedSequenceKey = selectedSequence?.key; - const selectedImageKeys = (selectedSequence?.images.map(d => d.key)) || []; + const selectedImageKeys = selectedSequence?.images.map(d => d.key) ?? []; // highlight sibling viewfields on either the selected or the hovered sequences const highlightedImageKeys = utilArrayUnion(hoveredImageKeys, selectedImageKeys); From 508c4d1e37df4b66697efcca2db83ce9592c4170 Mon Sep 17 00:00:00 2001 From: Noenandre <5470915+noenandre@users.noreply.github.com> Date: Wed, 15 Mar 2023 18:16:21 +0100 Subject: [PATCH 20/30] Split a promise into two. --- modules/services/pannellum_photo.js | 67 +++++++++++++---------------- 1 file changed, 30 insertions(+), 37 deletions(-) diff --git a/modules/services/pannellum_photo.js b/modules/services/pannellum_photo.js index f28b34753..64dc0e7f5 100644 --- a/modules/services/pannellum_photo.js +++ b/modules/services/pannellum_photo.js @@ -56,44 +56,37 @@ export default { }, loadPannellum: function(context) { - return new Promise((resolve, reject) => { + const head = d3_select('head'); - let loadedCount = 0; - function loaded() { - loadedCount += 1; - // wait until both files are loaded - if (loadedCount === 2) resolve(); - } - - const head = d3_select('head'); - - // load pannellum viewer css - head.selectAll('#ideditor-pannellum-viewercss') - .data([0]) - .enter() - .append('link') - .attr('id', 'ideditor-pannellum-viewercss') - .attr('rel', 'stylesheet') - .attr('crossorigin', 'anonymous') - .attr('href', context.asset(pannellumViewerCSS)) - .on('load.pannellum', loaded) - .on('error.pannellum', () => { - reject(); - }); - - // load streetside pannellum viewer js - head.selectAll('#ideditor-pannellum-viewerjs') - .data([0]) - .enter() - .append('script') - .attr('id', 'ideditor-pannellum-viewerjs') - .attr('crossorigin', 'anonymous') - .attr('src', context.asset(pannellumViewerJS)) - .on('load.pannellum', loaded) - .on('error.pannellum', () => { - reject(); - }); - }); + return Promise.all([ + new Promise((resolve, reject) => { + // load pannellum viewer css + head + .selectAll('#ideditor-pannellum-viewercss') + .data([0]) + .enter() + .append('link') + .attr('id', 'ideditor-pannellum-viewercss') + .attr('rel', 'stylesheet') + .attr('crossorigin', 'anonymous') + .attr('href', context.asset(pannellumViewerCSS)) + .on('load.pannellum', resolve) + .on('error.pannellum', reject); + }), + new Promise((resolve, reject) => { + // load pannellum viewer js + head + .selectAll('#ideditor-pannellum-viewerjs') + .data([0]) + .enter() + .append('script') + .attr('id', 'ideditor-pannellum-viewerjs') + .attr('crossorigin', 'anonymous') + .attr('src', context.asset(pannellumViewerJS)) + .on('load.pannellum', resolve) + .on('error.pannellum', reject); + }) + ]); }, showPhotoFrame: function (context) { From ddc9c6c67460e20a9531e6c759c61bebc5269b80 Mon Sep 17 00:00:00 2001 From: Noenandre <5470915+noenandre@users.noreply.github.com> Date: Sat, 25 Mar 2023 17:27:41 +0100 Subject: [PATCH 21/30] Remove a global module variable "_availableLayers"; move information to cache. Add new test "fetches available layers". --- modules/services/vegbilder.js | 115 ++++++++++++++++---------------- modules/svg/vegbilder.js | 2 +- test/spec/services/vegbilder.js | 10 ++- 3 files changed, 66 insertions(+), 61 deletions(-) diff --git a/modules/services/vegbilder.js b/modules/services/vegbilder.js index 1e9bed1a8..989180199 100644 --- a/modules/services/vegbilder.js +++ b/modules/services/vegbilder.js @@ -22,11 +22,6 @@ let _pannellumFrame; let _currentFrame; let _loadViewerPromise; let _vegbilderCache; -let _availableLayers; - -function abortRequest(controller) { - controller.abort(); -} async function fetchAvailableLayers() { const params = { @@ -47,17 +42,18 @@ async function fetchAvailableLayers() { XPathResult.ANY_TYPE ); let node; - _availableLayers = []; + const availableLayers = []; while ( (node = l.iterateNext()) !== null ) { - let match = node.textContent?.match(regexMatcher); + const match = node.textContent?.match(regexMatcher); if (match) { - _availableLayers.push({ + availableLayers.push({ name: match[0], is_sphere: !!match.groups?.image_type, year: parseInt(match.groups?.year, 10) }); } } + return availableLayers; } function filterAvailableLayers(photoContex) { @@ -67,50 +63,39 @@ function filterAvailableLayers(photoContex) { const toYear = toDateString ? new Date(toDateString).getFullYear() : null; const showsFlat = photoContex.showsFlat(); const showsPano = photoContex.showsPanoramic(); - return _availableLayers.filter(layerInfo => ( + return Array.from(_vegbilderCache.wfslayers.values()).filter(({layerInfo}) => ( (layerInfo.year >= fromYear) && (!toYear || (layerInfo.year <= toYear)) && ((!layerInfo.is_sphere && showsFlat) || (layerInfo.is_sphere && showsPano)) )); } -function loadWFSLayers(projection, margin, layers) { +function loadWFSLayers(projection, margin, wfslayers) { const tiles = tiler.margin(margin).getTiles(projection); - for (const {name} of layers) { - loadWFSLayer(projection, name, tiles); + for (const cache of wfslayers) { + loadWFSLayer(projection, cache, tiles); } } -function loadWFSLayer(projection, layername, tiles) { - let cache = _vegbilderCache.wfslayers.get(layername); - - if (!cache) { - cache = { - loaded: new Map(), - inflight: new Map(), - points: new Map(), - sequences: []}; - _vegbilderCache.wfslayers.set(layername, cache); - } - +function loadWFSLayer(projection, cache, tiles) { // abort inflight requests that are no longer needed - for (let key of cache.inflight.keys()) { + for (const [key, controller] of cache.inflight.entries()) { const wanted = tiles.some(tile => key === tile.id); if (!wanted) { - abortRequest(cache.inflight.get(key)); + controller.abort(); cache.inflight.delete(key); } } Promise.all(tiles.map( - tile => loadTile(cache, layername, tile) + tile => loadTile(cache, cache.layerInfo.name, tile) )).then(() => orderSequences(projection, cache)); } /** * loadNextTilePage() load data for the next tile page in line. */ -async function loadTile(cache, layername, tile) { +async function loadTile(cache, typename, tile) { const bbox = tile.extent.bbox(); const tileid = tile.id; if ((cache.loaded.get(tileid) === true) || cache.inflight.has(tileid)) return; @@ -119,7 +104,7 @@ async function loadTile(cache, layername, tile) { service: 'WFS', request: 'GetFeature', version: '2.0.0', - typenames: layername, + typenames: typename, bbox: [bbox.minY, bbox.minX, bbox.maxY, bbox.maxX].join(','), outputFormat: 'json' }; @@ -189,20 +174,19 @@ async function loadTile(cache, layername, tile) { } function orderSequences(projection, cache) { - const imageSequences = []; const {points} = cache; - const grouped = Array.from(points.values()).reduce((mapping, image) => { - let key = image.road_reference; - if (mapping.has(key)) { - mapping.get(key).push(image); + const grouped = Array.from(points.values()).reduce((grouped, image) => { + const key = image.road_reference; + if (grouped.has(key)) { + grouped.get(key).push(image); } else { - mapping.set(key, [image]); + grouped.set(key, [image]); } - return mapping; - }, new Map() - ); - for (const imageGroup of grouped.values()) { + return grouped; + }, new Map()); + + const imageSequences = Array.from(grouped.values()).reduce((imageSequences, imageGroup) => { imageGroup.sort((a, b) => { if (a.captured_at.valueOf() > b.captured_at.valueOf()) { return 1; @@ -221,8 +205,8 @@ function orderSequences(projection, cache) { let angle = null; for (const [lastImage, image] of d3_pairs(imageGroup)) { if (lastImage.ca === null) { - let b = projection(lastImage.loc); - let a = projection(image.loc); + const b = projection(lastImage.loc); + const a = projection(image.loc); if (!geoVecEqual(a, b)) { angle = geoVecAngle(a, b); angle *= (180 / Math.PI); @@ -242,7 +226,8 @@ function orderSequences(projection, cache) { } } imageSequences.push(imageSequence); - } + return imageSequences; + }, []); cache.sequences = imageSequences.map(images => { const seqence = { @@ -260,7 +245,7 @@ function orderSequences(projection, cache) { } function roadReference(properties) { - let { + const { FYLKENUMMER: county_number, VEGKATEGORI: road_class, VEGSTATUS: road_status, @@ -297,9 +282,9 @@ function localeTimestamp(date) { } function partitionViewport(projection) { - let z = geoScaleToZoom(projection.scale()); - let z2 = (Math.ceil(z * 2) / 2) + 2.5; // round to next 0.5 and add 2.5 - let tiler = utilTiler().zoomExtent([z2, z2]); + const zoom = geoScaleToZoom(projection.scale()); + const roundZoom = (Math.ceil(zoom * 2) / 2) + 2.5; // round to next 0.5 and add 2.5 + const tiler = utilTiler().zoomExtent([roundZoom, roundZoom]); return tiler.getTiles(projection) .map(tile => tile.extent); @@ -310,7 +295,7 @@ function searchLimited(limit, projection, rtree) { return partitionViewport(projection) .reduce((result, extent) => { - let found = rtree.search(extent.bbox()) + const found = rtree.search(extent.bbox()) .slice(0, limit) .map(d => d.data); @@ -330,19 +315,33 @@ export default { }, reset: async function () { + const availableLayers = await fetchAvailableLayers(); + if (_vegbilderCache) { - for (let layer of _vegbilderCache.wfslayers.values()) { - for (let tile of layer.inflight.values()) { abortRequest(tile); } + for (const layer of _vegbilderCache.wfslayers.values()) { + for (const controller of layer.inflight.values()) { + controller.abort(); + } } } + const wfslayers = availableLayers.reduce((wfslayers, layerInfo) => { + const cache = { + layerInfo, + loaded: new Map(), + inflight: new Map(), + points: new Map(), + sequences: [] + }; + wfslayers.set(layerInfo.name, cache); + return wfslayers; + }, new Map()); + _vegbilderCache = { - wfslayers: new Map(), + wfslayers, rtree: new RBush(), image2sequence_map: new Map() }; - - await fetchAvailableLayers(); }, @@ -357,16 +356,16 @@ export default { const min = [viewport[0][0], viewport[1][1]]; const max = [viewport[1][0], viewport[0][1]]; const bbox = geoExtent(projection.invert(min), projection.invert(max)).bbox(); - let seen = new Set(); - let line_strings = []; + const seen = new Set(); + const line_strings = []; - for (let {data} of _vegbilderCache.rtree.search(bbox)) { + for (const {data} of _vegbilderCache.rtree.search(bbox)) { const sequence = _vegbilderCache.image2sequence_map.get(data.key); if (!sequence) continue; const {key, geometry, images} = sequence; if (seen.has(key)) continue; seen.add(key); - let line = { + const line = { type: 'LineString', coordinates: geometry.coordinates, key, @@ -389,8 +388,8 @@ export default { loadImages: function (context, margin) { margin ??= 1; - const layers = filterAvailableLayers(context.photos()); - loadWFSLayers(context.projection, margin, layers); + const wfslayers = filterAvailableLayers(context.photos()); + loadWFSLayers(context.projection, margin, wfslayers); }, photoFrame: function() { diff --git a/modules/svg/vegbilder.js b/modules/svg/vegbilder.js index 2a608dd51..2a2066d6e 100644 --- a/modules/svg/vegbilder.js +++ b/modules/svg/vegbilder.js @@ -42,7 +42,7 @@ export function svgVegbilder(projection, context, dispatch) { * showLayer(). */ function showLayer() { - let service = getService(); + const service = getService(); if (!service) return; editOn(); diff --git a/test/spec/services/vegbilder.js b/test/spec/services/vegbilder.js index 4f1657067..d8a2936ec 100644 --- a/test/spec/services/vegbilder.js +++ b/test/spec/services/vegbilder.js @@ -141,12 +141,18 @@ describe('iD.serviceVegbilder', function() { const cache2 = vegbilder.cache(); expect(cache).to.equal(cache2); }); + + it('fetches available layers', function() { + const availableLayers = vegbilder.cache().wfslayers; + expect(availableLayers).to.have.key('vegbilder_1_0:Vegbilder_2020'); + expect(availableLayers).to.not.have.key('not_matched_layer:Vegbilder_2020'); + }); }); describe('#reset', function() { - it('resets cache', function() { + it('resets cache', async function() { vegbilder.cache().foo = 'bar'; - vegbilder.reset(); + await vegbilder.reset(); expect(vegbilder.cache()).to.not.have.property('foo'); }); }); From 4d44eedb3db6137f40ded7fcda34b11609ac0ada Mon Sep 17 00:00:00 2001 From: Noenandre <5470915+noenandre@users.noreply.github.com> Date: Sun, 23 Jul 2023 00:37:27 +0200 Subject: [PATCH 22/30] Too set autoLoad to false when adding a scene, seems too fix "Uncaught TypeError: Cannot set properties of undefined". --- modules/services/pannellum_photo.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/services/pannellum_photo.js b/modules/services/pannellum_photo.js index 64dc0e7f5..62220bde7 100644 --- a/modules/services/pannellum_photo.js +++ b/modules/services/pannellum_photo.js @@ -118,7 +118,7 @@ export default { if ( !(key in _currScenes) ) { let newSceneOptions = { showFullscreenCtrl: false, - autoLoad: true, + autoLoad: false, compass: true, yaw: 0, type: 'equirectangular', From f3bf7dbf4dda4a1a673474393860ff28847c05b2 Mon Sep 17 00:00:00 2001 From: Noenandre <5470915+noenandre@users.noreply.github.com> Date: Sun, 23 Jul 2023 14:16:19 +0200 Subject: [PATCH 23/30] Reorder execution of init. Set "on" binding before awaiting cache reset. Set "wfslayers" to a empty map immediately on reset. --- modules/services/vegbilder.js | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/modules/services/vegbilder.js b/modules/services/vegbilder.js index 989180199..3fc3daf4a 100644 --- a/modules/services/vegbilder.js +++ b/modules/services/vegbilder.js @@ -307,16 +307,13 @@ function searchLimited(limit, projection, rtree) { export default { init: async function () { + this.event = utilRebind(this, dispatch, 'on'); if (!_vegbilderCache) { await this.reset(); } - - this.event = utilRebind(this, dispatch, 'on'); }, reset: async function () { - const availableLayers = await fetchAvailableLayers(); - if (_vegbilderCache) { for (const layer of _vegbilderCache.wfslayers.values()) { for (const controller of layer.inflight.values()) { @@ -325,7 +322,16 @@ export default { } } - const wfslayers = availableLayers.reduce((wfslayers, layerInfo) => { + _vegbilderCache = { + wfslayers: new Map(), + rtree: new RBush(), + image2sequence_map: new Map() + }; + + const availableLayers = await fetchAvailableLayers(); + const {wfslayers} = _vegbilderCache; + + for (const layerInfo of availableLayers) { const cache = { layerInfo, loaded: new Map(), @@ -334,17 +340,9 @@ export default { sequences: [] }; wfslayers.set(layerInfo.name, cache); - return wfslayers; - }, new Map()); - - _vegbilderCache = { - wfslayers, - rtree: new RBush(), - image2sequence_map: new Map() - }; + } }, - images: function (projection) { const limit = 5; return searchLimited(limit, projection, _vegbilderCache.rtree); From d7902e1388406fa82138fd11695f1462d0794287 Mon Sep 17 00:00:00 2001 From: Martin Raifer Date: Wed, 2 Aug 2023 11:16:03 +0200 Subject: [PATCH 24/30] only show regional street-level photo providers in the available region and disable selection checkbox if not zoomed in enough to load the photos --- data/core.yaml | 2 ++ modules/services/vegbilder.js | 9 +++++++- modules/svg/kartaview_images.js | 4 ++++ modules/svg/mapilio_images.js | 4 ++++ modules/svg/mapillary_images.js | 4 ++++ modules/svg/mapillary_map_features.js | 4 ++++ modules/svg/mapillary_position.js | 4 ++++ modules/svg/mapillary_signs.js | 4 ++++ modules/svg/streetside.js | 4 ++++ modules/svg/vegbilder.js | 9 ++++++++ modules/ui/sections/photo_overlays.js | 30 +++++++++++++++++++++++++-- 11 files changed, 75 insertions(+), 3 deletions(-) diff --git a/data/core.yaml b/data/core.yaml index b5be4d062..6320908e5 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -1440,6 +1440,8 @@ en: mapilio: title: Mapilio tooltip: "Street-level photos from Mapilio" + street_side: + minzoom_tooltip: "Zoom in to see street-side photos" note: note: Note title: Edit note diff --git a/modules/services/vegbilder.js b/modules/services/vegbilder.js index 3fc3daf4a..3e2761629 100644 --- a/modules/services/vegbilder.js +++ b/modules/services/vegbilder.js @@ -1,12 +1,14 @@ import { json as d3_json, xml as d3_xml} from 'd3-fetch'; import { dispatch as d3_dispatch } from 'd3-dispatch'; import { pairs as d3_pairs } from 'd3-array'; +import RBush from 'rbush'; +import { iso1A2Codes } from '@rapideditor/country-coder'; import { t, localizer } from '../core/localizer'; import { utilQsString, utilTiler, utilRebind, utilArrayUnion, utilStringQs} from '../util'; import {geoExtent, geoScaleToZoom, geoVecAngle, geoVecEqual} from '../geo'; import pannellumPhotoFrame from './pannellum_photo'; import planePhotoFrame from './plane_photo'; -import RBush from 'rbush'; + const owsEndpoint = 'https://www.vegvesen.no/kart/ogc/vegbilder_1_0/ows?'; const tileZoom = 14; @@ -599,6 +601,11 @@ export default { } }, + validHere: function(extent) { + const bbox = Object.values(extent.bbox()); + return iso1A2Codes(bbox).includes('NO'); + }, + cache: function () { return _vegbilderCache; diff --git a/modules/svg/kartaview_images.js b/modules/svg/kartaview_images.js index 65cd48a17..3beb60862 100644 --- a/modules/svg/kartaview_images.js +++ b/modules/svg/kartaview_images.js @@ -301,6 +301,10 @@ export function svgKartaviewImages(projection, context, dispatch) { return !!getService(); }; + drawImages.rendered = function(zoom) { + return zoom >= minZoom; + }; + init(); return drawImages; diff --git a/modules/svg/mapilio_images.js b/modules/svg/mapilio_images.js index 1f4a679a8..ac62b52ef 100644 --- a/modules/svg/mapilio_images.js +++ b/modules/svg/mapilio_images.js @@ -243,6 +243,10 @@ export function svgMapilioImages(projection, context, dispatch) { return !!getService(); }; + drawImages.rendered = function(zoom) { + return zoom >= minZoom; + }; + init(); return drawImages; diff --git a/modules/svg/mapillary_images.js b/modules/svg/mapillary_images.js index 7e9d9ea08..6afba1c05 100644 --- a/modules/svg/mapillary_images.js +++ b/modules/svg/mapillary_images.js @@ -310,6 +310,10 @@ export function svgMapillaryImages(projection, context, dispatch) { return !!getService(); }; + drawImages.rendered = function(zoom) { + return zoom >= minZoom; + }; + init(); return drawImages; diff --git a/modules/svg/mapillary_map_features.js b/modules/svg/mapillary_map_features.js index 68a4fadf2..996cc2431 100644 --- a/modules/svg/mapillary_map_features.js +++ b/modules/svg/mapillary_map_features.js @@ -208,6 +208,10 @@ export function svgMapillaryMapFeatures(projection, context, dispatch) { return !!getService(); }; + drawMapFeatures.rendered = function(zoom) { + return zoom >= minZoom; + }; + init(); return drawMapFeatures; diff --git a/modules/svg/mapillary_position.js b/modules/svg/mapillary_position.js index e5af5154b..836cb26a5 100644 --- a/modules/svg/mapillary_position.js +++ b/modules/svg/mapillary_position.js @@ -157,6 +157,10 @@ export function svgMapillaryPosition(projection, context) { return !!getService(); }; + drawImages.rendered = function(zoom) { + return zoom >= minZoom; + }; + init(); return drawImages; diff --git a/modules/svg/mapillary_signs.js b/modules/svg/mapillary_signs.js index 8c6b00c5a..b2837a389 100644 --- a/modules/svg/mapillary_signs.js +++ b/modules/svg/mapillary_signs.js @@ -197,6 +197,10 @@ export function svgMapillarySigns(projection, context, dispatch) { return !!getService(); }; + drawSigns.rendered = function(zoom) { + return zoom >= minZoom; + }; + init(); return drawSigns; diff --git a/modules/svg/streetside.js b/modules/svg/streetside.js index 36649189f..73ec4eeb7 100644 --- a/modules/svg/streetside.js +++ b/modules/svg/streetside.js @@ -379,6 +379,10 @@ export function svgStreetside(projection, context, dispatch) { return !!getService(); }; + drawImages.rendered = function(zoom) { + return zoom >= minZoom; + }; + init(); return drawImages; diff --git a/modules/svg/vegbilder.js b/modules/svg/vegbilder.js index 2a2066d6e..5563e5ebc 100644 --- a/modules/svg/vegbilder.js +++ b/modules/svg/vegbilder.js @@ -371,6 +371,15 @@ export function svgVegbilder(projection, context, dispatch) { return !!getService(); }; + drawImages.rendered = function(zoom) { + return zoom >= minZoom; + }; + + drawImages.validHere = function(extent, zoom) { + return zoom >= (minZoom - 2) + && getService().validHere(extent); + }; + init(); return drawImages; diff --git a/modules/ui/sections/photo_overlays.js b/modules/ui/sections/photo_overlays.js index 7d5c76fe6..b9a9b5876 100644 --- a/modules/ui/sections/photo_overlays.js +++ b/modules/ui/sections/photo_overlays.js @@ -1,3 +1,4 @@ +import _debounce from 'lodash-es/debounce'; import { select as d3_select } from 'd3-selection'; @@ -33,7 +34,14 @@ export function uiSectionPhotoOverlays(context) { function drawPhotoItems(selection) { var photoKeys = context.photos().overlayLayerIDs(); var photoLayers = layers.all().filter(function(obj) { return photoKeys.indexOf(obj.id) !== -1; }); - var data = photoLayers.filter(function(obj) { return obj.layer.supported(); }); + var data = photoLayers.filter(function(obj) { + if (!obj.layer.supported()) return false; + if (layerEnabled(obj)) return true; + if (typeof obj.layer.validHere === 'function') { + return obj.layer.validHere(context.map().extent(), context.map().zoom()); + } + return true; + }); function layerSupported(d) { return d.layer && d.layer.supported(); @@ -41,6 +49,9 @@ export function uiSectionPhotoOverlays(context) { function layerEnabled(d) { return layerSupported(d) && d.layer.enabled(); } + function layerRendered(d) { + return d.layer.rendered?.(context.map().zoom()) ?? true; + } var ul = selection .selectAll('.layer-list-photos') @@ -77,7 +88,13 @@ export function uiSectionPhotoOverlays(context) { else titleID = d.id.replace(/-/g, '_') + '.tooltip'; d3_select(this) .call(uiTooltip() - .title(() => t.append(titleID)) + .title(() => { + if (!layerRendered(d)) { + return t.append('street_side.minzoom_tooltip'); + } else { + return t.append(titleID); + } + }) .placement('top') ); }); @@ -100,6 +117,7 @@ export function uiSectionPhotoOverlays(context) { .merge(liEnter) .classed('active', layerEnabled) .selectAll('input') + .property('disabled', d => !layerRendered(d)) .property('checked', layerEnabled); } @@ -320,5 +338,13 @@ export function uiSectionPhotoOverlays(context) { context.layers().on('change.uiSectionPhotoOverlays', section.reRender); context.photos().on('change.uiSectionPhotoOverlays', section.reRender); + context.map() + .on('move.background_list', + _debounce(function() { + // layers in-view may have changed due to map move + window.requestIdleCallback(section.reRender); + }, 1000) + ); + return section; } From edb7c3b0f2ba22d62d0239e100ee738c0ddc8327 Mon Sep 17 00:00:00 2001 From: Martin Raifer Date: Wed, 2 Aug 2023 11:41:16 +0200 Subject: [PATCH 25/30] only fetch vegbilder layers if they are needed i.e. when enabling the respective street-side photo layer --- modules/services/vegbilder.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/modules/services/vegbilder.js b/modules/services/vegbilder.js index 3e2761629..b93c07935 100644 --- a/modules/services/vegbilder.js +++ b/modules/services/vegbilder.js @@ -308,11 +308,8 @@ function searchLimited(limit, projection, rtree) { export default { - init: async function () { + init: function () { this.event = utilRebind(this, dispatch, 'on'); - if (!_vegbilderCache) { - await this.reset(); - } }, reset: async function () { @@ -383,10 +380,13 @@ export default { }, getSequenceForImage: function (image) { - return _vegbilderCache.image2sequence_map.get(image?.key); - }, + return _vegbilderCache?.image2sequence_map.get(image?.key); + }, - loadImages: function (context, margin) { + loadImages: async function (context, margin) { + if (!_vegbilderCache) { + await this.reset(); + } margin ??= 1; const wfslayers = filterAvailableLayers(context.photos()); loadWFSLayers(context.projection, margin, wfslayers); From 23d19e89ff8ed2d8ca7fbd78597f3bc8cfa2a868 Mon Sep 17 00:00:00 2001 From: Martin Raifer Date: Wed, 2 Aug 2023 16:18:25 +0200 Subject: [PATCH 26/30] =?UTF-8?q?fix=20zoom/pan=20behavior=20on=20non-360?= =?UTF-8?q?=C2=B0=20vegbilder=20photos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- css/60_photos.css | 4 +--- modules/services/plane_photo.js | 15 +++++---------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/css/60_photos.css b/css/60_photos.css index 98d568a68..96888a499 100644 --- a/css/60_photos.css +++ b/css/60_photos.css @@ -415,9 +415,7 @@ label.streetside-hires { } .vegbilder-wrapper .plane-frame { - display: flex; - align-items: center; - justify-content: center; + display: block; overflow: hidden; height: 100%; width: 100%; diff --git a/modules/services/plane_photo.js b/modules/services/plane_photo.js index 8a5b246fc..045ac812e 100644 --- a/modules/services/plane_photo.js +++ b/modules/services/plane_photo.js @@ -7,6 +7,7 @@ const dispatch = d3_dispatch('viewerChanged'); let _photo; let _wrapper; let imgZoom; +let _widthOverflow; function zoomPan (d3_event) { let t = d3_event.transform; @@ -17,10 +18,10 @@ function zoomBeahvior () { const {width: wrapperWidth, height: wrapperHeight} = _wrapper.node().getBoundingClientRect(); const {naturalHeight, naturalWidth} = _photo.node(); const intrinsicRatio = naturalWidth / naturalHeight; - const widthOverflow = wrapperHeight * intrinsicRatio - wrapperWidth; + _widthOverflow = wrapperHeight * intrinsicRatio - wrapperWidth; return d3_zoom() - .extent([[widthOverflow / 2, 0], [wrapperWidth + widthOverflow / 2, wrapperHeight]]) - .translateExtent([[0, 0], [wrapperWidth + widthOverflow, wrapperHeight]]) + .extent([[0, 0], [wrapperWidth, wrapperHeight]]) + .translateExtent([[0, 0], [wrapperWidth + _widthOverflow, wrapperHeight]]) .scaleExtent([1, 15]) .on('zoom', zoomPan); } @@ -40,12 +41,6 @@ export default { init: async function(context, selection) { this.event = utilRebind(this, dispatch, 'on'); - imgZoom = d3_zoom() - .extent([[0, 0], [320, 240]]) - .translateExtent([[0, 0], [320, 240]]) - .scaleExtent([1, 15]) - .on('zoom', this.zoomPan); - _wrapper = selection .append('div') .attr('class', 'photo-frame plane-frame') @@ -96,7 +91,7 @@ export default { if (!keepOrientation) { imgZoom = zoomBeahvior(); _wrapper.call(imgZoom); - _wrapper.call(imgZoom.transform, d3_zoomIdentity); + _wrapper.call(imgZoom.transform, d3_zoomIdentity.translate(-_widthOverflow / 2, 0)); } }); return this; From 31c5a60ac23a4ba913e79c7f15217f48ae07bed8 Mon Sep 17 00:00:00 2001 From: Martin Raifer Date: Wed, 2 Aug 2023 16:28:49 +0200 Subject: [PATCH 27/30] =?UTF-8?q?show=20spinner=20while=20loading=20non-36?= =?UTF-8?q?0=C2=B0=20"vegbilder"=20photos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- css/60_photos.css | 4 ++++ modules/services/plane_photo.js | 1 + 2 files changed, 5 insertions(+) diff --git a/css/60_photos.css b/css/60_photos.css index 96888a499..4b655c853 100644 --- a/css/60_photos.css +++ b/css/60_photos.css @@ -419,6 +419,9 @@ label.streetside-hires { overflow: hidden; height: 100%; width: 100%; + background-image: url(img/loader-black.gif); + background-position: center; + background-repeat: no-repeat; } .vegbilder-wrapper .plane-frame > img.plane-photo{ @@ -447,6 +450,7 @@ label.streetside-hires { .photo-controls button:focus { height: 18px; width: 18px; + line-height: 18px; background: rgba(0,0,0,0.65); color: #eee; border-radius: 0; diff --git a/modules/services/plane_photo.js b/modules/services/plane_photo.js index 045ac812e..103eb0a42 100644 --- a/modules/services/plane_photo.js +++ b/modules/services/plane_photo.js @@ -86,6 +86,7 @@ export default { }, selectPhoto: function (data, keepOrientation) { + loadImage(_photo, ''); loadImage(_photo, data.image_path) .then(() => { if (!keepOrientation) { From e52ec96bad1da9914e62b55920bfc69f70b65004 Mon Sep 17 00:00:00 2001 From: Martin Raifer Date: Wed, 2 Aug 2023 16:49:07 +0200 Subject: [PATCH 28/30] =?UTF-8?q?reset=20yaw=20when=20switching=20between?= =?UTF-8?q?=20360=C2=B0=20and=20planar=20photo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (and also immediately when switching between different 360° photos) --- modules/services/pannellum_photo.js | 1 + modules/services/plane_photo.js | 1 + modules/services/vegbilder.js | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/modules/services/pannellum_photo.js b/modules/services/pannellum_photo.js index 62220bde7..51ac406a8 100644 --- a/modules/services/pannellum_photo.js +++ b/modules/services/pannellum_photo.js @@ -139,6 +139,7 @@ export default { pitch = _pannellumViewer.getPitch(); } _pannellumViewer.loadScene(key, pitch, yaw); + dispatch.call('viewerChanged'); if (_currScenes.length > 3) { const old_key = _currScenes.shift(); diff --git a/modules/services/plane_photo.js b/modules/services/plane_photo.js index 103eb0a42..f654ef1ea 100644 --- a/modules/services/plane_photo.js +++ b/modules/services/plane_photo.js @@ -86,6 +86,7 @@ export default { }, selectPhoto: function (data, keepOrientation) { + dispatch.call('viewerChanged'); loadImage(_photo, ''); loadImage(_photo, data.image_path) .then(() => { diff --git a/modules/services/vegbilder.js b/modules/services/vegbilder.js index b93c07935..9735fd25b 100644 --- a/modules/services/vegbilder.js +++ b/modules/services/vegbilder.js @@ -449,8 +449,9 @@ export default { planePhotoFrame.init(context, wrapEnter) ]).then(([pannellumPhotoFrame, planePhotoFrame]) => { _pannellumFrame = pannellumPhotoFrame; - _planeFrame = planePhotoFrame; _pannellumFrame.event.on('viewerChanged', () => dispatch.call('viewerChanged')); + _planeFrame = planePhotoFrame; + _planeFrame.event.on('viewerChanged', () => dispatch.call('viewerChanged')); }); return _loadViewerPromise; From b1b7b9360e51016745304edd53e971a8987abb09 Mon Sep 17 00:00:00 2001 From: Martin Raifer Date: Wed, 2 Aug 2023 17:06:52 +0200 Subject: [PATCH 29/30] add to changelog --- CHANGELOG.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2927eb39..79820f0d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,13 +37,14 @@ _Breaking developer changes, which may affect downstream projects or sites that # Unreleased (2.27.0-dev) -#### :mega: Release Highlights -* Add [_Mapilio_](https://mapilio.com/openstreetmap) as new street-level imagery provider ([#9664], thanks [@channel-s]) #### :tada: New Features #### :sparkles: Usability & Accessibility * Show tag reference information for the currently filled-in tag value in UI fields (if available), instead of only showing the more generic _key_ documentation of the field ([#9786]) #### :scissors: Operations #### :camera: Street-Level +* Add [_Mapilio_](https://mapilio.com/openstreetmap) as new street-level imagery provider ([#9664], thanks [@channel-s]) +* Add photos from the [Norwegian Public Road Administration](https://vegbilder.atlas.vegvesen.no/) as new street-level imagery provider in Norway ([#9509], thanks [@noenandre]) +* Gray out street level layers in "Map Data" pane when map is zoomed out too far #### :white_check_mark: Validation #### :bug: Bugfixes * Validator: Don't falsely flag certain tags as "should be a closed area" if the tag also allows both area and line geometries in two separate presets (e.g. `highway=elevator` in the "Elevator" and "Inclined Lift" presets) @@ -56,9 +57,11 @@ _Breaking developer changes, which may affect downstream projects or sites that #### :hammer: Development [#8997]: https://github.com/openstreetmap/iD/issues/8997 -[#9786]: https://github.com/openstreetmap/iD/issues/9786 +[#9509]: https://github.com/openstreetmap/iD/pull/9509 [#9664]: https://github.com/openstreetmap/iD/pull/9664 +[#9786]: https://github.com/openstreetmap/iD/issues/9786 [@channel-s]: https://github.com/channel-s +[@noenandre]: https://github.com/noenandre # 2.26.2 From 066686ac1ab752b18d3452e4f71ba39a1b045025 Mon Sep 17 00:00:00 2001 From: Martin Raifer Date: Wed, 2 Aug 2023 17:33:17 +0200 Subject: [PATCH 30/30] fix test --- test/spec/svg/layers.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/spec/svg/layers.js b/test/spec/svg/layers.js index bfec1c9ce..0bc48daf9 100644 --- a/test/spec/svg/layers.js +++ b/test/spec/svg/layers.js @@ -26,7 +26,7 @@ describe('iD.svgLayers', function () { it('creates default data layers', function () { container.call(iD.svgLayers(projection, context)); var nodes = container.selectAll('svg .data-layer').nodes(); - expect(nodes.length).to.eql(16); + expect(nodes.length).to.eql(17); expect(d3.select(nodes[0]).classed('osm')).to.be.true; expect(d3.select(nodes[1]).classed('notes')).to.be.true; expect(d3.select(nodes[2]).classed('data')).to.be.true; @@ -40,10 +40,10 @@ describe('iD.svgLayers', function () { expect(d3.select(nodes[10]).classed('mapillary-signs')).to.be.true; expect(d3.select(nodes[11]).classed('kartaview')).to.be.true; expect(d3.select(nodes[12]).classed('mapilio')).to.be.true; - expect(d3.select(nodes[12]).classed('vegbilder')).to.be.true; - expect(d3.select(nodes[13]).classed('debug')).to.be.true; - expect(d3.select(nodes[14]).classed('geolocate')).to.be.true; - expect(d3.select(nodes[15]).classed('touch')).to.be.true; + expect(d3.select(nodes[13]).classed('vegbilder')).to.be.true; + expect(d3.select(nodes[14]).classed('debug')).to.be.true; + expect(d3.select(nodes[15]).classed('geolocate')).to.be.true; + expect(d3.select(nodes[16]).classed('touch')).to.be.true; }); });