modules/ui/fields/restrictions.js (492 lines of code) (raw):

import { dispatch as d3_dispatch } from 'd3-dispatch'; import { select as d3_select } from 'd3-selection'; import { Extent, geoZoomToScale, vecScale, vecSubtract } from '@id-sdk/math'; import { utilEntitySelector } from '@id-sdk/util'; import { presetManager } from '../../presets'; import { prefs } from '../../core/preferences'; import { t, localizer } from '../../core/localizer'; import { actionRestrictTurn } from '../../actions/restrict_turn'; import { actionUnrestrictTurn } from '../../actions/unrestrict_turn'; import { behaviorBreathe } from '../../behavior/breathe'; import { geoRawMercator } from '../../geo'; import { osmIntersection, osmInferRestriction, osmTurn, osmWay } from '../../osm'; import { svgLayers, svgLines, svgTurns, svgVertices } from '../../svg'; import { utilDisplayName, utilDisplayType, utilFunctor, utilRebind } from '../../util'; import { utilGetDimensions, utilSetDimensions } from '../../util/dimensions'; export function uiFieldRestrictions(field, context) { var dispatch = d3_dispatch('change'); var breathe = behaviorBreathe(context); prefs('turn-restriction-via-way', null); // remove old key var storedViaWay = prefs('turn-restriction-via-way0'); // use new key #6922 var storedDistance = prefs('turn-restriction-distance'); var _maxViaWay = storedViaWay !== null ? (+storedViaWay) : 0; var _maxDistance = storedDistance ? (+storedDistance) : 30; var _initialized = false; var _parent = d3_select(null); // the entire field var _container = d3_select(null); // just the map var _oldTurns; var _graph; var _vertexID; var _intersection; var _fromWayID; var _lastXPos; function restrictions(selection) { _parent = selection; // try to reuse the intersection, but always rebuild it if the graph has changed if (_vertexID && (context.graph() !== _graph || !_intersection)) { _graph = context.graph(); _intersection = osmIntersection(_graph, _vertexID, _maxDistance); } // It's possible for there to be no actual intersection here. // for example, a vertex of two `highway=path` // In this case, hide the field. var isOK = ( _intersection && _intersection.vertices.length && // has vertices _intersection.vertices // has the vertex that the user selected .filter(function(vertex) { return vertex.id === _vertexID; }).length && _intersection.ways.length > 2 && // has more than 2 ways _intersection.ways // has more than 1 TO way .filter(function(way) { return way.__to; }).length > 1 ); // Also hide in the case where d3_select(selection.node().parentNode).classed('hide', !isOK); // if form field is hidden or has detached from dom, clean up. if (!isOK || !context.container().select('.inspector-wrap.inspector-hidden').empty() || !selection.node().parentNode || !selection.node().parentNode.parentNode) { selection.call(restrictions.off); return; } var wrap = selection.selectAll('.form-field-input-wrap') .data([0]); wrap = wrap.enter() .append('div') .attr('class', 'form-field-input-wrap form-field-input-' + field.type) .merge(wrap); var container = wrap.selectAll('.restriction-container') .data([0]); // enter var containerEnter = container.enter() .append('div') .attr('class', 'restriction-container'); containerEnter .append('div') .attr('class', 'restriction-help'); // update _container = containerEnter .merge(container) .call(renderViewer); var controls = wrap.selectAll('.restriction-controls') .data([0]); // enter/update controls.enter() .append('div') .attr('class', 'restriction-controls-container') .append('div') .attr('class', 'restriction-controls') .merge(controls) .call(renderControls); } function renderControls(selection) { var distControl = selection.selectAll('.restriction-distance') .data([0]); distControl.exit() .remove(); var distControlEnter = distControl.enter() .append('div') .attr('class', 'restriction-control restriction-distance'); distControlEnter .append('span') .attr('class', 'restriction-control-label restriction-distance-label') .html(t.html('restriction.controls.distance') + ':'); distControlEnter .append('input') .attr('class', 'restriction-distance-input') .attr('type', 'range') .attr('min', '20') .attr('max', '50') .attr('step', '5'); distControlEnter .append('span') .attr('class', 'restriction-distance-text'); // update selection.selectAll('.restriction-distance-input') .property('value', _maxDistance) .on('input', function() { var val = d3_select(this).property('value'); _maxDistance = +val; _intersection = null; _container.selectAll('.layer-osm .layer-turns *').remove(); prefs('turn-restriction-distance', _maxDistance); _parent.call(restrictions); }); selection.selectAll('.restriction-distance-text') .html(displayMaxDistance(_maxDistance)); var viaControl = selection.selectAll('.restriction-via-way') .data([0]); viaControl.exit() .remove(); var viaControlEnter = viaControl.enter() .append('div') .attr('class', 'restriction-control restriction-via-way'); viaControlEnter .append('span') .attr('class', 'restriction-control-label restriction-via-way-label') .html(t.html('restriction.controls.via') + ':'); viaControlEnter .append('input') .attr('class', 'restriction-via-way-input') .attr('type', 'range') .attr('min', '0') .attr('max', '2') .attr('step', '1'); viaControlEnter .append('span') .attr('class', 'restriction-via-way-text'); // update selection.selectAll('.restriction-via-way-input') .property('value', _maxViaWay) .on('input', function() { var val = d3_select(this).property('value'); _maxViaWay = +val; _container.selectAll('.layer-osm .layer-turns *').remove(); prefs('turn-restriction-via-way0', _maxViaWay); _parent.call(restrictions); }); selection.selectAll('.restriction-via-way-text') .html(displayMaxVia(_maxViaWay)); } function renderViewer(selection) { if (!_intersection) return; var vgraph = _intersection.graph; var filter = utilFunctor(true); var projection = geoRawMercator(); // Reflow warning: `utilGetDimensions` calls `getBoundingClientRect` // Instead of asking the restriction-container for its dimensions, // we can ask the .sidebar, which can have its dimensions cached. // width: calc as sidebar - padding // height: hardcoded (from `80_app.css`) // var d = utilGetDimensions(selection); var sdims = utilGetDimensions(context.container().select('.sidebar')); var d = [ sdims[0] - 50, 370 ]; var c = vecScale(d, 0.5); var z = 22; projection.scale(geoZoomToScale(z)); // Calculate extent of all key vertices var extent = _intersection.vertices.reduce((extent, node) => { // update extent in place extent.min = [ Math.min(extent.min[0], node.loc[0]), Math.min(extent.min[1], node.loc[1]) ]; extent.max = [ Math.max(extent.max[0], node.loc[0]), Math.max(extent.max[1], node.loc[1]) ]; return extent; }, new Extent()); // If this is a large intersection, adjust zoom to fit extent if (_intersection.vertices.length > 1) { var padding = 180; // in z22 pixels var tl = projection([extent.min[0], extent.max[1]]); var br = projection([extent.max[0], extent.min[1]]); var hFactor = (br[0] - tl[0]) / (d[0] - padding); var vFactor = (br[1] - tl[1]) / (d[1] - padding); var hZoomDiff = Math.log(Math.abs(hFactor)) / Math.LN2; var vZoomDiff = Math.log(Math.abs(vFactor)) / Math.LN2; z = z - Math.max(hZoomDiff, vZoomDiff); projection.scale(geoZoomToScale(z)); } var padTop = 35; // reserve top space for hint text var extentCenter = projection(extent.center()); extentCenter[1] = extentCenter[1] - padTop; projection .translate(vecSubtract(c, extentCenter)) .clipExtent([[0, 0], d]); var drawLayers = svgLayers(projection, context).only(['osm','touch']).dimensions(d); var drawVertices = svgVertices(projection, context); var drawLines = svgLines(projection, context); var drawTurns = svgTurns(projection, context); var firstTime = selection.selectAll('.surface').empty(); selection .call(drawLayers); var surface = selection.selectAll('.surface') .classed('tr', true); if (firstTime) { _initialized = true; surface .call(breathe); } // This can happen if we've lowered the detail while a FROM way // is selected, and that way is no longer part of the intersection. if (_fromWayID && !vgraph.hasEntity(_fromWayID)) { _fromWayID = null; _oldTurns = null; } surface .call(utilSetDimensions, d) .call(drawVertices, vgraph, _intersection.vertices, filter, extent, z) .call(drawLines, vgraph, _intersection.ways, filter) .call(drawTurns, vgraph, _intersection.turns(_fromWayID, _maxViaWay)); surface .on('click.restrictions', click) .on('mouseover.restrictions', mouseover); surface .selectAll('.selected') .classed('selected', false); surface .selectAll('.related') .classed('related', false); var way; if (_fromWayID) { way = vgraph.entity(_fromWayID); surface .selectAll('.' + _fromWayID) .classed('selected', true) .classed('related', true); } document.addEventListener('resizeWindow', function () { utilSetDimensions(_container, null); redraw(1); }, false); updateHints(null); function click(d3_event) { surface .call(breathe.off) .call(breathe); var datum = d3_event.target.__data__; var entity = datum && datum.properties && datum.properties.entity; if (entity) { datum = entity; } if (datum instanceof osmWay && (datum.__from || datum.__via)) { _fromWayID = datum.id; _oldTurns = null; redraw(); } else if (datum instanceof osmTurn) { var actions, extraActions, turns, i; var restrictionType = osmInferRestriction(vgraph, datum, projection); if (datum.restrictionID && !datum.direct) { return; } else if (datum.restrictionID && !datum.only) { // NO -> ONLY var seen = {}; var datumOnly = JSON.parse(JSON.stringify(datum)); // deep clone the datum datumOnly.only = true; // but change this property restrictionType = restrictionType.replace(/^no/, 'only'); // Adding an ONLY restriction should destroy all other direct restrictions from the FROM towards the VIA. // We will remember them in _oldTurns, and restore them if the user clicks again. turns = _intersection.turns(_fromWayID, 2); extraActions = []; _oldTurns = []; for (i = 0; i < turns.length; i++) { var turn = turns[i]; if (seen[turn.restrictionID]) continue; // avoid deleting the turn twice (#4968, #4928) if (turn.direct && turn.path[1] === datum.path[1]) { seen[turns[i].restrictionID] = true; turn.restrictionType = osmInferRestriction(vgraph, turn, projection); _oldTurns.push(turn); extraActions.push(actionUnrestrictTurn(turn)); } } actions = _intersection.actions.concat(extraActions, [ actionRestrictTurn(datumOnly, restrictionType), t('operations.restriction.annotation.create') ]); } else if (datum.restrictionID) { // ONLY -> Allowed // Restore whatever restrictions we might have destroyed by cycling thru the ONLY state. // This relies on the assumption that the intersection was already split up when we // performed the previous action (NO -> ONLY), so the IDs in _oldTurns shouldn't have changed. turns = _oldTurns || []; extraActions = []; for (i = 0; i < turns.length; i++) { if (turns[i].key !== datum.key) { extraActions.push(actionRestrictTurn(turns[i], turns[i].restrictionType)); } } _oldTurns = null; actions = _intersection.actions.concat(extraActions, [ actionUnrestrictTurn(datum), t('operations.restriction.annotation.delete') ]); } else { // Allowed -> NO actions = _intersection.actions.concat([ actionRestrictTurn(datum, restrictionType), t('operations.restriction.annotation.create') ]); } context.perform.apply(context, actions); // At this point the datum will be changed, but will have same key.. // Refresh it and update the help.. var s = surface.selectAll('.' + datum.key); datum = s.empty() ? null : s.datum(); updateHints(datum); } else { _fromWayID = null; _oldTurns = null; redraw(); } } function mouseover(d3_event) { var datum = d3_event.target.__data__; updateHints(datum); } _lastXPos = _lastXPos || sdims[0]; function redraw(minChange) { var xPos = -1; if (minChange) { xPos = utilGetDimensions(context.container().select('.sidebar'))[0]; } if (!minChange || (minChange && Math.abs(xPos - _lastXPos) >= minChange)) { if (context.hasEntity(_vertexID)) { _lastXPos = xPos; _container.call(renderViewer); } } } function highlightPathsFrom(wayID) { surface.selectAll('.related') .classed('related', false) .classed('allow', false) .classed('restrict', false) .classed('only', false); surface.selectAll('.' + wayID) .classed('related', true); if (wayID) { var turns = _intersection.turns(wayID, _maxViaWay); for (var i = 0; i < turns.length; i++) { var turn = turns[i]; var ids = [turn.to.way]; var klass = (turn.no ? 'restrict' : (turn.only ? 'only' : 'allow')); if (turn.only || turns.length === 1) { if (turn.via.ways) { ids = ids.concat(turn.via.ways); } } else if (turn.to.way === wayID) { continue; } surface.selectAll(utilEntitySelector(ids)) .classed('related', true) .classed('allow', (klass === 'allow')) .classed('restrict', (klass === 'restrict')) .classed('only', (klass === 'only')); } } } function updateHints(datum) { var help = _container.selectAll('.restriction-help').html(''); var placeholders = {}; ['from', 'via', 'to'].forEach(function(k) { placeholders[k] = '<span class="qualifier">' + t('restriction.help.' + k) + '</span>'; }); var entity = datum && datum.properties && datum.properties.entity; if (entity) { datum = entity; } if (_fromWayID) { way = vgraph.entity(_fromWayID); surface .selectAll('.' + _fromWayID) .classed('selected', true) .classed('related', true); } // Hovering a way if (datum instanceof osmWay && datum.__from) { way = datum; highlightPathsFrom(_fromWayID ? null : way.id); surface.selectAll('.' + way.id) .classed('related', true); var clickSelect = (!_fromWayID || _fromWayID !== way.id); help .append('div') // "Click to select FROM {fromName}." / "FROM {fromName}" .html(t.html('restriction.help.' + (clickSelect ? 'select_from_name' : 'from_name'), { from: placeholders.from, fromName: displayName(way.id, vgraph) })); // Hovering a turn arrow } else if (datum instanceof osmTurn) { var restrictionType = osmInferRestriction(vgraph, datum, projection); var turnType = restrictionType.replace(/^(only|no)\_/, ''); var indirect = (datum.direct === false ? t.html('restriction.help.indirect') : ''); var klass, turnText, nextText; if (datum.no) { klass = 'restrict'; turnText = t.html('restriction.help.turn.no_' + turnType, { indirect: indirect }); nextText = t.html('restriction.help.turn.only_' + turnType, { indirect: '' }); } else if (datum.only) { klass = 'only'; turnText = t.html('restriction.help.turn.only_' + turnType, { indirect: indirect }); nextText = t.html('restriction.help.turn.allowed_' + turnType, { indirect: '' }); } else { klass = 'allow'; turnText = t.html('restriction.help.turn.allowed_' + turnType, { indirect: indirect }); nextText = t.html('restriction.help.turn.no_' + turnType, { indirect: '' }); } help .append('div') // "NO Right Turn (indirect)" .attr('class', 'qualifier ' + klass) .html(turnText); help .append('div') // "FROM {fromName} TO {toName}" .html(t.html('restriction.help.from_name_to_name', { from: placeholders.from, fromName: displayName(datum.from.way, vgraph), to: placeholders.to, toName: displayName(datum.to.way, vgraph) })); if (datum.via.ways && datum.via.ways.length) { var names = []; for (var i = 0; i < datum.via.ways.length; i++) { var prev = names[names.length - 1]; var curr = displayName(datum.via.ways[i], vgraph); if (!prev || curr !== prev) { // collapse identical names names.push(curr); } } help .append('div') // "VIA {viaNames}" .html(t.html('restriction.help.via_names', { via: placeholders.via, viaNames: names.join(', ') })); } if (!indirect) { help .append('div') // Click for "No Right Turn" .html(t.html('restriction.help.toggle', { turn: nextText.trim() })); } highlightPathsFrom(null); var alongIDs = datum.path.slice(); surface.selectAll(utilEntitySelector(alongIDs)) .classed('related', true) .classed('allow', (klass === 'allow')) .classed('restrict', (klass === 'restrict')) .classed('only', (klass === 'only')); // Hovering empty surface } else { highlightPathsFrom(null); if (_fromWayID) { help .append('div') // "FROM {fromName}" .html(t.html('restriction.help.from_name', { from: placeholders.from, fromName: displayName(_fromWayID, vgraph) })); } else { help .append('div') // "Click to select a FROM segment." .html(t.html('restriction.help.select_from', { from: placeholders.from })); } } } } function displayMaxDistance(maxDist) { var isImperial = !localizer.usesMetric(); var opts; if (isImperial) { var distToFeet = { // imprecise conversion for prettier display 20: 70, 25: 85, 30: 100, 35: 115, 40: 130, 45: 145, 50: 160 }[maxDist]; opts = { distance: t('units.feet', { quantity: distToFeet }) }; } else { opts = { distance: t('units.meters', { quantity: maxDist }) }; } return t.html('restriction.controls.distance_up_to', opts); } function displayMaxVia(maxVia) { return maxVia === 0 ? t.html('restriction.controls.via_node_only') : maxVia === 1 ? t.html('restriction.controls.via_up_to_one') : t.html('restriction.controls.via_up_to_two'); } function displayName(entityID, graph) { var entity = graph.entity(entityID); var name = utilDisplayName(entity) || ''; var matched = presetManager.match(entity, graph); var type = (matched && matched.name()) || utilDisplayType(entity.id); return name || type; } restrictions.entityIDs = function(val) { _intersection = null; _fromWayID = null; _oldTurns = null; _vertexID = val[0]; }; restrictions.tags = function() {}; restrictions.focus = function() {}; restrictions.off = function(selection) { if (!_initialized) return; selection.selectAll('.surface') .call(breathe.off) .on('click.restrictions', null) .on('mouseover.restrictions', null); d3_select(window) .on('resize.restrictions', null); }; return utilRebind(restrictions, dispatch, 'on'); } uiFieldRestrictions.supportsMultiselection = false;