diff --git a/css/80_app_fb.css b/css/80_app_fb.css
index 5ea3a6c22..882568f58 100644
--- a/css/80_app_fb.css
+++ b/css/80_app_fb.css
@@ -413,6 +413,11 @@ button.rapid-features.layer-off use {
height: 40px;
border-radius: 0;
}
+.ideditor[dir='rtl'] .modal.rapid-modal button.close {
+ right: unset;
+ left: 0;
+ }
+
.modal.rapid-modal button.close svg {
color: #da26d3;
}
@@ -736,11 +741,11 @@ hide this one and style something on top of it. */
}
/* Add/Manage Datasets modal */
-/* view-manage-wrap is an absolutely positioned div to create a new stacking context,
+/* catalog-wrap is an absolutely positioned div to create a new stacking context,
so we can put a modal on top of the other modal.
(it functions like .shaded for layout but without adding any shading)
*/
-.view-manage-wrap {
+.catalog-wrap {
position: absolute;
top: 0;
bottom: 0;
@@ -749,17 +754,17 @@ hide this one and style something on top of it. */
overflow: auto;
z-index: 51; /* above existing modal */
}
-.modal.rapid-modal.modal-view-manage {
+.modal.rapid-modal.modal-catalog {
width: 80%;
min-width: 600px;
max-width: 1000px;
min-height: 85%;
}
-.modal.rapid-modal.modal-view-manage p {
+.modal.rapid-modal.modal-catalog p {
font-size: 12px;
}
-.modal-view-manage .modal-section {
+.modal-catalog .modal-section {
display: flex;
flex-flow: row nowrap;
align-items: center;
@@ -767,46 +772,46 @@ hide this one and style something on top of it. */
padding: 0;
}
-.modal-view-manage .modal-section.rapid-view-manage-header {
+.modal-catalog .modal-section.rapid-catalog-header {
display: flex;
flex-flow: column nowrap;
padding: 10px 20px;
border-bottom: 1px solid #aaaa;
color: #fff;
}
-.modal-view-manage .modal-section.rapid-view-manage-header > div {
+.modal-catalog .modal-section.rapid-catalog-header > div {
display: flex;
flex: 1;
width: 100%;
align-items: center;
}
-.rapid-view-manage-header-icon {
+.rapid-catalog-header-icon {
flex: 0 0 40px;
}
-.rapid-view-manage-header-text {
+.rapid-catalog-header-text {
flex: 1 1 auto;
font-size: 24px;
padding: 0 5px;
}
-.rapid-view-manage-header-about {
+.rapid-catalog-header-about {
color: #ddd;
}
-.modal-view-manage .modal-section.rapid-view-manage-filter {
+.modal-catalog .modal-section.rapid-catalog-filter {
display: flex;
flex-flow: row nowrap;
padding: 10px 20px;
border-bottom: 1px solid #aaaa;
color: #fff;
}
-.rapid-view-manage-filter-search-wrap,
-.rapid-view-manage-filter-type-wrap {
+.rapid-catalog-filter-search-wrap,
+.rapid-catalog-filter-type-wrap {
position: relative;
flex: 1;
padding: 0 5px;
}
-.rapid-view-manage-filter-search-wrap > .icon {
+.rapid-catalog-filter-search-wrap > .icon {
position: absolute;
left: 16px;
top: 11px;
@@ -814,8 +819,13 @@ hide this one and style something on top of it. */
width: 16px;
height: 16px;
}
-.rapid-view-manage-filter-search,
-.rapid-view-manage-filter-type {
+.ideditor[dir='rtl'] .rapid-catalog-filter-search-wrap > .icon {
+ left: unset;
+ right: 16px;
+}
+
+.rapid-catalog-filter-search,
+.rapid-catalog-filter-type {
background: #444;
color: #ddd;
padding: 4px 12px;
@@ -824,13 +834,16 @@ hide this one and style something on top of it. */
font-size: 16px;
width: 90%;
}
-.rapid-view-manage-filter-search {
+.rapid-catalog-filter-search {
padding: 4px 12px 4px 40px;
}
-.rapid-view-manage-filter-search:focus,
-.rapid-view-manage-filter-search:active,
-.rapid-view-manage-filter-type:focus,
-.rapid-view-manage-filter-type:active {
+.ideditor[dir='rtl'] .rapid-catalog-filter-search {
+ padding: 4px 40px 4px 12px;
+}
+.rapid-catalog-filter-search:focus,
+.rapid-catalog-filter-search:active,
+.rapid-catalog-filter-type:focus,
+.rapid-catalog-filter-type:active {
background: #444;
color: #eee;
outline: none;
@@ -861,19 +874,19 @@ div.combobox.combobox-dataset-categories a:focus {
}
}
-.rapid-view-manage-filter-clear a {
+.rapid-catalog-filter-clear a {
padding: 15px;
font-size: 14px;
}
-.rapid-view-manage-filter-results {
+.rapid-catalog-filter-results {
flex: 1 1 200px;
padding: 0 5px;
text-align: end;
font-size: 20px;
}
-.modal-view-manage .rapid-view-manage-datasets-section {
+.modal-catalog .rapid-catalog-datasets-section {
display: flex;
flex-flow: column nowrap;
align-items: stretch;
@@ -881,28 +894,28 @@ div.combobox.combobox-dataset-categories a:focus {
}
/* give this section height, even when its contents are hidden */
-.rapid-view-manage-datasets-status,
-.rapid-view-manage-datasets {
+.rapid-catalog-datasets-status,
+.rapid-catalog-datasets {
flex: 1 1 9999px;
}
-.rapid-view-manage-datasets-status {
+.rapid-catalog-datasets-status {
font-size: 20px;
text-align: center;
margin: 50px;
}
-.rapid-view-manage-datasets-spinner {
+.rapid-catalog-datasets-spinner {
filter: brightness(2)contrast(0.8);
}
-.rapid-view-manage-datasets {
+.rapid-catalog-datasets {
display: flex;
flex-flow: row wrap;
justify-content: flex-start;
align-items: flex-start;
width: 100%;
}
-.rapid-view-manage-dataset {
+.rapid-catalog-dataset {
flex: 0 1 50%;
padding: 15px 25px;
margin-bottom: 10px;
@@ -911,38 +924,38 @@ div.combobox.combobox-dataset-categories a:focus {
flex-flow: row nowrap;
}
-.rapid-view-manage-dataset-label {
+.rapid-catalog-dataset-label {
flex: 1;
padding: 0 8px;
}
-.rapid-view-manage-dataset-thumb {
+.rapid-catalog-dataset-thumb {
flex: 0;
}
-img.rapid-view-manage-dataset-thumbnail {
+img.rapid-catalog-dataset-thumbnail {
border-radius: 10px;
width: 180px;
filter: invert(1)brightness(2)contrast(0.75);
}
-.rapid-view-manage-dataset button.rapid-view-manage-dataset-action {
+.rapid-catalog-dataset button.rapid-catalog-dataset-action {
font-size: 12px;
height: 28px;
border-radius: 14px;
margin: 10px 0;
padding: 0 15px;
}
-.rapid-view-manage-dataset-name {
+.rapid-catalog-dataset-name {
font-weight: bold;
font-size: 14px;
margin-bottom: 3px;
}
-.rapid-view-manage-dataset-license {
+.rapid-catalog-dataset-license {
display: inline-block;
}
-.rapid-view-manage-dataset-beta {
+.rapid-catalog-dataset-beta {
font-size: 10px;
}
-.rapid-view-manage-dataset-featured {
+.rapid-catalog-dataset-featured {
display: inline-block;
font-size: 11px;
background: #a21;
@@ -952,7 +965,7 @@ img.rapid-view-manage-dataset-thumbnail {
margin: 0px 10px;
line-height: 1.5;
}
-.rapid-view-manage-dataset-featured span {
+.rapid-catalog-dataset-featured span {
margin: 0px 3px;
}
diff --git a/modules/ui/UiRapidCatalog.js b/modules/ui/UiRapidCatalog.js
new file mode 100644
index 000000000..b553114b6
--- /dev/null
+++ b/modules/ui/UiRapidCatalog.js
@@ -0,0 +1,655 @@
+import { EventEmitter } from 'pixi.js';
+import { select, selection } from 'd3-selection';
+import { Extent } from '@rapid-sdk/math';
+import { marked } from 'marked';
+
+import { uiIcon } from './icon.js';
+import { uiCombobox} from './combobox.js';
+import { utilKeybinding, utilNoAuto } from '../util/index.js';
+
+const MAXRESULTS = 100;
+
+
+/**
+ * UiRapidCatalog
+ * This is the modal where the user can browse the catalog of datasets.
+ *
+ * Events available:
+ * `done` Fires when the user is finished and they are closing this modal
+ */
+export class UiRapidCatalog extends EventEmitter {
+
+ /**
+ * @constructor
+ * @param `context` Global shared application context
+ */
+ constructor(context, $parentModal) {
+ super();
+ this.context = context;
+
+ this._datasetInfo = null;
+ this._filterText = null;
+ this._filterCategory = null;
+ this._myClose = () => true; // custom close handler
+
+ // Child components
+ this.CategoryCombo = uiCombobox(context, 'dataset-categories');
+
+ // D3 selections
+ this.$parentModal = $parentModal;
+ this.$wrap = null;
+ this.$modal = null;
+ this.$content = null;
+
+ // Ensure methods used as callbacks always have `this` bound correctly.
+ // (This is also necessary when using `d3-selection.call`)
+ this.show = this.show.bind(this);
+ this.render = this.render.bind(this);
+ this.rerender = (() => this.render()); // call render without argument
+ this.renderDatasets = this.renderDatasets.bind(this);
+ this.isDatasetAdded = this.isDatasetAdded.bind(this);
+ this.sortDatasets = this.sortDatasets.bind(this);
+ this.toggleDataset = this.toggleDataset.bind(this);
+ this.highlight = this.highlight.bind(this);
+
+ // Setup event handlers
+ const l10n = context.systems.l10n;
+ l10n.on('localechange', this.rerender);
+ }
+
+
+ /**
+ * show
+ * This shows the catalog if it isn't alreaday being shown.
+ * For this kind of popup component, must first `show()` to create the modal.
+ * @param {d3-selection} $parent - A d3-selection to a HTMLElement that this component should render itself into
+ */
+ show($parent) {
+ const context = this.context;
+ const $container = context.container();
+
+ // 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`.
+ const $shaded = $container.selectAll('.shaded'); // container for the existing modal
+ if ($shaded.empty()) return;
+ if ($shaded.selectAll('.modal-catalog').size()) return; // catalog modal exists already
+
+ const origClose = this.$parentModal.close;
+ this.$parentModal.close = () => { /* ignore */ };
+
+ // override the close handler
+ this._myClose = () => {
+ this._filterText = null;
+ this._filterCategory = null;
+ this.$modal
+ .transition()
+ .duration(200)
+ .style('top', '0px')
+ .on('end', () => this.$wrap.remove());
+
+ this.$parentModal.close = origClose; // restore close handler
+
+ let keybinding = utilKeybinding('modal');
+ keybinding.on(['⌫', '⎋'], origClose);
+ select(document).call(keybinding);
+ this.emit('done');
+ };
+
+
+ let keybinding = utilKeybinding('modal');
+ keybinding.on(['⌫', '⎋'], this._myClose);
+ select(document).call(keybinding);
+
+ let $wrap = $shaded.selectAll('.catalog-wrap')
+ .data([0]);
+
+ // enter
+ const $$wrap = $wrap.enter()
+ .append('div')
+ .attr('class', 'catalog-wrap'); // need absolutely positioned div here for new stacking context
+
+ const $$modal = $$wrap
+ .append('div')
+ .attr('class', 'modal rapid-modal modal-catalog') // Rapid styling
+ .style('opacity', 0);
+
+ $$modal
+ .append('button')
+ .attr('class', 'close')
+ .on('click', this._myClose)
+ .call(uiIcon('#rapid-icon-close'));
+
+ $$modal
+ .append('div')
+ .attr('class', 'rapid-stack content');
+
+ // update
+ this.$wrap = $wrap = $wrap.merge($$wrap);
+ this.$modal = $wrap.selectAll('.modal-catalog');
+ this.$content = this.$modal.selectAll('.content');
+
+ this.$modal
+ .transition()
+ .style('opacity', 1);
+
+ this.render($parent);
+ }
+
+
+ /**
+ * render
+ * Accepts a parent selection, and renders the content under it.
+ * (The parent selection is required the first time, but can be inferred on subsequent renders.)
+ * @param {d3-selection} $parent - A d3-selection to a HTMLElement that this component should render itself into
+ */
+ render($parent = this.$parent) {
+ if ($parent instanceof selection) {
+ this.$parent = $parent;
+ } else {
+ return; // no parent - called too early?
+ }
+
+ const context = this.context;
+ const l10n = context.systems.l10n;
+
+ if (!this.$modal) return; // need to call `show()` first to create the modal.
+
+ const $content = this.$content;
+
+ /* Header section */
+ let $header = $content.selectAll('.rapid-catalog-header')
+ .data([0]);
+
+ const $$header = $header.enter()
+ .append('div')
+ .attr('class', 'modal-section rapid-catalog-header');
+
+ const $$line1 = $$header
+ .append('div');
+
+ $$line1
+ .append('div')
+ .attr('class', 'rapid-catalog-header-icon')
+ .call(uiIcon('#rapid-icon-data', 'icon-30'));
+
+ $$line1
+ .append('div')
+ .attr('class', 'rapid-catalog-header-text');
+
+ const $$line2 = $$header
+ .append('div');
+
+ $$line2
+ .append('div')
+ .attr('class', 'rapid-catalog-header-about');
+
+ // update
+ $header = $header.merge($$header);
+
+ $header.selectAll('.rapid-catalog-header-text')
+ .text(l10n.t('rapid_feature_toggle.esri.title'));
+
+ $header.selectAll('.rapid-catalog-header-about')
+ .html(marked.parse(l10n.t('rapid_feature_toggle.esri.about')));
+
+ $header.selectAll('.rapid-catalog-header-about a')
+ .attr('target', '_blank'); // make sure the markdown links go to a new page
+
+
+ /* Filter section */
+ let $filter = $content.selectAll('.rapid-catalog-filter')
+ .data([0]);
+
+ // enter
+ const $$filter = $filter.enter()
+ .append('div')
+ .attr('class', 'modal-section rapid-catalog-filter');
+
+ const $$filterSearch = $$filter
+ .append('div')
+ .attr('class', 'rapid-catalog-filter-search-wrap');
+
+ $$filterSearch
+ .call(uiIcon('#fas-filter', 'inline'));
+
+ $$filterSearch
+ .append('input')
+ .attr('class', 'rapid-catalog-filter-search')
+ .call(utilNoAuto)
+ .on('input', e => {
+ const element = e.currentTarget;
+ const val = (element && element.value) || '';
+ this._filterText = val.trim().toLowerCase();
+ $datasets.call(this.renderDatasets);
+ });
+
+ const $$filterType = $$filter
+ .append('div')
+ .attr('class', 'rapid-catalog-filter-type-wrap');
+
+ $$filterType
+ .append('input')
+ .attr('class', 'rapid-catalog-filter-type')
+ .call(utilNoAuto)
+ .call(this.CategoryCombo)
+ .on('blur change', e => {
+ const element = e.currentTarget;
+ const val = (element && element.value) || '';
+ const data = this.CategoryCombo.data();
+ if (data.some(item => item.value === val)) { // only allow picking values from the list
+ this._filterCategory = val;
+ } else {
+ e.currentTarget.value = '';
+ this._filterCategory = null;
+ }
+ $datasets.call(this.renderDatasets);
+ });
+
+ $$filter
+ .append('div')
+ .attr('class', 'rapid-catalog-filter-clear')
+ .append('a')
+ .attr('href', '#')
+ .on('click', e => {
+ e.preventDefault();
+ const element = e.currentTarget;
+ element.blur();
+ $content.selectAll('input').property('value', '');
+ this._filterText = null;
+ this._filterCategory = null;
+ $datasets.call(this.renderDatasets);
+ });
+
+ $$filter
+ .append('div')
+ .attr('class', 'rapid-catalog-filter-results');
+
+ // update
+ $filter = $filter.merge($$filter);
+
+ $filter.selectAll('.rapid-catalog-filter-search')
+ .attr('placeholder', l10n.t('rapid_feature_toggle.esri.filter_datasets'));
+
+ $filter.selectAll('.rapid-catalog-filter-type')
+ .attr('placeholder', l10n.t('rapid_feature_toggle.esri.any_type'));
+
+ $filter.selectAll('.rapid-catalog-filter-clear > a')
+ .text(l10n.t('rapid_feature_toggle.esri.clear_filters'));
+
+
+ /* Dataset section */
+ let $datasets = $content.selectAll('.rapid-catalog-datasets-section')
+ .data([0]);
+
+ // enter
+ const $$datasets = $datasets.enter()
+ .append('div')
+ .attr('class', 'modal-section rapid-catalog-datasets-section');
+
+ $$datasets
+ .append('div')
+ .attr('class', 'rapid-catalog-datasets-status');
+
+ $$datasets
+ .append('div')
+ .attr('class', 'rapid-catalog-datasets');
+
+ // update
+ $datasets = $datasets.merge($$datasets);
+
+ $datasets
+ .call(this.renderDatasets);
+
+
+ /* OK Button */
+ let $buttons = $content.selectAll('.modal-section.buttons')
+ .data([0]);
+
+ // enter
+ const $$buttons = $buttons.enter()
+ .append('div')
+ .attr('class', 'modal-section buttons');
+
+ $$buttons
+ .append('button')
+ .attr('class', 'button ok-button action')
+ .on('click', this._myClose);
+
+ // set focus (but only on enter)
+ const buttonNode = $$buttons.selectAll('button').node();
+ if (buttonNode) buttonNode.focus();
+
+ // update
+ $buttons = $buttons.merge($$buttons);
+
+ $buttons.selectAll('.button')
+ .text(l10n.t('confirm.okay'));
+ }
+
+
+ /**
+ * renderDatasets
+ * Renders datasets details into the `.rapid-catalog-datasets-section` div.
+ * @param {d3-selection} $container - A d3-selection to a HTMLElement that this component should render itself into
+ */
+ renderDatasets($container) {
+ const context = this.context;
+ const assets = context.systems.assets;
+ const l10n = context.systems.l10n;
+ const storage = context.systems.storage;
+
+ const showPreview = storage.getItem('rapid-internal-feature.previewDatasets') === 'true';
+ const esri = context.services.esri;
+
+ const $status = $container.selectAll('.rapid-catalog-datasets-status');
+ const $results = $container.selectAll('.rapid-catalog-datasets');
+
+ if (!esri || (Array.isArray(this._datasetInfo) && !this._datasetInfo.length)) {
+ $results.classed('hide', true);
+ $status.classed('hide', false).text(l10n.t('rapid_feature_toggle.esri.no_datasets'));
+ return;
+ }
+
+ if (!this._datasetInfo) {
+ $results.classed('hide', true);
+ $status.classed('hide', false)
+ .text(l10n.t('rapid_feature_toggle.esri.fetching_datasets'));
+
+ $status
+ .append('br');
+
+ $status
+ .append('img')
+ .attr('class', 'rapid-catalog-datasets-spinner')
+ .attr('src', assets.getFileURL('img/loader-black.gif'));
+
+ esri.startAsync()
+ .then(() => esri.loadDatasetsAsync())
+ .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} `;
+ return item;
+ });
+ this.CategoryCombo.data(combodata);
+
+ // Exclude preview datasets unless user has opted into them
+ this._datasetInfo = Object.values(results)
+ .filter(d => showPreview || !d.groupCategories.some(category => category.toLowerCase() === '/categories/preview'));
+ })
+ .then(() => this.rerender());
+
+ return;
+ }
+
+ $results.classed('hide', false);
+ $status.classed('hide', true);
+
+ // Apply filters
+ let count = 0;
+ this._datasetInfo.forEach(d => {
+ const title = (d.title || '').toLowerCase();
+ const snippet = (d.snippet || '').toLowerCase();
+
+ if (this.isDatasetAdded(d)) { // always show added datasets at the top of the list
+ d.filtered = false;
+ ++count;
+ return;
+ }
+ if (this._filterText && title.indexOf(this._filterText) === -1 && snippet.indexOf(this._filterText) === -1) {
+ d.filtered = true; // filterText not found anywhere in `title` or `snippet`
+ return;
+ }
+ if (this._filterCategory && !(d.groupCategories.some(category => category.toLowerCase() === `/categories/${this._filterCategory}`))) {
+ d.filtered = true; // filterCategory not found anywhere in `groupCategories``
+ return;
+ }
+
+ d.filtered = (++count > MAXRESULTS);
+ });
+
+
+ let $datasets = $results.selectAll('.rapid-catalog-dataset')
+ .data(this._datasetInfo, d => d.id);
+
+ // exit
+ $datasets.exit()
+ .remove();
+
+ // enter
+ const $$datasets = $datasets.enter()
+ .append('div')
+ .attr('class', 'rapid-catalog-dataset');
+
+ const $$labels = $$datasets
+ .append('div')
+ .attr('class', 'rapid-catalog-dataset-label');
+
+ $$labels
+ .append('div')
+ .attr('class', 'rapid-catalog-dataset-name');
+
+ const $$link = $$labels
+ .append('div')
+ .attr('class', 'rapid-catalog-dataset-license')
+ .append('a')
+ .attr('class', 'rapid-catalog-dataset-link')
+ .attr('target', '_blank')
+ .attr('href', d => d.itemURL);
+
+ $$link
+ .append('span')
+ .attr('class', 'rapid-catalog-dataset-link-text');
+
+ $$link
+ .call(uiIcon('#rapid-icon-out-link', 'inline'));
+
+ const $$featured = $$labels.selectAll('.rapid-catalog-dataset-featured')
+ .data(d => d.groupCategories.filter(d => d.toLowerCase() === '/categories/featured'))
+ .enter()
+ .append('div')
+ .attr('class', 'rapid-catalog-dataset-featured');
+
+ $$featured
+ .append('span')
+ .text('\u2b50'); // emoji star
+
+ $$featured
+ .append('span')
+ .attr('class', 'rapid-catalog-dataset-featured-text');
+
+ $$labels.selectAll('.rapid-catalog-dataset-beta')
+ .data(d => d.groupCategories.filter(d => d.toLowerCase() === '/categories/preview'))
+ .enter()
+ .append('div')
+ .attr('class', 'rapid-catalog-dataset-beta beta');
+
+ $$labels
+ .append('div')
+ .attr('class', 'rapid-catalog-dataset-snippet');
+
+ $$labels
+ .append('button')
+ .attr('class', 'rapid-catalog-dataset-action')
+ .on('click', this.toggleDataset);
+
+ const $$thumbnails = $$datasets
+ .append('div')
+ .attr('class', 'rapid-catalog-dataset-thumb');
+
+ $$thumbnails
+ .append('img')
+ .attr('class', 'rapid-catalog-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($$datasets)
+ .sort(this.sortDatasets)
+ .classed('hide', d => d.filtered);
+
+ $datasets.selectAll('.rapid-catalog-dataset-name')
+ .html(d => this.highlight(this._filterText, d.title));
+
+ $datasets.selectAll('.rapid-catalog-dataset-link-text')
+ .text(l10n.t('rapid_feature_toggle.esri.more_info'));
+
+ $datasets.selectAll('.rapid-catalog-dataset-featured-text')
+ .text(l10n.t('rapid_feature_toggle.esri.featured'));
+
+ $datasets.selectAll('.rapid-catalog-dataset-beta')
+ .attr('title', l10n.t('rapid_poweruser_features.beta'));
+
+ $datasets.selectAll('.rapid-catalog-dataset-snippet')
+ .html(d => this.highlight(this._filterText, d.snippet));
+
+ $datasets.selectAll('.rapid-catalog-dataset-action')
+ .classed('secondary', d => this.isDatasetAdded(d))
+ .text(d => this.isDatasetAdded(d) ? l10n.t('rapid_feature_toggle.esri.remove') : l10n.t('rapid_feature_toggle.esri.add_to_map'));
+
+ const numShown = this._datasetInfo.filter(d => !d.filtered).length;
+ const gt = (count > MAXRESULTS && numShown === MAXRESULTS) ? '>' : '';
+ this.$content.selectAll('.rapid-catalog-filter-results')
+ .text(l10n.t('rapid_feature_toggle.esri.datasets_found', { num: `${gt}${numShown}` }));
+ }
+
+
+ /**
+ * sortDatasets
+ * Added datasets to the beginning
+ * Featured datasets next
+ * All others sort by name
+ */
+ sortDatasets(a, b) {
+ const aAdded = this.isDatasetAdded(a);
+ const bAdded = this.isDatasetAdded(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);
+ }
+
+
+ /**
+ * toggleDataset
+ * Toggles the given dataset between added/removed.
+ * @param {Event} e? - triggering event (if any)
+ * @param {*} d - bound datum (the dataset in this case)
+ */
+ toggleDataset(e, d) {
+ const context = this.context;
+ const gfx = context.systems.gfx;
+ const l10n = context.systems.l10n;
+ const rapid = context.systems.rapid;
+ const urlhash = context.systems.urlhash;
+
+ const datasets = rapid.datasets;
+ const ds = datasets.get(d.id);
+
+ if (ds) {
+ ds.added = !ds.added;
+
+ } else { // hasn't been added yet
+ const esri = context.services.esri;
+ if (esri) { // start fetching layer info (the mapping between attributes and tags)
+ esri.loadLayerAsync(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 = rapid.colors;
+ const colorIndex = datasets.size % colors.length;
+
+ const 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],
+ dataUsed: ['esri', esri.getDataUsed(d.title)],
+ label: d.title,
+ license_markdown: l10n.t('rapid_feature_toggle.esri.license_markdown'),
+ licenseStringID: 'rapid_feature_toggle.esri.license_markdown'
+ };
+
+ if (d.extent) {
+ dataset.extent = new Extent(d.extent[0], d.extent[1]);
+ }
+
+ // Experiment: run building layers through MapWithAI conflation service
+ if (isBuildings) {
+ dataset.conflated = true;
+ dataset.service = 'mapwithai';
+
+ // and disable the Microsoft buildings to avoid clutter
+ const msBuildings = datasets.get('msBuildings');
+ if (msBuildings) {
+ msBuildings.enabled = false;
+ }
+ }
+
+ datasets.set(d.id, dataset);
+ }
+
+ // update url hash
+ const datasetIDs = [...rapid.datasets.values()]
+ .filter(ds => ds.added && ds.enabled)
+ .map(ds => ds.id)
+ .join(',');
+
+ urlhash.setParam('datasets', datasetIDs.length ? datasetIDs : null);
+
+ this.render();
+
+ context.enter('browse'); // return to browse mode (in case something was selected)
+ gfx.immediateRedraw();
+ }
+
+
+ /**
+ * isDatasetAdded
+ * @param {*} d - bound datum (the dataset in this case)
+ */
+ isDatasetAdded(d) {
+ const rapid = this.context.systems.rapid;
+ const ds = rapid.datasets.get(d.id);
+ return ds?.added;
+ }
+
+
+ /**
+ * highlight
+ */
+ highlight(needle, haystack) {
+ let html = haystack;
+ if (needle) {
+ const re = new RegExp('\(' + _escapeRegex(needle) + '\)', 'gi');
+ html = html.replace(re, '$1');
+ }
+
+ return html;
+
+ function _escapeRegex(s) {
+ return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
+ }
+ }
+
+}
+
diff --git a/modules/ui/UiRapidDatasetToggle.js b/modules/ui/UiRapidDatasetToggle.js
index 14714debc..cb1eb6e87 100644
--- a/modules/ui/UiRapidDatasetToggle.js
+++ b/modules/ui/UiRapidDatasetToggle.js
@@ -5,13 +5,13 @@ import { icon } from './intro/helper.js';
import { uiIcon } from './icon.js';
import { uiModal } from './modal.js';
import { uiRapidColorpicker } from './rapid_colorpicker.js';
-import { uiRapidViewManageDatasets } from './rapid_view_manage_datasets.js';
+import { UiRapidCatalog } from './UiRapidCatalog.js';
import { utilCmd } from '../util/cmd.js';
/**
* UiRapidDatasetToggle
- * This is the modal dialog where the user can toggle on and off datasets.
+ * This is the modal where the user can toggle on and off datasets.
* It is shown by clicking the main "Rapid" button in the top menu.
*
* @example
@@ -40,7 +40,6 @@ export class UiRapidDatasetToggle {
const scene = context.systems.gfx.scene;
// Child components (will be created in `show()`)
- this.CatalogModal = null;
this.ColorPicker = null;
// D3 selections
@@ -82,9 +81,6 @@ export class UiRapidDatasetToggle {
this.$modal.select('.modal')
.attr('class', 'modal rapid-modal');
- this.CatalogModal = uiRapidViewManageDatasets(context, this.$modal)
- .on('done', this.rerender);
-
this.ColorPicker = uiRapidColorpicker(context, this.$modal)
.on('change', this.changeColor);
@@ -198,7 +194,10 @@ export class UiRapidDatasetToggle {
const $$manageDatasets = $manageDatasets.enter()
.append('div')
.attr('class', 'modal-section rapid-checkbox rapid-manage-datasets')
- .on('click', () => context.container().call(this.CatalogModal));
+ .on('click', () => {
+ const CatalogModal = new UiRapidCatalog(context, this.$modal).on('done', this.rerender);
+ context.container().call(CatalogModal.show);
+ });
$$manageDatasets
.append('div')
diff --git a/modules/ui/UiRapidPowerUserFeatures.js b/modules/ui/UiRapidPowerUserFeatures.js
index 50d265985..20a0aca29 100644
--- a/modules/ui/UiRapidPowerUserFeatures.js
+++ b/modules/ui/UiRapidPowerUserFeatures.js
@@ -5,7 +5,7 @@ import { uiModal } from './modal.js';
/**
* UiRapidPowerUserFeatures
- * This is the modal dialog where the user can toggle on and off power user features.
+ * This is the modal where the user can toggle on and off power user features.
* It is shown by clicking the "Beta" button in the top menu, if `&poweruser=true` is in the url.
*/
export class UiRapidPowerUserFeatures {
diff --git a/modules/ui/index.js b/modules/ui/index.js
index a6a90584d..3f5ff17b5 100644
--- a/modules/ui/index.js
+++ b/modules/ui/index.js
@@ -55,13 +55,13 @@ export { uiPopover } from './popover.js';
export { uiPresetIcon } from './preset_icon.js';
export { uiPresetList } from './preset_list.js';
export { UiProjectLinks } from './UiProjectLinks.js';
+export { UiRapidCatalog } from './UiRapidCatalog.js';
export { uiRapidColorpicker } from './rapid_colorpicker.js';
export { UiRapidDatasetToggle } from './UiRapidDatasetToggle.js';
export { uiRapidFirstEditDialog } from './rapid_first_edit_dialog.js';
export { UiRapidInspector } from './UiRapidInspector.js';
export { UiRapidPowerUserFeatures } from './UiRapidPowerUserFeatures.js';
// export { uiRapidSplash } from './rapid_splash.js';
-export { uiRapidViewManageDatasets } from './rapid_view_manage_datasets.js';
export { uiRestore } from './restore.js';
export { UiScale } from './UiScale.js';
export { uiSection } from './section.js';
diff --git a/modules/ui/rapid_view_manage_datasets.js b/modules/ui/rapid_view_manage_datasets.js
deleted file mode 100644
index 4def71f55..000000000
--- a/modules/ui/rapid_view_manage_datasets.js
+++ /dev/null
@@ -1,520 +0,0 @@
-import { dispatch as d3_dispatch } from 'd3-dispatch';
-import { select as d3_select } from 'd3-selection';
-import { Extent } from '@rapid-sdk/math';
-import { marked } from 'marked';
-
-import { uiIcon } from './icon.js';
-import { uiCombobox} from './combobox.js';
-import { utilKeybinding, utilNoAuto, utilRebind } from '../util/index.js';
-
-
-export function uiRapidViewManageDatasets(context, parentModal) {
- const assets = context.systems.assets;
- const gfx = context.systems.gfx;
- const l10n = context.systems.l10n;
- const rapid = context.systems.rapid;
- const storage = context.systems.storage;
- const urlhash = context.systems.urlhash;
-
- 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(uiIcon('#rapid-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(uiIcon('#rapid-icon-data', 'icon-30'));
-
- line1
- .append('div')
- .attr('class', 'rapid-view-manage-header-text')
- .text(l10n.t('rapid_feature_toggle.esri.title'));
-
- let line2 = headerEnter
- .append('div');
-
- line2
- .append('div')
- .attr('class', 'rapid-view-manage-header-about')
- .html(marked.parse(l10n.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(uiIcon('#fas-filter', 'inline'));
-
- filterSearchEnter
- .append('input')
- .attr('class', 'rapid-view-manage-filter-search')
- .attr('placeholder', l10n.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', l10n.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(l10n.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(l10n.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 = storage.getItem('rapid-internal-feature.previewDatasets') === 'true';
- const esri = context.services.esri;
-
- if (!esri || (Array.isArray(_datasetInfo) && !_datasetInfo.length)) {
- results.classed('hide', true);
- status.classed('hide', false).text(l10n.t('rapid_feature_toggle.esri.no_datasets'));
- return;
- }
-
- if (!_datasetInfo) {
- results.classed('hide', true);
- status.classed('hide', false)
- .text(l10n.t('rapid_feature_toggle.esri.fetching_datasets'));
-
- status
- .append('br');
-
- status
- .append('img')
- .attr('class', 'rapid-view-manage-datasets-spinner')
- .attr('src', assets.getFileURL('img/loader-black.gif'));
-
- esri.startAsync()
- .then(() => esri.loadDatasetsAsync())
- .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} `;
- 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(l10n.t('rapid_feature_toggle.esri.more_info'))
- .call(uiIcon('#rapid-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(l10n.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', l10n.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) ? l10n.t('rapid_feature_toggle.esri.remove') : l10n.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(l10n.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 = rapid.datasets;
- const ds = datasets.get(d.id);
-
- if (ds) {
- ds.added = !ds.added;
-
- } else { // hasn't been added yet
- const esri = context.services.esri;
- if (esri) { // start fetching layer info (the mapping between attributes and tags)
- esri.loadLayerAsync(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 = rapid.colors;
- const colorIndex = datasets.size % colors.length;
-
- const 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],
- dataUsed: ['esri', esri.getDataUsed(d.title)],
- label: d.title,
- license_markdown: l10n.t('rapid_feature_toggle.esri.license_markdown'),
- licenseStringID: 'rapid_feature_toggle.esri.license_markdown'
- };
-
- if (d.extent) {
- dataset.extent = new Extent(d.extent[0], d.extent[1]);
- }
-
- // Experiment: run building layers through MapWithAI conflation service
- if (isBuildings) {
- dataset.conflated = true;
- dataset.service = 'mapwithai';
-
- // and disable the Microsoft buildings to avoid clutter
- const msBuildings = datasets.get('msBuildings');
- if (msBuildings) {
- msBuildings.enabled = false;
- }
- }
-
- datasets.set(d.id, dataset);
- }
-
- // update url hash
- const datasetIDs = [...rapid.datasets.values()]
- .filter(ds => ds.added && ds.enabled)
- .map(ds => ds.id)
- .join(',');
-
- urlhash.setParam('datasets', datasetIDs.length ? datasetIDs : null);
-
- _content.call(renderModalContent);
-
- context.enter('browse'); // return to browse mode (in case something was selected)
- gfx.immediateRedraw();
- }
-
-
- function datasetAdded(d) {
- const ds = rapid.datasets.get(d.id);
- return ds?.added;
- }
-
-
- function highlight(needle, haystack) {
- let html = haystack;
- if (needle) {
- const re = new RegExp('\(' + escapeRegex(needle) + '\)', 'gi');
- html = html.replace(re, '$1');
- }
- return html;
- }
-
- function escapeRegex(s) {
- return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
- }
-
- return utilRebind(render, dispatch, 'on');
-}