mirror of
https://github.com/FoggedLens/iD.git
synced 2026-02-19 12:13:43 +00:00
609 lines
18 KiB
JavaScript
609 lines
18 KiB
JavaScript
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 { services } from './';
|
|
|
|
|
|
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 response = await d3_xml(urlForRequest);
|
|
const regexMatcher = /^vegbilder_1_0:Vegbilder(?<image_type>_360)?_(?<year>\d{4})$/;
|
|
const availableLayers = [];
|
|
for (const node of response.querySelectorAll('FeatureType > Name')) {
|
|
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 = {
|
|
service: 'photo',
|
|
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 sequence = {
|
|
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, sequence);
|
|
}
|
|
return sequence;
|
|
});
|
|
}
|
|
|
|
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
|
|
.showPhotoFrame(wrap)
|
|
.selectPhoto(d, keepOrientation);
|
|
|
|
return this;
|
|
},
|
|
|
|
showViewer: function (context) {
|
|
const viewer = context.container().select('.photoviewer');
|
|
const isHidden = viewer.selectAll('.photo-wrapper.vegbilder-wrapper.hide').size();
|
|
|
|
if (isHidden) {
|
|
for (const service of Object.values(services)) {
|
|
if (service === this) continue;
|
|
if (typeof service.hideViewer === 'function') {
|
|
service.hideViewer(context);
|
|
}
|
|
}
|
|
viewer
|
|
.classed('hide', false)
|
|
.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) {
|
|
const hash = utilStringQs(window.location.hash);
|
|
if (key) {
|
|
hash.photo = 'vegbilder/' + key;
|
|
} else {
|
|
delete hash.photo;
|
|
}
|
|
window.history.replaceState(null, '', '#' + utilQsString(hash, true));
|
|
},
|
|
|
|
validHere: function(extent) {
|
|
const bbox = Object.values(extent.bbox());
|
|
return iso1A2Codes(bbox).includes('NO');
|
|
},
|
|
|
|
|
|
cache: function () {
|
|
return _vegbilderCache;
|
|
}
|
|
|
|
};
|