diff --git a/css/80_app.css b/css/80_app.css index a9f8561f2..febe4d6bf 100644 --- a/css/80_app.css +++ b/css/80_app.css @@ -848,7 +848,7 @@ a.hide-toggle { position: relative; } -.feature-list-pane { +.feature-list-wrap { display: flex; flex: 1 0 0px; flex-flow: column nowrap; diff --git a/data/core.yaml b/data/core.yaml index 017c722d8..c172f91fa 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -719,8 +719,7 @@ en: unknown: Unknown incomplete: feature_list: Search features - edit: Edit feature - edit_features: Edit features + multiselect: Multiple Selection check: "yes": "Yes" "no": "No" @@ -814,6 +813,7 @@ en: osm: tooltip: Map data from OpenStreetMap title: OpenStreetMap Data + feature: OpenStreetMap Feature key: O notes: tooltip: Note data from OpenStreetMap @@ -1105,7 +1105,6 @@ en: live: live lose_changes: "You have unsaved changes. Switching the map server will discard them. Are you sure you want to switch servers?" dev: dev - rapid_multiselect: Multiple Selection rapid_splash: welcome: Introducing AI-assisted mapping! text: "Select the {rapidicon} option to start the Rapid walkthrough. If you're new to OSM and haven't mapped before, select the first {walkthrough} option. Finally, click the last {edit} option if you want to get straight to mapping. You can always access the walkthrough later from the help menu." diff --git a/data/l10n/core.en.json b/data/l10n/core.en.json index 07b8f0305..d90707368 100644 --- a/data/l10n/core.en.json +++ b/data/l10n/core.en.json @@ -923,8 +923,7 @@ "unknown": "Unknown", "incomplete": "", "feature_list": "Search features", - "edit": "Edit feature", - "edit_features": "Edit features", + "multiselect": "Multiple Selection", "check": { "yes": "Yes", "no": "No", @@ -1033,6 +1032,7 @@ "osm": { "tooltip": "Map data from OpenStreetMap", "title": "OpenStreetMap Data", + "feature": "OpenStreetMap Feature", "key": "O" }, "notes": { @@ -1401,7 +1401,6 @@ "lose_changes": "You have unsaved changes. Switching the map server will discard them. Are you sure you want to switch servers?", "dev": "dev" }, - "rapid_multiselect": "Multiple Selection", "rapid_splash": { "welcome": "Introducing AI-assisted mapping!", "text": "Select the {rapidicon} option to start the Rapid walkthrough. If you're new to OSM and haven't mapped before, select the first {walkthrough} option. Finally, click the last {edit} option if you want to get straight to mapping. You can always access the walkthrough later from the help menu.", diff --git a/modules/behaviors/SelectBehavior.js b/modules/behaviors/SelectBehavior.js index d0fc977a3..ca80c0126 100644 --- a/modules/behaviors/SelectBehavior.js +++ b/modules/behaviors/SelectBehavior.js @@ -16,7 +16,7 @@ const FAR_TOLERANCE = 12; * Properties available: * `enabled` `true` if the event handlers are enabled, `false` if not. * `lastDown` `eventData` Object for the most recent down event - * `lastUp` `eventData` Object for the most recent up event (to detect dbl clicks) + * `lastUp` `eventData` Object for the most recent up event (to detect dbl clicks) * `lastMove` `eventData` Object for the most recent move event * `lastSpace` `eventData` Object for the most recent move event used to trigger a spacebar click * `lastClick` `eventData` Object for the most recent click event diff --git a/modules/modes/SelectOsmMode.js b/modules/modes/SelectOsmMode.js index f401e93ed..9a7c70831 100644 --- a/modules/modes/SelectOsmMode.js +++ b/modules/modes/SelectOsmMode.js @@ -134,8 +134,7 @@ export class SelectOsmMode extends AbstractMode { editor.on('merge', this._merge); hover.on('hoverchange', this._hover); - ui.sidebar - .select(entityIDs, this._newFeature); + ui.sidebar.showInspector(entityIDs, this._newFeature); return true; } diff --git a/modules/pixi/PixiRenderer.js b/modules/pixi/PixiRenderer.js index a28b68b97..7e40f7ab4 100644 --- a/modules/pixi/PixiRenderer.js +++ b/modules/pixi/PixiRenderer.js @@ -66,7 +66,6 @@ export class PixiRenderer extends EventEmitter { // Make sure callbacks have `this` bound correctly this._tick = this._tick.bind(this); - this._onHoverChange = this._onHoverChange.bind(this); // Disable mipmapping, we always want textures near the resolution they are at. PIXI.BaseTexture.defaultOptions.mipmap = PIXI.MIPMAP_MODES.OFF; @@ -163,41 +162,6 @@ export class PixiRenderer extends EventEmitter { _sharedTextures = new PixiTextures(context); } this.textures = _sharedTextures; - - // Event listeners to respond to any changes in selection or hover - context.behaviors.hover.on('hoverchange', this._onHoverChange); - } - - - /** - * _onHoverChange - * Respond to any change in hover - */ - _onHoverChange(eventData) { - const context = this.context; - const scene = this.scene; - const ui = context.systems.ui; - - const target = eventData.target; - const layer = target?.layer; - const dataID = target?.dataID; - - const hoverData = target?.data; - const modeID = context.mode?.id; - if (modeID !== 'select' && modeID !== 'select-osm') { - ui.sidebar.hover(hoverData ? [hoverData] : []); - } - - scene.clearClass('hover'); - if (layer && dataID) { - // Only set hover class if this target isn't currently drawing - const drawingIDs = layer.getDataWithClass('drawing'); - if (!drawingIDs.has(dataID)) { - layer.setClass('hover', dataID); - } - } - - this.render(); } diff --git a/modules/ui/UiFeatureList.js b/modules/ui/UiFeatureList.js new file mode 100644 index 000000000..b31b05c71 --- /dev/null +++ b/modules/ui/UiFeatureList.js @@ -0,0 +1,572 @@ +import { select as d3_select } from 'd3-selection'; +import { Extent, geoSphericalDistance } from '@rapid-sdk/math'; +import * as sexagesimal from '@mapbox/sexagesimal'; + +import { Graph } from '../core/lib/index.js'; +import { osmEntity } from '../osm/entity.js'; +import { uiIcon } from './icon.js'; +import { uiCmd } from './cmd.js'; +import { utilHighlightEntities, utilIsColorValid, utilNoAuto } from '../util/index.js'; + + +/** + * UiFeatureList + * The feature list allows users to search for features and display the search results. + * + * @example + *
+ *
// Contains the text "Search Features" + *
// Contains the `input` search field + *
// Contains the search results + *
+ */ +export class UiFeatureList { + + /** + * @constructor + * @param `context` Global shared application context + */ + constructor(context) { + this.context = context; + + this._geocodeResults = null; + + // D3 selections + this.$parent = null; + this.$featureList = null; + this.$search = null; + this.$list = null; + + // Ensure methods used as callbacks always have `this` bound correctly. + // (This is also necessary when using `d3-selection.call`) + this.render = this.render.bind(this); + this._clearSearch = this._clearSearch.bind(this); + this._click = this._click.bind(this); + this._drawList = this._drawList.bind(this); + this._focusSearch = this._focusSearch.bind(this); + this._input = this._input.bind(this); + this._keydown = this._keydown.bind(this); + this._keypress = this._keypress.bind(this); + this._mouseout = this._mouseout.bind(this); + this._mouseover = this._mouseover.bind(this); + this._nominatimSearch = this._nominatimSearch.bind(this); + + // Setup event listeners + context.on('modechange', this._clearSearch); +// context.systems.map +// .on('drawn.feature-list', mapDrawn); + + const key = uiCmd('⌘F'); + context.keybinding().on(key, this._focusSearch); + } + + + /** + * 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 HTMLEement that this component should render itself into + */ + render($parent = this.$parent) { + if ($parent) { + this.$parent = $parent; + } else { + return; // no parent - called too early? + } + + const context = this.context; + const l10n = context.systems.l10n; + + // add .feature-list-wrap + let $featureList = $parent.selectAll('.feature-list-wrap') + .data([0]); + + const $$featureList = $featureList.enter() + .append('div') + .attr('class', 'feature-list-wrap inspector-hidden'); // UiSidebar will manage its visibility + + this.$featureList = $featureList = $featureList.merge($$featureList); + + + // add .header + $featureList.selectAll('.header') + .data([0]) + .enter() + .append('div') + .attr('class', 'header fillL') + .append('h3'); + + // update + $featureList.selectAll('.header h3') + .text(l10n.t('inspector.feature_list')); + + + // add .search-header + const $$searchWrap = $featureList.selectAll('.search-header') + .data([0]) + .enter() + .append('div') + .attr('class', 'search-header'); + + $$searchWrap + .call(uiIcon('#rapid-icon-search')); + + $$searchWrap + .append('input') + .attr('type', 'search') + .call(utilNoAuto) + .on('keypress', this._keypress) + .on('keydown', this._keydown) + .on('input', this._input); + + this.$search = $featureList.selectAll('.search-header input'); + + // update + this.$search + .attr('placeholder', l10n.t('inspector.search')); + + + // add .inspector-body and .feature-list + $featureList.selectAll('.inspector-body') + .data([0]) + .enter() + .append('div') + .attr('class', 'inspector-body') + .append('div') + .attr('class', 'feature-list'); + + this.$list = $featureList.selectAll('.feature-list'); + + // update + this._drawList(); + } + + + /* + * _drawList + * redraw the results list + */ + _drawList() { + if (!this.$search || !this.$list) return; // called too early? + + const context = this.context; + const l10n = context.systems.l10n; + const nominatim = context.services.nominatim; + + const value = this.$search.property('value'); + const results = this._getSearchResults(); + + const $list = this.$list; + $list.classed('filtered', value.length); + + let $$resultsItem = $list.selectAll('.no-results-item') + .data([0]) + .enter() + .append('button') + .property('disabled', true) + .attr('class', 'no-results-item') + .call(uiIcon('#rapid-icon-alert', 'pre-text')); + + $$resultsItem.append('span') + .attr('class', 'entity-name'); + + $list.selectAll('.no-results-item .entity-name') + .text(l10n.t('geocoder.no_results_worldwide')); + + if (nominatim) { + $list.selectAll('.geocode-item') + .data([0]) + .enter() + .append('button') + .attr('class', 'geocode-item secondary-action') + .on('click', this._nominatimSearch) + .append('div') + .attr('class', 'label') + .append('span') + .attr('class', 'entity-name') + .text(l10n.t('geocoder.search')); + } + + $list.selectAll('.no-results-item') + .style('display', (value.length && !results.length) ? 'block' : 'none'); + + $list.selectAll('.geocode-item') + .style('display', (value && this._geocodeResults === undefined) ? 'block' : 'none'); + + $list.selectAll('.feature-list-item') + .data([-1]) + .remove(); + + let $items = $list.selectAll('.feature-list-item') + .data(results, d => d.id); + + let $$items = $items.enter() + .insert('button', '.geocode-item') + .attr('class', 'feature-list-item') + .on('mouseover', this._mouseover) + .on('mouseout', this._mouseout) + .on('click', this._click); + + let $$label = $$items + .append('div') + .attr('class', 'label'); + + $$label + .each((d, i, nodes) => { + d3_select(nodes[i]) + .call(uiIcon(`#rapid-icon-${d.geometry}`, 'pre-text')); + }); + + $$label + .append('span') + .attr('class', 'entity-type') + .text(d => d.type); + + $$label + .append('span') + .attr('class', 'entity-name') + .classed('has-color', d => !!this._getColor(d.entity)) + .style('border-color', d => this._getColor(d.entity)) + .text(d => d.name); + + $$items + .style('opacity', 0) + .transition() + .style('opacity', 1); + + $items.order(); + + $items.exit() + .remove(); + } + + + /* + * _focusSearch + * Handler for the ⌘F shortcut to focus the search input + * @param {KeyboardEvent} e? - the keypress event (if any) + */ + _focusSearch(e) { + if (!this.$search) return; // called too early? + if (this.context.mode?.id !== 'browse') return; + + e?.preventDefault(); + this.$search.node().focus(); + } + + + /* + * _keydown + * Handler for keydown event - unfocus the search if user presses `Escape` + * @param {KeyboardEvent} e - the keydown event + */ + _keydown(e) { + if (!this.$search) return; // called too early? + + if (e.keyCode === 27) { // escape + this.$search.node().blur(); + } + } + + + /* + * _keypress + * Handler for keypress events + * @param {KeyboardEvent} e - the keypress event + */ + _keypress(e) { + if (!this.$search || !this.$list) return; // called too early? + + const q = this.$search.property('value'); + const $items = this.$list.selectAll('.feature-list-item'); + if (e.keyCode === 13 && q.length && $items.size()) { // ↩ Return + this._click(e, $items.datum()); + } + } + + + /* + * _input + * Handler for input events - on typing redraw the list + */ + _input() { + this._geocodeResults = undefined; + this._drawList(); + } + + + /* + * _clearSearch + */ + _clearSearch() { + if (!this.$search) return; // called too early? + + this.$search.property('value', ''); + this._drawList(); + } + + + /* + * _getColor + * If this entity has a color (e.g. a transit route) + * @param {Entity} entity - The OSM Entity to check + * @result {string?} The color string, if any + */ + _getColor(entity) { + const val = entity?.type === 'relation' && entity?.tags.colour; + return (val && utilIsColorValid(val)) ? val : null; + } + + + /* + * _mouseover + * Handler for mouseover events on the list items + * @param {MouseEvent} e - the mouseover event + * @param {Object} d - data bound to the list item + */ + _mouseover(e, d) { + if (!d.id || d.id === -1) return; + utilHighlightEntities([d.id], true, this.context); + } + + + /* + * _mouseout + * Handler for mouseout events on the list items + * @param {MouseEvent} e - the mouseout event + * @param {Object} d - data bound to the list item + */ + _mouseout(e, d) { + if (!d.id || d.id === -1) return; + utilHighlightEntities([d.id], false, this.context); + } + + + /* + * _click + * Handler for click events on the list items, + * may also be called by the keypress handler + * @param {Event} e - the click or keypress event + * @param {Object} d - data bound to the list item + */ + _click(e, d) { + e.preventDefault(); + + const context = this.context; + const map = context.systems.map; + const osm = context.services.osm; + + if (d.location) { + map.centerZoomEase([d.location[1], d.location[0]], 19); + + } else if (d.id !== -1) { // looks like an OSM Entity + utilHighlightEntities([d.id], false, context); + map.selectEntityID(d.id, true); // select and fit , download first if necessary + + } else if (osm && d.noteID) { // looks like an OSM Note + const selectNote = (note) => { + map.scene.enableLayers('notes'); + map.centerZoomEase(note.loc, 19); + const selection = new Map().set(note.id, note); + context.enter('select', { selection: selection }); + }; + + let note = osm.getNote(d.noteID); + if (note) { + selectNote(note); + } else { + osm.loadNote(d.noteID, (err) => { + if (err) return; + note = osm.getNote(d.noteID); + if (note) { + selectNote(note); + } + }); + } + } + } + + + /* + * _nominatimSearch + * Search Nominatim, then display those results + */ + _nominatimSearch() { + if (!this.$search) return; // called too early? + + const nominatim = this.context.services.nominatim; + if (!nominatim) return; + + const q = this.$search.property('value'); + + nominatim.search(q, (err, results) => { + this._geocodeResults = results || []; + this._drawList(); + }); + } + + +// _mapDrawn(e) { +// if (e.full) { +// this._drawList(); +// } +// } + + + /* + * _getSearchResults + * This does the search + * @return {Array} Array of search results + */ + _getSearchResults() { + if (!this.$search) return; // called too early? + + const context = this.context; + const editor = context.systems.editor; + const l10n = context.systems.l10n; + const presets = context.systems.presets; + + const centerLoc = context.viewport.centerLoc(); + const q = this.$search.property('value').toLowerCase(); + let results = []; + + if (!q) return results; + + // User typed something that looks like a coordinate pair + const locationMatch = sexagesimal.pair(q.toUpperCase()) || l10n.dmsMatcher(q); + if (locationMatch) { + const loc = [ parseFloat(locationMatch[0]), parseFloat(locationMatch[1]) ]; + results.push({ + id: -1, + geometry: 'point', + type: l10n.t('inspector.location'), + name: l10n.dmsCoordinatePair([loc[1], loc[0]]), + location: loc + }); + } + + // User typed something that looks like an OSM entity id (node/way/relation/note) + const idMatch = !locationMatch && q.match(/(?:^|\W)(node|way|relation|note|[nwr])\W?0*([1-9]\d*)(?:\W|$)/i); + if (idMatch) { + const entityType = idMatch[1].charAt(0); // n,w,r + const entityID = idMatch[2]; + + if (idMatch[1] === 'note') { + results.push({ + id: -1, + noteID: entityID, + geometry: 'note', + type: l10n.t('note.note'), + name: entityID + }); + } else { + results.push({ + id: entityType + entityID, + geometry: entityType === 'n' ? 'point' : entityType === 'w' ? 'line' : 'relation', + type: l10n.displayType(entityType), + name: entityID + }); + } + } + + // Search for what the user typed in the local and base graphs + // Gather affected ids + const graph = editor.staging.graph; + const base = graph.base.entities; + const local = graph.local.entities; + const ids = new Set([...base.keys(), ...local.keys()]); + + let localResults = []; + for (let id of ids) { + if (local.has(id) && local.get(id) === undefined) continue; // deleted locally + const entity = graph.hasEntity(id); + if (!entity) continue; + + const name = l10n.displayName(entity.tags) || ''; + if (name.toLowerCase().indexOf(q) < 0) continue; + + const matched = presets.match(entity, graph); + const type = (matched && matched.name()) || l10n.displayType(entity.id); + const extent = entity.extent(graph); + const distance = extent ? geoSphericalDistance(centerLoc, extent.center()) : 0; + + localResults.push({ + id: entity.id, + entity: entity, + geometry: entity.geometry(graph), + type: type, + name: name, + distance: distance + }); + + if (localResults.length > 100) break; + } + + localResults = localResults.sort((a, b) => a.distance - b.distance); + results = results.concat(localResults); + + + // Search for what the user typed in geocode results + for (const d of (this._geocodeResults || [])) { + if (!d.osm_type || !d.osm_id) continue; // some results may be missing these - iD#1890 + + // Make a temporary osmEntity so we can preset match and better localize the search result - iD#4725 + const id = osmEntity.id.fromOSM(d.osm_type, d.osm_id); + const tags = {}; + tags[d.class] = d.type; + + const attrs = { id: id, type: d.osm_type, tags: tags }; + if (d.osm_type === 'way') { // for ways, add some fake closed nodes + attrs.nodes = ['a','a']; // so that geometry area is possible + } + + const tempEntity = osmEntity(attrs); + const tempGraph = new Graph([tempEntity]); + const preset = presets.match(tempEntity, tempGraph); + const type = (preset && preset.name()) || l10n.displayType(id); + + results.push({ + id: tempEntity.id, + geometry: tempEntity.geometry(tempGraph), + type: type, + name: d.display_name, + extent: new Extent( + [ parseFloat(d.boundingbox[3]), parseFloat(d.boundingbox[0]) ], + [ parseFloat(d.boundingbox[2]), parseFloat(d.boundingbox[1]) ] + ) + }); + } + + // If the user just typed a number, offer them some OSM IDs + if (q.match(/^[0-9]+$/)) { + results.push({ + id: 'n' + q, + geometry: 'point', + type: l10n.t('inspector.node'), + name: q + }); + results.push({ + id: 'w' + q, + geometry: 'line', + type: l10n.t('inspector.way'), + name: q + }); + results.push({ + id: 'r' + q, + geometry: 'relation', + type: l10n.t('inspector.relation'), + name: q + }); + results.push({ + id: -1, + noteID: q, + geometry: 'note', + type: l10n.t('note.note'), + name: q + }); + } + + return results; + } + +} diff --git a/modules/ui/UiInspector.js b/modules/ui/UiInspector.js index e727c1344..5d6645487 100644 --- a/modules/ui/UiInspector.js +++ b/modules/ui/UiInspector.js @@ -110,13 +110,10 @@ export class UiInspector { const $$inspector = $inspector.enter() .append('div') - .attr('class', 'inspector-wrap inspector-hidden'); + .attr('class', 'inspector-wrap inspector-hidden'); // UiSidebar will manage its visibility this.$inspector = $inspector = $inspector.merge($$inspector); - $inspector - .classed('inspector-hidden', !entityIDs.length); - // add .panewrap let $paneWrap = $inspector.selectAll('.panewrap') diff --git a/modules/ui/UiSidebar.js b/modules/ui/UiSidebar.js index 719db7a06..44100cc86 100644 --- a/modules/ui/UiSidebar.js +++ b/modules/ui/UiSidebar.js @@ -1,12 +1,12 @@ import { interpolateNumber as d3_interpolateNumber } from 'd3-interpolate'; import { Extent, vecLength } from '@rapid-sdk/math'; -import { utilArrayIdentical } from '@rapid-sdk/util'; import _throttle from 'lodash-es/throttle.js'; import { osmEntity, QAItem } from '../osm/index.js'; import { uiDataEditor } from './data_editor.js'; -import { uiFeatureList } from './feature_list.js'; +import { UiFeatureList } from './UiFeatureList.js'; import { UiInspector } from './UiInspector.js'; +import { uiDetectionInspector } from './detection_inspector.js'; import { uiKeepRightEditor } from './keepRight_editor.js'; import { uiMapRouletteEditor } from './maproulette_editor.js'; import { uiOsmoseEditor } from './osmose_editor.js'; @@ -24,34 +24,36 @@ const DEFAULT_WIDTH = 400; // needs to match the flex-basis in our css file * UiSidebar * The Sidebar is positioned to the side of the map and can show various information. * It can appear either on the left or right side of the map (depending on `l10n.isRTL`) - * While editing and interacting with the map, some sidebar components may be classed as hidden, - * and custom components can be allowed to cover up the feature picker or OSM Inspector. + * While editing and interacting with the map, the sidebar will control which child + * component is visible. * * @example *