diff --git a/css/80_app.css b/css/80_app.css index 1b030adbc..b56efd8e3 100644 --- a/css/80_app.css +++ b/css/80_app.css @@ -4172,7 +4172,7 @@ li.issue-fix-item button:not(.actionable) .fix-icon { place-content: center; place-items: center; /* for testing */ -/* background-image: url('https://upload.wikimedia.org/wikipedia/commons/thumb/a/ad/RCA_Indian_Head_Test_Pattern.svg/1200px-RCA_Indian_Head_Test_Pattern.svg.png');*/ +/* background-image: url(img/test-pattern.png);*/ /* background-size: cover;*/ /* background-repeat: no-repeat;*/ /* background-position: center center;*/ diff --git a/css/80_app_fb.css b/css/80_app_fb.css index 618defccb..fa2a5268d 100644 --- a/css/80_app_fb.css +++ b/css/80_app_fb.css @@ -357,9 +357,9 @@ button.rapid-features.layer-off use { /* For things that should stack in rows */ .rapid-stack { - display: flex; - flex-direction: column; - align-items: flex-start; + display: flex; + flex-direction: column; + align-items: flex-start; } /* Rapid modal dialogs */ @@ -491,11 +491,8 @@ button.rapid-features.layer-off use { /* dark scrollbars */ -.modal.rapid-modal { - scrollbar-width: thin; -} .modal.rapid-modal ::-webkit-scrollbar { - width: 10px; + width: 8px; } .modal.rapid-modal ::-webkit-scrollbar-track { background: #444; @@ -905,73 +902,91 @@ div.combobox.combobox-dataset-categories a:focus { text-align: center; margin: 50px; } -.rapid-catalog-datasets-spinner { - filter: brightness(2)contrast(0.8); -} .rapid-catalog-datasets { - display: flex; - flex-flow: row wrap; - justify-content: flex-start; - align-items: flex-start; - width: 100%; + display: flex; + flex-flow: row wrap; + justify-content: flex-start; + align-items: flex-start; + padding: 5px; + width: 100%; } .rapid-catalog-dataset { - flex: 0 1 50%; - padding: 15px 25px; - margin-bottom: 10px; - color: #eee; - display: flex; - flex-flow: row nowrap; + display: flex; + flex: 1 1 40%; + flex-flow: row nowrap; + padding: 10px; + margin: 5px; + border-radius: 5px; + height: 220px; + color: #eee; +} +.rapid-catalog-dataset.added { + background: rgba(55, 55, 55, 0.9); } .rapid-catalog-dataset-label { - flex: 1; - padding: 0 8px; + flex: 1; + padding: 0 8px; } .rapid-catalog-dataset-thumb { - flex: 0; + flex: 0; } img.rapid-catalog-dataset-thumbnail { - border-radius: 10px; - width: 180px; - filter: invert(1)brightness(2)contrast(0.75); + border-radius: 10px; + object-fit: cover; + height: 130px; + width: 180px; } - -.rapid-catalog-dataset button.rapid-catalog-dataset-action { - font-size: 12px; - height: 28px; - border-radius: 14px; - margin: 10px 0; - padding: 0 15px; +img.rapid-catalog-dataset-thumbnail.inverted { + filter: invert(1) brightness(2) contrast(0.75); } + .rapid-catalog-dataset-name { font-weight: bold; font-size: 14px; margin-bottom: 3px; } -.rapid-catalog-dataset-license { - display: inline-block; + +.dataset-categories { + display: flex; + flex-flow: row wrap; +} +.dataset-category { + display: inline-block; + font-size: 10px; + padding: 1px 4px; + border-radius: 3px; + background: #666; + color: #eee; + margin-left: unset; + margin-right: 4px; + line-height: 1.5; } -.rapid-catalog-dataset-beta { - font-size: 10px; +.ideditor[dir='rtl'] .dataset-category { + margin-right: unset; + margin-left: 4px; } -.rapid-catalog-dataset-featured { - display: inline-block; - font-size: 11px; - background: #a21; - color: #dcdcdc; - padding: 1px 7px; - border-radius: 5px; - margin: 0px 10px; - line-height: 1.5; +.dataset-category-preview { + background: rgb(203,16,237); + background: linear-gradient(0deg, rgba(108,1,167,1) 6%, rgba(203,16,237,1) 50%, rgb(229, 140, 253) 90%, rgb(201, 42, 251) 100%); +} +.dataset-category-featured { + background: #a21; } -.rapid-catalog-dataset-featured span { - margin: 0px 3px; +.dataset-added-text { + color: #16da16; } -/* Colorpicker popup */ +.rapid-catalog-dataset button.rapid-catalog-dataset-action { + font-size: 12px; + height: 28px; + border-radius: 14px; + margin: 10px 0; + padding: 0 15px; +} +/* Colorpicker popup */ .colorpicker-popup { position: absolute; padding: 10px; diff --git a/data/core.yaml b/data/core.yaml index 0d38afd9e..ca2a2333e 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -1118,32 +1118,48 @@ en: rapid_feature_license: Facebook's Map With AI License rapid_feature_toggle: license: License - toggle_all: Toggle all {rapidicon} features - view_manage_datasets: "Add/Manage Datasets" + toggle_all: Toggle all {rapidicon} data + add_manage_datasets: "Add/Manage Datasets" center_map: Center map here worldwide: Worldwide + no_datasets: No datasets available. + remove: Remove + add_dataset: Add Dataset + dataset_added: Dataset added + more_info: More Info + about_the_catalog: "These datasets have been provided as open data by various organizations and the Esri user community for the purpose of improving OpenStreetMap.
You can learn more by clicking the links below, or visiting [the Rapid Guide](https://github.com/facebookmicrosites/Open-Mapping-At-Facebook/wiki/Esri-ArcGIS-FAQ) or [Esri/ArcGIS dataset page on the OSM Wiki](https://wiki.openstreetmap.org/wiki/Esri/ArcGIS_Datasets)." # This string may contain markdown + clear_filters: Clear Filters + filter_datasets: filter datasets # placeholder text for the filter text + any_type: any type # placeholder text for the filter type dropdown + datasets_found: + one: "{n} dataset found" + other: "{gt}{n} datasets found" # {gt} = placeholder for greater than symbol '>', {n} = count + category: + addresses: addresses + buildings: buildings + esri: Esri + featured: featured + footways: footways + meta: Meta + microsoft: Microsoft + overture: Overture + places: places + preview: preview + roads: roads + trees: trees fbRoads: label: Facebook Roads + description: AI predicted roads detected from Maxar Premium imagery and available in over 80 countries. msBuildings: label: Microsoft Buildings + description: Open building footprints from around the world. Detected from Bing Maps imagery between 2014 and 2024 including Maxar, Airbus, and IGN France imagery. overture: places: label: Overture Places + description: "Contains over 53 million point representations of real-world entities: schools, businesses, hospitals, religious organizations, landmarks, mountain peaks, and much more." omdFootways: label: Open Data Footways - esri: - title: ArcGIS Datasets - about: "These datasets have been provided as open data by the ArcGIS user community for the purpose of improving OpenStreetMap.
You can learn more by visiting [the Rapid Guide](https://github.com/facebookmicrosites/Open-Mapping-At-Facebook/wiki/Esri-ArcGIS-FAQ) or [Esri/ArcGIS dataset page on the OSM Wiki](https://wiki.openstreetmap.org/wiki/Esri/ArcGIS_Datasets)." # This string may contain markdown - filter_datasets: filter datasets - any_type: any type - clear_filters: Clear Filters - datasets_found: "{num} dataset(s) found" - featured: featured - fetching_datasets: Fetching available datasets... - no_datasets: No datasets available. - remove: Remove - add_to_map: Add Dataset - more_info: More Info + description: Open footways data collected from various local government entities. rapid_poweruser_features: beta: Beta Feature diff --git a/data/l10n/core.en.json b/data/l10n/core.en.json index a8ee897f4..db0088a41 100644 --- a/data/l10n/core.en.json +++ b/data/l10n/core.en.json @@ -1399,37 +1399,54 @@ "rapid_feature_license": "Facebook's Map With AI License", "rapid_feature_toggle": { "license": "License", - "toggle_all": "Toggle all {rapidicon} features", - "view_manage_datasets": "Add/Manage Datasets", + "toggle_all": "Toggle all {rapidicon} data", + "add_manage_datasets": "Add/Manage Datasets", "center_map": "Center map here", "worldwide": "Worldwide", + "no_datasets": "No datasets available.", + "remove": "Remove", + "add_dataset": "Add Dataset", + "dataset_added": "Dataset added", + "more_info": "More Info", + "about_the_catalog": "These datasets have been provided as open data by various organizations and the Esri user community for the purpose of improving OpenStreetMap.
You can learn more by clicking the links below, or visiting [the Rapid Guide](https://github.com/facebookmicrosites/Open-Mapping-At-Facebook/wiki/Esri-ArcGIS-FAQ) or [Esri/ArcGIS dataset page on the OSM Wiki](https://wiki.openstreetmap.org/wiki/Esri/ArcGIS_Datasets).", + "clear_filters": "Clear Filters", + "filter_datasets": "filter datasets", + "any_type": "any type", + "datasets_found": { + "one": "{n} dataset found", + "other": "{gt}{n} datasets found" + }, + "category": { + "addresses": "addresses", + "buildings": "buildings", + "esri": "Esri", + "featured": "featured", + "footways": "footways", + "meta": "Meta", + "microsoft": "Microsoft", + "overture": "Overture", + "places": "places", + "preview": "preview", + "roads": "roads", + "trees": "trees" + }, "fbRoads": { - "label": "Facebook Roads" + "label": "Facebook Roads", + "description": "AI predicted roads detected from Maxar Premium imagery and available in over 80 countries." }, "msBuildings": { - "label": "Microsoft Buildings" + "label": "Microsoft Buildings", + "description": "Open building footprints from around the world. Detected from Bing Maps imagery between 2014 and 2024 including Maxar, Airbus, and IGN France imagery." }, "overture": { "places": { - "label": "Overture Places" + "label": "Overture Places", + "description": "Contains over 53 million point representations of real-world entities: schools, businesses, hospitals, religious organizations, landmarks, mountain peaks, and much more." } }, "omdFootways": { - "label": "Open Data Footways" - }, - "esri": { - "title": "ArcGIS Datasets", - "about": "These datasets have been provided as open data by the ArcGIS user community for the purpose of improving OpenStreetMap.
You can learn more by visiting [the Rapid Guide](https://github.com/facebookmicrosites/Open-Mapping-At-Facebook/wiki/Esri-ArcGIS-FAQ) or [Esri/ArcGIS dataset page on the OSM Wiki](https://wiki.openstreetmap.org/wiki/Esri/ArcGIS_Datasets).", - "filter_datasets": "filter datasets", - "any_type": "any type", - "clear_filters": "Clear Filters", - "datasets_found": "{num} dataset(s) found", - "featured": "featured", - "fetching_datasets": "Fetching available datasets...", - "no_datasets": "No datasets available.", - "remove": "Remove", - "add_to_map": "Add Dataset", - "more_info": "More Info" + "label": "Open Data Footways", + "description": "Open footways data collected from various local government entities." } }, "rapid_poweruser_features": { diff --git a/dist/img/data-buildings.png b/dist/img/data-buildings.png new file mode 100644 index 000000000..729ff6b78 Binary files /dev/null and b/dist/img/data-buildings.png differ diff --git a/dist/img/data-footways.png b/dist/img/data-footways.png new file mode 100644 index 000000000..0a36db502 Binary files /dev/null and b/dist/img/data-footways.png differ diff --git a/dist/img/data-points.png b/dist/img/data-points.png new file mode 100644 index 000000000..e5d10ddb4 Binary files /dev/null and b/dist/img/data-points.png differ diff --git a/dist/img/data-roads.png b/dist/img/data-roads.png new file mode 100644 index 000000000..881851362 Binary files /dev/null and b/dist/img/data-roads.png differ diff --git a/dist/img/test-pattern.png b/dist/img/test-pattern.png new file mode 100644 index 000000000..42bca5dde Binary files /dev/null and b/dist/img/test-pattern.png differ diff --git a/modules/core/FilterSystem.js b/modules/core/FilterSystem.js index f4abb6b4a..9511c0c9e 100644 --- a/modules/core/FilterSystem.js +++ b/modules/core/FilterSystem.js @@ -110,10 +110,10 @@ export class FilterSystem extends AbstractSystem { } } - const storage = this.context.systems.storage; - const urlhash = this.context.systems.urlhash; + const context = this.context; + const urlhash = context.systems.urlhash; + const prerequisites = Promise.all([ - storage.initAsync(), urlhash.initAsync() ]); @@ -121,21 +121,8 @@ export class FilterSystem extends AbstractSystem { .then(() => { // Setup event handlers.. urlhash.on('hashchange', this._hashchange); - - // Take initial values from urlhash first, localstorage second - const toHide = urlhash.getParam('disable_features') ?? storage.getItem('disabled-features'); - - if (toHide) { - const filterIDs = toHide.replace(/;/g, ',').split(',').map(s => s.trim()).filter(Boolean); - for (const filterID of filterIDs) { - this._hidden.add(filterID); - const filter = this._filters.get(filterID); - filter.enabled = false; - } - } }); - // // warm up the feature matching cache upon merging fetched data // const editor = this.context.systems.editor; // editor.on('merge.features', function(newEntities) { @@ -161,7 +148,22 @@ export class FilterSystem extends AbstractSystem { * @return {Promise} Promise resolved when this component has completed startup */ startAsync() { + const context = this.context; + const storage = context.systems.storage; + const urlhash = context.systems.urlhash; + + // Take filter values from urlhash first, localstorage second, + // Default to having boundaries hidden + const toHide = urlhash.getParam('disable_features') ?? storage.getItem('disabled-features') ?? 'boundaries'; + const filterIDs = toHide.replace(/;/g, ',').split(',').map(s => s.trim()).filter(Boolean); + for (const filterID of filterIDs) { + this._hidden.add(filterID); + const filter = this._filters.get(filterID); + filter.enabled = false; + } + this._update(); this._started = true; + return Promise.resolve(); } diff --git a/modules/core/MapSystem.js b/modules/core/MapSystem.js index 9c9def544..5ad4947a3 100644 --- a/modules/core/MapSystem.js +++ b/modules/core/MapSystem.js @@ -36,7 +36,7 @@ export class MapSystem extends AbstractSystem { constructor(context) { super(context); this.id = 'map'; - this.dependencies = new Set(['editor', 'filters', 'gfx', 'imagery', 'l10n', 'photos', 'storage', 'styles', 'urlhash']); + this.dependencies = new Set(['editor', 'filters', 'gfx', 'imagery', 'l10n', 'photos', 'rapid', 'storage', 'styles', 'urlhash']); // display options this.areaFillOptions = ['wireframe', 'partial', 'full']; @@ -83,6 +83,7 @@ export class MapSystem extends AbstractSystem { const imagery = context.systems.imagery; const l10n = context.systems.l10n; const photos = context.systems.photos; + const rapid = context.systems.rapid; const storage = context.systems.storage; const styles = context.systems.styles; const urlhash = context.systems.urlhash; @@ -174,6 +175,12 @@ export class MapSystem extends AbstractSystem { gfx.immediateRedraw(); }); + rapid + .on('datasetchange', () => { + scene.dirtyLayers(['rapid', 'rapid-overlay', 'overture']); + gfx.immediateRedraw(); + }); + l10n .on('localechange', () => { this._setupKeybinding(); diff --git a/modules/core/RapidSystem.js b/modules/core/RapidSystem.js index 49752484e..c476dbf33 100644 --- a/modules/core/RapidSystem.js +++ b/modules/core/RapidSystem.js @@ -2,9 +2,9 @@ import { gpx } from '@tmcw/togeojson'; import { Extent } from '@rapid-sdk/math'; import { AbstractSystem } from './AbstractSystem.js'; -import { RapidDataset } from './lib/RapidDataset.js'; const RAPID_MAGENTA = '#da26d3'; +const OVERTURE_CYAN = '#00ffff'; const RAPID_COLORS = [ '#ff0000', // red '#ffa500', // orange @@ -19,10 +19,18 @@ const RAPID_COLORS = [ ]; +// Convert a single value, an Array of values, or a Set of values. +function asSet(vals) { + if (vals instanceof Set) return vals; + return new Set(vals !== undefined && [].concat(vals)); +} + + /** * `RapidSystem` maintains all the Rapid datasets * * Events available: + * `datasetchange` Fires when datasets are added/removed from the list * `taskchanged` */ export class RapidSystem extends AbstractSystem { @@ -34,21 +42,25 @@ export class RapidSystem extends AbstractSystem { constructor(context) { super(context); this.id = 'rapid'; - this.dependencies = new Set(['editor', 'l10n', 'map', 'urlhash']); + this.dependencies = new Set(['assets', 'editor', 'l10n', 'map', 'urlhash']); - this.datasets = new Map(); // Map - currently "added" datasets - this.catalog = new Map(); // Map - all the datasets we know about + this.catalog = new Map(); // Map - all the datasets we know about + this.categories = new Set(); // Set - all the dataset 'categories' we know about + this._addedDatasetIDs = new Set(); // Set - currently "added" datasets - is it on the menu? + this._enabledDatasetIDs = new Set(); // Set - currently "enabled" datasets - is it checked? // Watch edit history to keep track of which features have been accepted by the user. // These features will be filtered out when drawing - this.acceptIDs = new Set(); - this.ignoreIDs = new Set(); + this.acceptIDs = new Set(); // Set + this.ignoreIDs = new Set(); // Set + this._nextColorIndex = 2; // see note in _datasetsChanged() this._taskExtent = null; this._isTaskBoundsRect = null; this._hadPoweruser = false; // true if the user had poweruser mode at any point in their editing this._initPromise = null; + this._startPromise = null; // Ensure methods used as callbacks always have `this` bound correctly. this._hashchange = this._hashchange.bind(this); @@ -72,14 +84,12 @@ export class RapidSystem extends AbstractSystem { const context = this.context; const editor = context.systems.editor; - const l10n = context.systems.l10n; const map = context.systems.map; const urlhash = context.systems.urlhash; const prerequisites = Promise.all([ editor.initAsync(), map.initAsync(), // RapidSystem should listen for hashchange after MapSystem - l10n.initAsync(), urlhash.initAsync() ]); @@ -87,72 +97,7 @@ export class RapidSystem extends AbstractSystem { .then(() => { urlhash.on('hashchange', this._hashchange); editor.on('stablechange', this._stablechange); - - const fbRoads = new RapidDataset(context, { - id: 'fbRoads', - beta: false, - added: true, // whether it should appear in the list - enabled: false, // whether the user has checked it on - conflated: true, - service: 'mapwithai', - color: RAPID_MAGENTA, - dataUsed: ['mapwithai', 'Facebook Roads'], - licenseUrl: 'https://rapideditor.org/doc/license/MapWithAILicense.pdf', - labelStringID: 'rapid_feature_toggle.fbRoads.label' - }); - - const msBuildings = new RapidDataset(context, { - id: 'msBuildings', - beta: false, - added: true, // whether it should appear in the list - enabled: false, // whether the user has checked it on - conflated: true, - service: 'mapwithai', - color: RAPID_MAGENTA, - dataUsed: ['mapwithai', 'Microsoft Buildings'], - licenseUrl: 'https://github.com/microsoft/USBuildingFootprints/blob/master/LICENSE-DATA', - labelStringID: 'rapid_feature_toggle.msBuildings.label' - }); - - const places = new RapidDataset(context, { - id: 'overture-places', - beta: false, - added: true, // whether it should appear in the list - enabled: false, // whether the user has checked it on - conflated: true, - service: 'overture', - color: '#00ffff', - dataUsed: ['overture', 'Overture Places'], - licenseUrl: 'https://docs.overturemaps.org/attribution/#places', - labelStringID: 'rapid_feature_toggle.overture.places.label' - }); - - const footways = new RapidDataset(context, { - id: 'omdFootways', - beta: false, - added: true, // whether it should appear in the list - enabled: false, // whether the user has checked it on - conflated: true, - service: 'mapwithai', - overlay: { - url: 'https://external.xx.fbcdn.net/maps/vtp/rapid_overlay_footways/1/{z}/{x}/{y}/', - minZoom: 1, - maxZoom: 15, - }, - tags: 'opendata', - color: RAPID_MAGENTA, - dataUsed: ['mapwithai', 'Open Footways'], - licenseUrl: 'https://rapideditor.org/doc/license/MapWithAILicense.pdf', - labelStringID: 'rapid_feature_toggle.omdFootways.label' - }); - - // by default add these ones - for (const ds of [fbRoads, msBuildings, places, footways]) { - ds.added = true; - this.datasets.set(ds.id, ds); - } - - }); + }); } @@ -162,8 +107,47 @@ export class RapidSystem extends AbstractSystem { * @return {Promise} Promise resolved when this component has completed startup */ startAsync() { - this._started = true; - return Promise.resolve(); + if (this._startPromise) return this._startPromise; + + // We wait until startAsync to create the dataset catalog because the services need to be initialized. + const context = this.context; + const urlhash = context.systems.urlhash; + + const esri = context.services.esri; + const mapwithai = context.services.mapwithai; + const overture = context.services.overture; + + // This code is written in a way that we can work with whatever + // data-providing services are installed. + const services = []; + if (esri) services.push(esri); + if (mapwithai) services.push(mapwithai); + if (overture) services.push(overture); + + const prerequisites = Promise.all(services.map(service => service.startAsync())); + + return this._startPromise = prerequisites + .then(() => { + // Gather all available datasets and categories into the dataset catalog.. + for (const service of services) { + const datasets = service.getAvailableDatasets(); + for (const dataset of datasets) { + this.catalog.set(dataset.id, dataset); + for (const category of dataset.categories) { + this.categories.add(category); + } + } + } + + // Set some defaults + if (!urlhash.initialHashParams.has('datasets')) { + this._addedDatasetIDs = new Set(['fbRoads', 'msBuildings', 'overture-places', 'omdFootways']); // on menu + this._enabledDatasetIDs = new Set(['fbRoads', 'msBuildings']); // checked + this._datasetsChanged(); + } + + this._started = true; + }); } @@ -179,6 +163,88 @@ export class RapidSystem extends AbstractSystem { } + /** + * addDatasets + * Add datasets to the menu. (Does not set their checked 'enabled' state.) + * @param {Set|Array|string} datasetIDs - Set or Array of datasetIDs to add, or a single string datasetID + */ + addDatasets(datasetIDs) { + for (const datasetID of asSet(datasetIDs)) { // coax ids into a Set + this._addedDatasetIDs.add(datasetID); + } + this._datasetsChanged(); + } + + + /** + * removeDatasets + * Remove datasets from the menu. (Also unchecks their 'enabled' state) + * @param {Set|Array|string} datasetIDs - Set or Array of datasetIDs to remove, or a single string datasetID + */ + removeDatasets(datasetIDs) { + for (const datasetID of asSet(datasetIDs)) { // coax ids into a Set + this._addedDatasetIDs.delete(datasetID); + this._enabledDatasetIDs.delete(datasetID); + } + this._datasetsChanged(); + } + + + /** + * enableDatasets + * Checks the dataset as enabled. (Also ensures that the dataset is 'added' to the menu). + * @param {Set|Array|string} datasetIDs - Set or Array of datasetIDs to enable, or a single string datasetID + */ + enableDatasets(datasetIDs) { + for (const datasetID of asSet(datasetIDs)) { // coax ids into a Set + this._addedDatasetIDs.add(datasetID); + this._enabledDatasetIDs.add(datasetID); + } + this._datasetsChanged(); + } + + + /** + * disableDatasets + * Unchecks the dataset as disabled. (Does not affect whether the dataset is 'added' to the menu) + * @param {Set|Array|string} datasetIDs - Set or Array of datasetIDs to disable, or a single string datasetID + */ + disableDatasets(datasetIDs) { + for (const datasetID of asSet(datasetIDs)) { // coax ids into a Set + this._enabledDatasetIDs.delete(datasetID); + } + this._datasetsChanged(); + } + + + /** + * toggleDatasets + * Toggles the given datasets enabled state, does not affect any other datasets. + * @param {Set|Array|string} datasetIDs - Set or Array of datasetIDs to toggle, or a single string datasetID + */ + toggleDatasets(datasetIDs) { + for (const datasetID of asSet(datasetIDs)) { // coax ids into a Set + this._addedDatasetIDs.add(datasetID); // it needs to be added to the menu + if (this._enabledDatasetIDs.has(datasetID)) { + this._enabledDatasetIDs.delete(datasetID); + } else { + this._enabledDatasetIDs.add(datasetID); + } + } + this._datasetsChanged(); + } + + + // return just the added ones + get datasets() { + const results = new Map(); + for (const datasetID of this._addedDatasetIDs) { + const dataset = this.catalog.get(datasetID); + results.set(datasetID, dataset); + } + return results; + } + get colors() { return RAPID_COLORS; } @@ -260,7 +326,6 @@ export class RapidSystem extends AbstractSystem { } - /** * _stablechange * This is called anytime the history changes, we recompute the accepted/ignored sets. @@ -287,7 +352,6 @@ export class RapidSystem extends AbstractSystem { } else if (annotation?.type === 'rapid_ignore_feature') { if (annotation.entityID) this.ignoreIDs.add(annotation.entityID); } - } } @@ -306,70 +370,59 @@ export class RapidSystem extends AbstractSystem { } // datasets - let toEnable = new Set(); const newDatasets = currParams.get('datasets'); const oldDatasets = prevParams.get('datasets'); if (newDatasets !== oldDatasets) { + this._enabledDatasetIDs.clear(); if (typeof newDatasets === 'string') { - toEnable = new Set(newDatasets.split(',')); + const toEnable = newDatasets.replace(/;/g, ',').split(',').map(s => s.trim()).filter(Boolean); + this.enableDatasets(toEnable); + } else { // all removed + this._datasetsChanged(); } + } + } + + + /** + * _datasetsChanged + * Handle changes in dataset state, update the urlhash, emit 'datasetchange' + */ + _datasetsChanged() { + const context = this.context; + const urlhash = context.systems.urlhash; - // Update all known datasets - for (const [datasetID, dataset] of this.datasets) { - if (toEnable.has(datasetID)) { - dataset.enabled = true; - toEnable.delete(datasetID); // delete marks it as done + const enabledIDs = []; + for (const [datasetID, dataset] of this.catalog) { + // This code is a bit weird - I don't like it and we should change it... + // I'm trying to match the legacy color-choosing behavior from before Rapid#1642 (which changed a bunch of things) + // - If adding fbRoads/msBuildings, choose "Rapid magenta". + // - If adding an Overture dataset, choose "Overture cyan". + // - If adding an Esri dataset, choose a color based on how many datasets were added already. + const wasAdded = dataset.added; + const nowAdded = this._addedDatasetIDs.has(datasetID); + if (!wasAdded && nowAdded && dataset.color === RAPID_MAGENTA) { // being added right now with the default color + if (dataset.categories.has('meta') || dataset.categories.has('microsoft')) { + dataset.color = RAPID_MAGENTA; + } else if (dataset.categories.has('overture')) { + dataset.color = OVERTURE_CYAN; } else { - dataset.enabled = false; + dataset.color = RAPID_COLORS[this._nextColorIndex++ % RAPID_COLORS.length]; } } - } + dataset.added = nowAdded; + dataset.enabled = this._enabledDatasetIDs.has(datasetID); - // If there are remaining datasets to enable, try to load them from Esri. - const context = this.context; - const esri = context.services.esri; - if (!esri || !toEnable.size) return; - - esri.startAsync() - .then(() => esri.loadDatasetsAsync()) - .then(results => { - for (const datasetID of toEnable) { - const d = results[datasetID]; - if (!d) continue; // dataset with requested id not found, fail silently - - // *** Code here is copied from `UiRapidCatalog.js` `toggleDataset()` *** - esri.loadLayerAsync(d.id); // start fetching layer info (the mapping between attributes and tags) - - const isBeta = d.groupCategories.some(cat => cat.toLowerCase() === '/categories/preview'); - const isBuildings = d.groupCategories.some(cat => cat.toLowerCase() === '/categories/buildings'); - const nextColor = this.datasets.size % RAPID_COLORS.length; - - const dataset = new RapidDataset(context, { - 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: RAPID_COLORS[nextColor], - dataUsed: ['esri', esri.getDataUsed(d.title)], - label: d.title, - licenseUrl: 'https://wiki.openstreetmap.org/wiki/Esri/ArcGIS_Datasets#License' - }); - - if (d.extent) { - dataset.extent = new Extent(d.extent[0], d.extent[1]); - } + if (dataset.added && dataset.enabled) { + enabledIDs.push(datasetID); + } + } - // Test running building layers through MapWithAI conflation service - if (isBuildings) { - dataset.conflated = true; - dataset.service = 'mapwithai'; - } + // datasets + urlhash.setParam('datasets', enabledIDs.length ? enabledIDs.join(',') : null); - this.datasets.set(dataset.id, dataset); // add it - } - }); + this.emit('datasetchange'); } + } diff --git a/modules/core/UrlHashSystem.js b/modules/core/UrlHashSystem.js index a9b166df8..4b3c12a3a 100644 --- a/modules/core/UrlHashSystem.js +++ b/modules/core/UrlHashSystem.js @@ -69,14 +69,6 @@ export class UrlHashSystem extends AbstractSystem { const q = utilStringQs(window.location.hash); this._initParams = new Map(Object.entries(q)); - // Set some defaults (maybe come up with a less hacky way of doing this) - if (!this._initParams.has('datasets')) { - this._initParams.set('datasets', 'fbRoads,msBuildings'); - } - if (!this._initParams.has('disable_features')) { - this._initParams.set('disable_features', 'boundaries'); - } - this._currParams = new Map(this._initParams); // make copy this._currHash = null; // cached window.location.hash this._prevParams = null; @@ -121,6 +113,7 @@ export class UrlHashSystem extends AbstractSystem { const editor = context.systems.editor; const l10n = context.systems.l10n; const photos = context.systems.photos; + const rapid = context.systems.rapid; const map = context.systems.map; const ui = context.systems.ui; @@ -130,6 +123,7 @@ export class UrlHashSystem extends AbstractSystem { l10n.startAsync(), map.startAsync(), photos.startAsync(), + rapid.startAsync(), ui.startAsync() ]); diff --git a/modules/core/lib/RapidDataset.js b/modules/core/lib/RapidDataset.js index b06e7f7fc..191edcb5f 100644 --- a/modules/core/lib/RapidDataset.js +++ b/modules/core/lib/RapidDataset.js @@ -1,5 +1,6 @@ const RAPID_MAGENTA = '#da26d3'; + export class RapidDataset { /** @@ -11,25 +12,48 @@ export class RapidDataset { this.context = context; this.id = props.id; - this.beta = props.beta ?? false; - this.added = props.added ?? false; // whether it should appear in the list - this.enabled = props.enabled ?? false; // whether the user has checked it on - this.conflated = props.conflated ?? false; - - this.service = props.service; + this.service = props.service; // 'esri', 'mapwithai', 'overture' + this.categories = props.categories ?? new Set(); // e.g. 'buildings' 'addresses' this.color = props.color ?? RAPID_MAGENTA; this.dataUsed = props.dataUsed ?? []; + this.extent = props.extent; this.overlay = props.overlay; this.tags = props.tags; - this.extent = props.extent; - this.licenseUrl = props.licenseUrl; + + this.itemUrl = props.itemUrl ?? ''; + this.licenseUrl = props.licenseUrl ?? ''; + this.thumbnailUrl = props.thumbnailUrl ?? this.getThumbnail(); + + // flags + this.added = props.added ?? false; // whether it should appear in the list + this.beta = props.beta ?? this.categories.has('preview'); + this.enabled = props.enabled ?? false; // whether the user has checked it on + this.featured = props.featured ?? this.categories.has('featured'); + this.filtered = props.filtered ?? false; // filtered from the catalog display + this.hidden = props.hidden ?? false; // hide this dataset from the catalog (e.g. the walkthrough data) + this.conflated = props.conflated ?? false; this.labelStringID = props.labelStringID; + this.descriptionStringID = props.descriptionStringID; - // If a `label` property are passed in, store it, - // but prefer to use localize on the fly + // If a `label` or `description` properties are passed in, store them, + // but prefer to use the methods below to localize on the fly.. this._label = props.label; + this._description = props.description; this.label = this.getLabel(); + this.description = this.getDescription(); + } + + // Choose a default thumbnail if we weren't supplied one. + getThumbnail() { + let type; + if (this.categories.has('buildings')) type = 'buildings'; + else if (this.categories.has('footways')) type = 'footways'; + else if (this.categories.has('roads')) type = 'roads'; + else type = 'points'; + + const assets = this.context.systems.assets; + return assets.getFileURL(`img/data-${type}.png`); } // Attempt to localize the dataset name, fallback to 'label' or 'id' @@ -37,4 +61,11 @@ export class RapidDataset { const l10n = this.context.systems.l10n; return this.labelStringID ? l10n.t(this.labelStringID) : (this._label || this.id); } + + // Attempt to localize the dataset description, fallback to empty string + getDescription() { + const l10n = this.context.systems.l10n; + return this.descriptionStringID ? l10n.t(this.descriptionStringID) : (this._description || ''); + } + } diff --git a/modules/services/EsriService.js b/modules/services/EsriService.js index dbc30e0e6..ba856a4c2 100644 --- a/modules/services/EsriService.js +++ b/modules/services/EsriService.js @@ -1,9 +1,9 @@ -import { select as d3_select } from 'd3-selection'; -import { Tiler } from '@rapid-sdk/math'; +import { select } from 'd3-selection'; +import { Extent, Tiler } from '@rapid-sdk/math'; import { utilQsString } from '@rapid-sdk/util'; import { AbstractSystem } from '../core/AbstractSystem.js'; -import { Graph, Tree } from '../core/lib/index.js'; +import { Graph, Tree, RapidDataset } from '../core/lib/index.js'; import { osmNode, osmRelation, osmWay } from '../osm/index.js'; import { utilFetchResponse } from '../util/index.js'; @@ -50,7 +50,8 @@ export class EsriService extends AbstractSystem { * @return {Promise} Promise resolved when this component has completed initialization */ initAsync() { - return this.resetAsync(); + return this.resetAsync() + .then(() => this.loadDatasetsAsync()); } @@ -85,6 +86,49 @@ export class EsriService extends AbstractSystem { } + /** + * getAvailableDatasets + * Called by `RapidSystem` to get the datasets that this service provides. + * @return {Array} The datasets this service provides + */ + getAvailableDatasets() { + // Convert the internal datasets into "Rapid" datasets for the catalog. + // We expect them to be all loaded now because `loadDatasetsAsync` is called by `initAsync` + // and `getAvailableDatasets` is called by RapidSystem's `startAsync`. + return Object.values(this._datasets).map(d => { + // gather categories + const categories = new Set(['esri']); + for (const c of d.groupCategories) { + categories.add(c.toLowerCase().replace('/categories/', '')); + } + + const dataset = new RapidDataset(this.context, { + id: d.id, + conflated: false, + service: 'esri', + categories: categories, + dataUsed: ['esri', this.getDataUsed(d.title)], + label: d.title, + description: d.snippet, + itemUrl: `${HOMEROOT}/item.html?id=${d.id}`, + licenseUrl: 'https://wiki.openstreetmap.org/wiki/Esri/ArcGIS_Datasets#License', + thumbnailUrl: `${APIROOT}/items/${d.id}/info/${d.thumbnail}?w=400` + }); + + if (d.extent) { + dataset.extent = new Extent(d.extent[0], d.extent[1]); + } + + // Test running building layers through MapWithAI conflation service + if (categories.has('buildings')) { + dataset.conflated = true; + dataset.service = 'mapwithai'; + } + return dataset; + }); + } + + /** * getData * Get already loaded data that appears in the current map view @@ -280,10 +324,6 @@ export class EsriService extends AbstractSystem { // .geometryType "esriGeometryPoint" or "esriGeometryPolygon" ? } - _itemURL(itemID) { - return `${HOMEROOT}/item.html?id=${itemID}`; - } - _tileURL(ds, extent, page) { page = page || 0; const layerID = ds.layer.id; @@ -313,15 +353,12 @@ export class EsriService extends AbstractSystem { ds.lastv = null; // cleanup the `licenseInfo` field by removing styles (not used currently) - let license = d3_select(document.createElement('div')); + let license = select(document.createElement('div')); license.html(ds.licenseInfo); // set innerHtml license.selectAll('*') .attr('style', null) .attr('size', null); ds.license_html = license.html(); // get innerHtml - - // generate public link to this item - ds.itemURL = this._itemURL(ds.id); } diff --git a/modules/services/MapWithAIService.js b/modules/services/MapWithAIService.js index b661f9540..41942a61e 100644 --- a/modules/services/MapWithAIService.js +++ b/modules/services/MapWithAIService.js @@ -2,7 +2,7 @@ import { Tiler } from '@rapid-sdk/math'; import { utilStringQs } from '@rapid-sdk/util'; import { AbstractSystem } from '../core/AbstractSystem.js'; -import { Graph, Tree } from '../core/lib/index.js'; +import { Graph, Tree, RapidDataset } from '../core/lib/index.js'; import { osmEntity, osmNode, osmWay } from '../osm/index.js'; import { utilFetchResponse } from '../util/index.js'; @@ -80,6 +80,70 @@ export class MapWithAIService extends AbstractSystem { } + /** + * getAvailableDatasets + * Called by `RapidSystem` to get the datasets that this service provides. + * @return {Array} The datasets this service provides + */ + getAvailableDatasets() { + const context = this.context; + + const fbRoads = new RapidDataset(context, { + id: 'fbRoads', + conflated: true, + service: 'mapwithai', + categories: new Set(['meta', 'roads', 'featured']), + dataUsed: ['mapwithai', 'Facebook Roads'], + itemUrl: 'https://github.com/facebookmicrosites/Open-Mapping-At-Facebook', + licenseUrl: 'https://rapideditor.org/doc/license/MapWithAILicense.pdf', + labelStringID: 'rapid_feature_toggle.fbRoads.label', + descriptionStringID: 'rapid_feature_toggle.fbRoads.description' + }); + + const msBuildings = new RapidDataset(context, { + id: 'msBuildings', + conflated: true, + service: 'mapwithai', + categories: new Set(['microsoft', 'buildings', 'featured']), + dataUsed: ['mapwithai', 'Microsoft Buildings'], + itemUrl: 'https://github.com/microsoft/GlobalMLBuildingFootprints', + licenseUrl: 'https://github.com/microsoft/USBuildingFootprints/blob/master/LICENSE-DATA', + labelStringID: 'rapid_feature_toggle.msBuildings.label', + descriptionStringID: 'rapid_feature_toggle.msBuildings.description' + }); + + const omdFootways = new RapidDataset(context, { + id: 'omdFootways', + conflated: true, + service: 'mapwithai', + categories: new Set(['meta', 'footways', 'featured']), + overlay: { + url: 'https://external.xx.fbcdn.net/maps/vtp/rapid_overlay_footways/1/{z}/{x}/{y}/', + minZoom: 1, + maxZoom: 15, + }, + tags: 'opendata', + dataUsed: ['mapwithai', 'Open Footways'], + itemUrl: 'https://github.com/facebookmicrosites/Open-Mapping-At-Facebook', + licenseUrl: 'https://rapideditor.org/doc/license/MapWithAILicense.pdf', + labelStringID: 'rapid_feature_toggle.omdFootways.label', + descriptionStringID: 'rapid_feature_toggle.omdFootways.description' + }); + + const introGraph = new RapidDataset(context, { + id: 'rapid_intro_graph', + hidden: true, + conflated: false, + service: 'mapwithai', + color: '#da26d3', + dataUsed: [], + label: 'Rapid Walkthrough' + }); + + return [fbRoads, msBuildings, omdFootways, introGraph]; + } + + /** * resetAsync * Called after completing an edit session to reset any internal state diff --git a/modules/services/OvertureService.js b/modules/services/OvertureService.js index f818c6f07..cb7f63004 100644 --- a/modules/services/OvertureService.js +++ b/modules/services/OvertureService.js @@ -1,4 +1,5 @@ import { AbstractSystem } from '../core/AbstractSystem.js'; +import { RapidDataset } from '../core/lib/index.js'; import { utilFetchResponse } from '../util/index.js'; const PMTILES_ROOT_URL = 'https://overturemaps-tiles-us-west-2-beta.s3.us-west-2.amazonaws.com/'; @@ -79,6 +80,30 @@ export class OvertureService extends AbstractSystem { } + /** + * getAvailableDatasets + * Called by `RapidSystem` to get the datasets that this service provides. + * @return {Array} The datasets this service provides + */ + getAvailableDatasets() { + // just this one for now + const places = new RapidDataset(this.context, { + id: 'overture-places', + conflated: true, + service: 'overture', + categories: new Set(['overture', 'places', 'featured']), + color: '#00ffff', + dataUsed: ['overture', 'Overture Places'], + itemUrl: 'https://docs.overturemaps.org/guides/places/', + licenseUrl: 'https://docs.overturemaps.org/attribution/#places', + labelStringID: 'rapid_feature_toggle.overture.places.label', + descriptionStringID: 'rapid_feature_toggle.overture.places.description' + }); + + return [places]; + } + + /** * loadTiles * Use the vector tile service to schedule any data requests needed to cover the current map view diff --git a/modules/ui/UiRapidCatalog.js b/modules/ui/UiRapidCatalog.js index aca6ff5f8..e60e3eb23 100644 --- a/modules/ui/UiRapidCatalog.js +++ b/modules/ui/UiRapidCatalog.js @@ -1,9 +1,7 @@ import { EventEmitter } from 'pixi.js'; import { select } from 'd3-selection'; -import { Extent } from '@rapid-sdk/math'; import { marked } from 'marked'; -import { RapidDataset } from '../core/lib/RapidDataset.js'; import { uiIcon } from './icon.js'; import { uiCombobox} from './combobox.js'; import { utilKeybinding, utilNoAuto } from '../util/index.js'; @@ -28,7 +26,6 @@ export class UiRapidCatalog extends EventEmitter { super(); this.context = context; - this._datasetInfo = null; this._filterText = null; this._filterCategory = null; this._myClose = () => true; // custom close handler @@ -47,7 +44,7 @@ export class UiRapidCatalog extends EventEmitter { 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.sortCategories = this.sortCategories.bind(this); this.sortDatasets = this.sortDatasets.bind(this); this.toggleDataset = this.toggleDataset.bind(this); this.highlight = this.highlight.bind(this); @@ -178,10 +175,10 @@ export class UiRapidCatalog extends EventEmitter { $header = $header.merge($$header); $header.selectAll('.rapid-catalog-header-text') - .text(l10n.t('rapid_feature_toggle.esri.title')); + .text(l10n.t('rapid_feature_toggle.add_manage_datasets')); $header.selectAll('.rapid-catalog-header-about') - .html(marked.parse(l10n.t('rapid_feature_toggle.esri.about'))); + .html(marked.parse(l10n.t('rapid_feature_toggle.about_the_catalog'))); $header.selectAll('.rapid-catalog-header-about a') .attr('target', '_blank'); // make sure the markdown links go to a new page @@ -214,6 +211,11 @@ export class UiRapidCatalog extends EventEmitter { $datasets.call(this.renderDatasets); }); + // set focus (but only on enter) + const inputNode = $$filterSearch.selectAll('.rapid-catalog-filter-search').node(); + if (inputNode) inputNode.focus(); + + const $$filterType = $$filter .append('div') .attr('class', 'rapid-catalog-filter-type-wrap'); @@ -259,13 +261,13 @@ export class UiRapidCatalog extends EventEmitter { $filter = $filter.merge($$filter); $filter.selectAll('.rapid-catalog-filter-search') - .attr('placeholder', l10n.t('rapid_feature_toggle.esri.filter_datasets')); + .attr('placeholder', l10n.t('rapid_feature_toggle.filter_datasets')); $filter.selectAll('.rapid-catalog-filter-type') - .attr('placeholder', l10n.t('rapid_feature_toggle.esri.any_type')); + .attr('placeholder', l10n.t('rapid_feature_toggle.any_type')); $filter.selectAll('.rapid-catalog-filter-clear > a') - .text(l10n.t('rapid_feature_toggle.esri.clear_filters')); + .text(l10n.t('rapid_feature_toggle.clear_filters')); /* Dataset section */ @@ -306,10 +308,6 @@ export class UiRapidCatalog extends EventEmitter { .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); @@ -327,94 +325,71 @@ export class UiRapidCatalog extends EventEmitter { if (!this.$modal) return; // need to call `show()` first to create the modal. const context = this.context; - const assets = context.systems.assets; const l10n = context.systems.l10n; + const rapid = context.systems.rapid; const storage = context.systems.storage; const $content = this.$modal.selectAll('.content'); const showPreview = storage.getItem('rapid-internal-feature.previewDatasets') === 'true'; - const esri = context.services.esri; const $status = $selection.selectAll('.rapid-catalog-datasets-status'); const $results = $selection.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) { + if (!rapid.catalog.size) { $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()); - + $status.classed('hide', false).text(l10n.t('rapid_feature_toggle.no_datasets')); return; } $results.classed('hide', false); $status.classed('hide', true); - // Apply filters + // Update categories combo + // (redo it every time, in case the user toggles preview datasets on/off) + const categories = new Set(rapid.categories); // make copy + if (!showPreview) categories.delete('preview'); + + const comboData = Array.from(categories).sort().map(d => { + const display = l10n.t(`rapid_feature_toggle.category.${d}`, { default: d }); + const item = { display: display, title: d, value: d }; + if (d === 'preview') item.display = `${display} `; + return item; + }); + + this.CategoryCombo.data(comboData); + + + // Gather datasets.. let count = 0; - this._datasetInfo.forEach(d => { - const title = (d.title || '').toLowerCase(); - const snippet = (d.snippet || '').toLowerCase(); + const datasets = [...rapid.catalog.values()] + .filter(d => !d.hidden) + .sort(this.sortDatasets); - if (this.isDatasetAdded(d)) { // always show added datasets at the top of the list + // Apply filters.. + for (const d of datasets) { + const label = d.getLabel().toLowerCase(); + const description = d.getDescription().toLowerCase(); + + if (d.added) { // always show added datasets at the top of the list d.filtered = false; ++count; - return; + continue; } - 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._filterText && !label.includes(this._filterText) && !description.includes(this._filterText)) { + d.filtered = true; // filterText not found anywhere in `label` or `description` + continue; } - if (this._filterCategory && !(d.groupCategories.some(category => category.toLowerCase() === `/categories/${this._filterCategory}`))) { - d.filtered = true; // filterCategory not found anywhere in `groupCategories`` - return; + if (this._filterCategory && !(d.categories.has(this._filterCategory))) { + d.filtered = true; // filterCategory not found anywhere in `categories`` + continue; } d.filtered = (++count > MAXRESULTS); - }); - + } + // The datasets let $datasets = $results.selectAll('.rapid-catalog-dataset') - .data(this._datasetInfo, d => d.id); + .data(datasets, d => d.id); // exit $datasets.exit() @@ -425,21 +400,43 @@ export class UiRapidCatalog extends EventEmitter { .append('div') .attr('class', 'rapid-catalog-dataset'); - const $$labels = $$datasets + const $$label = $$datasets .append('div') .attr('class', 'rapid-catalog-dataset-label'); - $$labels + $$label .append('div') .attr('class', 'rapid-catalog-dataset-name'); - const $$link = $$labels + const $$categories = $$label + .append('div') + .attr('class', 'dataset-categories'); + + $$categories.selectAll('.dataset-category') + .data(d => { + const categories = new Set(d.categories); // make copy + if (d.beta) categories.add('preview'); // make sure beta datasets have 'preview' category + return Array.from(categories).sort(this.sortCategories); + }, d => d) + .enter() + .append('div') + .attr('class', d => { + // include 'beta' class for preview category + return `dataset-category dataset-category-${d}` + (d === 'preview' ? ' beta' : ''); + }); + + $$label + .append('div') + .attr('class', 'rapid-catalog-dataset-snippet'); + + const $$link = $$label + .filter(d => d.itemUrl) .append('div') - .attr('class', 'rapid-catalog-dataset-license') + .attr('class', 'rapid-catalog-dataset-more-info') .append('a') .attr('class', 'rapid-catalog-dataset-link') .attr('target', '_blank') - .attr('href', d => d.itemURL); + .attr('href', d => d.itemUrl); $$link .append('span') @@ -448,73 +445,64 @@ export class UiRapidCatalog extends EventEmitter { $$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 + $$label .append('div') - .attr('class', 'rapid-catalog-dataset-snippet'); + .attr('class', 'dataset-added-text'); - $$labels + $$label .append('button') .attr('class', 'rapid-catalog-dataset-action') .on('click', this.toggleDataset); - const $$thumbnails = $$datasets + const $$thumbnail = $$datasets .append('div') .attr('class', 'rapid-catalog-dataset-thumb'); - $$thumbnails + $$thumbnail .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`); + .classed('inverted', d => d.categories.has('esri')) // invert colors from light->dark + .attr('src', d => d.thumbnailUrl); // update - $datasets = $datasets.merge($$datasets) - .sort(this.sortDatasets) + $datasets = $datasets.merge($$datasets).order(); + + $datasets + .classed('added', d => d.added) .classed('hide', d => d.filtered); $datasets.selectAll('.rapid-catalog-dataset-name') - .html(d => this.highlight(this._filterText, d.title)); + .html(d => this.highlight(this._filterText, d.getLabel())); $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')); + .text(l10n.t('rapid_feature_toggle.more_info')); + + $$datasets.selectAll('.dataset-category') + .text(d => { + if (d === 'preview') return ''; + const star = (d === 'featured') ? '\u2b50 ' : ''; // 2b50 = emoji star + const text = l10n.t(`rapid_feature_toggle.category.${d}`, { default: d }); + return star + text; + }); - $datasets.selectAll('.rapid-catalog-dataset-beta') - .attr('title', l10n.t('rapid_poweruser_features.beta')); + $datasets.selectAll('.dataset-category-preview') + .attr('title', l10n.t('rapid_poweruser_features.beta')); // alt text $datasets.selectAll('.rapid-catalog-dataset-snippet') - .html(d => this.highlight(this._filterText, d.snippet)); + .html(d => this.highlight(this._filterText, d.getDescription())); + + $datasets.selectAll('.dataset-added-text') + .text(d => d.added ? '\u2705 ' + l10n.t('rapid_feature_toggle.dataset_added') : ''); // 2705 = emoji check $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')); + .classed('secondary', d => d.added) + .text(d => d.added ? l10n.t('rapid_feature_toggle.remove') : l10n.t('rapid_feature_toggle.add_dataset')); // update the count - const numShown = this._datasetInfo.filter(d => !d.filtered).length; - const gt = (count > MAXRESULTS && numShown === MAXRESULTS) ? '>' : ''; + const n = datasets.filter(d => !d.filtered).length; + const gt = (count > MAXRESULTS) ? '>' : ''; $content.selectAll('.rapid-catalog-filter-results') - .text(l10n.t('rapid_feature_toggle.esri.datasets_found', { num: `${gt}${numShown}` })); + .text(l10n.t('rapid_feature_toggle.datasets_found', { n: n, gt: gt })); } @@ -525,105 +513,51 @@ export class UiRapidCatalog extends EventEmitter { * 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); + return a.added && !b.added ? -1 + : b.added && !a.added ? 1 + : a.featured && !b.featured ? -1 + : b.featured && !a.featured ? 1 + : a.label.localeCompare(b.label); + } + + + /** + * sortCategories + * Featured before everything else + * Preview after everything else + * All others sort alphabetically + */ + sortCategories(a, b) { + return a === 'featured' && b !== 'featured' ? -1 + : b === 'featured' && a !== 'featured' ? 1 + : a === 'preview' && b !== 'preview' ? 1 + : b === 'preview' && a !== 'preview' ? -1 + : a.localeCompare(b); } /** * toggleDataset * Toggles the given dataset between added/removed. - * @param {Event} e? - triggering event (if any) - * @param {*} d - bound datum (the dataset in this case) + * @param {Event} e? - triggering event (if any) + * @param {RapidDataset} d - bound datum (the dataset in this case) */ toggleDataset(e, d) { const context = this.context; - const gfx = context.systems.gfx; 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 = new RapidDataset(context, { - 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, - licenseUrl: 'https://wiki.openstreetmap.org/wiki/Esri/ArcGIS_Datasets#License' - }); - - if (d.extent) { - dataset.extent = new Extent(d.extent[0], d.extent[1]); + const added = rapid.datasets; + + if (added.has(d.id)) { + rapid.removeDatasets(d.id); // remove from menu and disable/uncheck + } else { + rapid.enableDatasets(d.id); // add to menu and enable/check + // If adding an Esri building dataset, disable the Microsoft buildings to avoid clutter + if (d.categories.has('esri') && d.categories.has('buildings') && added.has('msBuildings')) { + rapid.disableDatasets('msBuildings'); } - - // 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(dataset.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; + this.render(); } diff --git a/modules/ui/UiRapidDatasetToggle.js b/modules/ui/UiRapidDatasetToggle.js index c3bcea3ff..84606c5aa 100644 --- a/modules/ui/UiRapidDatasetToggle.js +++ b/modules/ui/UiRapidDatasetToggle.js @@ -51,7 +51,6 @@ export class UiRapidDatasetToggle { this.rerender = (() => this.render()); // call render without argument this.renderDatasets = this.renderDatasets.bind(this); this.changeColor = this.changeColor.bind(this); - this.isDatasetEnabled = this.isDatasetEnabled.bind(this); this.toggleDataset = this.toggleDataset.bind(this); this.toggleRapid = this.toggleRapid.bind(this); @@ -205,7 +204,7 @@ export class UiRapidDatasetToggle { $manageDatasets = $manageDatasets.merge($$manageDatasets); $manageDatasets.selectAll('.rapid-feature-label') - .text(l10n.t('rapid_feature_toggle.view_manage_datasets')); + .text(l10n.t('rapid_feature_toggle.add_manage_datasets')); $manageDatasets.selectAll('.rapid-checkbox-label use') .attr('xlink:href', l10n.isRTL() ? '#rapid-icon-backward' : '#rapid-icon-forward'); @@ -267,85 +266,73 @@ export class UiRapidDatasetToggle { .append('div') .attr('class', 'rapid-checkbox rapid-checkbox-dataset'); - $$rows + const $$label = $$rows .append('div') - .attr('class', 'rapid-feature') - .each((d, i, nodes) => { - const $$row = select(nodes[i]); + .attr('class', 'rapid-feature'); - // line1: name and details - const $$line1 = $$row - .append('div') - .attr('class', 'rapid-feature-label-container'); + // line1: name and optional beta badge + const $$line1 = $$label + .append('div') + .attr('class', 'rapid-feature-label-container'); - $$line1 - .append('div') - .attr('class', 'rapid-feature-label'); + $$line1 + .append('div') + .attr('class', 'rapid-feature-label'); - if (d.beta) { - $$line1 - .append('div') - .attr('class', 'rapid-feature-label-beta beta'); - } + $$line1 + .filter(d => d.beta) + .append('div') + .attr('class', 'rapid-feature-label-beta beta'); - if (d.description) { - $$line1 - .append('div') - .attr('class', 'rapid-feature-label-divider'); + // line2: extent and license link + const $$line2 = $$label + .append('div') + .attr('class', 'rapid-feature-extent-container'); - $$line1 - .append('div') - .attr('class', 'rapid-feature-description'); - } + $$line2 + .each((d, i, nodes) => { + const $$extent = select(nodes[i]); - // line2: dataset extent - const $$line2 = $$row - .append('div') - .attr('class', 'rapid-feature-extent-container'); - - $$line2 - .each((d, i, nodes) => { - const $$extent = select(nodes[i]); - - // if the data spans more than 100°*100°, it might as well be worldwide - if (d.extent && d.extent.area() < 10000) { - $$extent - .append('a') - .attr('class', 'rapid-feature-extent-center-map') - .attr('href', '#') - .on('click', (e) => { - e.preventDefault(); - map.extent(d.extent); - }); - } else { - $$extent - .append('span') - .attr('class', 'rapid-feature-extent-worldwide'); - } - }); - - if (d.licenseUrl) { - $$line2 - .append('div') - .attr('class', 'rapid-feature-label-divider'); - - const $$link = $$line2 - .append('div') - .attr('class', 'rapid-feature-license') + // if the data spans more than 100°*100°, it might as well be worldwide + if (d.extent && d.extent.area() < 10000) { + $$extent .append('a') - .attr('class', 'rapid-feature-licence-link') - .attr('target', '_blank') - .attr('href', d.licenseUrl); - - $$link + .attr('class', 'rapid-feature-extent-center-map') + .attr('href', '#') + .on('click', (e) => { + e.preventDefault(); + map.extent(d.extent); + }); + } else { + $$extent .append('span') - .attr('class', 'rapid-feature-license-link-text'); - - $$link - .call(uiIcon('#rapid-icon-out-link', 'inline')); + .attr('class', 'rapid-feature-extent-worldwide'); } }); + const $$license = $$line2 + .filter(d => d.licenseUrl); + + $$license + .append('div') + .attr('class', 'rapid-feature-label-divider'); + + const $$link = $$license + .append('div') + .attr('class', 'rapid-feature-license') + .append('a') + .attr('class', 'rapid-feature-licence-link') + .attr('target', '_blank') + .attr('href', d => d.licenseUrl); + + $$link + .append('span') + .attr('class', 'rapid-feature-license-link-text'); + + $$link + .call(uiIcon('#rapid-icon-out-link', 'inline')); + + const $$inputs = $$rows .append('div') .attr('class', 'rapid-checkbox-inputs'); @@ -380,7 +367,7 @@ export class UiRapidDatasetToggle { .text(d => d.getLabel()); $rows.selectAll('.rapid-feature-label-beta') - .attr('title', l10n.t('rapid_poweruser_features.beta')); + .attr('title', l10n.t('rapid_poweruser_features.beta')); // alt text $rows.selectAll('.rapid-feature-description') .text(d => d.description); @@ -402,22 +389,11 @@ export class UiRapidDatasetToggle { .classed('disabled', !isRapidEnabled); $rows.selectAll('.rapid-feature-checkbox') - .property('checked', d => this.isDatasetEnabled(d)) + .property('checked', d => d.enabled) .attr('disabled', isRapidEnabled ? null : true); } - /** - * isDatasetEnabled - * @param {*} d - bound datum (the dataset in this case) - */ - isDatasetEnabled(d) { - const rapid = this.context.systems.rapid; - const dataset = rapid.datasets.get(d.id); - return dataset?.enabled; - } - - /** * toggleRapid * Called when a user has clicked the checkbox to toggle all Rapid layers on/off. @@ -433,28 +409,14 @@ export class UiRapidDatasetToggle { * toggleDataset * Called when a user has clicked the checkbox to toggle a dataset on/off. * @param {Event} e? - triggering event (if any) - * @param {*} d - bound datum (the dataset in this case) + * @param {*} d - bound datum (the RapidDataset in this case) */ toggleDataset(e, d) { const context = this.context; const rapid = context.systems.rapid; - const scene = context.systems.gfx.scene; - const urlhash = context.systems.urlhash; - - const dataset = rapid.datasets.get(d.id); - if (dataset) { - dataset.enabled = !dataset.enabled; - - // 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); - scene.dirtyLayers(['rapid', 'rapid-overlay', 'overture']); - context.enter('browse'); // return to browse mode (in case something was selected) - } + context.enter('browse'); // return to browse mode (in case something was selected) + rapid.toggleDatasets(d.id); } diff --git a/modules/ui/UiRapidInspector.js b/modules/ui/UiRapidInspector.js index bb6a7bdd2..e8515d2c6 100644 --- a/modules/ui/UiRapidInspector.js +++ b/modules/ui/UiRapidInspector.js @@ -295,7 +295,7 @@ export class UiRapidInspector { .text(dataset.getLabel()); $featureInfo.selectAll('.dataset-beta') - .attr('title', l10n.t('rapid_poweruser_features.beta')); + .attr('title', l10n.t('rapid_poweruser_features.beta')); // alt text } diff --git a/modules/ui/intro/intro.js b/modules/ui/intro/intro.js index 13fbf8ab2..e39558a53 100644 --- a/modules/ui/intro/intro.js +++ b/modules/ui/intro/intro.js @@ -112,8 +112,9 @@ export function uiIntro(context, skipToRapid) { brightness: imagery.brightness, baseLayer: imagery.baseLayerSource(), overlayLayers: imagery.overlayLayerSources(), - layersEnabled: new Set(), // Set(layerID) - datasetsEnabled: new Set(), // Set(datasetID) + layersEnabled: new Set(), // Set + datasetsAdded: new Set(rapid._addedDatasetIDs), // Set + datasetsEnabled: new Set(rapid._enabledDatasetIDs), // Set edits: editor.toJSON() }; @@ -125,25 +126,9 @@ export function uiIntro(context, skipToRapid) { } context.scene().onlyLayers(['background', 'osm', 'labels']); - // Remember which Rapid datasets were enabled before - we will show only a fake walkthrough dataset - for (const [datasetID, dataset] of rapid.datasets) { - if (dataset.enabled) { - _original.datasetsEnabled.add(datasetID); - dataset.enabled = false; - } - } - - rapid.datasets.set('rapid_intro_graph', { - id: 'rapid_intro_graph', - beta: false, - added: true, - enabled: false, // start disabled, rapid chapter will enable it - conflated: false, - service: 'mapwithai', - color: '#da26d3', - dataUsed: [], - label: 'Rapid Walkthrough' - }); + // Show only a fake walkthrough dataset + rapid.removeDatasets(rapid._addedDatasetIDs); + rapid.addDatasets('rapid_intro_graph'); // Setup imagery const introSource = imagery.getSourceByID('Bing'); @@ -261,10 +246,9 @@ export function uiIntro(context, skipToRapid) { } // Restore Rapid datasets and service - for (const [datasetID, dataset] of rapid.datasets) { - dataset.enabled = _original.datasetsEnabled.has(datasetID); - } - rapid.datasets.delete('rapid_intro_graph'); + rapid.removeDatasets('rapid_intro_graph'); + rapid.addDatasets(_original.datasetsAdded); // added to menu + rapid.enableDatasets(_original.datasetsEnabled); // enabled/checked _curtain.disable(); _navwrap.remove(); diff --git a/modules/ui/intro/rapid.js b/modules/ui/intro/rapid.js index 0f6ee4893..2f317a9df 100644 --- a/modules/ui/intro/rapid.js +++ b/modules/ui/intro/rapid.js @@ -58,8 +58,7 @@ export function uiIntroRapid(context, curtain) { // Make sure Rapid data is on.. context.scene().enableLayers('rapid'); - const dataset = rapid.datasets.get('rapid_intro_graph'); - dataset.enabled = true; + rapid.enableDatasets('rapid_intro_graph'); const loc = tulipLaneExtent.center(); const msec = transitionTime(loc, map.center()); @@ -109,10 +108,9 @@ export function uiIntroRapid(context, curtain) { editor.restoreCheckpoint('initial'); ui.togglePanes(); // close issue pane - // Make sure Rapid data is on (in case the user unchecked it in a previous step).. + // Make sure Rapid data is on.. context.scene().enableLayers('rapid'); - const dataset = rapid.datasets.get('rapid_intro_graph'); - dataset.enabled = true; + rapid.enableDatasets('rapid_intro_graph'); return new Promise((resolve, reject) => { _rejectStep = reject; @@ -279,10 +277,9 @@ export function uiIntroRapid(context, curtain) { context.enter('browse'); editor.restoreCheckpoint('initial'); - // Make sure Rapid data is on (in case the user unchecked it in a previous step).. + // Make sure Rapid data is on.. context.scene().enableLayers('rapid'); - const dataset = rapid.datasets.get('rapid_intro_graph'); - dataset.enabled = true; + rapid.enableDatasets('rapid_intro_graph'); const loc = tulipLaneExtent.center(); const msec = transitionTime(loc, map.center()); @@ -375,8 +372,7 @@ export function uiIntroRapid(context, curtain) { chapter.exit = () => { // Make sure Rapid data is off.. context.scene().disableLayers('rapid'); - const dataset = rapid.datasets.get('rapid_intro_graph'); - dataset.enabled = false; + rapid.disableDatasets('rapid_intro_graph'); _chapterCancelled = true; diff --git a/test/browser/core/MapSystem.Test.js b/test/browser/core/MapSystem.Test.js index d4e6a77dd..ebb425dd1 100644 --- a/test/browser/core/MapSystem.Test.js +++ b/test/browser/core/MapSystem.Test.js @@ -46,6 +46,7 @@ describe('MapSystem', () => { gfx: new MockGfxSystem(this), imagery: new MockSystem(this), photos: new MockSystem(this), + rapid: new MockSystem(this), l10n: new MockLocalizationSystem(this), storage: new MockStorageSystem(this), urlhash: new MockSystem(this),