modules/ui/rapid_view_manage_datasets.js (387 lines of code) (raw):
import { dispatch as d3_dispatch } from 'd3-dispatch';
import { select as d3_select } from 'd3-selection';
import { Extent } from '@id-sdk/math';
import { utilQsString, utilStringQs } from '@id-sdk/util';
import marked from 'marked';
import { t } from '../core/localizer';
import { prefs } from '../core/preferences';
import { modeBrowse } from '../modes';
import { services } from '../services';
import { svgIcon } from '../svg/icon';
import { uiCombobox} from './combobox';
import { utilKeybinding, utilNoAuto, utilRebind } from '../util';
export function uiRapidViewManageDatasets(context, parentModal) {
const rapidContext = context.rapidContext();
const dispatch = d3_dispatch('done');
const categoryCombo = uiCombobox(context, 'dataset-categories');
const MAXRESULTS = 100;
let _content = d3_select(null);
let _filterText;
let _filterCategory;
let _datasetInfo;
let _myClose = () => true; // custom close handler
function render() {
// Unfortunately `uiModal` is written in a way that there can be only one at a time.
// So we have to roll our own modal here instead of just creating a second `uiModal`.
let shaded = context.container().selectAll('.shaded'); // container for the existing modal
if (shaded.empty()) return;
if (shaded.selectAll('.modal-view-manage').size()) return; // view/manage modal exists already
const origClose = parentModal.close;
parentModal.close = () => { /* ignore */ };
// override the close handler
_myClose = () => {
_filterText = null;
_filterCategory = null;
myModal
.transition()
.duration(200)
.style('top', '0px')
.on('end', () => myShaded.remove());
parentModal.close = origClose; // restore close handler
let keybinding = utilKeybinding('modal');
keybinding.on(['⌫', '⎋'], origClose);
d3_select(document).call(keybinding);
dispatch.call('done');
};
let keybinding = utilKeybinding('modal');
keybinding.on(['⌫', '⎋'], _myClose);
d3_select(document).call(keybinding);
let myShaded = shaded
.append('div')
.attr('class', 'view-manage-wrap'); // need absolutely positioned div here for new stacking context
let myModal = myShaded
.append('div')
.attr('class', 'modal rapid-modal modal-view-manage') // RapiD styling
.style('opacity', 0);
myModal
.append('button')
.attr('class', 'close')
.on('click', _myClose)
.call(svgIcon('#iD-icon-close'));
_content = myModal
.append('div')
.attr('class', 'rapid-stack content');
_content
.call(renderModalContent);
_content.selectAll('.ok-button')
.node()
.focus();
myModal
.transition()
.style('opacity', 1);
}
function renderModalContent(selection) {
/* Header section */
let headerEnter = selection.selectAll('.rapid-view-manage-header')
.data([0])
.enter()
.append('div')
.attr('class', 'modal-section rapid-view-manage-header');
let line1 = headerEnter
.append('div');
line1
.append('div')
.attr('class', 'rapid-view-manage-header-icon')
.call(svgIcon('#iD-icon-data', 'icon-30'));
line1
.append('div')
.attr('class', 'rapid-view-manage-header-text')
.text(t('rapid_feature_toggle.esri.title'));
let line2 = headerEnter
.append('div');
line2
.append('div')
.attr('class', 'rapid-view-manage-header-about')
.html(marked(t('rapid_feature_toggle.esri.about')));
line2.selectAll('a')
.attr('target', '_blank');
/* Filter section */
let filterEnter = selection.selectAll('.rapid-view-manage-filter')
.data([0])
.enter()
.append('div')
.attr('class', 'modal-section rapid-view-manage-filter');
let filterSearchEnter = filterEnter
.append('div')
.attr('class', 'rapid-view-manage-filter-search-wrap');
filterSearchEnter
.call(svgIcon('#fas-filter', 'inline'));
filterSearchEnter
.append('input')
.attr('class', 'rapid-view-manage-filter-search')
.attr('placeholder', t('rapid_feature_toggle.esri.filter_datasets'))
.call(utilNoAuto)
.on('input', d3_event => {
const element = d3_event.currentTarget;
const val = (element && element.value) || '';
_filterText = val.trim().toLowerCase();
dsSection.call(renderDatasets);
});
let filterTypeEnter = filterEnter
.append('div')
.attr('class', 'rapid-view-manage-filter-type-wrap');
filterTypeEnter
.append('input')
.attr('class', 'rapid-view-manage-filter-type')
.attr('placeholder', t('rapid_feature_toggle.esri.any_type'))
.call(utilNoAuto)
.call(categoryCombo)
.on('blur change', d3_event => {
const element = d3_event.currentTarget;
const val = (element && element.value) || '';
const data = categoryCombo.data();
if (data.some(item => item.value === val)) { // only allow picking values from the list
_filterCategory = val;
} else {
d3_event.currentTarget.value = '';
_filterCategory = null;
}
dsSection.call(renderDatasets);
});
filterEnter
.append('div')
.attr('class', 'rapid-view-manage-filter-clear')
.append('a')
.attr('href', '#')
.text(t('rapid_feature_toggle.esri.clear_filters'))
.on('click', d3_event => {
d3_event.preventDefault();
const element = d3_event.currentTarget;
element.blur();
selection.selectAll('input').property('value', '');
_filterText = null;
_filterCategory = null;
dsSection.call(renderDatasets);
});
filterEnter
.append('div')
.attr('class', 'rapid-view-manage-filter-results');
/* Dataset section */
let dsSection = selection.selectAll('.rapid-view-manage-datasets-section')
.data([0]);
// enter
let dsSectionEnter = dsSection.enter()
.append('div')
.attr('class', 'modal-section rapid-view-manage-datasets-section');
dsSectionEnter
.append('div')
.attr('class', 'rapid-view-manage-datasets-status');
dsSectionEnter
.append('div')
.attr('class', 'rapid-view-manage-datasets');
// update
dsSection = dsSection
.merge(dsSectionEnter)
.call(renderDatasets);
/* OK Button */
let buttonsEnter = selection.selectAll('.modal-section.buttons')
.data([0])
.enter()
.append('div')
.attr('class', 'modal-section buttons');
buttonsEnter
.append('button')
.attr('class', 'button ok-button action')
.on('click', _myClose)
.text(t('confirm.okay'));
}
function renderDatasets(selection) {
const status = selection.selectAll('.rapid-view-manage-datasets-status');
const results = selection.selectAll('.rapid-view-manage-datasets');
const showPreview = prefs('rapid-internal-feature.previewDatasets') === 'true';
const service = services.esriData;
if (!service || (Array.isArray(_datasetInfo) && !_datasetInfo.length)) {
results.classed('hide', true);
status.classed('hide', false).text(t('rapid_feature_toggle.esri.no_datasets'));
return;
}
if (!_datasetInfo) {
results.classed('hide', true);
status.classed('hide', false)
.text(t('rapid_feature_toggle.esri.fetching_datasets'));
status
.append('br');
status
.append('img')
.attr('class', 'rapid-view-manage-datasets-spinner')
.attr('src', context.imagePath('loader-black.gif'));
service.loadDatasets()
.then(results => {
// Build set of available categories
let categories = new Set();
Object.values(results).forEach(d => {
d.groupCategories.forEach(c => {
categories.add(c.toLowerCase().replace('/categories/', ''));
});
});
if (!showPreview) categories.delete('preview');
const combodata = Array.from(categories).sort().map(c => {
let item = { title: c, value: c };
if (c === 'preview') item.display = `${c} <span class="rapid-view-manage-dataset-beta beta"></span>`;
return item;
});
categoryCombo.data(combodata);
// Exclude preview datasets unless user has opted into them
_datasetInfo = Object.values(results)
.filter(d => showPreview || !d.groupCategories.some(category => category.toLowerCase() === '/categories/preview'));
return _datasetInfo;
})
.then(() => _content.call(renderModalContent));
return;
}
results.classed('hide', false);
status.classed('hide', true);
// Apply filters
let count = 0;
_datasetInfo.forEach(d => {
const title = (d.title || '').toLowerCase();
const snippet = (d.snippet || '').toLowerCase();
if (datasetAdded(d)) { // always show added datasets at the top of the list
d.filtered = false;
++count;
return;
}
if (_filterText && title.indexOf(_filterText) === -1 && snippet.indexOf(_filterText) === -1) {
d.filtered = true; // filterText not found anywhere in `title` or `snippet`
return;
}
if (_filterCategory && !(d.groupCategories.some(category => category.toLowerCase() === `/categories/${_filterCategory}`))) {
d.filtered = true; // filterCategory not found anywhere in `groupCategories``
return;
}
d.filtered = (++count > MAXRESULTS);
});
let datasets = results.selectAll('.rapid-view-manage-dataset')
.data(_datasetInfo, d => d.id);
// exit
datasets.exit()
.remove();
// enter
let datasetsEnter = datasets.enter()
.append('div')
.attr('class', 'rapid-view-manage-dataset');
let labelsEnter = datasetsEnter
.append('div')
.attr('class', 'rapid-view-manage-dataset-label');
labelsEnter
.append('div')
.attr('class', 'rapid-view-manage-dataset-name');
labelsEnter
.append('div')
.attr('class', 'rapid-view-manage-dataset-license')
.append('a')
.attr('class', 'rapid-view-manage-dataset-link')
.attr('target', '_blank')
.attr('href', d => d.itemURL)
.text(t('rapid_feature_toggle.esri.more_info'))
.call(svgIcon('#iD-icon-out-link', 'inline'));
let featuredEnter = labelsEnter.selectAll('.rapid-view-manage-dataset-featured')
.data(d => d.groupCategories.filter(d => d.toLowerCase() === '/categories/featured'))
.enter()
.append('div')
.attr('class', 'rapid-view-manage-dataset-featured');
featuredEnter
.append('span')
.text('\u2b50');
featuredEnter
.append('span')
.text(t('rapid_feature_toggle.esri.featured'));
labelsEnter.selectAll('.rapid-view-manage-dataset-beta')
.data(d => d.groupCategories.filter(d => d.toLowerCase() === '/categories/preview'))
.enter()
.append('div')
.attr('class', 'rapid-view-manage-dataset-beta beta')
.attr('title', t('rapid_poweruser_features.beta'));
labelsEnter
.append('div')
.attr('class', 'rapid-view-manage-dataset-snippet');
labelsEnter
.append('button')
.attr('class', 'rapid-view-manage-dataset-action')
.on('click', toggleDataset);
let thumbsEnter = datasetsEnter
.append('div')
.attr('class', 'rapid-view-manage-dataset-thumb');
thumbsEnter
.append('img')
.attr('class', 'rapid-view-manage-dataset-thumbnail')
.attr('src', d => `https://openstreetmap.maps.arcgis.com/sharing/rest/content/items/${d.id}/info/${d.thumbnail}?w=400`);
// update
datasets = datasets
.merge(datasetsEnter)
.sort(sortDatasets)
.classed('hide', d => d.filtered);
datasets.selectAll('.rapid-view-manage-dataset-name')
.html(d => highlight(_filterText, d.title));
datasets.selectAll('.rapid-view-manage-dataset-snippet')
.html(d => highlight(_filterText, d.snippet));
datasets.selectAll('.rapid-view-manage-dataset-action')
.classed('secondary', d => datasetAdded(d))
.text(d => datasetAdded(d) ? t('rapid_feature_toggle.esri.remove') : t('rapid_feature_toggle.esri.add_to_map'));
const numShown = _datasetInfo.filter(d => !d.filtered).length;
const gt = (count > MAXRESULTS && numShown === MAXRESULTS) ? '>' : '';
_content.selectAll('.rapid-view-manage-filter-results')
.text(t('rapid_feature_toggle.esri.datasets_found', { num: `${gt}${numShown}` }));
}
// Sort:
// Added datasets to the beginning
// Featured datasets next
// All others sort by name
function sortDatasets(a, b) {
const aAdded = datasetAdded(a);
const bAdded = datasetAdded(b);
const aFeatured = a.groupCategories.some(d => d.toLowerCase() === '/categories/featured');
const bFeatured = b.groupCategories.some(d => d.toLowerCase() === '/categories/featured');
return aAdded && !bAdded ? -1
: bAdded && !aAdded ? 1
: aFeatured && !bFeatured ? -1
: bFeatured && !aFeatured ? 1
: a.title.localeCompare(b.title);
}
function toggleDataset(d3_event, d) {
const datasets = rapidContext.datasets();
const ds = datasets[d.id];
if (ds) {
ds.added = !ds.added;
} else { // hasn't been added yet
const service = services.esriData;
if (service) { // start fetching layer info (the mapping between attributes and tags)
service.loadLayer(d.id);
}
const isBeta = d.groupCategories.some(cat => cat.toLowerCase() === '/categories/preview');
const isBuildings = d.groupCategories.some(cat => cat.toLowerCase() === '/categories/buildings');
// pick a new color
const colors = rapidContext.colors();
const colorIndex = Object.keys(datasets).length % colors.length;
let dataset = {
id: d.id,
beta: isBeta,
added: true, // whether it should appear in the list
enabled: true, // whether the user has checked it on
conflated: false,
service: 'esri',
color: colors[colorIndex],
label: d.title,
license_markdown: t('rapid_feature_toggle.esri.license_markdown')
};
if (d.extent) {
dataset.extent = new Extent(d.extent[0], d.extent[1]);
}
// Test running building layers through FBML conflation service
if (isBuildings) {
dataset.conflated = true;
dataset.service = 'fbml';
// and disable the Microsoft buildings to avoid clutter
if (datasets.msBuildings) {
datasets.msBuildings.enabled = false;
}
}
datasets[d.id] = dataset;
}
// update url hash
let hash = utilStringQs(window.location.hash);
hash.datasets = Object.values(datasets)
.filter(ds => ds.added && ds.enabled)
.map(ds => ds.id)
.join(',');
if (!window.mocha) {
window.location.replace('#' + utilQsString(hash, true)); // update hash
}
_content.call(renderModalContent);
context.enter(modeBrowse(context)); // return to browse mode (in case something was selected)
context.map().pan([0,0]); // trigger a map redraw
}
function datasetAdded(d) {
const datasets = rapidContext.datasets();
return datasets[d.id] && datasets[d.id].added;
}
function highlight(needle, haystack) {
let html = haystack;
if (needle) {
const re = new RegExp('\(' + escapeRegex(needle) + '\)', 'gi');
html = html.replace(re, '<mark>$1</mark>');
}
return html;
}
function escapeRegex(s) {
return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
}
return utilRebind(render, dispatch, 'on');
}