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] 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'));