Add a search box, matching NPS's Park Tiles viewer
Top-left map control (topmost, above Home/zoom) with a text input: "lat, lng" is parsed and jumped to directly (same plain-text shape the topbar's coordinate readout already copies to the clipboard), anything else is geocoded via Nominatim — OSM's own geocoder, free, no API key, matching the online basemap's own OSM-derived style. Online only, same story as this app's other non-basemap online features (Radar, the Wikipedia layer) — no offline geocoding index to fall back to.
This commit is contained in:
1 parent
23a84ac185
commit
b4a1c6c0cc
5 files changed
+186
No files matched your search
@@ -879,6 +879,72 @@ html, body {
|
|||||||
color: #333;
|
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 {
|
.notes-ctrl {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ export const SUNSET_ICON = `
|
|||||||
<path d="M9 10l3 3 3-3"/>
|
<path d="M9 10l3 3 3-3"/>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
|
|
||||||
|
export const SEARCH_ICON = `
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="11" cy="11" r="7"/>
|
||||||
|
<path d="M20 20l-4.35-4.35"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
export const NEW_FOLDER_ICON = `
|
export const NEW_FOLDER_ICON = `
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/>
|
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { MeasureControl } from "/js/measure.js";
|
|||||||
import { NotesControl } from "/js/notes.js";
|
import { NotesControl } from "/js/notes.js";
|
||||||
import { PluginsControl } from "/js/plugins.js";
|
import { PluginsControl } from "/js/plugins.js";
|
||||||
import { LocateControl } from "/js/locate.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 { LAYERS_ICON, WAYPOINT_ICON, SUNRISE_ICON, SUNSET_ICON, PENDING_ICON } from "/js/icons.js";
|
||||||
import { formatCoords, formatSunInfo } from "/js/sun.js";
|
import { formatCoords, formatSunInfo } from "/js/sun.js";
|
||||||
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
|
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
|
||||||
@@ -405,6 +406,7 @@ const SLOVAKIA_BOUNDS = [
|
|||||||
[16.83, 47.73],
|
[16.83, 47.73],
|
||||||
[22.57, 49.61],
|
[22.57, 49.61],
|
||||||
];
|
];
|
||||||
|
map.addControl(new SearchControl(), "top-left");
|
||||||
map.addControl(new HomeButton({ bounds: SLOVAKIA_BOUNDS }), "top-left");
|
map.addControl(new HomeButton({ bounds: SLOVAKIA_BOUNDS }), "top-left");
|
||||||
// showCompass: false — this map never rotates, so a bearing-reset button
|
// showCompass: false — this map never rotates, so a bearing-reset button
|
||||||
// (NavigationControl's default third button) would just be dead weight.
|
// (NavigationControl's default third button) would just be dead weight.
|
||||||
|
|||||||
@@ -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 = `
|
||||||
|
<button type="button" class="search-ctrl__button" title="Search" aria-label="Search">${SEARCH_ICON}</button>
|
||||||
|
<div class="search-panel" hidden>
|
||||||
|
<form class="search-panel__form">
|
||||||
|
<input type="text" class="search-panel__input" placeholder="Search, or lat, lng">
|
||||||
|
</form>
|
||||||
|
<ul class="search-panel__results"></ul>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 = `<li class="search-panel__status">Searching…</li>`;
|
||||||
|
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 = `<li class="search-panel__status">Search unavailable — check your connection.</li>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._renderResults(places);
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderResults(places) {
|
||||||
|
this._results = places;
|
||||||
|
if (places.length === 0) {
|
||||||
|
this._resultsEl.innerHTML = `<li class="search-panel__status">No results</li>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._resultsEl.innerHTML = places
|
||||||
|
.map((place, i) => `<li class="search-panel__result" data-index="${i}">${escapeHtml(place.display_name)}</li>`)
|
||||||
|
.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
"/js/panels.js": "/js/panels.js?v=<%= ASSET_VERSION %>",
|
"/js/panels.js": "/js/panels.js?v=<%= ASSET_VERSION %>",
|
||||||
"/js/plugins.js": "/js/plugins.js?v=<%= ASSET_VERSION %>",
|
"/js/plugins.js": "/js/plugins.js?v=<%= ASSET_VERSION %>",
|
||||||
"/js/radar.js": "/js/radar.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/shadow.js": "/js/shadow.js?v=<%= ASSET_VERSION %>",
|
||||||
"/js/sun.js": "/js/sun.js?v=<%= ASSET_VERSION %>",
|
"/js/sun.js": "/js/sun.js?v=<%= ASSET_VERSION %>",
|
||||||
"/js/track_profile.js": "/js/track_profile.js?v=<%= ASSET_VERSION %>"
|
"/js/track_profile.js": "/js/track_profile.js?v=<%= ASSET_VERSION %>"
|
||||||
|
|||||||
Reference in new issue
Block a user