diff --git a/public/css/style.css b/public/css/style.css index d4a8a7e..0753c90 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -879,6 +879,72 @@ html, body { color: #333; } +.search-ctrl { + position: relative; +} + +.search-ctrl__button svg { + display: block; + margin: 0 auto; + width: 18px; + height: 18px; +} + +.search-ctrl__button.active { + background: #dbe9ff; +} + +.search-panel { + position: absolute; + top: 0; + left: 100%; + margin-left: 8px; + z-index: 20; + width: 240px; + background: #fff; + border: 1px solid #ddd; + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + padding: 6px 8px; + font-size: 13px; +} + +.search-panel[hidden] { + display: none; +} + +.search-panel__input { + width: 100%; + box-sizing: border-box; + border: 1px solid #ccc; + border-radius: 4px; + padding: 5px 7px; + font: inherit; +} + +.search-panel__results { + list-style: none; + margin: 6px 0 0; + padding: 0; + max-height: 220px; + overflow-y: auto; +} + +.search-panel__result { + padding: 6px 4px; + border-radius: 4px; + cursor: pointer; +} + +.search-panel__result:hover { + background: #f0f0f0; +} + +.search-panel__status { + padding: 6px 4px; + color: #888; +} + .notes-ctrl { position: relative; } diff --git a/public/js/icons.js b/public/js/icons.js index 5f35733..5ecd1c8 100644 --- a/public/js/icons.js +++ b/public/js/icons.js @@ -62,6 +62,12 @@ export const SUNSET_ICON = ` `; +export const SEARCH_ICON = ` + + + +`; + export const NEW_FOLDER_ICON = ` diff --git a/public/js/map.js b/public/js/map.js index 5e664bb..58974e7 100644 --- a/public/js/map.js +++ b/public/js/map.js @@ -14,6 +14,7 @@ import { MeasureControl } from "/js/measure.js"; import { NotesControl } from "/js/notes.js"; import { PluginsControl } from "/js/plugins.js"; import { LocateControl } from "/js/locate.js"; +import { SearchControl } from "/js/search.js"; import { LAYERS_ICON, WAYPOINT_ICON, SUNRISE_ICON, SUNSET_ICON, PENDING_ICON } from "/js/icons.js"; import { formatCoords, formatSunInfo } from "/js/sun.js"; import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; @@ -405,6 +406,7 @@ const SLOVAKIA_BOUNDS = [ [16.83, 47.73], [22.57, 49.61], ]; +map.addControl(new SearchControl(), "top-left"); map.addControl(new HomeButton({ bounds: SLOVAKIA_BOUNDS }), "top-left"); // showCompass: false — this map never rotates, so a bearing-reset button // (NavigationControl's default third button) would just be dead weight. diff --git a/public/js/search.js b/public/js/search.js new file mode 100644 index 0000000..a4b72ad --- /dev/null +++ b/public/js/search.js @@ -0,0 +1,111 @@ +import { SEARCH_ICON } from "/js/icons.js"; +import { escapeHtml } from "/js/gpx.js"; + +const NOMINATIM_API = "https://nominatim.openstreetmap.org/search"; +// "lat, lng" / "lat lng" decimal degrees — the same plain-text shape the +// topbar's own coordinate readout copies to the clipboard (map.js). Tried +// before hitting the geocoder, so pasting a coordinate pair jumps straight +// there with no network round-trip. +const COORD_PATTERN = /^\s*(-?\d+(?:\.\d+)?)\s*[, ]\s*(-?\d+(?:\.\d+)?)\s*$/; + +// A place-name/coordinate search box, matching NPS's own Park Tiles viewer. +// Online only, like this app's other non-basemap online features (Radar, +// the Wikipedia layer) — no offline geocoding index to fall back to. +// Nominatim: OpenStreetMap's own geocoder, free, no API key, matching the +// online basemap's own OSM-derived style already used here. +export class SearchControl { + constructor() { + this._results = []; + } + + onAdd(map) { + this._map = map; + this._container = document.createElement("div"); + this._container.className = "maplibregl-ctrl maplibregl-ctrl-group search-ctrl"; + this._container.innerHTML = ` + + + `; + this._button = this._container.querySelector(".search-ctrl__button"); + this._panel = this._container.querySelector(".search-panel"); + this._input = this._container.querySelector(".search-panel__input"); + this._resultsEl = this._container.querySelector(".search-panel__results"); + + this._button.addEventListener("click", () => this._toggle()); + this._container.querySelector(".search-panel__form").addEventListener("submit", (event) => { + event.preventDefault(); + this._search(); + }); + + return this._container; + } + + onRemove() { + this._container.remove(); + } + + getDefaultPosition() { + return "top-left"; + } + + _toggle() { + const opening = this._panel.hidden; + this._panel.hidden = !opening; + this._button.classList.toggle("active", opening); + if (opening) this._input.focus(); + } + + async _search() { + const query = this._input.value.trim(); + if (!query) return; + + const coords = query.match(COORD_PATTERN); + if (coords) { + this._resultsEl.innerHTML = ""; + this._jumpTo(Number(coords[1]), Number(coords[2])); + return; + } + + this._resultsEl.innerHTML = `
  • Searching…
  • `; + let places; + try { + const url = new URL(NOMINATIM_API); + url.searchParams.set("q", query); + url.searchParams.set("format", "jsonv2"); + url.searchParams.set("limit", "5"); + places = await (await fetch(url)).json(); + } catch { + this._resultsEl.innerHTML = `
  • Search unavailable — check your connection.
  • `; + return; + } + this._renderResults(places); + } + + _renderResults(places) { + this._results = places; + if (places.length === 0) { + this._resultsEl.innerHTML = `
  • No results
  • `; + return; + } + this._resultsEl.innerHTML = places + .map((place, i) => `
  • ${escapeHtml(place.display_name)}
  • `) + .join(""); + this._resultsEl.querySelectorAll(".search-panel__result").forEach((el) => { + el.addEventListener("click", () => { + const place = this._results[Number(el.dataset.index)]; + this._jumpTo(Number(place.lat), Number(place.lon)); + }); + }); + } + + _jumpTo(lat, lng) { + this._map.flyTo({ center: [lng, lat], zoom: Math.max(this._map.getZoom(), 14) }); + this._panel.hidden = true; + this._button.classList.remove("active"); + } +} diff --git a/views/layout.erb b/views/layout.erb index afa9a71..d80bca0 100644 --- a/views/layout.erb +++ b/views/layout.erb @@ -43,6 +43,7 @@ "/js/panels.js": "/js/panels.js?v=<%= ASSET_VERSION %>", "/js/plugins.js": "/js/plugins.js?v=<%= ASSET_VERSION %>", "/js/radar.js": "/js/radar.js?v=<%= ASSET_VERSION %>", + "/js/search.js": "/js/search.js?v=<%= ASSET_VERSION %>", "/js/shadow.js": "/js/shadow.js?v=<%= ASSET_VERSION %>", "/js/sun.js": "/js/sun.js?v=<%= ASSET_VERSION %>", "/js/track_profile.js": "/js/track_profile.js?v=<%= ASSET_VERSION %>"