diff --git a/css/20_map_fb.css b/css/20_map_fb.css index b42f5401b6..ab1cc0bb26 100644 --- a/css/20_map_fb.css +++ b/css/20_map_fb.css @@ -54,4 +54,46 @@ /*In wireframe mode, restrain the stroke width to something barely wider than normal.*/ .fill-wireframe.highlight-edited g.lines > path.line.graphedited, .fill-wireframe.highlight-edited g.linegroup.line-stroke > path.way.line.stroke.tagedited { stroke-width: 2 !important; +} + +.bbox-svg { + top: 0; + left: 0; + overflow: hidden; + height: 100%; + width: 100%; + position: absolute; + pointer-events: none; +} + +.map-bbox { + fill: none; + stroke: rgba(0, 255, 255, 0.70); + stroke-width: 1; + shape-rendering: crispEdges; + z-index: 1; + pointer-events: none; +} + +.map-bbox.thick { + stroke-width: 5; +} + +.grids-svg { + top: 0; + left: 0; + overflow: hidden; + height: 100%; + width: 100%; + position: absolute; + pointer-events: none; +} + +.map-grids { + fill: none; + stroke: rgba(0, 255, 255, 0.40); + stroke-width: 2; + shape-rendering: crispEdges; + z-index: 1; + pointer-events: none; } \ No newline at end of file diff --git a/data/core.yaml b/data/core.yaml index a9318b3183..ee384d6190 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -586,6 +586,10 @@ en: switch: Switch back to this background custom: Custom overlays: Overlays + grid: + grids: Grids + no_grid: No Grid + n_by_n: "{num} by {num} Grid" imagery_source_faq: Imagery Info / Report a Problem reset: reset reset_all: Reset All diff --git a/modules/core/rapid_context.js b/modules/core/rapid_context.js index 7f0f38ad31..d92a4a15fe 100644 --- a/modules/core/rapid_context.js +++ b/modules/core/rapid_context.js @@ -1,18 +1,31 @@ import { geoExtent } from '../geo'; import toGeoJSON from '@mapbox/togeojson'; +import { dispatch as d3_dispatch } from 'd3-dispatch'; +import { utilRebind } from '../util'; - -export function coreRapidContext(context) { +export function coreRapidContext() { var rapidContext = {}; rapidContext.version = '1.0.1'; + var _isTaskBoundsRect = undefined; + var dispatch = d3_dispatch('task_extent_set'); + + function distinct (value, index, self) { + return self.indexOf(value) === index; + } var taskExtent; rapidContext.setTaskExtentByGpxData = function(gpxData) { var dom = (new DOMParser()).parseFromString(gpxData, 'text/xml'); var gj = toGeoJSON.gpx(dom); + + var lineStringCount = gj.features.reduce(function (accumulator, currentValue) { + return accumulator + (currentValue.geometry.type === 'LineString' ? 1 : 0); + }, 0); + if (gj.type === 'FeatureCollection') { var minlat, minlon, maxlat, maxlon; + // Calculate task extent. gj.features.forEach(function(f) { if (f.geometry.type === 'Point') { var lon = f.geometry.coordinates[0]; @@ -20,15 +33,57 @@ export function coreRapidContext(context) { if (minlat === undefined || lat < minlat) minlat = lat; if (minlon === undefined || lon < minlon) minlon = lon; if (maxlat === undefined || lat > maxlat) maxlat = lat; - if (maxlon === undefined || lon > maxlon) maxlon = lon; + if (maxlon === undefined || lon > maxlon) maxlon = lon; + } + + if (f.geometry.type === 'LineString' && lineStringCount === 1) { + var lats = f.geometry.coordinates.map(function(f) {return f[0];}); + var lngs = f.geometry.coordinates.map(function(f) {return f[1];}); + var uniqueLats = lats.filter(distinct); + var uniqueLngs = lngs.filter(distinct); + + var eachLatHas2Lngs = true; + uniqueLats.forEach(function (lat) { + var lngsForThisLat = f.geometry.coordinates + // Filter the coords to the ones with this lat + .filter(function(coord){ return coord[0] === lat; }) + // Make an array of lngs that associate with that lat + .map(function(coord){ return coord[1]; }) + // Finally, filter for uniqueness + .filter(distinct); + + if (lngsForThisLat.length !== 2) { + eachLatHas2Lngs = false; + } + }); + // Check for exactly two unique latitudes, two unique longitudes, + //and that each latitude was associated with exactly 2 longitudes, + // + if (uniqueLats.length === 2 && uniqueLngs.length === 2 && eachLatHas2Lngs) { + _isTaskBoundsRect = true; + } else { + _isTaskBoundsRect = false; + } } }); taskExtent = new geoExtent([minlon, minlat], [maxlon, maxlat]); + dispatch.call('task_extent_set'); } }; + + rapidContext.getTaskExtent = function() { return taskExtent; }; - return rapidContext; + + rapidContext.isTaskRectangular = function() { + if (!taskExtent) { + return false; + } + + return _isTaskBoundsRect; + }; + + return utilRebind(rapidContext, dispatch, 'on'); } diff --git a/modules/geo/extent.js b/modules/geo/extent.js index bc408b2a90..22bab94076 100644 --- a/modules/geo/extent.js +++ b/modules/geo/extent.js @@ -15,6 +15,15 @@ export function geoExtent(min, max) { } } + +export function geoExtentFromBounds(mapBounds) { + return geoExtent([ + [mapBounds.minlon, mapBounds.minlat], + [mapBounds.maxlon, mapBounds.maxlat] + ]); +} + + geoExtent.prototype = new Array(2); Object.assign(geoExtent.prototype, { diff --git a/modules/geo/index.js b/modules/geo/index.js index 88402d97a3..36141402c7 100644 --- a/modules/geo/index.js +++ b/modules/geo/index.js @@ -1,4 +1,5 @@ export { geoExtent } from './extent.js'; +export { geoExtentFromBounds } from './extent.js'; export { geoLatToMeters } from './geo.js'; export { geoLonToMeters } from './geo.js'; diff --git a/modules/renderer/background.js b/modules/renderer/background.js index 16e111750c..a27c168e71 100644 --- a/modules/renderer/background.js +++ b/modules/renderer/background.js @@ -24,6 +24,7 @@ export function rendererBackground(context) { var _contrast = 1; var _saturation = 1; var _sharpness = 1; + var _numGridSplits = 0; // No grid by default. function background(selection) { @@ -132,6 +133,13 @@ export function rendererBackground(context) { } + background.numGridSplits = function(_) { + if (!arguments.length) return _numGridSplits; + _numGridSplits = _; + dispatch.call('change'); + return background; + }; + background.updateImagery = function() { var b = baseLayer.source(); if (context.inIntro() || !b) return; diff --git a/modules/renderer/map.js b/modules/renderer/map.js index 8e52f720a4..72f705aa2e 100644 --- a/modules/renderer/map.js +++ b/modules/renderer/map.js @@ -5,6 +5,7 @@ import { interpolate as d3_interpolate } from 'd3-interpolate'; import { scaleLinear as d3_scaleLinear } from 'd3-scale'; import { event as d3_event, select as d3_select } from 'd3-selection'; import { zoom as d3_zoom, zoomIdentity as d3_zoomIdentity } from 'd3-zoom'; +import { geoPath as d3_geoPath } from 'd3-geo'; import { t } from '../util/locale'; import { geoExtent, geoRawMercator, geoScaleToZoom, geoZoomToScale } from '../geo'; @@ -539,6 +540,59 @@ export function rendererMap(context) { } + function drawMapGrid() { + // Add bounding box to imported OSM file layer + var d3Path = d3_geoPath(projection), + mapBoundsExtent = context.rapidContext().getTaskExtent(); + var minlat = mapBoundsExtent[0][1], + minlon = mapBoundsExtent[0][0], + maxlat = mapBoundsExtent[1][1], + maxlon = mapBoundsExtent[1][0], + numGridSplits = context.background().numGridSplits(); + + var gridsSvg = surface.selectAll('.grids-svg') + .data([0]); + + // Since there is no z-order within an svg, + // and we want the grid to appear on top of everything else, + // insert(), not append(), it at the start of the data layer. + gridsSvg.enter() + .insert('svg', ':first-child') + .attr('class', 'grids-svg'); + + gridsSvg.exit() + .remove(); + + var gridsData = []; + + for (var i = 1; i < numGridSplits; i++) { + var midlon = minlon + (maxlon - minlon) * i / numGridSplits, + midlat = minlat + (maxlat - minlat) * i / numGridSplits; + gridsData.push({ + type: 'LineString', + coordinates:[[midlon, minlat], [midlon, maxlat]] + }); + gridsData.push({ + type: 'LineString', + coordinates:[[minlon, midlat], [maxlon, midlat]] + }); + } + + var gridsPath = gridsSvg.selectAll('.map-grids') + .data(gridsData); + + gridsPath.attr('d', d3Path); + + gridsPath.enter() + .append('path') + .attr('class', 'map-grids') + .attr('d', d3Path); + + gridsPath.exit() + .remove(); + } + + function redraw(difference, extent) { if (surface.empty() || !_redrawEnabled) return; @@ -566,6 +620,9 @@ export function rendererMap(context) { surface .classed('low-zoom', zoom <= lowzoom(lat)); + if (context.rapidContext().isTaskRectangular()) { + drawMapGrid(); + } if (!difference) { supersurface.call(context.background()); diff --git a/modules/svg/layers.js b/modules/svg/layers.js index 6bb80cb178..c231a6cbd5 100644 --- a/modules/svg/layers.js +++ b/modules/svg/layers.js @@ -54,6 +54,10 @@ export function svgLayers(projection, context) { .append('defs') .attr('class', 'surface-defs'); + defs.enter() + .append('svg') + .attr('class', 'grids-svg'); + var groups = svg.selectAll('.data-layer') .data(_layers); diff --git a/modules/ui/background.js b/modules/ui/background.js index 56087e7719..6d6bd72870 100644 --- a/modules/ui/background.js +++ b/modules/ui/background.js @@ -6,6 +6,7 @@ import { event as d3_event, select as d3_select } from 'd3-selection'; import { t, textDirection } from '../util/locale'; import { svgIcon } from '../svg/icon'; import { uiBackgroundDisplayOptions } from './background_display_options'; +import { uiGridDisplayOptions } from './grid_display_options'; import { uiBackgroundOffset } from './background_offset'; import { uiCmd } from './cmd'; import { uiDisclosure } from './disclosure'; @@ -14,7 +15,6 @@ import { uiSettingsCustomBackground } from './settings/custom_background'; import { uiTooltipHtml } from './tooltipHtml'; import { tooltip } from '../util/tooltip'; - export function uiBackground(context) { var key = t('background.key'); @@ -26,8 +26,10 @@ export function uiBackground(context) { var _backgroundList = d3_select(null); var _overlayList = d3_select(null); var _displayOptionsContainer = d3_select(null); + var _gridOptionsContainer = d3_select(null); var _offsetContainer = d3_select(null); + var gridDisplayOptions = uiGridDisplayOptions(context); var backgroundDisplayOptions = uiBackgroundDisplayOptions(context); var backgroundOffset = uiBackgroundOffset(context); @@ -92,7 +94,6 @@ export function uiBackground(context) { document.activeElement.blur(); } - function customChanged(d) { if (d && d.template) { _customSource.template(d.template); @@ -257,6 +258,7 @@ export function uiBackground(context) { updateOverlayList(); } + function updateBackgroundList() { _backgroundList .call(drawListItems, 'radio', chooseBackground, function(d) { return !d.isHidden() && !d.overlay; }); @@ -277,6 +279,9 @@ export function uiBackground(context) { updateOverlayList(); } + _gridOptionsContainer + .call(gridDisplayOptions); + _displayOptionsContainer .call(backgroundDisplayOptions); @@ -363,6 +368,18 @@ export function uiBackground(context) { .content(renderOverlayList) ); + // grid list + _gridOptionsContainer = content + .append('div') + .attr('class', 'grid-overlay-list-container'); + + context.rapidContext().on('task_extent_set.background', function() { + // Show grid options only if the task bbox is rectangular + if (!context.rapidContext().isTaskRectangular()) { + _gridOptionsContainer.remove(); + } + }); + // display options _displayOptionsContainer = content .append('div') diff --git a/modules/ui/grid_display_options.js b/modules/ui/grid_display_options.js new file mode 100644 index 0000000000..5bee732dfc --- /dev/null +++ b/modules/ui/grid_display_options.js @@ -0,0 +1,81 @@ +import { event as d3_event } from 'd3-selection'; + +import { t } from '../util/locale'; +import { uiDisclosure } from './disclosure'; + + +export function uiGridDisplayOptions(context) { + function chooseGrid(d) { + d3_event.preventDefault(); + context.background().numGridSplits(d.numSplit); + } + + + function render(selection) { + // the grid list + var container = selection.selectAll('.layer-grid-list') + .data([0]); + + var gridList = container.enter() + .append('ul') + .attr('class', 'layer-list layer-grid-list') + .attr('dir', 'auto') + .merge(container); + + var gridItems = gridList.selectAll('li') + .data( + [{numSplit: 0, name: t('background.grid.no_grid')}, + {numSplit: 2, name: t('background.grid.n_by_n', {num: 2})}, + {numSplit: 3, name: t('background.grid.n_by_n', {num: 3})}, + {numSplit: 4, name: t('background.grid.n_by_n', {num: 4})}, + {numSplit: 5, name: t('background.grid.n_by_n', {num: 5})}, + {numSplit: 6, name: t('background.grid.n_by_n', {num: 6})}], + function(d) { return d.name; } + ); + + var enter = gridItems.enter() + .insert('li', '.custom-gridsopt') + .attr('class', 'gridsopt'); + + var label = enter.append('label'); + label.append('input') + .attr('type', 'radio') + .attr('name', 'grids') + .property('checked', function(d){ + return (d.numSplit === context.background().numGridSplits()); + }) + .on('change', chooseGrid); + + label.append('span') + .text(function(d) {return d.name;}); + + gridItems.exit() + .remove(); + } + + + function gridDisplayOptions(selection) { + context.rapidContext().on('task_extent_set.grid_display_options', function() { + if (context.rapidContext().isTaskRectangular()) { + selection + .call(uiDisclosure(context, 'grid_display_options', context.rapid) + .title(t('background.grid.grids')) + .content(render) + ); + } + }); + + if (!context.rapidContext().isTaskRectangular()){ + return; + } + + selection + .call(uiDisclosure(context, 'grid_display_options', context.rapid) + .title(t('background.grid.grids')) + .content(render) + ); + } + + + return gridDisplayOptions; +}