-
+
+ {onToggleControls ? (
+
+
+
+
+
+ ) : null}
+ {/* On mobile (<=900px) the view toggle and these controls move to
+ the map's floating side panels. */}
{onViewModeChange ? (
-
- ) : null}
- {hasSelection && onZoomToSelection ? (
-
-
-
- ) : null}
- {hasSelection && onTogglePatch ? (
-
-
-
+
+
+
) : null}
+
+ {hasSelection && onZoomToSelection ? (
+
+
+
+ ) : null}
+ {hasSelection && onTogglePatch ? (
+
+
+
+ ) : null}
+
diff --git a/src/components/map/EarthMap.tsx b/src/components/map/EarthMap.tsx
index f29542f..7832c5e 100644
--- a/src/components/map/EarthMap.tsx
+++ b/src/components/map/EarthMap.tsx
@@ -24,6 +24,8 @@ import type { MapSelection, MapViewMode, MapViewState } from "@/types/map";
import { useTheme } from "@/providers/ThemeProvider";
import { EditorShell } from "@/components/layout/EditorShell";
import { Nav } from "@/components/layout/Nav";
+import { MapSideControls } from "@/components/map/MapSideControls";
+import { MapSearch } from "@/components/map/MapSearch";
import { MapReadout } from "@/components/map/MapReadout";
import { GlobeSelectionOverlay } from "@/components/map/GlobeSelectionOverlay";
@@ -58,6 +60,7 @@ export function EarthMap() {
const [mapSize, setMapSize] = useState({ width: 0, height: 0 });
const [selection, setSelection] = useState
(null);
const [showPatch, setShowPatch] = useState(true);
+ const [controlsOpen, setControlsOpen] = useState(false);
const [historyYears, setHistoryYears] = useState(DEFAULT_HISTORY_YEARS);
const [loadingSeries, setLoadingSeries] = useState(false);
const [seriesProgress, setSeriesProgress] = useState<{
@@ -235,6 +238,8 @@ export function EarthMap() {
return (
setControlsOpen(false)}
header={
}
/>
diff --git a/src/components/map/MapSearch.tsx b/src/components/map/MapSearch.tsx
new file mode 100644
index 0000000..c3d68aa
--- /dev/null
+++ b/src/components/map/MapSearch.tsx
@@ -0,0 +1,240 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { CrosshairIcon } from "@/icons/CrosshairIcon";
+import { SearchIcon } from "@/icons/SearchIcon";
+import {
+ geocodeAddress,
+ parseCoordinates,
+ type SearchResult,
+} from "@/lib/search/geocode";
+import { loadSearchHistory, pushSearchHistory } from "@/lib/search/history";
+
+type MapSearchProps = {
+ onSelect: (lon: number, lat: number) => void;
+ className?: string;
+};
+
+type Status = "idle" | "loading" | "done" | "error";
+
+const DEBOUNCE_MS = 300;
+
+export function MapSearch({ onSelect, className }: MapSearchProps) {
+ const inputRef = useRef
(null);
+ const skipNextFetchRef = useRef(false);
+
+ const [query, setQuery] = useState("");
+ const [results, setResults] = useState([]);
+ const [status, setStatus] = useState("idle");
+ const [open, setOpen] = useState(false);
+ const [history, setHistory] = useState(() =>
+ loadSearchHistory(),
+ );
+ const [activeIndex, setActiveIndex] = useState(0);
+
+ const trimmed = query.trim();
+ const coord = parseCoordinates(trimmed);
+
+ // Focus the input when the user presses "/" outside of another field.
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== "/") return;
+ const target = event.target as HTMLElement | null;
+ const tag = target?.tagName;
+ if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) {
+ return;
+ }
+ event.preventDefault();
+ inputRef.current?.focus();
+ };
+ document.addEventListener("keydown", onKeyDown);
+ return () => document.removeEventListener("keydown", onKeyDown);
+ }, []);
+
+ // Debounced address lookup. Empty/coordinate input is resolved in the change
+ // handler, so this effect only fires network requests and updates state from
+ // their async results.
+ useEffect(() => {
+ if (skipNextFetchRef.current) {
+ skipNextFetchRef.current = false;
+ return;
+ }
+ const q = query.trim();
+ if (!q || parseCoordinates(q)) return;
+
+ const controller = new AbortController();
+ const timer = setTimeout(() => {
+ geocodeAddress(q, controller.signal)
+ .then((next) => {
+ setResults(next);
+ setStatus("done");
+ setActiveIndex(0);
+ })
+ .catch(() => {
+ if (controller.signal.aborted) return;
+ setResults([]);
+ setStatus("error");
+ });
+ }, DEBOUNCE_MS);
+
+ return () => {
+ clearTimeout(timer);
+ controller.abort();
+ };
+ }, [query]);
+
+ const onQueryChange = (value: string) => {
+ setQuery(value);
+ setActiveIndex(0);
+ const q = value.trim();
+ if (q && !parseCoordinates(q)) {
+ setStatus("loading");
+ } else {
+ setResults([]);
+ setStatus(q ? "done" : "idle");
+ }
+ };
+
+ const list: SearchResult[] = trimmed
+ ? coord
+ ? [coord, ...results]
+ : results
+ : history;
+
+ const showEmpty =
+ Boolean(trimmed) && !coord && status !== "loading" && results.length === 0;
+ const showDropdown =
+ open && (list.length > 0 || status === "loading" || showEmpty);
+
+ const select = (result: SearchResult) => {
+ onSelect(result.lon, result.lat);
+ setHistory(pushSearchHistory(result));
+ skipNextFetchRef.current = true;
+ setQuery(result.label);
+ setOpen(false);
+ inputRef.current?.blur();
+ };
+
+ const onKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ setActiveIndex((index) => Math.min(list.length - 1, index + 1));
+ } else if (event.key === "ArrowUp") {
+ event.preventDefault();
+ setActiveIndex((index) => Math.max(0, index - 1));
+ } else if (event.key === "Enter") {
+ const target = list[activeIndex] ?? list[0] ?? coord;
+ if (target) {
+ event.preventDefault();
+ select(target);
+ }
+ } else if (event.key === "Escape") {
+ setOpen(false);
+ inputRef.current?.blur();
+ }
+ };
+
+ return (
+
+
+
+
+
+
onQueryChange(event.target.value)}
+ onFocus={() => setOpen(true)}
+ onBlur={() => setOpen(false)}
+ onKeyDown={onKeyDown}
+ placeholder="Search address or coordinates"
+ aria-label="Search address or coordinates"
+ className="min-w-0 flex-1 bg-transparent text-sm text-editor-fg-primary placeholder:text-editor-fg-tertiary focus:outline-none"
+ />
+ {query ? (
+
+ ) : !open ? (
+
+ /
+
+ ) : null}
+
+
+ {showDropdown ? (
+
event.preventDefault()}
+ className="absolute inset-x-0 top-full z-10 mt-2 overflow-hidden rounded-editor-sm border border-editor-border bg-editor-bg-base py-1 shadow-editor"
+ >
+ {status === "loading" ? (
+
Searching…
+ ) : showEmpty ? (
+
+ No matches for “{trimmed}”.
+
+ ) : (
+ <>
+ {!trimmed ? (
+
+ Recent
+
+ ) : null}
+ {list.map((result, index) => (
+
+ ))}
+ >
+ )}
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/map/MapSideControls.tsx b/src/components/map/MapSideControls.tsx
new file mode 100644
index 0000000..af77e36
--- /dev/null
+++ b/src/components/map/MapSideControls.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import { IconButton } from "@/components/ui/IconButton";
+import { CrosshairIcon } from "@/icons/CrosshairIcon";
+import { GlobeIcon } from "@/icons/GlobeIcon";
+import { MapIcon } from "@/icons/MapIcon";
+import { PatchIcon } from "@/icons/PatchIcon";
+import type { MapViewMode } from "@/types/map";
+
+type MapSideControlsProps = {
+ viewMode: MapViewMode;
+ onViewModeChange: (mode: MapViewMode) => void;
+ hasSelection: boolean;
+ onZoomToSelection: () => void;
+ showPatch: boolean;
+ onTogglePatch: () => void;
+};
+
+const ISLAND_CLASS =
+ "pointer-events-auto flex flex-col items-center gap-1.5 rounded-editor-sm " +
+ "border border-editor-border bg-editor-bg-base p-1.5 shadow-editor";
+
+// Mobile-only counterpart to the header nav controls. On narrow layouts
+// (<=900px, matching EditorShell's stacking breakpoint) the view toggle and the
+// crosshair/patch controls move out of the header and float against the right
+// edge of the map as two stacked "islands", mirroring the issue #49 mockup. The
+// view toggle renders as icon buttons the same size as the other controls.
+export function MapSideControls({
+ viewMode,
+ onViewModeChange,
+ hasSelection,
+ onZoomToSelection,
+ showPatch,
+ onTogglePatch,
+}: MapSideControlsProps) {
+ return (
+
+
+ onViewModeChange("2d")}
+ >
+
+
+ onViewModeChange("sphere")}
+ >
+
+
+
+ {hasSelection ? (
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/ui/IconButton.tsx b/src/components/ui/IconButton.tsx
index 00d5a54..8a484e4 100644
--- a/src/components/ui/IconButton.tsx
+++ b/src/components/ui/IconButton.tsx
@@ -10,6 +10,12 @@ type IconButtonProps = {
"aria-pressed"?: boolean;
/** When set, renders an anchor to this URL instead of a button. */
href?: string;
+ /**
+ * "plain" drops the border/background so the button reads as a bare icon —
+ * used inside the mobile floating islands, which already carry their own
+ * border and shadow.
+ */
+ variant?: "default" | "plain";
};
// Every IconButton renders inside the editor nav, so the editor sizing/colour
@@ -26,6 +32,16 @@ const ICON_BUTTON_PRESSED_CLASS =
"hover:text-accent hover:border-[color-mix(in_srgb,var(--accent)_45%,transparent)] " +
"hover:bg-[color-mix(in_srgb,var(--accent)_16%,transparent)]";
+// Borderless variant — no border or resting background.
+const ICON_BUTTON_PLAIN_CLASS =
+ "peer grid size-8 place-items-center rounded-editor-sm border border-transparent " +
+ "bg-transparent text-editor-fg-secondary transition-all duration-200 " +
+ "hover:bg-editor-bg-secondary hover:text-editor-fg-primary";
+
+const ICON_BUTTON_PLAIN_PRESSED_CLASS =
+ "bg-[color-mix(in_srgb,var(--accent)_16%,transparent)] text-accent " +
+ "hover:text-accent hover:bg-[color-mix(in_srgb,var(--accent)_16%,transparent)]";
+
// Appears below the button on hover or keyboard focus. Decorative only — the
// button keeps its aria-label so screen readers do not double-announce.
const TOOLTIP_CLASS =
@@ -47,11 +63,17 @@ export function IconButton({
"aria-controls": ariaControls,
"aria-pressed": ariaPressed,
href,
+ variant = "default",
}: IconButtonProps) {
const tooltipLabel = tooltip ?? ariaLabel;
+ const plain = variant === "plain";
const classes = [
- ICON_BUTTON_CLASS,
- ariaPressed ? ICON_BUTTON_PRESSED_CLASS : null,
+ plain ? ICON_BUTTON_PLAIN_CLASS : ICON_BUTTON_CLASS,
+ ariaPressed
+ ? plain
+ ? ICON_BUTTON_PLAIN_PRESSED_CLASS
+ : ICON_BUTTON_PRESSED_CLASS
+ : null,
className,
]
.filter(Boolean)
diff --git a/src/icons/GlobeIcon.tsx b/src/icons/GlobeIcon.tsx
new file mode 100644
index 0000000..baee5c6
--- /dev/null
+++ b/src/icons/GlobeIcon.tsx
@@ -0,0 +1,19 @@
+export function GlobeIcon() {
+ return (
+
+ );
+}
diff --git a/src/icons/MapIcon.tsx b/src/icons/MapIcon.tsx
new file mode 100644
index 0000000..9fd8fc0
--- /dev/null
+++ b/src/icons/MapIcon.tsx
@@ -0,0 +1,19 @@
+export function MapIcon() {
+ return (
+
+ );
+}
diff --git a/src/icons/SearchIcon.tsx b/src/icons/SearchIcon.tsx
new file mode 100644
index 0000000..fa18ce4
--- /dev/null
+++ b/src/icons/SearchIcon.tsx
@@ -0,0 +1,18 @@
+export function SearchIcon() {
+ return (
+
+ );
+}
diff --git a/src/lib/search/geocode.ts b/src/lib/search/geocode.ts
new file mode 100644
index 0000000..571e065
--- /dev/null
+++ b/src/lib/search/geocode.ts
@@ -0,0 +1,84 @@
+// Geocoding helpers for the map search box. Address lookups use Nominatim
+// (OpenStreetMap) — keyless and CORS-enabled, matching the keyless OpenFreeMap
+// tiles the map already uses. Raw coordinate input is parsed locally.
+
+export type SearchResult = {
+ id: string;
+ /** Primary line, e.g. "Jena". */
+ label: string;
+ /** Secondary greyed line, e.g. "Thuringia · Germany" or "Coordinates". */
+ detail: string;
+ lon: number;
+ lat: number;
+};
+
+// Accepts "lat, lon", "lat lon", or "lat; lon" with an optional leading sign.
+const COORD_PATTERN =
+ /^\s*(-?\d{1,2}(?:\.\d+)?)\s*[,;\s]\s*(-?\d{1,3}(?:\.\d+)?)\s*$/;
+
+export function parseCoordinates(query: string): SearchResult | null {
+ const match = query.match(COORD_PATTERN);
+ if (!match) return null;
+
+ const lat = Number(match[1]);
+ const lon = Number(match[2]);
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
+ if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
+
+ return {
+ id: `coord:${lat},${lon}`,
+ label: `${lat.toFixed(4)}, ${lon.toFixed(4)}`,
+ detail: "Coordinates",
+ lon,
+ lat,
+ };
+}
+
+type NominatimPlace = {
+ place_id: number;
+ lat: string;
+ lon: string;
+ name?: string;
+ display_name: string;
+};
+
+const NOMINATIM_URL = "https://nominatim.openstreetmap.org/search";
+
+function toResult(place: NominatimPlace): SearchResult {
+ const parts = place.display_name.split(", ");
+ const label = place.name?.trim() || parts[0] || place.display_name;
+ const rest = parts[0] === label ? parts.slice(1) : parts;
+ const detail = rest.slice(0, 3).join(" · ");
+
+ return {
+ id: `osm:${place.place_id}`,
+ label,
+ detail,
+ lon: Number(place.lon),
+ lat: Number(place.lat),
+ };
+}
+
+export async function geocodeAddress(
+ query: string,
+ signal?: AbortSignal,
+): Promise {
+ const params = new URLSearchParams({
+ q: query,
+ format: "jsonv2",
+ limit: "6",
+ });
+
+ const response = await fetch(`${NOMINATIM_URL}?${params.toString()}`, {
+ signal,
+ headers: { Accept: "application/json" },
+ });
+ if (!response.ok) {
+ throw new Error("Search request failed");
+ }
+
+ const data = (await response.json()) as NominatimPlace[];
+ return data
+ .map(toResult)
+ .filter((result) => Number.isFinite(result.lon) && Number.isFinite(result.lat));
+}
diff --git a/src/lib/search/history.ts b/src/lib/search/history.ts
new file mode 100644
index 0000000..6182798
--- /dev/null
+++ b/src/lib/search/history.ts
@@ -0,0 +1,32 @@
+// Recent map searches, persisted to localStorage so they survive reloads.
+
+import type { SearchResult } from "@/lib/search/geocode";
+
+const STORAGE_KEY = "earthprints:search-history";
+const MAX_ENTRIES = 5;
+
+export function loadSearchHistory(): SearchResult[] {
+ if (typeof window === "undefined") return [];
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw) as SearchResult[];
+ return Array.isArray(parsed) ? parsed.slice(0, MAX_ENTRIES) : [];
+ } catch {
+ return [];
+ }
+}
+
+export function pushSearchHistory(result: SearchResult): SearchResult[] {
+ if (typeof window === "undefined") return [];
+ const next = [
+ result,
+ ...loadSearchHistory().filter((entry) => entry.id !== result.id),
+ ].slice(0, MAX_ENTRIES);
+ try {
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
+ } catch {
+ // Ignore quota/serialization failures — history is best-effort.
+ }
+ return next;
+}