Files
iD/modules/ui/fields/restrictions.js
T
Martin Raifer 2f9285e011 improve rendering of turn-restriction boxes in narrow sidebars
Previously the applied paddings were too large for narrow sidebars, causing the turn-restriction min-map view to be much more zoomed out than needed.
This tweaks the paddings such that even for the slimmest sidebar the rendering is still acceptable (i.e. no turn arrows to be rendered completely outside of the viewport) and at the same time allows generous margins for wider sidebars.

This also fixes a minor bug where the mini-map is not properly alligned vertically
2021-11-05 14:46:53 +01:00

653 lines
24 KiB
JavaScript

import { dispatch as d3_dispatch } from 'd3-dispatch';
import { select as d3_select } from 'd3-selection';
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 { geoExtent, geoRawMercator, geoVecScale, geoVecSubtract, geoZoomToScale } from '../../geo';
import { osmIntersection, osmInferRestriction, osmTurn, osmWay } from '../../osm';
import { svgLayers, svgLines, svgTurns, svgVertices } from '../../svg';
import { utilDisplayName, utilDisplayType, utilEntitySelector, 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 = geoVecScale(d, 0.5);
var z = 22;
projection.scale(geoZoomToScale(z));
// Calculate extent of all key vertices
var extent = geoExtent();
for (var i = 0; i < _intersection.vertices.length; i++) {
extent._extend(_intersection.vertices[i].extent());
}
var padTop = 35; // reserve top space for hint text
// If this is a large intersection, adjust zoom to fit extent
if (_intersection.vertices.length > 1) {
var hPadding = Math.min(160, Math.max(110, d[0] * 0.4));
var vPadding = 160;
var tl = projection([extent[0][0], extent[1][1]]);
var br = projection([extent[1][0], extent[0][1]]);
var hFactor = (br[0] - tl[0]) / (d[0] - hPadding);
var vFactor = (br[1] - tl[1]) / (d[1] - vPadding - padTop);
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 extentCenter = projection(extent.center());
extentCenter[1] = extentCenter[1] - padTop / 2;
projection
.translate(geoVecSubtract(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;