modules/renderer/background.js (409 lines of code) (raw):

import { dispatch as d3_dispatch } from 'd3-dispatch'; import { interpolateNumber as d3_interpolateNumber } from 'd3-interpolate'; import { select as d3_select } from 'd3-selection'; import { Extent, geoMetersToOffset, geoOffsetToMeters} from '@id-sdk/math'; import { utilQsString, utilStringQs } from '@id-sdk/util'; import whichPolygon from 'which-polygon'; import { prefs } from '../core/preferences'; import { fileFetcher } from '../core/file_fetcher'; import { rendererBackgroundSource } from './background_source'; import { rendererTileLayer } from './tile_layer'; import { utilDetect } from '../util/detect'; import { utilRebind } from '../util/rebind'; let _imageryIndex = null; export function rendererBackground(context) { const dispatch = d3_dispatch('change'); const detected = utilDetect(); const baseLayer = rendererTileLayer(context).projection(context.projection); let _checkedBlocklists = []; let _isValid = true; let _overlayLayers = []; let _brightness = 1; let _contrast = 1; let _saturation = 1; let _sharpness = 1; var _numGridSplits = 0; // No grid by default. function ensureImageryIndex() { return fileFetcher.get('imagery') .then(sources => { if (_imageryIndex) return _imageryIndex; _imageryIndex = { imagery: sources, features: {} }; // use which-polygon to support efficient index and querying for imagery const features = sources.map(source => { if (!source.polygon) return null; // workaround for editor-layer-index weirdness.. // Add an extra array nest to each element in `source.polygon` // so the rings are not treated as a bunch of holes: // what we have: [ [[outer],[hole],[hole]] ] // what we want: [ [[outer]],[[outer]],[[outer]] ] const rings = source.polygon.map(ring => [ring]); const feature = { type: 'Feature', properties: { id: source.id }, geometry: { type: 'MultiPolygon', coordinates: rings } }; _imageryIndex.features[source.id] = feature; return feature; }).filter(Boolean); _imageryIndex.query = whichPolygon({ type: 'FeatureCollection', features: features }); // Instantiate `rendererBackgroundSource` objects for each source _imageryIndex.backgrounds = sources.map(source => { if (source.type === 'bing') { return rendererBackgroundSource.Bing(source, dispatch); } else if (/^EsriWorldImagery/.test(source.id)) { return rendererBackgroundSource.Esri(source); } else { return rendererBackgroundSource(source); } }); // Add 'None' _imageryIndex.backgrounds.unshift(rendererBackgroundSource.None()); // Add 'Custom' let template = prefs('background-custom-template') || ''; const custom = rendererBackgroundSource.Custom(template); _imageryIndex.backgrounds.unshift(custom); return _imageryIndex; }); } function background(selection) { const currSource = baseLayer.source(); // If we are displaying an Esri basemap at high zoom, // check its tilemap to see how high the zoom can go if (context.map().zoom() > 18) { if (currSource && /^EsriWorldImagery/.test(currSource.id)) { const center = context.map().center(); currSource.fetchTilemap(center); } } // Is the imagery valid here? - #4827 const sources = background.sources(context.map().extent()); const wasValid = _isValid; _isValid = !!sources.filter(d => d === currSource).length; if (wasValid !== _isValid) { // change in valid status background.updateImagery(); } let baseFilter = ''; if (detected.cssfilters) { if (_brightness !== 1) { baseFilter += ` brightness(${_brightness})`; } if (_contrast !== 1) { baseFilter += ` contrast(${_contrast})`; } if (_saturation !== 1) { baseFilter += ` saturate(${_saturation})`; } if (_sharpness < 1) { // gaussian blur const blur = d3_interpolateNumber(0.5, 5)(1 - _sharpness); baseFilter += ` blur(${blur}px)`; } } let base = selection.selectAll('.layer-background') .data([0]); base = base.enter() .insert('div', '.layer-data') .attr('class', 'layer layer-background') .merge(base); if (detected.cssfilters) { base.style('filter', baseFilter || null); } else { base.style('opacity', _brightness); } let imagery = base.selectAll('.layer-imagery') .data([0]); imagery.enter() .append('div') .attr('class', 'layer layer-imagery') .merge(imagery) .call(baseLayer); let maskFilter = ''; let mixBlendMode = ''; if (detected.cssfilters && _sharpness > 1) { // apply unsharp mask mixBlendMode = 'overlay'; maskFilter = 'saturate(0) blur(3px) invert(1)'; let contrast = _sharpness - 1; maskFilter += ` contrast(${contrast})`; let brightness = d3_interpolateNumber(1, 0.85)(_sharpness - 1); maskFilter += ` brightness(${brightness})`; } let mask = base.selectAll('.layer-unsharp-mask') .data(detected.cssfilters && _sharpness > 1 ? [0] : []); mask.exit() .remove(); mask.enter() .append('div') .attr('class', 'layer layer-mask layer-unsharp-mask') .merge(mask) .call(baseLayer) .style('filter', maskFilter || null) .style('mix-blend-mode', mixBlendMode || null); let overlays = selection.selectAll('.layer-overlay') .data(_overlayLayers, d => d.source().name()); overlays.exit() .remove(); overlays.enter() .insert('div', '.layer-data') .attr('class', 'layer layer-overlay') .merge(overlays) .each((layer, i, nodes) => d3_select(nodes[i]).call(layer)); } background.numGridSplits = function(_) { if (!arguments.length) return _numGridSplits; _numGridSplits = _; dispatch.call('change'); return background; }; background.updateImagery = function() { let currSource = baseLayer.source(); if (context.inIntro() || !currSource) return; let o = _overlayLayers .filter(d => !d.source().isLocatorOverlay() && !d.source().isHidden()) .map(d => d.source().id) .join(','); const meters = geoOffsetToMeters(currSource.offset()); const EPSILON = 0.01; const x = +meters[0].toFixed(2); const y = +meters[1].toFixed(2); let hash = utilStringQs(window.location.hash); let id = currSource.id; if (id === 'custom') { id = `custom:${currSource.template()}`; } if (id) { hash.background = id; } else { delete hash.background; } if (o) { hash.overlays = o; } else { delete hash.overlays; } if (Math.abs(x) > EPSILON || Math.abs(y) > EPSILON) { hash.offset = `${x},${y}`; } else { delete hash.offset; } if (!window.mocha) { window.location.replace('#' + utilQsString(hash, true)); } let imageryUsed = []; let photoOverlaysUsed = []; const currUsed = currSource.imageryUsed(); if (currUsed && _isValid) { imageryUsed.push(currUsed); } _overlayLayers .filter(d => !d.source().isLocatorOverlay() && !d.source().isHidden()) .forEach(d => imageryUsed.push(d.source().imageryUsed())); const dataLayer = context.layers().layer('data'); if (dataLayer && dataLayer.enabled() && dataLayer.hasData()) { imageryUsed.push(dataLayer.getSrc()); } const photoOverlayLayers = { streetside: 'Bing Streetside', mapillary: 'Mapillary Images', 'mapillary-map-features': 'Mapillary Map Features', 'mapillary-signs': 'Mapillary Signs', openstreetcam: 'OpenStreetCam Images' }; for (let layerID in photoOverlayLayers) { const layer = context.layers().layer(layerID); if (layer && layer.enabled()) { photoOverlaysUsed.push(layerID); imageryUsed.push(photoOverlayLayers[layerID]); } } context.history().imageryUsed(imageryUsed); context.history().photoOverlaysUsed(photoOverlaysUsed); }; background.sources = (extent, zoom, includeCurrent) => { if (!_imageryIndex) return []; // called before init()? let visible = {}; (_imageryIndex.query.bbox(extent.rectangle(), true) || []) .forEach(d => visible[d.id] = true); const currSource = baseLayer.source(); // Recheck blocked sources only if we detect new blocklists pulled from the OSM API. const osm = context.connection(); const blocklists = (osm && osm.imageryBlocklists()) || []; const blocklistChanged = (blocklists.length !== _checkedBlocklists.length) || blocklists.some((regex, index) => String(regex) !== _checkedBlocklists[index]); if (blocklistChanged) { _imageryIndex.backgrounds.forEach(source => { source.isBlocked = blocklists.some(regex => regex.test(source.template())); }); _checkedBlocklists = blocklists.map(regex => String(regex)); } return _imageryIndex.backgrounds.filter(source => { if (includeCurrent && currSource === source) return true; // optionally always include the current imagery if (source.isBlocked) return false; // even bundled sources may be blocked - #7905 if (!source.polygon) return true; // always include imagery with worldwide coverage if (zoom && zoom < 6) return false; // optionally exclude local imagery at low zooms return visible[source.id]; // include imagery visible in given extent }); }; background.dimensions = (val) => { if (!val) return; baseLayer.dimensions(val); _overlayLayers.forEach(layer => layer.dimensions(val)); }; background.baseLayerSource = function(d) { if (!arguments.length) return baseLayer.source(); // test source against OSM imagery blocklists.. const osm = context.connection(); if (!osm) return background; const blocklists = osm.imageryBlocklists(); const template = d.template(); let fail = false; let tested = 0; let regex; for (let i = 0; i < blocklists.length; i++) { regex = blocklists[i]; fail = regex.test(template); tested++; if (fail) break; } // ensure at least one test was run. if (!tested) { regex = /.*\.google(apis)?\..*\/(vt|kh)[\?\/].*([xyz]=.*){3}.*/; fail = regex.test(template); } baseLayer.source(!fail ? d : background.findSource('none')); dispatch.call('change'); background.updateImagery(); return background; }; background.findSource = (id) => { if (!id || !_imageryIndex) return null; // called before init()? return _imageryIndex.backgrounds.find(d => d.id && d.id === id); }; background.bing = () => { background.baseLayerSource(background.findSource('Bing')); }; background.showsLayer = (d) => { const currSource = baseLayer.source(); if (!d || !currSource) return false; return d.id === currSource.id || _overlayLayers.some(layer => d.id === layer.source().id); }; background.overlayLayerSources = () => { return _overlayLayers.map(layer => layer.source()); }; background.toggleOverlayLayer = (d) => { let layer; for (let i = 0; i < _overlayLayers.length; i++) { layer = _overlayLayers[i]; if (layer.source() === d) { _overlayLayers.splice(i, 1); dispatch.call('change'); background.updateImagery(); return; } } layer = rendererTileLayer(context) .source(d) .projection(context.projection) .dimensions(baseLayer.dimensions() ); _overlayLayers.push(layer); dispatch.call('change'); background.updateImagery(); }; background.nudge = (d, zoom) => { const currSource = baseLayer.source(); if (currSource) { currSource.nudge(d, zoom); dispatch.call('change'); background.updateImagery(); } return background; }; background.offset = function(d) { const currSource = baseLayer.source(); if (!arguments.length) { return (currSource && currSource.offset()) || [0, 0]; } if (currSource) { currSource.offset(d); dispatch.call('change'); background.updateImagery(); } return background; }; background.brightness = function(d) { if (!arguments.length) return _brightness; _brightness = d; if (context.mode()) dispatch.call('change'); return background; }; background.contrast = function(d) { if (!arguments.length) return _contrast; _contrast = d; if (context.mode()) dispatch.call('change'); return background; }; background.saturation = function(d) { if (!arguments.length) return _saturation; _saturation = d; if (context.mode()) dispatch.call('change'); return background; }; background.sharpness = function(d) { if (!arguments.length) return _sharpness; _sharpness = d; if (context.mode()) dispatch.call('change'); return background; }; let _loadPromise; background.ensureLoaded = () => { if (_loadPromise) return _loadPromise; function parseMapParams(qmap) { if (!qmap) return false; const params = qmap.split('/').map(Number); if (params.length < 3 || params.some(isNaN)) return false; return new Extent([params[2], params[1]]); // lon,lat } const hash = utilStringQs(window.location.hash); const requested = hash.background || hash.layer; let extent = parseMapParams(hash.map); return _loadPromise = ensureImageryIndex() .then(imageryIndex => { const first = imageryIndex.backgrounds.length && imageryIndex.backgrounds[0]; let best; if (!requested && extent) { best = background.sources(extent).find(s => s.best()); } // Decide which background layer to display if (requested && requested.indexOf('custom:') === 0) { const template = requested.replace(/^custom:/, ''); const custom = background.findSource('custom'); background.baseLayerSource(custom.template(template)); prefs('background-custom-template', template); } else { background.baseLayerSource( background.findSource(requested) || best || background.findSource(prefs('background-last-used')) || background.findSource('Maxar-Premium') || background.findSource('Bing') || first || background.findSource('none') ); } const locator = imageryIndex.backgrounds.find(d => d.overlay && d.default); if (locator) { background.toggleOverlayLayer(locator); } const overlays = (hash.overlays || '').split(','); overlays.forEach(overlay => { overlay = background.findSource(overlay); if (overlay) { background.toggleOverlayLayer(overlay); } }); if (hash.gpx) { const gpx = context.layers().layer('data'); if (gpx) { gpx.url(hash.gpx, '.gpx'); } } if (hash.offset) { const offset = hash.offset .replace(/;/g, ',') .split(',') .map(n => !isNaN(n) && n); if (offset.length === 2) { background.offset(geoMetersToOffset(offset)); } } }) .catch(() => { /* ignore */ }); }; return utilRebind(background, dispatch, 'on'); }