diff --git a/CHANGELOG.md b/CHANGELOG.md index 36c284c76..a0dd6e132 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,14 +37,15 @@ _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]) * Don't suggest _discardable_ (i.e. deprecated and automatically removed tags) in the auto-suggestions of the raw tag editor ([#9817], thanks [@k-yle]) #### :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) @@ -60,10 +61,12 @@ _Breaking developer changes, which may affect downstream projects or sites that [#8997]: https://github.com/openstreetmap/iD/issues/8997 [#9233]: https://github.com/openstreetmap/iD/issues/9233 +[#9509]: https://github.com/openstreetmap/iD/pull/9509 [#9664]: https://github.com/openstreetmap/iD/pull/9664 [#9786]: https://github.com/openstreetmap/iD/issues/9786 [#9817]: https://github.com/openstreetmap/iD/pull/9817 [@channel-s]: https://github.com/channel-s +[@noenandre]: https://github.com/noenandre # 2.26.2 diff --git a/css/60_photos.css b/css/60_photos.css index 438f6ad2c..4b655c853 100644 --- a/css/60_photos.css +++ b/css/60_photos.css @@ -68,12 +68,10 @@ } -.photo-wrapper, -.photo-wrapper img { +.photo-wrapper { width: 100%; height: 100%; overflow: hidden; - object-fit: cover; } .photo-wrapper .photo-attribution { @@ -185,6 +183,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 { @@ -313,7 +323,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; @@ -376,6 +387,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; } @@ -391,6 +409,26 @@ label.streetside-hires { transform-origin: 0 0; } +.vegbilder-wrapper { + position: relative; + background-color: #000; +} + +.vegbilder-wrapper .plane-frame { + display: block; + 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{ + width: auto; + height: 100%; + transform-origin: 0 0; +} /* photo-controls (step forward, back, rotate) */ .photo-controls-wrap { @@ -412,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/data/core.yaml b/data/core.yaml index 03fc38bee..6320908e5 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -1358,6 +1358,11 @@ 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" + publisher: "Norwegian Public Roads Administration" + view_on: "View it on Vegbilder" mapillary_images: tooltip: "Street-level photos from Mapillary" mapillary_map_features: @@ -1435,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/renderer/background.js b/modules/renderer/background.js index 236dfc517..90d69d97f 100644 --- a/modules/renderer/background.js +++ b/modules/renderer/background.js @@ -252,6 +252,7 @@ export function rendererBackground(context) { 'mapillary-map-features': 'Mapillary Map Features', 'mapillary-signs': 'Mapillary Signs', kartaview: 'KartaView Images', + vegbilder: 'Norwegian Road Administration Images', mapilio: 'Mapilio Images' }; diff --git a/modules/renderer/photos.js b/modules/renderer/photos.js index 227b4b20a..7c3fa952a 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', 'mapilio']; + var _layerIDs = ['streetside', 'mapillary', 'mapillary-map-features', 'mapillary-signs', 'kartaview', 'mapilio', 'vegbilder']; var _allPhotoTypes = ['flat', 'panoramic']; var _shownPhotoTypes = _allPhotoTypes.slice(); // shallow copy var _dateFilters = ['fromDate', 'toDate']; @@ -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/index.js b/modules/services/index.js index 108412f4e..896fcc127 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'; @@ -24,6 +25,7 @@ export let services = { mapillary: serviceMapillary, nsi: serviceNsi, kartaview: serviceKartaview, + vegbilder: serviceVegbilder, osm: serviceOsm, osmWikibase: serviceOsmWikibase, maprules: serviceMapRules, @@ -44,6 +46,7 @@ export { serviceNominatim, serviceNsi, serviceKartaview, + serviceVegbilder, serviceOsm, serviceOsmWikibase, serviceStreetside, diff --git a/modules/services/pannellum_photo.js b/modules/services/pannellum_photo.js new file mode 100644 index 000000000..51ac406a8 --- /dev/null +++ b/modules/services/pannellum_photo.js @@ -0,0 +1,156 @@ +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) { + const head = d3_select('head'); + + 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) { + 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: false, + 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); + dispatch.call('viewerChanged'); + + 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..f654ef1ea --- /dev/null +++ b/modules/services/plane_photo.js @@ -0,0 +1,106 @@ +import { dispatch as d3_dispatch } from 'd3-dispatch'; +import { zoom as d3_zoom, zoomIdentity as d3_zoomIdentity } from 'd3-zoom'; +import { utilSetTransform, utilRebind } from '../util'; + +const dispatch = d3_dispatch('viewerChanged'); + +let _photo; +let _wrapper; +let imgZoom; +let _widthOverflow; + +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; + _widthOverflow = wrapperHeight * intrinsicRatio - wrapperWidth; + return d3_zoom() + .extent([[0, 0], [wrapperWidth, 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) { + this.event = utilRebind(this, dispatch, 'on'); + + _wrapper = selection + .append('div') + .attr('class', 'photo-frame plane-frame') + .classed('hide', true); + + _photo = _wrapper + .append('img') + .attr('class', 'plane-photo'); + + context.ui().photoviewer.on('resize.plane', () => { + imgZoom = zoomBeahvior(); + _wrapper.call(imgZoom); + }); + + 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, keepOrientation) { + dispatch.call('viewerChanged'); + loadImage(_photo, ''); + loadImage(_photo, data.image_path) + .then(() => { + if (!keepOrientation) { + imgZoom = zoomBeahvior(); + _wrapper.call(imgZoom); + _wrapper.call(imgZoom.transform, d3_zoomIdentity.translate(-_widthOverflow / 2, 0)); + } + }); + return this; + }, + + getYaw: function() { + return 0; + } + +}; diff --git a/modules/services/streetside.js b/modules/services/streetside.js index 52af3c3ef..b176e672e 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 new file mode 100644 index 000000000..9735fd25b --- /dev/null +++ b/modules/services/vegbilder.js @@ -0,0 +1,615 @@ +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'; + + +const owsEndpoint = 'https://www.vegvesen.no/kart/ogc/vegbilder_1_0/ows?'; +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 _planeFrame; +let _pannellumFrame; +let _currentFrame; +let _loadViewerPromise; +let _vegbilderCache; + +async function fetchAvailableLayers() { + const params = { + service: 'WFS', + request: 'GetCapabilities', + version: '2.0.0', + }; + + 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; + const availableLayers = []; + while ( (node = l.iterateNext()) !== null ) { + const match = node.textContent?.match(regexMatcher); + if (match) { + availableLayers.push({ + name: match[0], + is_sphere: !!match.groups?.image_type, + year: parseInt(match.groups?.year, 10) + }); + } + } + return availableLayers; +} + +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 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, wfslayers) { + const tiles = tiler.margin(margin).getTiles(projection); + for (const cache of wfslayers) { + loadWFSLayer(projection, cache, tiles); + } +} + +function loadWFSLayer(projection, cache, tiles) { + // abort inflight requests that are no longer needed + for (const [key, controller] of cache.inflight.entries()) { + const wanted = tiles.some(tile => key === tile.id); + if (!wanted) { + controller.abort(); + cache.inflight.delete(key); + } + } + + Promise.all(tiles.map( + 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, typename, tile) { + const bbox = tile.extent.bbox(); + const tileid = tile.id; + if ((cache.loaded.get(tileid) === true) || cache.inflight.has(tileid)) return; + + const params = { + service: 'WFS', + request: 'GetFeature', + version: '2.0.0', + typenames: typename, + bbox: [bbox.minY, bbox.minX, bbox.maxY, bbox.maxX].join(','), + outputFormat: 'json' + }; + + const controller = new AbortController(); + cache.inflight.set(tileid, controller); + + const options = { + method: 'GET', + signal: controller.signal, + }; + + const urlForRequest = owsEndpoint + utilQsString(params); + + let featureCollection; + try { + featureCollection = await d3_json(urlForRequest, options); + } catch { + cache.loaded.set(tileid, false); + return; + } finally { + cache.inflight.delete(tileid); + } + + cache.loaded.set(tileid, true); + + 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, + URLPREVIEW : preview_path, + BILDETYPE: image_type, + METER: metering, + FELTKODE: lane_code + } = properties; + const lane_number = parseInt(lane_code.match(/^[0-9]+/)[0], 10); + const direction = lane_number % 2 === 0 ? directionEnum.backward : directionEnum.forward; + const data = { + loc, + key, + ca, + image_path, + preview_path, + road_reference: roadReference(properties), + metering, + lane_code, + direction, + captured_at: new Date(captured_at), + is_sphere: image_type === '360' + }; + + cache.points.set(key, data); + + return { + minX: loc[0], minY: loc[1], maxX: loc[0], maxY: loc[1], data + }; + }); + + _vegbilderCache.rtree.load(features); + dispatch.call('loadedImages'); +} + +function orderSequences(projection, cache) { + const {points} = cache; + + const grouped = Array.from(points.values()).reduce((grouped, image) => { + const key = image.road_reference; + if (grouped.has(key)) { + grouped.get(key).push(image); + } else { + grouped.set(key, [image]); + } + 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; + } 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 imageSequence = [imageGroup[0]]; + let angle = null; + for (const [lastImage, image] of d3_pairs(imageGroup)) { + if (lastImage.ca === null) { + const b = projection(lastImage.loc); + const a = projection(image.loc); + if (!geoVecEqual(a, b)) { + 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 + ) { + imageSequence.push(image); + } else { + imageSequences.push(imageSequence); + imageSequence = [image]; + } + } + imageSequences.push(imageSequence); + return imageSequences; + }, []); + + cache.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.image2sequence_map.set(image.key, seqence); + } + return seqence; + }); +} + +function roadReference(properties) { + const { + 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, + AAR: year, + } = properties; + + let reference; + + if (year >= 2020) { + 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 = `${county_number}${road_class}${road_status}${road_number} HP${parcel}`; + } + + 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) { + 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); +} + +function searchLimited(limit, projection, rtree) { + limit ??= 5; + + return partitionViewport(projection) + .reduce((result, extent) => { + const found = rtree.search(extent.bbox()) + .slice(0, limit) + .map(d => d.data); + + return result.concat(found); + }, []); +} + + +export default { + + init: function () { + this.event = utilRebind(this, dispatch, 'on'); + }, + + reset: async function () { + if (_vegbilderCache) { + for (const layer of _vegbilderCache.wfslayers.values()) { + for (const controller of layer.inflight.values()) { + controller.abort(); + } + } + } + + _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(), + inflight: new Map(), + points: new Map(), + sequences: [] + }; + wfslayers.set(layerInfo.name, cache); + } + }, + + images: function (projection) { + const limit = 5; + return searchLimited(limit, projection, _vegbilderCache.rtree); + }, + + + sequences: function (projection) { + 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(); + const seen = new Set(); + const line_strings = []; + + 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); + const line = { + type: 'LineString', + coordinates: geometry.coordinates, + key, + images + }; + line_strings.push(line); + } + return line_strings; + }, + + cachedImage: function (key) { + for (const {points} of _vegbilderCache.wfslayers.values()) { + if (points.has(key)) return points.get(key); + } + }, + + getSequenceForImage: function (image) { + return _vegbilderCache?.image2sequence_map.get(image?.key); + }, + + loadImages: async function (context, margin) { + if (!_vegbilderCache) { + await this.reset(); + } + margin ??= 1; + const wfslayers = filterAvailableLayers(context.photos()); + loadWFSLayers(context.projection, margin, wfslayers); + }, + + 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]; + + if (!nextImage) return; + + context.map().centerEase(nextImage.loc); + this.selectImage(context, nextImage.key, true); + }; + + 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('►'); + + _loadViewerPromise = Promise.all([ + pannellumPhotoFrame.init(context, wrapEnter), + planePhotoFrame.init(context, wrapEnter) + ]).then(([pannellumPhotoFrame, planePhotoFrame]) => { + _pannellumFrame = pannellumPhotoFrame; + _pannellumFrame.event.on('viewerChanged', () => dispatch.call('viewerChanged')); + _planeFrame = planePhotoFrame; + _planeFrame.event.on('viewerChanged', () => dispatch.call('viewerChanged')); + }); + + return _loadViewerPromise; + }, + + 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); } + + this.setStyles(context, null, true); + + if (!d) return this; + + const wrap = context.container().select('.photoviewer .vegbilder-wrapper'); + const attribution = wrap.selectAll('.photo-attribution').text(''); + + if (d.captured_at) { + attribution + .append('span') + .attr('class', 'captured_at') + .text(localeTimestamp(d.captured_at)); + } + + attribution + .append('a') + .attr('target', '_blank') + .attr('href', 'https://vegvesen.no') + .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; + + _currentFrame + .selectPhoto(d, keepOrientation) + .showPhotoFrame(wrap); + + 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') + .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?.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?.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); + + 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.key === hoveredSequenceKey) + .classed('currentView', d => d.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_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'; + } + } + + 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)); + } + }, + + validHere: function(extent) { + const bbox = Object.values(extent.bbox()); + return iso1A2Codes(bbox).includes('NO'); + }, + + + cache: function () { + return _vegbilderCache; + } + +}; diff --git a/modules/svg/index.js b/modules/svg/index.js index 798afe3f7..c7fe6ca0c 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/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/layers.js b/modules/svg/layers.js index 9f5e9a503..36f6d767e 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'; @@ -38,6 +39,7 @@ export function svgLayers(projection, context) { { id: 'mapillary-signs', layer: svgMapillarySigns(projection, context, dispatch) }, { id: 'kartaview', layer: svgKartaviewImages(projection, context, dispatch) }, { id: 'mapilio', layer: svgMapilioImages(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/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 new file mode 100644 index 000000000..5563e5ebc --- /dev/null +++ b/modules/svg/vegbilder.js @@ -0,0 +1,386 @@ +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 _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() { + const 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'); + } + + function click(d3_event, d) { + const service = getService(); + if (!service) return; + + 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, selected) { + let t = svgPointTransform(projection)(d); + let rot = d.ca; + if (d === selected) { + rot += _viewerYaw; + } + if (rot) { + t += ' rotate(' + Math.floor(rot) + ',0,0)'; + } + return t; + } + + + function viewerChanged() { + const service = getService(); + if (!service) return; + + const frame = service.photoFrame(); + + // update viewfield rotation + _viewerYaw = frame.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', (d) => transform(d, d)); + } + + 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; + } + + 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[images.length - 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(). + */ + 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 = []; + + if (service) { + // The WFS-layer for that year or image type may not be loaded after a filter is changed + service.loadImages(context); + + sequences = service.sequences(projection); + images = showMarkers ? service.images(projection) : []; + images = filterImages(images); + sequences = filterSequences(sequences); + } + + let traces = layer.selectAll('.sequences').selectAll('.sequence') + .data(sequences, d => d.key); + + // exit + traces.exit() + .remove(); + + // enter/update + 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', (d) => transform(d, selected)) + .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_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'; + } + } + } + + /** + * 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(context); + } 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(); + }; + + 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/photoviewer.js b/modules/ui/photoviewer.js index e25f5f6d3..0d1240ff8 100644 --- a/modules/ui/photoviewer.js +++ b/modules/ui/photoviewer.js @@ -25,6 +25,7 @@ export function uiPhotoviewer(context) { if (services.mapillary) { services.mapillary.hideViewer(context); } if (services.kartaview) { services.kartaview.hideViewer(context); } if (services.mapilio) { services.mapilio.hideViewer(context); } + if (services.vegbilder) { services.vegbilder.hideViewer(context); } }) .append('div') .call(svgIcon('#iD-icon-close')); 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; } diff --git a/package.json b/package.json index 853a040b3..c539cf248 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", diff --git a/test/spec/services/vegbilder.js b/test/spec/services/vegbilder.js new file mode 100644 index 000000000..d8a2936ec --- /dev/null +++ b/test/spec/services/vegbilder.js @@ -0,0 +1,305 @@ +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; + }); + + after(function() { + delete iD.services.vegbilder; + }); + + 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([-66409, 853915]) + .clipExtent([[0,0], dimensions]); + + vegbilder = iD.services.vegbilder; + await vegbilder.reset(); + fetchMock.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('image2sequence_map'); + + vegbilder.init(); + 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', async function() { + vegbilder.cache().foo = 'bar'; + await vegbilder.reset(); + 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 + }]); + }); + }); +}); 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 22c1da3ea..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,9 +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[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; }); });