modules/svg/rapid_features.js (328 lines of code) (raw):
import { select as d3_select} from 'd3-selection';
import { geoScaleToZoom } from '@id-sdk/math';
import _throttle from 'lodash-es/throttle';
import { services } from '../services';
import { svgPath, svgPointTransform } from './index';
let _enabled = false;
let _initialized = false;
let _FbMlService;
let _EsriService;
let _actioned;
export function svgRapidFeatures(projection, context, dispatch) {
const RAPID_MAGENTA = '#da26d3';
const throttledRedraw = _throttle(() => dispatch.call('change'), 1000);
const gpxInUrl = context.initialHashParams.hasOwnProperty('gpx');
let _layer = d3_select(null);
function init() {
if (_initialized) return; // run once
_enabled = true;
_initialized = true;
_actioned = new Set();
// Watch history to synchronize the displayed layer with features
// that have been accepted or rejected by the user.
context.history().on('undone.aifeatures', onHistoryUndone);
context.history().on('change.aifeatures', onHistoryChange);
context.history().on('restore.aifeatures', onHistoryRestore);
}
// Services are loosly coupled in iD, so we use these functions
// to gain access to them, and bind the event handlers a single time.
function getFbMlService() {
if (services.fbMLRoads && !_FbMlService) {
_FbMlService = services.fbMLRoads;
_FbMlService.event.on('loadedData', throttledRedraw);
}
return _FbMlService;
}
function getEsriService() {
if (services.esriData && !_EsriService) {
_EsriService = services.esriData;
_EsriService.event.on('loadedData', throttledRedraw);
}
return _EsriService;
}
function wasRapidEdit(annotation) {
return annotation && annotation.type && /^rapid/.test(annotation.type);
}
function onHistoryUndone(currentStack, previousStack) {
const annotation = previousStack.annotation;
if (!wasRapidEdit(annotation)) return;
_actioned.delete(annotation.id);
if (_enabled) { dispatch.call('change'); } // redraw
}
function onHistoryChange(/* difference */) {
const annotation = context.history().peekAnnotation();
if (!wasRapidEdit(annotation)) return;
_actioned.add(annotation.id);
if (_enabled) { dispatch.call('change'); } // redraw
}
function onHistoryRestore() {
_actioned = new Set();
context.history().peekAllAnnotations().forEach(annotation => {
if (wasRapidEdit(annotation)) {
_actioned.add(annotation.id);
// origid (the original entity ID), a.k.a. datum.__origid__,
// is a hack used to deal with non-deterministic way-splitting
// in the roads service. Each way "split" will have an origid
// attribute for the original way it was derived from. In this
// particular case, restoring from history on page reload, we
// prevent new splits (possibly different from before the page
// reload) from being displayed by storing the origid and
// checking against it in render().
if (annotation.origid) {
_actioned.add(annotation.origid);
}
}
});
if (_actioned.size && _enabled) {
dispatch.call('change'); // redraw
}
}
function showLayer() {
throttledRedraw();
layerOn();
}
function hideLayer() {
throttledRedraw.cancel();
layerOff();
}
function layerOn() {
_layer.style('display', 'block');
}
function layerOff() {
_layer.style('display', 'none');
}
function isArea(d) {
return (d.type === 'relation' || (d.type === 'way' && d.isArea()));
}
function featureKey(d) {
return d.__fbid__;
}
function render(selection) {
const rapidContext = context.rapidContext();
// Ensure Rapid layer and <defs> exists
_layer = selection.selectAll('.layer-ai-features')
.data(_enabled ? [0] : []);
_layer.exit()
.remove();
let layerEnter = _layer.enter()
.append('g')
.attr('class', 'layer-ai-features');
layerEnter
.append('defs')
.attr('class', 'rapid-defs');
_layer = layerEnter
.merge(_layer);
const surface = context.surface();
const waitingForTaskExtent = gpxInUrl && !rapidContext.getTaskExtent();
if (!surface || surface.empty() || waitingForTaskExtent) return; // not ready to draw yet, starting up
// Gather available datasets, generate a unique fill pattern
// and a layer group for each dataset. Fill pattern styling is complicated.
// Style needs to apply in the def, not where the pattern is used.
const rapidDatasets = rapidContext.datasets();
const datasets = Object.values(rapidDatasets)
.filter(dataset => dataset.added && dataset.enabled);
let defs = _layer.selectAll('.rapid-defs');
let dsPatterns = defs.selectAll('.rapid-fill-pattern')
.data(datasets, d => d.id);
// exit
dsPatterns.exit()
.remove();
// enter
let dsPatternsEnter = dsPatterns.enter()
.append('pattern')
.attr('id', d => `fill-${d.id}`)
.attr('class', 'rapid-fill-pattern')
.attr('width', 5)
.attr('height', 15)
.attr('patternUnits', 'userSpaceOnUse')
.attr('patternTransform', (d, i) => {
const r = (45 + (67 * i)) % 180; // generate something different for each layer
return `rotate(${r})`;
});
dsPatternsEnter
.append('line')
.attr('class', 'ai-building-line')
.attr('stroke', 'currentColor')
.attr('stroke-width', '2px')
.attr('stroke-opacity', 0.6)
.attr('y2', '15');
// update
dsPatterns = dsPatternsEnter
.merge(dsPatterns)
.style('color', d => d.color || RAPID_MAGENTA);
let dsGroups = _layer.selectAll('.layer-rapid-dataset')
.data(datasets, d => d.id);
// exit
dsGroups.exit()
.remove();
// enter/update
dsGroups = dsGroups.enter()
.append('g')
.attr('class', d => `layer-rapid-dataset layer-rapid-dataset-${d.id}`)
.merge(dsGroups)
.style('color', d => d.color || RAPID_MAGENTA)
.each(eachDataset);
}
function eachDataset(dataset, i, nodes) {
const rapidContext = context.rapidContext();
const selection = d3_select(nodes[i]);
const service = dataset.service === 'fbml' ? getFbMlService(): getEsriService();
if (!service) return;
// Adjust the dataset id for whether we want the data conflated or not.
const internalID = dataset.id + (dataset.conflated ? '-conflated' : '');
const graph = service.graph(internalID);
const getPath = svgPath(projection, graph);
const getTransform = svgPointTransform(projection);
// Gather data
let geoData = {
paths: [],
vertices: [],
points: []
};
if (context.map().zoom() >= context.minEditableZoom()) {
/* Facebook AI/ML */
if (dataset.service === 'fbml') {
service.loadTiles(internalID, projection, rapidContext.getTaskExtent());
let pathData = service
.intersects(internalID, context.map().extent())
.filter(d => d.type === 'way' && !_actioned.has(d.id) && !_actioned.has(d.__origid__) ) // see onHistoryRestore()
.filter(getPath);
// fb_ai service gives us roads and buildings together,
// so filter further according to which dataset we're drawing
if (dataset.id === 'fbRoads' || dataset.id === 'rapid_intro_graph') {
geoData.paths = pathData.filter(d => !!d.tags.highway);
let seen = {};
geoData.paths.forEach(d => {
const first = d.first();
const last = d.last();
if (!seen[first]) {
seen[first] = true;
geoData.vertices.push(graph.entity(first));
}
if (!seen[last]) {
seen[last] = true;
geoData.vertices.push(graph.entity(last));
}
});
} else if (dataset.id === 'msBuildings') {
geoData.paths = pathData.filter(isArea);
// no vertices
} else {
// esri data via fb service
geoData.paths = pathData.filter(isArea);
}
/* ESRI ArcGIS */
} else if (dataset.service === 'esri') {
service.loadTiles(internalID, projection);
let visibleData = service
.intersects(internalID, context.map().extent())
.filter(d => !_actioned.has(d.id) && !_actioned.has(d.__origid__) ); // see onHistoryRestore()
geoData.points = visibleData
.filter(d => d.type === 'node' && !!d.__fbid__); // standalone only (not vertices/childnodes)
geoData.paths = visibleData
.filter(d => d.type === 'way' || d.type === 'relation')
.filter(getPath);
}
}
selection
.call(drawPaths, geoData.paths, dataset, getPath)
.call(drawVertices, geoData.vertices, getTransform)
.call(drawPoints, geoData.points, getTransform);
}
function drawPaths(selection, pathData, dataset, getPath) {
// Draw shadow, casing, stroke layers
let linegroups = selection
.selectAll('g.linegroup')
.data(['shadow', 'casing', 'stroke']);
linegroups = linegroups.enter()
.append('g')
.attr('class', d => `linegroup linegroup-${d}`)
.merge(linegroups);
// Draw paths
let paths = linegroups
.selectAll('path')
.data(pathData, featureKey);
// exit
paths.exit()
.remove();
// enter/update
paths = paths.enter()
.append('path')
.attr('style', d => isArea(d) ? `fill: url(#fill-${dataset.id})` : null)
.attr('class', (d, i, nodes) => {
const currNode = nodes[i];
const linegroup = currNode.parentNode.__data__;
const klass = isArea(d) ? 'building' : 'road';
return `line ${linegroup} ${klass} data${d.__fbid__}`;
})
.merge(paths)
.attr('d', getPath);
}
function drawVertices(selection, vertexData, getTransform) {
const vertRadii = {
// z16-, z17, z18+
stroke: [3.5, 4, 4.5],
fill: [2, 2, 2.5]
};
let vertexGroup = selection
.selectAll('g.vertexgroup')
.data(vertexData.length ? [0] : []);
vertexGroup.exit()
.remove();
vertexGroup = vertexGroup.enter()
.append('g')
.attr('class', 'vertexgroup')
.merge(vertexGroup);
let vertices = vertexGroup
.selectAll('g.vertex')
.data(vertexData, d => d.id);
// exit
vertices.exit()
.remove();
// enter
let enter = vertices.enter()
.append('g')
.attr('class', d => `node vertex ${d.id}`);
enter
.append('circle')
.attr('class', 'stroke');
enter
.append('circle')
.attr('class', 'fill');
// update
const zoom = geoScaleToZoom(projection.scale());
const radiusIdx = (zoom < 17 ? 0 : zoom < 18 ? 1 : 2);
vertices = vertices
.merge(enter)
.attr('transform', getTransform)
.call(selection => {
['stroke', 'fill'].forEach(klass => {
selection.selectAll('.' + klass)
.attr('r', vertRadii[klass][radiusIdx]);
});
});
}
function drawPoints(selection, pointData, getTransform) {
const pointRadii = {
// z16-, z17, z18+
shadow: [4.5, 7, 8],
stroke: [4.5, 7, 8],
fill: [2.5, 4, 5]
};
let pointGroup = selection
.selectAll('g.pointgroup')
.data(pointData.length ? [0] : []);
pointGroup.exit()
.remove();
pointGroup = pointGroup.enter()
.append('g')
.attr('class', 'pointgroup')
.merge(pointGroup);
let points = pointGroup
.selectAll('g.point')
.data(pointData, featureKey);
// exit
points.exit()
.remove();
// enter
let enter = points.enter()
.append('g')
.attr('class', d => `node point data${d.__fbid__}`);
enter
.append('circle')
.attr('class', 'shadow');
enter
.append('circle')
.attr('class', 'stroke');
enter
.append('circle')
.attr('class', 'fill');
// update
const zoom = geoScaleToZoom(projection.scale());
const radiusIdx = (zoom < 17 ? 0 : zoom < 18 ? 1 : 2);
points = points
.merge(enter)
.attr('transform', getTransform)
.call(selection => {
['shadow', 'stroke', 'fill'].forEach(klass => {
selection.selectAll('.' + klass)
.attr('r', pointRadii[klass][radiusIdx]);
});
});
}
render.showAll = function() {
return _enabled;
};
render.enabled = function(val) {
if (!arguments.length) return _enabled;
_enabled = val;
if (_enabled) {
showLayer();
} else {
hideLayer();
}
dispatch.call('change');
return render;
};
init();
return render;
}