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'); -}