Skip to content

Commit 4589b72

Browse files
committed
Zoom to changeset sooner, fix handling changesets without bbox
1 parent 8c8e9ea commit 4589b72

File tree

7 files changed

+179
-114
lines changed

7 files changed

+179
-114
lines changed
Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
export interface WritableSearchFormTabContext {
2-
setQuery(query: string, zoom?: boolean, smooth?: boolean, autofocus?: boolean): Promise<void>;
2+
setQuery(query: string, zoom?: boolean, smooth?: boolean, autofocus?: boolean): {
3+
/**
4+
* A promise that is resolved when the bbox of the result is known and has been zoomed to (may be much earlier
5+
* than when the result is finished loading, for example for changesets.
6+
*/
7+
zoomed: Promise<void>;
8+
9+
/**
10+
* A promise that is resolved when the result has finished loading and is displayed.
11+
*/
12+
loaded: Promise<void>;
13+
}
314
}
415

516
export type SearchFormTabContext = Readonly<WritableSearchFormTabContext>;

frontend/src/lib/components/leaflet-map/leaflet-map-components.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -425,9 +425,9 @@ function useHashHandler(map: Ref<Map>, client: Ref<ClientContext>, context: Faci
425425
const searchFormTab = context.components.searchFormTab;
426426
queryChangePromise = (async () => {
427427
if (!e.query)
428-
await searchFormTab?.setQuery("", false, false);
428+
await searchFormTab?.setQuery("", false, false).zoomed;
429429
else if (!await openSpecialQuery(e.query, context, e.zoom, smooth))
430-
await searchFormTab?.setQuery(e.query, e.zoom, smooth, autofocus);
430+
await searchFormTab?.setQuery(e.query, e.zoom, smooth, autofocus).zoomed;
431431
})();
432432
await queryChangePromise;
433433
})

frontend/src/lib/components/search-form/search-form-tab.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@
2727
}
2828
2929
const searchFormTabContext: WritableSearchFormTabContext = {
30-
async setQuery(query, zoom = false, smooth = true, autofocus = false): Promise<void> {
30+
setQuery(query, zoom = false, smooth = true, autofocus = false): { zoomed: Promise<void>; loaded: Promise<void> } {
3131
searchForm.value!.setSearchString(query);
3232
searchBoxContext.value.activateTab(`fm${context.id}-search-form-tab`, { expand: !!query, autofocus });
33-
await searchForm.value!.search(zoom, undefined, smooth);
33+
return searchForm.value!.search(zoom, undefined, smooth);
3434
}
3535
};
3636

frontend/src/lib/components/search-form/search-form.vue

Lines changed: 124 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
import Icon from "../ui/icon.vue";
33
import { find, getCurrentLanguage, getElevationForPoint, isSearchId, parseUrlQuery, loadDirectUrlQuery, type AnalyzedChangeset } from "facilmap-utils";
44
import { useToasts } from "../ui/toasts/toasts.vue";
5-
import type { FindOnMapResult, SearchResult } from "facilmap-types";
5+
import type { Bbox, FindOnMapResult, SearchResult } from "facilmap-types";
66
import SearchResults from "../search-results/search-results.vue";
7-
import { flyTo, getZoomDestinationForChangeset, getZoomDestinationForMapResult, getZoomDestinationForResults, getZoomDestinationForSearchResult, normalizeZoomDestination, openSpecialQuery } from "../../utils/zoom";
7+
import { flyTo, getZoomDestinationForBbox, getZoomDestinationForMapResult, getZoomDestinationForResults, getZoomDestinationForSearchResult, normalizeZoomDestination, openSpecialQuery, type ZoomDestination } from "../../utils/zoom";
88
import { Util } from "leaflet";
99
import { isMapResult } from "../../utils/search";
1010
import storage from "../../utils/storage";
@@ -43,10 +43,11 @@
4343
const mapResults = ref<FindOnMapResult[]>();
4444
const fileResult = ref<FileResultObject>();
4545
const changesetResult = ref<AnalyzedChangeset>();
46+
const changesetZoomDestination = ref<ZoomDestination>();
4647
4748
const zoomDestination = computed(() => {
48-
if (changesetResult.value) {
49-
return getZoomDestinationForChangeset(changesetResult.value);
49+
if (changesetZoomDestination.value) {
50+
return changesetZoomDestination.value;
5051
} else {
5152
return getZoomDestinationForResults([
5253
...(searchResults.value || []),
@@ -83,110 +84,128 @@
8384
void search(storage.autoZoom, storage.zoomToAll);
8485
}
8586
86-
async function search(zoom: boolean, zoomToAll?: boolean, smooth = true): Promise<void> {
87-
if (searchString.value != loadedSearchString.value) {
88-
reset();
89-
90-
if(searchString.value.trim() != "") {
91-
try {
92-
if (await openSpecialQuery(searchString.value, context, zoom)) {
93-
searchString.value = "";
94-
return;
95-
}
96-
97-
const query = searchString.value;
98-
loadingSearchString.value = searchString.value;
99-
loadingSearchAbort = new AbortController();
100-
const signal = loadingSearchAbort.signal;
101-
102-
const onProgress = throttle((p) => {
103-
if (!signal.aborted) {
104-
loadingSearchProgress.value = p * 100;
87+
function search(zoom: boolean, zoomToAll?: boolean, smooth = true): { zoomed: Promise<void>; loaded: Promise<void> } {
88+
let onZoomed: () => void;
89+
return {
90+
zoomed: new Promise((resolve) => {
91+
onZoomed = resolve;
92+
}),
93+
loaded: (async () => {
94+
if (searchString.value != loadedSearchString.value) {
95+
reset();
96+
97+
if(searchString.value.trim() != "") {
98+
try {
99+
if (await openSpecialQuery(searchString.value, context, zoom)) {
100+
searchString.value = "";
101+
return;
102+
}
103+
104+
const query = searchString.value;
105+
loadingSearchString.value = searchString.value;
106+
loadingSearchAbort = new AbortController();
107+
const signal = loadingSearchAbort.signal;
108+
109+
const onProgress = throttle((p) => {
110+
if (!signal.aborted) {
111+
loadingSearchProgress.value = p * 100;
112+
}
113+
}, 200);
114+
const loadedUrl = await mapContext.value.runOperation(async () => await loadDirectUrlQuery(query, {
115+
signal: loadingSearchAbort!.signal,
116+
onProgress,
117+
onBbox: (bbox: Bbox) => {
118+
if (!signal.aborted) {
119+
changesetZoomDestination.value = getZoomDestinationForBbox(bbox);
120+
if (zoom) {
121+
zoomToAllResults(smooth);
122+
}
123+
}
124+
onZoomed();
125+
}
126+
}));
127+
onProgress.flush();
128+
const url = parseUrlQuery(query);
129+
130+
const [newSearchResults, newMapResults] = await Promise.all([
131+
loadedUrl ? loadedUrl :
132+
url ? client.value.find({ query, loadUrls: true }) : (
133+
mapContext.value.runOperation(async () => await find(query, {
134+
lang: isLanguageExplicit() ? getCurrentLanguage() : undefined
135+
}))
136+
),
137+
client.value.mapData ? client.value.findOnMap({ query }) : undefined
138+
]);
139+
140+
if (signal.aborted)
141+
return;
142+
143+
loadingSearchString.value = undefined;
144+
loadingSearchProgress.value = undefined;
145+
loadingSearchAbort = undefined;
146+
loadedSearchString.value = query;
147+
148+
if(isSearchId(query) && Array.isArray(newSearchResults) && newSearchResults.length > 0 && newSearchResults[0].display_name) {
149+
searchString.value = newSearchResults[0].display_name;
150+
loadedSearchString.value = query;
151+
}
152+
153+
if (typeof newSearchResults == "string") {
154+
const parsed = await mapContext.value.runOperation(async () => await parseFiles([ new TextEncoder().encode(newSearchResults) ]));
155+
if (signal.aborted)
156+
return; // Another search has been started in the meantime
157+
fileResult.value = parsed;
158+
mapContext.value.components.searchResultsLayer.setResults(fileResult.value.features);
159+
} else if ("changeset" in newSearchResults) {
160+
changesetResult.value = newSearchResults;
161+
mapContext.value.components.changesetLayer.setChangeset(newSearchResults);
162+
} else {
163+
const reactiveResults = reactive(newSearchResults);
164+
searchResults.value = reactiveResults;
165+
mapContext.value.components.searchResultsLayer.setResults(newSearchResults);
166+
mapResults.value = newMapResults ?? undefined;
167+
168+
const points = newSearchResults.filter((res) => (res.lon && res.lat));
169+
if(points.length > 0) {
170+
(async () => {
171+
const elevations = await Promise.all(points.map(async (point) => {
172+
return await getElevationForPoint({ lat: Number(point.lat), lon: Number(point.lon) });
173+
}));
174+
elevations.forEach((elevation, i) => {
175+
reactiveResults[i].elevation = elevation;
176+
});
177+
})().catch((err) => {
178+
console.warn("Error fetching search result elevations", err);
179+
});
180+
}
181+
}
182+
} catch(err: any) {
183+
if (err.name !== "AbortError") {
184+
toasts.showErrorToast(`fm${context.id}-search-form-error`, () => i18n.t("search-form.search-error"), err);
185+
}
186+
return;
105187
}
106-
}, 200);
107-
const loadedUrl = await mapContext.value.runOperation(async () => await loadDirectUrlQuery(query, {
108-
signal: loadingSearchAbort!.signal,
109-
onProgress
110-
}));
111-
onProgress.flush();
112-
const url = parseUrlQuery(query);
113-
114-
const [newSearchResults, newMapResults] = await Promise.all([
115-
loadedUrl ? loadedUrl :
116-
url ? client.value.find({ query, loadUrls: true }) : (
117-
mapContext.value.runOperation(async () => await find(query, {
118-
lang: isLanguageExplicit() ? getCurrentLanguage() : undefined
119-
}))
120-
),
121-
client.value.mapData ? client.value.findOnMap({ query }) : undefined
122-
]);
123-
124-
if (signal.aborted)
125-
return;
126-
127-
loadingSearchString.value = undefined;
128-
loadingSearchProgress.value = undefined;
129-
loadingSearchAbort = undefined;
130-
loadedSearchString.value = query;
131-
132-
if(isSearchId(query) && Array.isArray(newSearchResults) && newSearchResults.length > 0 && newSearchResults[0].display_name) {
133-
searchString.value = newSearchResults[0].display_name;
134-
loadedSearchString.value = query;
135188
}
136-
137-
if (typeof newSearchResults == "string") {
138-
const parsed = await mapContext.value.runOperation(async () => await parseFiles([ new TextEncoder().encode(newSearchResults) ]));
139-
if (signal.aborted)
140-
return; // Another search has been started in the meantime
141-
fileResult.value = parsed;
142-
mapContext.value.components.searchResultsLayer.setResults(fileResult.value.features);
143-
} else if ("changeset" in newSearchResults) {
144-
changesetResult.value = newSearchResults;
145-
mapContext.value.components.changesetLayer.setChangeset(newSearchResults);
146-
} else {
147-
const reactiveResults = reactive(newSearchResults);
148-
searchResults.value = reactiveResults;
149-
mapContext.value.components.searchResultsLayer.setResults(newSearchResults);
150-
mapResults.value = newMapResults ?? undefined;
151-
152-
const points = newSearchResults.filter((res) => (res.lon && res.lat));
153-
if(points.length > 0) {
154-
(async () => {
155-
const elevations = await Promise.all(points.map(async (point) => {
156-
return await getElevationForPoint({ lat: Number(point.lat), lon: Number(point.lon) });
157-
}));
158-
elevations.forEach((elevation, i) => {
159-
reactiveResults[i].elevation = elevation;
160-
});
161-
})().catch((err) => {
162-
console.warn("Error fetching search result elevations", err);
163-
});
164-
}
165-
}
166-
} catch(err: any) {
167-
if (err.name !== "AbortError") {
168-
toasts.showErrorToast(`fm${context.id}-search-form-error`, () => i18n.t("search-form.search-error"), err);
169-
}
170-
return;
171189
}
172-
}
173-
}
174190
175-
if (zoomToAll || (zoomToAll == null && (searchResults.value?.length ?? 0) + (mapResults.value?.length ?? 0) > 1)) {
176-
if (zoom)
177-
zoomToAllResults(smooth);
178-
} else if (mapResults.value && mapResults.value.length > 0 && (mapResults.value[0].similarity == 1 || (!searchResults.value || searchResults.value.length == 0))) {
179-
mapContext.value.components.selectionHandler.setSelectedItems([{ type: mapResults.value[0].kind, id: mapResults.value[0].id }])
180-
if (zoom)
181-
zoomToResult(mapResults.value[0], smooth);
182-
} else if (searchResults.value && searchResults.value.length > 0) {
183-
mapContext.value.components.selectionHandler.setSelectedItems([{ type: "searchResult", result: searchResults.value[0], layerId }]);
184-
if (zoom)
185-
zoomToResult(searchResults.value[0], smooth);
186-
} else if (fileResult.value || changesetResult.value) {
187-
if (zoom)
188-
zoomToAllResults(smooth);
189-
}
191+
if (zoomToAll || (zoomToAll == null && (searchResults.value?.length ?? 0) + (mapResults.value?.length ?? 0) > 1)) {
192+
if (zoom)
193+
zoomToAllResults(smooth);
194+
} else if (mapResults.value && mapResults.value.length > 0 && (mapResults.value[0].similarity == 1 || (!searchResults.value || searchResults.value.length == 0))) {
195+
mapContext.value.components.selectionHandler.setSelectedItems([{ type: mapResults.value[0].kind, id: mapResults.value[0].id }])
196+
if (zoom)
197+
zoomToResult(mapResults.value[0], smooth);
198+
} else if (searchResults.value && searchResults.value.length > 0) {
199+
mapContext.value.components.selectionHandler.setSelectedItems([{ type: "searchResult", result: searchResults.value[0], layerId }]);
200+
if (zoom)
201+
zoomToResult(searchResults.value[0], smooth);
202+
} else if (fileResult.value) {
203+
if (zoom)
204+
zoomToAllResults(smooth);
205+
}
206+
// For changesets, we already zoomed above in onBbox()
207+
})()
208+
};
190209
}
191210
192211
function reset(): void {
@@ -202,6 +221,7 @@
202221
mapResults.value = undefined;
203222
fileResult.value = undefined;
204223
changesetResult.value = undefined;
224+
changesetZoomDestination.value = undefined;
205225
mapContext.value.components.searchResultsLayer.setResults([]);
206226
mapContext.value.components.changesetLayer.setChangeset(undefined);
207227
};

frontend/src/lib/utils/zoom.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { type LatLng, latLng, type LatLngBounds, latLngBounds, type Map } from "
22
import { fmToLeafletBbox, type HashQuery, type OverpassElement } from "facilmap-leaflet";
33
import type { RouteWithTrackPoints } from "facilmap-client";
44
import type { SelectedItem } from "./selection";
5-
import type { FindOnMapLine, FindOnMapMarker, FindOnMapResult, Line, Marker, SearchResult } from "facilmap-types";
5+
import type { Bbox, FindOnMapLine, FindOnMapMarker, FindOnMapResult, Line, Marker, SearchResult } from "facilmap-types";
66
import type { Geometry } from "geojson";
77
import { isMapResult } from "./search";
88
import { decodeLonLatUrl, decodeRouteQuery, encodeRouteQuery, normalizeLineName, normalizeMarkerName, parseRouteQuery, type AnalyzedChangeset, type ChangesetFeature } from "facilmap-utils";
@@ -88,6 +88,12 @@ export function getZoomDestinationForChangeset(changeset: AnalyzedChangeset): Zo
8888
};
8989
}
9090

91+
export function getZoomDestinationForBbox(bbox: Bbox): ZoomDestination {
92+
return {
93+
bounds: latLngBounds([[bbox.bottom, bbox.left], [bbox.top, bbox.right]])
94+
};
95+
}
96+
9197
export function getZoomDestinationForChangesetFeature(feature: ChangesetFeature): ZoomDestination | undefined {
9298
if (feature.type === "node") {
9399
if (feature.old && feature.new && (feature.old.lat !== feature.new.lat || feature.old.lon !== feature.new.lon)) {

utils/src/osm/changeset.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { intersectionBy, memoize, pullAllBy } from "lodash-es";
22
import { getFeatureAtTimestamp, getFixedChangesetDiff, getPreviousVersions, nodeListToSegments, segmentsToMultiPolyline, type NodeListSegment } from "./utils";
33
import * as OSM from "osm-api";
44
import type { DistributedPick } from "../types";
5-
import type { Point } from "facilmap-types";
5+
import type { Bbox, Point } from "facilmap-types";
66
import { scaleProgress, sendProgress, type OnProgress } from "../utils";
77

88
export type ChangesetFeature = {
@@ -25,11 +25,18 @@ export type AnalyzedChangeset = {
2525
features: ChangesetFeature[];
2626
};
2727

28-
export async function analyzeChangeset(changesetId: number, onProgress?: OnProgress): Promise<AnalyzedChangeset> {
28+
export async function analyzeChangeset(changesetId: number, onProgress?: OnProgress & { onBbox?: (bbox: Bbox) => void }): Promise<AnalyzedChangeset> {
2929
const nodeHistory = memoize((nodeId: number) => OSM.getFeatureHistory("node", nodeId));
3030
const wayHistory = memoize((wayId: number) => OSM.getFeatureHistory("way", wayId));
3131

3232
const changeset = await OSM.getChangeset(changesetId);
33+
34+
// Some old changesets (for example 123456) seem to have no bbox
35+
const hasBbox = changeset.min_lat != null && changeset.min_lon != null && changeset.max_lat != null && changeset.max_lon != null;
36+
if (hasBbox) {
37+
onProgress?.onBbox?.({ top: changeset.max_lat, left: changeset.min_lon, bottom: changeset.min_lat, right: changeset.max_lon });
38+
}
39+
3340
const timeBefore = new Date(changeset.created_at.getTime() - 1);
3441
sendProgress(onProgress, 0.01);
3542
const rawDiff = await OSM.getChangesetDiff(changesetId);
@@ -140,5 +147,26 @@ export async function analyzeChangeset(changesetId: number, onProgress?: OnProgr
140147
}
141148
});
142149

150+
if (!hasBbox) {
151+
const allNodes = features.flatMap((f) => {
152+
if (f.type === "node") {
153+
return [...f.old ? [f.old] : [], ...f.new ? [f.new] : []];
154+
} else if (f.type === "way") {
155+
return [...f.deleted, ...f.created, ...f.unchanged].flat();
156+
} else {
157+
return [];
158+
}
159+
});
160+
const allLats = allNodes.map((n) => n.lat);
161+
const allLons = allNodes.map((n) => n.lon);
162+
163+
changeset.min_lat = Math.min(...allLats);
164+
changeset.max_lat = Math.max(...allLats);
165+
changeset.min_lon = Math.min(...allLons);
166+
changeset.max_lon = Math.max(...allLons);
167+
168+
onProgress?.onBbox?.({ top: changeset.max_lat, left: changeset.min_lon, bottom: changeset.min_lat, right: changeset.max_lon });
169+
}
170+
143171
return { changeset, features };
144172
}

0 commit comments

Comments
 (0)