diff --git a/index.html b/index.html index e98405f41..bbe9c706d 100644 --- a/index.html +++ b/index.html @@ -75,6 +75,19 @@ A pleasing map of Canada + + + + + + + + + + diff --git a/src/layer.js b/src/layer.js index c2a7b3f9f..698174a3f 100644 --- a/src/layer.js +++ b/src/layer.js @@ -104,6 +104,8 @@ export class MapLayer extends HTMLElement { if (this._layerControl && !this.hidden) { this._layerControl.addOrUpdateOverlay(this._layer, this.label); } + // Update search control to update searchable layers + this._layer._map.options.mapEl._searchBar._updateSearchableLayers(); }, {once:true}); //listener stops listening after event occurs once //if map is already created then dispatch createmap event, allowing layer to be built if(this.parentNode._map)this.parentNode.dispatchEvent(new CustomEvent('createmap')); @@ -204,7 +206,8 @@ export class MapLayer extends HTMLElement { total++; layer._extent._mapExtents[i].removeAttribute("disabled"); layer._extent._mapExtents[i].disabled = false; - if(!(layer._extent._mapExtents[i].templatedLayer._templates[j].layer.isVisible)){ + if(!(layer._extent._mapExtents[i].templatedLayer._templates[j].layer && + layer._extent._mapExtents[i].templatedLayer._templates[j].layer.isVisible)){ count++; layer._extent._mapExtents[i].setAttribute("disabled", ""); layer._extent._mapExtents[i].disabled = true; diff --git a/src/mapml-viewer.js b/src/mapml-viewer.js index 51644ad8d..15dc37470 100644 --- a/src/mapml-viewer.js +++ b/src/mapml-viewer.js @@ -134,7 +134,7 @@ export class MapViewer extends HTMLElement { this._controlsList = new DOMTokenList( this.getAttribute("controlslist"), this, "controlslist", - ["noreload","nofullscreen","nozoom","nolayer"] + ["noreload","nofullscreen","nozoom","nolayer","nosearch"] ); // the dimension attributes win, if they're there. A map does not @@ -354,6 +354,9 @@ export class MapViewer extends HTMLElement { totalSize += 49; this._fullScreenControl = M.fullscreenButton().addTo(this._map); } + if (!this._searchBar) { + this._searchBar = M.searchBar().addTo(this._map); + } } // Sets controls by hiding/unhiding them based on the map attribute @@ -372,12 +375,17 @@ export class MapViewer extends HTMLElement { this._setControlsVisibility("layercontrol",true); this._setControlsVisibility("reload",true); this._setControlsVisibility("zoom",true); + this._setControlsVisibility("search",true); } _showControls() { this._setControlsVisibility("fullscreen",false); this._setControlsVisibility("layercontrol",false); this._setControlsVisibility("reload",false); this._setControlsVisibility("zoom",false); + // show search control, if any layer is searchable + if (this._searchBar?.searchableLayers.length > 0) { + this._setControlsVisibility("search",false); + } // prune the controls shown if necessary // this logic could be embedded in _showControls @@ -398,11 +406,17 @@ export class MapViewer extends HTMLElement { case 'nozoom': this._setControlsVisibility("zoom",true); break; + case 'nosearch': + this._setControlsVisibility("search",true); + break; } }); } - if (this._layerControl && this._layerControl._layers.length === 0) { + if (this._layerControl?._layers.length === 0) { this._layerControl._container.setAttribute("hidden",""); + if (this._searchBar) { + this._setControlsVisibility("search",true); + } } } @@ -431,6 +445,11 @@ export class MapViewer extends HTMLElement { container = this._layerControl._container; } break; + case "search": + if (this._searchBar) { + container = this._searchBar._container; + } + break; } if (container) { if (hide) { diff --git a/src/mapml.css b/src/mapml.css index 3f747e39d..d943041e5 100644 --- a/src/mapml.css +++ b/src/mapml.css @@ -242,6 +242,41 @@ background-size: 34px; } +.mapml-search-control { + position: absolute; + margin-left: 55px !important; + width: 201px; +} +.mapml-search-control-section { + width: 180px; + display: grid; + grid-template-columns: 83% 1fr; +} +.mapml-search-control-section input[type=search] { + padding: 5px; + font-size: 16px; + border: 1px solid grey; + width: 100%; + border-radius: 5px; + grid-column: 1; + overflow: hidden; +} +.mapml-search-control-section input[type=button] { + width: 100%; + height: 30px; + border-radius: 5px; + border: 1px solid black; + grid-column: 2; + overflow: hidden; + background-image: url("data:image/svg+xml,%3Csvg xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:cc='http://creativecommons.org/ns%23' xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns%23' xmlns:svg='http://www.w3.org/2000/svg' xmlns='http://www.w3.org/2000/svg' xmlns:sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd' xmlns:inkscape='http://www.inkscape.org/namespaces/inkscape' width='40' height='40' viewBox='0 0 500.00001 500.00001' id='svg4162' version='1.1' inkscape:version='0.92.3 (2405546, 2018-03-11)' sodipodi:docname='Search_Icon.svg'%3E%3Cdefs id='defs4164'/%3E%3Csodipodi:namedview id='base' pagecolor='%23ffffff' bordercolor='%23666666' borderopacity='1.0' inkscape:pageopacity='0.0' inkscape:pageshadow='2' inkscape:zoom='0.954' inkscape:cx='250' inkscape:cy='250' inkscape:document-units='px' inkscape:current-layer='layer1' showgrid='false' units='px' inkscape:window-width='1366' inkscape:window-height='706' inkscape:window-x='-8' inkscape:window-y='-8' inkscape:window-maximized='1'/%3E%3Cmetadata id='metadata4167'%3E%3Crdf:RDF%3E%3Ccc:Work rdf:about=''%3E%3Cdc:format%3Eimage/svg+xml%3C/dc:format%3E%3Cdc:type rdf:resource='http://purl.org/dc/dcmitype/StillImage'/%3E%3Cdc:title/%3E%3C/cc:Work%3E%3C/rdf:RDF%3E%3C/metadata%3E%3Cg inkscape:label='Layer 1' inkscape:groupmode='layer' id='layer1' transform='translate(0,-552.36216)'%3E%3Cg id='g1400' transform='translate(-4.3609793,-7.6704785)'%3E%3Cpath inkscape:connector-curvature='0' id='path4714' d='M 232.83952,614.96702 A 154.04816,154.04794 0 0 0 78.79153,769.01382 154.04816,154.04794 0 0 0 232.83952,923.06184 154.04816,154.04794 0 0 0 386.88751,769.01382 154.04816,154.04794 0 0 0 232.83952,614.96702 Z m 0,26.77613 A 129.95832,127.2707 0 0 1 362.79832,769.01382 129.95832,127.2707 0 0 1 232.83952,896.28449 129.95832,127.2707 0 0 1 102.88194,769.01382 129.95832,127.2707 0 0 1 232.83952,641.74315 Z' style='opacity:1;fill:%232b0000;fill-opacity:1;stroke:none;stroke-opacity:1'/%3E%3Crect ry='18.08342' rx='33.249443' transform='matrix(0.65316768,0.7572133,-0.60689051,0.79478545,0,0)' y='319.55432' x='794.8775' height='36.16684' width='173.02675' id='rect4721' style='opacity:1;fill:%232b0000;fill-opacity:1;stroke:none;stroke-opacity:1'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); + background-size: 27px; + background-repeat: no-repeat; + background-position: center; +} +.mapml-search-control input[type=button]:hover { + background-color: #fafafa; +} + /* Revert Leaflet styles that are causing misalignment. */ .leaflet-control-layers-selector { margin-top: revert; diff --git a/src/mapml/control/SearchControl.js b/src/mapml/control/SearchControl.js new file mode 100644 index 000000000..1bc9a4f2e --- /dev/null +++ b/src/mapml/control/SearchControl.js @@ -0,0 +1,168 @@ +export var SearchBar = L.Control.extend({ + + options: { + position: 'topleft' + }, + + onAdd: function(map) { + this._initLayout(); + this._map = map; + + L.DomEvent.on(this._button, 'click', this.search, this); + + // get a list of layers that are searchable + this.searchableLayers = []; + + return this._container; + }, + + onRemove: function(map) { + L.DomEvent.off(this._button); + }, + + _initLayout() { + const className = 'mapml-search-control', + container = this._container = L.DomUtil.create('div', className), + section = this._section = L.DomUtil.create('div', `${className}-section`); + + // TODO - Create a label for the search input + this._input = L.DomUtil.create('input', `${className}-input`, section); + this._input.setAttribute("list", "suggestions"); // connect to datalist + this._input.type = 'search'; + this._input.size = '15'; + this._input.onkeyup = (e)=> { + if (e.code === 'Enter') { + this.search(); + } else { + this.suggest(); + } + }; + + this._createSuggestions(); + + this._button = L.DomUtil.create('input', `${className}-button`, section); + this._button.type = 'button'; + this._button.title = 'Search'; + + container.appendChild(section); + }, + + search() { + let input = this._input; + this._updateSearchableLayers(input.value); + + // TODO - search through all layers when multiple searchable layers present, + // currently hardcoded to only search the first searchable layer + fetch(this.searchableLayers[0], { + "headers": { + "accept": "text/mapml", + }, + "method": "GET", + "mode": "cors", + }) + .then((response) => { + if (response.ok) { + return response.text(); + } + throw new Error('Invalid Search Response'); + }) + .then((data)=> { + // TODO - work with data variable - mapml response or geojson + let l = document.createElement("layer-"); + l.src = this.searchableLayers[0]; + l.checked = true; + + this._map.options.mapEl.appendChild(l); + }) + .catch((error) => { + console.error("Error:", error); + }); + }, + + // suggest values when user is typing in the search bar + suggest() { + let val = this._input.value; + if (val.length >= 3) { + for (let layer of [... this._map.options.mapEl.layers]) { + if (layer._layer && layer._layer._templatedLayer.search) { + //layer.search = layer.search.replace('QUERY', input.value); + //let link = this.parseLink(layer._layer._templatedLayer._templates[0], val); + //this.searchableLayers.push(link); + let suggestionLink = layer._layer._templatedLayer._templates[0].linkEl.parentElement.querySelector("map-link[rel=searchSuggestion]"); + + if (suggestionLink) { + const query = suggestionLink.getAttribute("query"); + const desc = suggestionLink.getAttribute("desc"); + let tref = suggestionLink.getAttribute("tref"); + + // TODO - currently hardcoded to get first map-input, need to loop through all + let inpName = layer._layer._templatedLayer._templates[0].values[0].getAttribute("name"); + + tref = tref.replace('{' + inpName + '}', val); + fetch(tref) + .then((response) => response.json()) + .then((data)=> { + this.clearItems(); + for (const obj in data) { + this.addItem(data[obj][query], data[obj][desc]); + } + }); + } + } + } + } else { + this.clearItems(); + } + }, + + _updateSearchableLayers(val){ + this.searchableLayers = []; + for (let layer of [... this._map.options.mapEl.layers]) { + if (layer._layer && layer._layer._templatedLayer?.search) { + //layer.search = layer.search.replace('QUERY', input.value); + let link = this.parseLink(layer._layer._templatedLayer._templates[0], val); + this.searchableLayers.push(link); + } + } + if (this.searchableLayers.length === 0) { + this._map.options.mapEl._setControlsVisibility("search",true); + } else { + this._map.options.mapEl._setControlsVisibility("search",false); + } + }, + + parseLink(template, val) { + let link = template.template; + let inpName = template.values[0].getAttribute("name"); + let inpType = template.values[0].getAttribute("type"); + //let inpVal = template.values[0].innerHTML; + link = link.replace('{' + inpName + '}',val); + return link; + }, + + _createSuggestions() { + this._suggestion = L.DomUtil.create( + 'datalist', + 'leaflet-searchbox-autocomplete', + this._container); + this._suggestion.id = "suggestions"; + this._items = []; + }, + + addItem(value, text) { + var listItem = L.DomUtil.create('option', 'leaflet-searchbox-autocomplete-item', this._suggestion); + listItem.innerHTML = text; + listItem.value = value; + this._items.push(listItem); + }, + + clearItems() { + this._suggestion.innerHTML = ''; + this._items = []; + } +}); + + +export var searchBar = function (options) { + return new SearchBar(options); + }; \ No newline at end of file diff --git a/src/mapml/index.js b/src/mapml/index.js index 938a47deb..b14a019b9 100644 --- a/src/mapml/index.js +++ b/src/mapml/index.js @@ -54,6 +54,7 @@ import { Util } from './utils/Util'; import { ReloadButton, reloadButton } from './control/ReloadButton'; import { FullscreenButton, fullscreenButton } from './control/FullscreenButton'; import {attributionControl} from "./control/AttributionControl"; +import { SearchBar, searchBar } from "./control/SearchControl"; import { Crosshair, crosshair } from "./layers/Crosshair"; import { Feature, feature } from "./features/feature"; import { FeatureRenderer, featureRenderer } from './features/featureRenderer'; @@ -659,6 +660,9 @@ M.fullscreenButton = fullscreenButton; M.attributionControl = attributionControl; +M.SearchBar = SearchBar; +M.searchBar = searchBar; + M.StaticTileLayer = StaticTileLayer; M.staticTileLayer = staticTileLayer; diff --git a/src/mapml/layers/MapMLLayer.js b/src/mapml/layers/MapMLLayer.js index 24c19cefd..473f3d1c4 100644 --- a/src/mapml/layers/MapMLLayer.js +++ b/src/mapml/layers/MapMLLayer.js @@ -875,7 +875,7 @@ export var MapMLLayer = L.Layer.extend({ function _initTemplateVars(serverExtent, metaExtent, projection, mapml, base, projectionMatch){ var templateVars = []; // set up the URL template and associated inputs (which yield variable values when processed) - var tlist = serverExtent.querySelectorAll('map-link[rel=tile],map-link[rel=image],map-link[rel=features],map-link[rel=query]'), + var tlist = serverExtent.querySelectorAll('map-link[rel=tile],map-link[rel=image],map-link[rel=features],map-link[rel=query],map-link[rel=search]'), varNamesRe = (new RegExp('(?:\{)(.*?)(?:\})','g')), zoomInput = serverExtent.querySelector('map-input[type="zoom" i]'), includesZoom = false, extentFallback = {}; @@ -1051,7 +1051,7 @@ export var MapMLLayer = L.Layer.extend({ layer._extent._mapExtents = []; // stores all the map-extent elements in the layer layer._extent._templateVars = []; // stores all template variables coming from all extents for(let j = 0; j < serverExtent.length; j++){ - if (serverExtent[j].querySelector('map-link[rel=tile],map-link[rel=image],map-link[rel=features],map-link[rel=query]') && + if (serverExtent[j].querySelector('map-link[rel=tile],map-link[rel=image],map-link[rel=features],map-link[rel=query],map-link[rel=search]') && serverExtent[j].hasAttribute("units")) { layer._extent._mapExtents.push(serverExtent[j]); projectionMatch = projectionMatch || selectedAlternate; diff --git a/src/mapml/layers/TemplatedLayer.js b/src/mapml/layers/TemplatedLayer.js index 623c3f36a..28661050c 100644 --- a/src/mapml/layers/TemplatedLayer.js +++ b/src/mapml/layers/TemplatedLayer.js @@ -28,6 +28,8 @@ export var TemplatedLayer = L.Layer.extend({ templates[i].extentBounds = inputData.bounds; templates[i].zoomBounds = inputData.zoomBounds; this._queries.push(L.extend(templates[i], this._setupQueryVars(templates[i]))); + } else if (templates[i].rel === 'search') { + this.search = true; } } }, @@ -207,7 +209,7 @@ export var TemplatedLayer = L.Layer.extend({ }, onAdd: function (map) { for (var i=0;i { await page.evaluateHandle((viewer) => viewer.setAttribute("controls", ""), viewerHandle); await page.evaluateHandle( (viewer) => document.body.appendChild(viewer), viewerHandle); - let leftControlCount = await page.$eval(".leaflet-top.leaflet-left", (div) => div.childElementCount); - expect(leftControlCount).toBe(2); - let zoomHidden = await page.$eval(".leaflet-top.leaflet-left > .leaflet-control-zoom", (div) => div.hidden); let reloadHidden = await page.$eval(".leaflet-top.leaflet-left > .mapml-reload-button", (div) => div.hidden); + let searchHidden = await page.$eval(".leaflet-top.leaflet-left > .mapml-search-control", (div) => div.hidden); expect(zoomHidden).toEqual(false); expect(reloadHidden).toEqual(false); + expect(searchHidden).toEqual(true); await page.evaluate( viewer => viewer.removeAttribute("controls"), viewerHandle); zoomHidden = await page.$eval(".leaflet-top.leaflet-left > .leaflet-control-zoom", (div) => div.hidden); reloadHidden = await page.$eval(".leaflet-top.leaflet-left > .mapml-reload-button", (div) => div.hidden); + searchHidden = await page.$eval(".leaflet-top.leaflet-left > .mapml-search-control", (div) => div.hidden); expect(zoomHidden).toEqual(true); expect(reloadHidden).toEqual(true); + expect(searchHidden).toEqual(true); await page.evaluate( viewer => viewer.setAttribute("controls",""), viewerHandle); zoomHidden = await page.$eval(".leaflet-top.leaflet-left > .leaflet-control-zoom", (div) => div.hidden); reloadHidden = await page.$eval(".leaflet-top.leaflet-left > .mapml-reload-button", (div) => div.hidden); + searchHidden = await page.$eval(".leaflet-top.leaflet-left > .mapml-search-control", (div) => div.hidden); expect(zoomHidden).toEqual(false); expect(reloadHidden).toEqual(false); + expect(searchHidden).toEqual(true); // remove map for next test await page.evaluateHandle(() => document.querySelector('mapml-viewer').remove());