modules/renderer/map.js (815 lines of code) (raw):

import { dispatch as d3_dispatch } from 'd3-dispatch'; import { interpolate as d3_interpolate } from 'd3-interpolate'; import { scaleLinear as d3_scaleLinear } from 'd3-scale'; import { select as d3_select } from 'd3-selection'; import { zoom as d3_zoom, zoomIdentity as d3_zoomIdentity } from 'd3-zoom'; import { Extent, geoScaleToZoom, geoZoomToScale } from '@id-sdk/math'; import { utilEntityAndDeepMemberIDs } from '@id-sdk/util'; import _throttle from 'lodash-es/throttle'; import { prefs } from '../core/preferences'; import { geoRawMercator} from '../geo'; import { modeBrowse } from '../modes/browse'; import { svgAreas, svgLabels, svgLayers, svgLines, svgMidpoints, svgPoints, svgVertices } from '../svg'; import { utilFastMouse, utilFunctor, utilSetTransform, utilTotalExtent } from '../util/util'; import { utilBindOnce } from '../util/bind_once'; import { utilDetect } from '../util/detect'; import { utilGetDimensions } from '../util/dimensions'; import { utilRebind } from '../util/rebind'; import { utilZoomPan } from '../util/zoom_pan'; import { utilDoubleUp } from '../util/double_up'; // constants var TILESIZE = 256; var minZoom = 2; var maxZoom = 24; var kMin = geoZoomToScale(minZoom, TILESIZE); var kMax = geoZoomToScale(maxZoom, TILESIZE); function clamp(num, min, max) { return Math.max(min, Math.min(num, max)); } export function rendererMap(context) { var dispatch = d3_dispatch( 'move', 'drawn', 'crossEditableZoom', 'hitMinZoom', 'changeHighlighting', 'changeAreaFill' ); var projection = context.projection; var curtainProjection = context.curtainProjection; var drawLayers; var drawPoints; var drawVertices; var drawLines; var drawAreas; var drawMidpoints; var drawLabels; var _selection = d3_select(null); var supersurface = d3_select(null); var wrapper = d3_select(null); var surface = d3_select(null); var _dimensions = [1, 1]; var _dblClickZoomEnabled = true; var _redrawEnabled = true; var _gestureTransformStart; var _transformStart = projection.transform(); var _transformLast; var _isTransformed = false; var _minzoom = 0; var _getMouseCoords; var _lastPointerEvent; var _lastWithinEditableZoom; // whether a pointerdown event started the zoom var _pointerDown = false; // use pointer events on supported platforms; fallback to mouse events var _pointerPrefix = 'PointerEvent' in window ? 'pointer' : 'mouse'; // use pointer event interaction if supported; fallback to touch/mouse events in d3-zoom var _zoomerPannerFunction = 'PointerEvent' in window ? utilZoomPan : d3_zoom; var _zoomerPanner = _zoomerPannerFunction() .scaleExtent([kMin, kMax]) .interpolate(d3_interpolate) .filter(zoomEventFilter) .on('zoom.map', zoomPan) .on('start.map', function(d3_event) { _pointerDown = d3_event && (d3_event.type === 'pointerdown' || (d3_event.sourceEvent && d3_event.sourceEvent.type === 'pointerdown')); }) .on('end.map', function() { _pointerDown = false; }); var _doubleUpHandler = utilDoubleUp(); var scheduleRedraw = _throttle(redraw, 750); // var isRedrawScheduled = false; // var pendingRedrawCall; // function scheduleRedraw() { // // Only schedule the redraw if one has not already been set. // if (isRedrawScheduled) return; // isRedrawScheduled = true; // var that = this; // var args = arguments; // pendingRedrawCall = window.requestIdleCallback(function () { // // Reset the boolean so future redraws can be set. // isRedrawScheduled = false; // redraw.apply(that, args); // }, { timeout: 1400 }); // } function cancelPendingRedraw() { scheduleRedraw.cancel(); // isRedrawScheduled = false; // window.cancelIdleCallback(pendingRedrawCall); } function map(selection) { _selection = selection; context .on('change.map', immediateRedraw); var osm = context.connection(); if (osm) { osm.on('change.map', immediateRedraw); } function didUndoOrRedo(targetTransform) { var mode = context.mode().id; if (mode !== 'browse' && mode !== 'select') return; if (targetTransform) { map.transformEase(targetTransform); } } context.history() .on('merge.map', function() { scheduleRedraw(); }) .on('change.map', immediateRedraw) .on('undone.map', function(stack, fromStack) { didUndoOrRedo(fromStack.transform); }) .on('redone.map', function(stack) { didUndoOrRedo(stack.transform); }); context.background() .on('change.map', immediateRedraw); context.features() .on('redraw.map', immediateRedraw); drawLayers .on('change.map', function() { context.background().updateImagery(); immediateRedraw(); }); selection .on('wheel.map mousewheel.map', function(d3_event) { // disable swipe-to-navigate browser pages on trackpad/magic mouse – #5552 d3_event.preventDefault(); }) .call(_zoomerPanner) .call(_zoomerPanner.transform, projection.transform()) .on('dblclick.zoom', null); // override d3-zoom dblclick handling map.supersurface = supersurface = selection.append('div') .attr('class', 'supersurface') .call(utilSetTransform, 0, 0); // Need a wrapper div because Opera can't cope with an absolutely positioned // SVG element: http://bl.ocks.org/jfirebaugh/6fbfbd922552bf776c16 wrapper = supersurface .append('div') .attr('class', 'layer layer-data'); map.surface = surface = wrapper .call(drawLayers) .selectAll('.surface'); surface .call(drawLabels.observe) .call(_doubleUpHandler) .on(_pointerPrefix + 'down.zoom', function(d3_event) { _lastPointerEvent = d3_event; if (d3_event.button === 2) { d3_event.stopPropagation(); } }, true) .on(_pointerPrefix + 'up.zoom', function(d3_event) { _lastPointerEvent = d3_event; if (resetTransform()) { immediateRedraw(); } }) .on(_pointerPrefix + 'move.map', function(d3_event) { _lastPointerEvent = d3_event; }) .on(_pointerPrefix + 'over.vertices', function(d3_event) { if (map.editableDataEnabled() && !_isTransformed) { var hover = d3_event.target.__data__; surface.call(drawVertices.drawHover, context.graph(), hover, map.extent()); dispatch.call('drawn', this, { full: false }); } }) .on(_pointerPrefix + 'out.vertices', function(d3_event) { if (map.editableDataEnabled() && !_isTransformed) { var hover = d3_event.relatedTarget && d3_event.relatedTarget.__data__; surface.call(drawVertices.drawHover, context.graph(), hover, map.extent()); dispatch.call('drawn', this, { full: false }); } }); var detected = utilDetect(); // only WebKit supports gesture events if ('GestureEvent' in window && // Listening for gesture events on iOS 13.4+ breaks double-tapping, // but we only need to do this on desktop Safari anyway. – #7694 !detected.isMobileWebKit) { // Desktop Safari sends gesture events for multitouch trackpad pinches. // We can listen for these and translate them into map zooms. surface .on('gesturestart.surface', function(d3_event) { d3_event.preventDefault(); _gestureTransformStart = projection.transform(); }) .on('gesturechange.surface', gestureChange); } // must call after surface init updateAreaFill(); _doubleUpHandler.on('doubleUp.map', function(d3_event, p0) { if (!_dblClickZoomEnabled) return; // don't zoom if targeting something other than the map itself if (typeof d3_event.target.__data__ === 'object' && // or area fills !d3_select(d3_event.target).classed('fill')) return; var zoomOut = d3_event.shiftKey; var t = projection.transform(); var p1 = t.invert(p0); t = t.scale(zoomOut ? 0.5 : 2); t.x = p0[0] - p1[0] * t.k; t.y = p0[1] - p1[1] * t.k; map.transformEase(t); }); context.on('enter.map', function() { if (!map.editableDataEnabled(true /* skip zoom check */)) return; if (_isTransformed) return; // redraw immediately any objects affected by a change in selectedIDs. var graph = context.graph(); var selectedAndParents = {}; context.selectedIDs().forEach(function(id) { var entity = graph.hasEntity(id); if (entity) { selectedAndParents[entity.id] = entity; if (entity.type === 'node') { graph.parentWays(entity).forEach(function(parent) { selectedAndParents[parent.id] = parent; }); } } }); var data = Object.values(selectedAndParents); var filter = function(d) { return d.id in selectedAndParents; }; data = context.features().filter(data, graph); surface .call(drawVertices.drawSelected, graph, map.extent()) .call(drawLines, graph, data, filter) .call(drawAreas, graph, data, filter) .call(drawMidpoints, graph, data, filter, map.trimmedExtent()); dispatch.call('drawn', this, { full: false }); // redraw everything else later scheduleRedraw(); }); map.dimensions(utilGetDimensions(selection)); } function zoomEventFilter(d3_event) { // Fix for #2151, (see also d3/d3-zoom#60, d3/d3-brush#18) // Intercept `mousedown` and check if there is an orphaned zoom gesture. // This can happen if a previous `mousedown` occurred without a `mouseup`. // If we detect this, dispatch `mouseup` to complete the orphaned gesture, // so that d3-zoom won't stop propagation of new `mousedown` events. if (d3_event.type === 'mousedown') { var hasOrphan = false; var listeners = window.__on; for (var i = 0; i < listeners.length; i++) { var listener = listeners[i]; if (listener.name === 'zoom' && listener.type === 'mouseup') { hasOrphan = true; break; } } if (hasOrphan) { var event = window.CustomEvent; if (event) { event = new event('mouseup'); } else { event = window.document.createEvent('Event'); event.initEvent('mouseup', false, false); } // Event needs to be dispatched with an event.view property. event.view = window; window.dispatchEvent(event); } } return d3_event.button !== 2; // ignore right clicks } function pxCenter() { return [_dimensions[0] / 2, _dimensions[1] / 2]; } function drawEditable(difference, extent) { var mode = context.mode(); var graph = context.graph(); var features = context.features(); var all = context.history().intersects(map.extent()); var fullRedraw = false; var data; var set; var filter; var applyFeatureLayerFilters = true; if (map.isInWideSelection()) { data = []; utilEntityAndDeepMemberIDs(mode.selectedIDs(), context.graph()).forEach(function(id) { var entity = context.hasEntity(id); if (entity) data.push(entity); }); fullRedraw = true; filter = utilFunctor(true); // selected features should always be visible, so we can skip filtering applyFeatureLayerFilters = false; } else if (difference) { var complete = difference.complete(map.extent()); data = Object.values(complete).filter(Boolean); set = new Set(Object.keys(complete)); filter = function(d) { return set.has(d.id); }; features.clear(data); } else { // force a full redraw if gatherStats detects that a feature // should be auto-hidden (e.g. points or buildings).. if (features.gatherStats(all, graph, _dimensions)) { extent = undefined; } if (extent) { data = context.history().intersects(map.extent().intersection(extent)); set = new Set(data.map(function(entity) { return entity.id; })); filter = function(d) { return set.has(d.id); }; } else { data = all; fullRedraw = true; filter = utilFunctor(true); } } if (applyFeatureLayerFilters) { data = features.filter(data, graph); } else { context.features().resetStats(); } if (mode && mode.id === 'select') { // update selected vertices - the user might have just double-clicked a way, // creating a new vertex, triggering a partial redraw without a mode change surface.call(drawVertices.drawSelected, graph, map.extent()); } surface .call(drawVertices, graph, data, filter, map.extent(), fullRedraw) .call(drawLines, graph, data, filter) .call(drawAreas, graph, data, filter) .call(drawMidpoints, graph, data, filter, map.trimmedExtent()) .call(drawLabels, graph, data, filter, _dimensions, fullRedraw) .call(drawPoints, graph, data, filter); dispatch.call('drawn', this, {full: true}); } map.init = function() { drawLayers = svgLayers(projection, context); drawPoints = svgPoints(projection, context); drawVertices = svgVertices(projection, context); drawLines = svgLines(projection, context); drawAreas = svgAreas(projection, context); drawMidpoints = svgMidpoints(projection, context); drawLabels = svgLabels(projection, context); }; function editOff() { context.features().resetStats(); surface.selectAll('.layer-osm *').remove(); surface.selectAll('.layer-touch:not(.markers) *').remove(); var allowed = { 'browse': true, 'save': true, 'select-note': true, 'select-data': true, 'select-error': true }; var mode = context.mode(); if (mode && !allowed[mode.id]) { context.enter(modeBrowse(context)); } dispatch.call('drawn', this, {full: true}); } function gestureChange(d3_event) { // Remap Safari gesture events to wheel events - #5492 // We want these disabled most places, but enabled for zoom/unzoom on map surface // https://developer.mozilla.org/en-US/docs/Web/API/GestureEvent var e = d3_event; e.preventDefault(); var props = { deltaMode: 0, // dummy values to ignore in zoomPan deltaY: 1, // dummy values to ignore in zoomPan clientX: e.clientX, clientY: e.clientY, screenX: e.screenX, screenY: e.screenY, x: e.x, y: e.y }; var e2 = new WheelEvent('wheel', props); e2._scale = e.scale; // preserve the original scale e2._rotation = e.rotation; // preserve the original rotation _selection.node().dispatchEvent(e2); } function zoomPan(event, key, transform) { var source = event && event.sourceEvent || event; var eventTransform = transform || (event && event.transform); var x = eventTransform.x; var y = eventTransform.y; var k = eventTransform.k; // Special handling of 'wheel' events: // They might be triggered by the user scrolling the mouse wheel, // or 2-finger pinch/zoom gestures, the transform may need adjustment. if (source && source.type === 'wheel') { // assume that the gesture is already handled by pointer events if (_pointerDown) return; var detected = utilDetect(); var dX = source.deltaX; var dY = source.deltaY; var x2 = x; var y2 = y; var k2 = k; var t0, p0, p1; // Normalize mousewheel scroll speed (Firefox) - #3029 // If wheel delta is provided in LINE units, recalculate it in PIXEL units // We are essentially redoing the calculations that occur here: // https://github.com/d3/d3-zoom/blob/78563a8348aa4133b07cac92e2595c2227ca7cd7/src/zoom.js#L203 // See this for more info: // https://github.com/basilfx/normalize-wheel/blob/master/src/normalizeWheel.js if (source.deltaMode === 1 /* LINE */) { // Convert from lines to pixels, more if the user is scrolling fast. // (I made up the exp function to roughly match Firefox to what Chrome does) // These numbers should be floats, because integers are treated as pan gesture below. var lines = Math.abs(source.deltaY); var sign = (source.deltaY > 0) ? 1 : -1; dY = sign * clamp( Math.exp((lines - 1) * 0.75) * 4.000244140625, 4.000244140625, // min 350.000244140625 // max ); // On Firefox Windows and Linux we always get +/- the scroll line amount (default 3) // There doesn't seem to be any scroll acceleration. // This multiplier increases the speed a little bit - #5512 if (detected.os !== 'mac') { dY *= 5; } // recalculate x2,y2,k2 t0 = _isTransformed ? _transformLast : _transformStart; p0 = _getMouseCoords(source); p1 = t0.invert(p0); k2 = t0.k * Math.pow(2, -dY / 500); k2 = clamp(k2, kMin, kMax); x2 = p0[0] - p1[0] * k2; y2 = p0[1] - p1[1] * k2; // 2 finger map pinch zooming (Safari) - #5492 // These are fake `wheel` events we made from Safari `gesturechange` events.. } else if (source._scale) { // recalculate x2,y2,k2 t0 = _gestureTransformStart; p0 = _getMouseCoords(source); p1 = t0.invert(p0); k2 = t0.k * source._scale; k2 = clamp(k2, kMin, kMax); x2 = p0[0] - p1[0] * k2; y2 = p0[1] - p1[1] * k2; // 2 finger map pinch zooming (all browsers except Safari) - #5492 // Pinch zooming via the `wheel` event will always have: // - `ctrlKey = true` // - `deltaY` is not round integer pixels (ignore `deltaX`) } else if (source.ctrlKey && !isInteger(dY)) { dY *= 6; // slightly scale up whatever the browser gave us // recalculate x2,y2,k2 t0 = _isTransformed ? _transformLast : _transformStart; p0 = _getMouseCoords(source); p1 = t0.invert(p0); k2 = t0.k * Math.pow(2, -dY / 500); k2 = clamp(k2, kMin, kMax); x2 = p0[0] - p1[0] * k2; y2 = p0[1] - p1[1] * k2; // Trackpad scroll zooming with shift or alt/option key down } else if ((source.altKey || source.shiftKey) && isInteger(dY)) { // recalculate x2,y2,k2 t0 = _isTransformed ? _transformLast : _transformStart; p0 = _getMouseCoords(source); p1 = t0.invert(p0); k2 = t0.k * Math.pow(2, -dY / 500); k2 = clamp(k2, kMin, kMax); x2 = p0[0] - p1[0] * k2; y2 = p0[1] - p1[1] * k2; // 2 finger map panning (Mac only, all browsers except Firefox #8595) - #5492, #5512 // Panning via the `wheel` event will always have: // - `ctrlKey = false` // - `deltaX`,`deltaY` are round integer pixels } else if (detected.os === 'mac' && detected.browser !== 'Firefox' && !source.ctrlKey && isInteger(dX) && isInteger(dY)) { p1 = projection.translate(); x2 = p1[0] - dX; y2 = p1[1] - dY; k2 = projection.scale(); k2 = clamp(k2, kMin, kMax); } // something changed - replace the event transform if (x2 !== x || y2 !== y || k2 !== k) { x = x2; y = y2; k = k2; eventTransform = d3_zoomIdentity.translate(x2, y2).scale(k2); if (_zoomerPanner._transform) { // utilZoomPan interface _zoomerPanner._transform(eventTransform); } else { // d3_zoom interface _selection.node().__zoom = eventTransform; } } } if (_transformStart.x === x && _transformStart.y === y && _transformStart.k === k) { return; // no change } if (geoScaleToZoom(k, TILESIZE) < _minzoom) { surface.interrupt(); dispatch.call('hitMinZoom', this, map); setCenterZoom(map.center(), context.minEditableZoom(), 0, true); scheduleRedraw(); dispatch.call('move', this, map); return; } projection.transform(eventTransform); var withinEditableZoom = map.withinEditableZoom(); if (_lastWithinEditableZoom !== withinEditableZoom) { if (_lastWithinEditableZoom !== undefined) { // notify that the map zoomed in or out over the editable zoom threshold dispatch.call('crossEditableZoom', this, withinEditableZoom); } _lastWithinEditableZoom = withinEditableZoom; } var scale = k / _transformStart.k; var tX = (x / scale - _transformStart.x) * scale; var tY = (y / scale - _transformStart.y) * scale; if (context.inIntro()) { curtainProjection.transform({ x: x - tX, y: y - tY, k: k }); } if (source) { _lastPointerEvent = event; } _isTransformed = true; _transformLast = eventTransform; utilSetTransform(supersurface, tX, tY, scale); scheduleRedraw(); dispatch.call('move', this, map); function isInteger(val) { return typeof val === 'number' && isFinite(val) && Math.floor(val) === val; } } function resetTransform() { if (!_isTransformed) return false; utilSetTransform(supersurface, 0, 0); _isTransformed = false; if (context.inIntro()) { curtainProjection.transform(projection.transform()); } return true; } function redraw(difference, extent) { if (surface.empty() || !_redrawEnabled) return; // If we are in the middle of a zoom/pan, we can't do differenced redraws. // It would result in artifacts where differenced entities are redrawn with // one transform and unchanged entities with another. if (resetTransform()) { difference = extent = undefined; } var zoom = map.zoom(); var z = String(~~zoom); if (surface.attr('data-zoom') !== z) { surface.attr('data-zoom', z); } // class surface as `lowzoom` around z17-z18.5 (based on latitude) var lat = map.center()[1]; var lowzoom = d3_scaleLinear() .domain([-60, 0, 60]) .range([17, 18.5, 17]) .clamp(true); surface .classed('low-zoom', zoom <= lowzoom(lat)); if (!difference) { supersurface.call(context.background()); wrapper.call(drawLayers); } // OSM if (map.editableDataEnabled() || map.isInWideSelection()) { context.loadTiles(projection); drawEditable(difference, extent); } else { editOff(); } _transformStart = projection.transform(); return map; } var immediateRedraw = function(difference, extent) { if (!difference && !extent) cancelPendingRedraw(); redraw(difference, extent); }; map.lastPointerEvent = function() { return _lastPointerEvent; }; map.mouse = function(d3_event) { var event = d3_event || _lastPointerEvent; if (event) { var s; while ((s = event.sourceEvent)) { event = s; } return _getMouseCoords(event); } return null; }; // returns Lng/Lat map.mouseCoordinates = function() { var coord = map.mouse() || pxCenter(); return projection.invert(coord); }; map.dblclickZoomEnable = function(val) { if (!arguments.length) return _dblClickZoomEnabled; _dblClickZoomEnabled = val; return map; }; map.redrawEnable = function(val) { if (!arguments.length) return _redrawEnabled; _redrawEnabled = val; return map; }; map.isTransformed = function() { return _isTransformed; }; function setTransform(t2, duration, force) { var t = projection.transform(); if (!force && t2.k === t.k && t2.x === t.x && t2.y === t.y) return false; if (duration) { _selection .transition() .duration(duration) .on('start', function() { map.startEase(); }) .call(_zoomerPanner.transform, d3_zoomIdentity.translate(t2.x, t2.y).scale(t2.k)); } else { projection.transform(t2); _transformStart = t2; _selection.call(_zoomerPanner.transform, _transformStart); } return true; } function setCenterZoom(loc2, z2, duration, force) { var c = map.center(); var z = map.zoom(); if (loc2[0] === c[0] && loc2[1] === c[1] && z2 === z && !force) return false; var proj = geoRawMercator().transform(projection.transform()); // copy projection var k2 = clamp(geoZoomToScale(z2, TILESIZE), kMin, kMax); proj.scale(k2); var t = proj.translate(); var point = proj(loc2); var center = pxCenter(); t[0] += center[0] - point[0]; t[1] += center[1] - point[1]; return setTransform(d3_zoomIdentity.translate(t[0], t[1]).scale(k2), duration, force); } map.pan = function(delta, duration) { var t = projection.translate(); var k = projection.scale(); t[0] += delta[0]; t[1] += delta[1]; if (duration) { _selection .transition() .duration(duration) .on('start', function() { map.startEase(); }) .call(_zoomerPanner.transform, d3_zoomIdentity.translate(t[0], t[1]).scale(k)); } else { projection.translate(t); _transformStart = projection.transform(); _selection.call(_zoomerPanner.transform, _transformStart); dispatch.call('move', this, map); immediateRedraw(); } return map; }; map.dimensions = function(val) { if (!arguments.length) return _dimensions; _dimensions = val; drawLayers.dimensions(_dimensions); context.background().dimensions(_dimensions); projection.clipExtent([[0, 0], _dimensions]); _getMouseCoords = utilFastMouse(supersurface.node()); scheduleRedraw(); return map; }; function zoomIn(delta) { setCenterZoom(map.center(), ~~map.zoom() + delta, 250, true); } function zoomOut(delta) { setCenterZoom(map.center(), ~~map.zoom() - delta, 250, true); } map.zoomIn = function() { zoomIn(1); }; map.zoomInFurther = function() { zoomIn(4); }; map.canZoomIn = function() { return map.zoom() < maxZoom; }; map.zoomOut = function() { zoomOut(1); }; map.zoomOutFurther = function() { zoomOut(4); }; map.canZoomOut = function() { return map.zoom() > minZoom; }; map.center = function(loc2) { if (!arguments.length) { return projection.invert(pxCenter()); } if (setCenterZoom(loc2, map.zoom())) { dispatch.call('move', this, map); } scheduleRedraw(); return map; }; map.unobscuredCenterZoomEase = function(loc, zoom) { var offset = map.unobscuredOffsetPx(); var proj = geoRawMercator().transform(projection.transform()); // copy projection // use the target zoom to calculate the offset center proj.scale(geoZoomToScale(zoom, TILESIZE)); var locPx = proj(loc); var offsetLocPx = [locPx[0] + offset[0], locPx[1] + offset[1]]; var offsetLoc = proj.invert(offsetLocPx); map.centerZoomEase(offsetLoc, zoom); }; map.unobscuredOffsetPx = function() { var openPane = context.container().select('.map-panes .map-pane.shown'); if (!openPane.empty()) { return [openPane.node().offsetWidth/2, 0]; } return [0, 0]; }; map.zoom = function(z2) { if (!arguments.length) { return Math.max(geoScaleToZoom(projection.scale(), TILESIZE), 0); } if (z2 < _minzoom) { surface.interrupt(); dispatch.call('hitMinZoom', this, map); z2 = context.minEditableZoom(); } if (setCenterZoom(map.center(), z2)) { dispatch.call('move', this, map); } scheduleRedraw(); return map; }; map.centerZoom = function(loc2, z2) { if (setCenterZoom(loc2, z2)) { dispatch.call('move', this, map); } scheduleRedraw(); return map; }; map.zoomTo = function(entity) { var extent = entity.extent(context.graph()); if (!isFinite(extent.area())) return map; var z2 = clamp(map.trimmedExtentZoom(extent), 0, 20); return map.centerZoom(extent.center(), z2); }; map.centerEase = function(loc2, duration) { duration = duration || 250; setCenterZoom(loc2, map.zoom(), duration); return map; }; map.zoomEase = function(z2, duration) { duration = duration || 250; setCenterZoom(map.center(), z2, duration, false); return map; }; map.centerZoomEase = function(loc2, z2, duration) { duration = duration || 250; setCenterZoom(loc2, z2, duration, false); return map; }; map.transformEase = function(t2, duration) { duration = duration || 250; setTransform(t2, duration, false /* don't force */); return map; }; map.zoomToEase = function(val, duration) { var extent; if (Array.isArray(val)) { extent = utilTotalExtent(val, context.graph()); } else { extent = val.extent(context.graph()); } if (!isFinite(extent.area())) return map; var z2 = clamp(map.trimmedExtentZoom(extent), 0, 20); return map.centerZoomEase(extent.center(), z2, duration); }; map.startEase = function() { utilBindOnce(surface, _pointerPrefix + 'down.ease', function() { map.cancelEase(); }); return map; }; map.cancelEase = function() { _selection.interrupt(); return map; }; map.extent = function(extent) { if (!arguments.length) { return new Extent( projection.invert([0, _dimensions[1]]), projection.invert([_dimensions[0], 0]) ); } else { map.centerZoom(extent.center(), map.extentZoom(extent)); } }; map.trimmedExtent = function(extent) { if (!arguments.length) { var headerY = 71; var footerY = 30; var pad = 10; return new Extent( projection.invert([pad, _dimensions[1] - footerY - pad]), projection.invert([_dimensions[0] - pad, headerY + pad]) ); } else { map.centerZoom(extent.center(), map.trimmedExtentZoom(extent)); } }; function calcExtentZoom(extent, dim) { var tl = projection([extent.min[0], extent.max[1]]); var br = projection([extent.max[0], extent.min[1]]); // Calculate maximum zoom that fits extent var hFactor = (br[0] - tl[0]) / dim[0]; var vFactor = (br[1] - tl[1]) / dim[1]; var hZoomDiff = Math.log(Math.abs(hFactor)) / Math.LN2; var vZoomDiff = Math.log(Math.abs(vFactor)) / Math.LN2; var zoomDiff = Math.max(hZoomDiff, vZoomDiff); var currZoom = map.zoom(); return isFinite(zoomDiff) ? (currZoom - zoomDiff) : currZoom; } map.extentZoom = function(extent) { return calcExtentZoom(extent, _dimensions); }; map.trimmedExtentZoom = function(extent) { var trimY = 120; var trimX = 40; var trimmed = [_dimensions[0] - trimX, _dimensions[1] - trimY]; return calcExtentZoom(extent, trimmed); }; map.withinEditableZoom = function() { return map.zoom() >= context.minEditableZoom(); }; map.isInWideSelection = function() { return !map.withinEditableZoom() && context.selectedIDs().length; }; map.editableDataEnabled = function(skipZoomCheck) { var layer = context.layers().layer('osm'); if (!layer || !layer.enabled()) return false; return skipZoomCheck || map.withinEditableZoom(); }; map.notesEditable = function() { var layer = context.layers().layer('notes'); if (!layer || !layer.enabled()) return false; return map.withinEditableZoom(); }; map.minzoom = function(val) { if (!arguments.length) return _minzoom; _minzoom = val; return map; }; map.toggleHighlightEdited = function() { surface.classed('highlight-edited', !surface.classed('highlight-edited')); map.pan([0,0]); // trigger a redraw dispatch.call('changeHighlighting', this); }; map.areaFillOptions = ['wireframe', 'partial', 'full']; map.activeAreaFill = function(val) { if (!arguments.length) return prefs('area-fill') || 'partial'; prefs('area-fill', val); if (val !== 'wireframe') { prefs('area-fill-toggle', val); } updateAreaFill(); map.pan([0,0]); // trigger a redraw dispatch.call('changeAreaFill', this); return map; }; map.toggleWireframe = function() { var activeFill = map.activeAreaFill(); if (activeFill === 'wireframe') { activeFill = prefs('area-fill-toggle') || 'partial'; } else { activeFill = 'wireframe'; } map.activeAreaFill(activeFill); }; function updateAreaFill() { var activeFill = map.activeAreaFill(); map.areaFillOptions.forEach(function(opt) { surface.classed('fill-' + opt, Boolean(opt === activeFill)); }); } map.layers = () => drawLayers; map.doubleUpHandler = function() { return _doubleUpHandler; }; return utilRebind(map, dispatch, 'on'); }