Skip to content

Commit 50b56d3

Browse files
committed
feat(photos): manual placement and drag for non-geotagged photos
When a single selected photo has no usable EXIF GPS, the Add Geotagged Photos dialog now offers a manual-placement workflow instead of a hard error. The photo is dropped at the current map center and a draggable pin lets the user fine-tune its position on the map before committing. - loadPhotosAtLocation builds a point layer for no-GPS photos at a given center, still reading EXIF metadata and thumbnails. - relocatePhotoFeatures moves the placed points as the pin is dragged. - MapController.startManualPlacement drops the draggable pin and a themed hint popup with a Done button; it lives outside React so it survives the dialog closing (the modal overlay would otherwise block dragging). Fixes #894
1 parent 44d7fa1 commit 50b56d3

6 files changed

Lines changed: 357 additions & 2 deletions

File tree

apps/geolibre-desktop/src/components/layout/add-data/sources/PhotosSource.tsx

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,47 @@
1+
import { useAppStore } from "@geolibre/core";
12
import { Button, Label } from "@geolibre/ui";
2-
import { Images } from "lucide-react";
3+
import { Images, MapPin } from "lucide-react";
34
import { useState } from "react";
45
import { useTranslation } from "react-i18next";
56
import {
67
type GeotaggedPhotoResult,
78
loadGeotaggedPhotos,
9+
loadPhotosAtLocation,
10+
relocatePhotoFeatures,
811
} from "../../../../lib/geotagged-photos";
912
import { pickImageFilesWithFallback } from "../../../../lib/tauri-io";
1013
import { createBaseLayer, errorMessage } from "../helpers";
1114
import { AddDataSourceForm, useAddDataSource } from "../shared";
1215

16+
/** Round a lng/lat for the placement prompt so it reads cleanly. */
17+
function formatCoordinate(value: number): string {
18+
return value.toFixed(4);
19+
}
20+
1321
/**
1422
* Add Data source that imports a set of geotagged photos as a point layer.
1523
* Each image is placed from its EXIF GPS coordinates with a thumbnail and EXIF
1624
* metadata stored on the feature; photos without GPS are skipped and reported.
25+
*
26+
* When a single photo carries no usable GPS, the dialog offers a manual
27+
* placement workflow instead of a hard error: the photo is dropped at the
28+
* current map center and a draggable pin lets the user fine-tune its position
29+
* on the map (issue #894).
1730
*/
1831
export function PhotosSource() {
1932
const { t } = useTranslation();
2033
// Captured once on mount so the "did the user rename it?" comparisons stay
2134
// stable even if the UI language changes while the dialog is open.
2235
const [defaultName] = useState(() => t("addData.photos.defaultName"));
2336
const source = useAddDataSource(defaultName);
37+
const updateLayer = useAppStore((s) => s.updateLayer);
2438
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
2539
const [summary, setSummary] = useState<GeotaggedPhotoResult | null>(null);
40+
// Set when a single photo had no GPS: holds the map center the photo would be
41+
// placed at, switching the dialog to the manual-placement prompt.
42+
const [manualCenter, setManualCenter] = useState<[number, number] | null>(
43+
null,
44+
);
2645

2746
const handleChoosePhotos = async () => {
2847
source.setError(null);
@@ -43,6 +62,16 @@ export function PhotosSource() {
4362

4463
const result = await loadGeotaggedPhotos(selectedFiles);
4564
if (result.located === 0) {
65+
// A single photo with no GPS pivots to manual placement instead of a hard
66+
// stop; multiple no-GPS photos still report the original error.
67+
if (selectedFiles.length === 1) {
68+
const center =
69+
source.shell.mapControllerRef.current?.readView().center ?? null;
70+
if (center) {
71+
setManualCenter([center[0], center[1]]);
72+
return;
73+
}
74+
}
4675
throw new Error(
4776
t("addData.photos.errorNoGps", { count: result.total }),
4877
);
@@ -70,6 +99,43 @@ export function PhotosSource() {
7099
setSummary(result);
71100
});
72101

102+
const handleManualPlace = source.runSubmit(async () => {
103+
if (!manualCenter) return;
104+
const name = source.layerName.trim() || defaultName;
105+
const result = await loadPhotosAtLocation(selectedFiles, manualCenter);
106+
const layer = {
107+
...createBaseLayer(
108+
name,
109+
"geojson",
110+
{ type: "geojson" },
111+
{
112+
sourceKind: "geotagged-photos",
113+
featureCount: result.located,
114+
skipped: result.skipped,
115+
withoutThumbnail: result.withoutThumbnail,
116+
total: result.total,
117+
manualPlacement: true,
118+
},
119+
),
120+
geojson: result.featureCollection,
121+
};
122+
source.shell.addLayer(layer, source.beforeLayer);
123+
// Hand the user a draggable pin on the map to fine-tune the position. It
124+
// lives outside React, so closing the dialog (below) does not cancel it;
125+
// each drag rewrites the layer's coordinates in the store.
126+
source.shell.mapControllerRef.current?.startManualPlacement(manualCenter, {
127+
hint: t("addData.photos.manualHint"),
128+
doneLabel: t("common.done"),
129+
onMove: (lngLat) =>
130+
updateLayer(layer.id, {
131+
geojson: relocatePhotoFeatures(result.featureCollection, lngLat),
132+
}),
133+
});
134+
// Close the dialog so the map (and the drag pin) become interactive; the
135+
// modal overlay would otherwise block dragging.
136+
source.shell.closeDialog();
137+
});
138+
73139
if (summary) {
74140
return (
75141
<div className="space-y-4">
@@ -99,6 +165,47 @@ export function PhotosSource() {
99165
);
100166
}
101167

168+
if (manualCenter) {
169+
return (
170+
<form className="space-y-4" onSubmit={handleManualPlace}>
171+
<div className="space-y-2 rounded-md border border-border p-3 text-sm">
172+
<p className="font-medium text-foreground">
173+
{t("addData.photos.manualPromptTitle")}
174+
</p>
175+
<p className="text-muted-foreground">
176+
{t("addData.photos.manualPromptBody")}
177+
</p>
178+
<p className="text-xs text-muted-foreground">
179+
{t("addData.photos.manualPromptCenter", {
180+
lng: formatCoordinate(manualCenter[0]),
181+
lat: formatCoordinate(manualCenter[1]),
182+
})}
183+
</p>
184+
</div>
185+
{source.error ? (
186+
<p className="text-sm text-destructive">{source.error}</p>
187+
) : null}
188+
<div className="flex justify-end gap-2">
189+
<Button
190+
type="button"
191+
variant="outline"
192+
onClick={() => {
193+
setManualCenter(null);
194+
source.setError(null);
195+
}}
196+
disabled={source.isSubmitting}
197+
>
198+
{t("common.cancel")}
199+
</Button>
200+
<Button type="submit" disabled={source.isSubmitting}>
201+
<MapPin className="mr-2 h-3.5 w-3.5" />
202+
{t("addData.photos.manualPlace")}
203+
</Button>
204+
</div>
205+
</form>
206+
);
207+
}
208+
102209
return (
103210
<AddDataSourceForm
104211
layerName={source.layerName}

apps/geolibre-desktop/src/i18n/locales/en.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,12 @@
325325
"skippedNote_one": "{{count}} photo was skipped (no GPS location).",
326326
"skippedNote_other": "{{count}} photos were skipped (no GPS location).",
327327
"noThumbnailNote_one": "{{count}} photo has no thumbnail (HEIC or unsupported format).",
328-
"noThumbnailNote_other": "{{count}} photos have no thumbnail (HEIC or unsupported format)."
328+
"noThumbnailNote_other": "{{count}} photos have no thumbnail (HEIC or unsupported format).",
329+
"manualPromptTitle": "No GPS location was found in this photo.",
330+
"manualPromptBody": "Place it at the current map center and drag it into position on the map.",
331+
"manualPromptCenter": "Map center: {{lng}}, {{lat}}",
332+
"manualPlace": "Place at map center",
333+
"manualHint": "Drag this pin to position the photo, then click Done."
329334
},
330335
"mbtiles": {
331336
"defaultName": "MBTiles Layer",

apps/geolibre-desktop/src/index.css

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1842,6 +1842,65 @@ body,
18421842
border-left-color: hsl(var(--popover));
18431843
}
18441844

1845+
/* The draggable photo-placement pin's hint popup (manual placement of a
1846+
non-geotagged photo). Themed to match the app's popover surface. */
1847+
.geolibre-placement-popup-root .maplibregl-popup-content {
1848+
background: hsl(var(--popover));
1849+
border: 1px solid hsl(var(--border));
1850+
border-radius: 0.5rem;
1851+
box-shadow:
1852+
0 10px 15px -3px rgb(0 0 0 / 0.18),
1853+
0 4px 6px -4px rgb(0 0 0 / 0.18);
1854+
color: hsl(var(--popover-foreground));
1855+
padding: 0.625rem 0.75rem;
1856+
}
1857+
1858+
.geolibre-placement-popup-root.maplibregl-popup-anchor-top .maplibregl-popup-tip,
1859+
.geolibre-placement-popup-root.maplibregl-popup-anchor-top-left
1860+
.maplibregl-popup-tip,
1861+
.geolibre-placement-popup-root.maplibregl-popup-anchor-top-right
1862+
.maplibregl-popup-tip {
1863+
border-bottom-color: hsl(var(--popover));
1864+
}
1865+
1866+
.geolibre-placement-popup-root.maplibregl-popup-anchor-bottom
1867+
.maplibregl-popup-tip,
1868+
.geolibre-placement-popup-root.maplibregl-popup-anchor-bottom-left
1869+
.maplibregl-popup-tip,
1870+
.geolibre-placement-popup-root.maplibregl-popup-anchor-bottom-right
1871+
.maplibregl-popup-tip {
1872+
border-top-color: hsl(var(--popover));
1873+
}
1874+
1875+
.geolibre-placement-popup {
1876+
display: flex;
1877+
flex-direction: column;
1878+
gap: 0.5rem;
1879+
max-width: 13rem;
1880+
}
1881+
1882+
.geolibre-placement-popup-hint {
1883+
margin: 0;
1884+
font-size: 0.8125rem;
1885+
line-height: 1.25rem;
1886+
color: hsl(var(--popover-foreground));
1887+
}
1888+
1889+
.geolibre-placement-popup-done {
1890+
align-self: flex-end;
1891+
border-radius: 0.375rem;
1892+
background: hsl(var(--primary));
1893+
color: hsl(var(--primary-foreground));
1894+
padding: 0.25rem 0.75rem;
1895+
font-size: 0.8125rem;
1896+
font-weight: 500;
1897+
cursor: pointer;
1898+
}
1899+
1900+
.geolibre-placement-popup-done:hover {
1901+
background: hsl(var(--primary) / 0.9);
1902+
}
1903+
18451904
.swipe-control-panel .swipe-control-select {
18461905
height: 28px;
18471906
line-height: 28px;

apps/geolibre-desktop/src/lib/geotagged-photos.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,3 +303,71 @@ export async function loadGeotaggedPhotos(
303303
withoutThumbnail,
304304
};
305305
}
306+
307+
/**
308+
* Build a point layer for photos that carry no usable GPS by placing every one
309+
* at `center` (typically the current map view center). EXIF metadata and inline
310+
* thumbnails are still read so a manually placed photo carries the same feature
311+
* properties as a GPS-located one; the caller then lets the user drag the point
312+
* into its final position.
313+
*
314+
* @param files - The image files to place. Anything the EXIF/thumbnail readers
315+
* cannot parse is still placed at the center with whatever could be read.
316+
* @param center - The `[lng, lat]` to drop every photo at.
317+
* @returns The point layer plus counts shaped like {@link loadGeotaggedPhotos}
318+
* (`skipped` is always 0 because manual placement never drops a photo).
319+
*/
320+
export async function loadPhotosAtLocation(
321+
files: File[],
322+
center: [number, number],
323+
): Promise<GeotaggedPhotoResult> {
324+
const features: Feature<Point>[] = [];
325+
let withoutThumbnail = 0;
326+
327+
for (const file of files) {
328+
const fileName = file.name || "photo";
329+
const exif = (await readPhotoExif(file)) ?? {};
330+
const thumbnail = await createThumbnailDataUrl(file, fileName);
331+
if (!thumbnail) withoutThumbnail += 1;
332+
333+
features.push({
334+
type: "Feature",
335+
geometry: {
336+
type: "Point",
337+
coordinates: [center[0], center[1]],
338+
},
339+
properties: buildPhotoProperties(fileName, exif, thumbnail),
340+
});
341+
}
342+
343+
return {
344+
featureCollection: { type: "FeatureCollection", features },
345+
total: files.length,
346+
located: features.length,
347+
skipped: 0,
348+
withoutThumbnail,
349+
};
350+
}
351+
352+
/**
353+
* Return a copy of a photo point collection with every feature moved to
354+
* `[lng, lat]`. Used while the user drags the manual-placement handle so the
355+
* rendered points follow the marker; feature properties (thumbnail, EXIF) are
356+
* preserved.
357+
*
358+
* @param collection - The photo point collection to relocate.
359+
* @param position - The `[lng, lat]` to move every feature to.
360+
* @returns A new collection with the same features at the new position.
361+
*/
362+
export function relocatePhotoFeatures(
363+
collection: FeatureCollection<Point>,
364+
[lng, lat]: [number, number],
365+
): FeatureCollection<Point> {
366+
return {
367+
type: "FeatureCollection",
368+
features: collection.features.map((feature) => ({
369+
...feature,
370+
geometry: { type: "Point", coordinates: [lng, lat] },
371+
})),
372+
};
373+
}

packages/map/src/map-controller.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -856,6 +856,86 @@ export class MapController {
856856
);
857857
}
858858

859+
/**
860+
* Drop a draggable pin at `lngLat` so the user can fine-tune the position of a
861+
* feature that was just placed without coordinates of its own (e.g. a
862+
* non-geotagged photo dropped at the map center). Every drag reports the new
863+
* position through `onMove`; clicking the pin's "Done" button (label supplied
864+
* by the caller so it stays translatable) removes the pin and runs `onDone`.
865+
*
866+
* The pin and its hint popup live outside the React tree, so the interaction
867+
* survives the dialog that started it being closed. Returns a disposer that
868+
* removes the pin early (e.g. if the caller needs to abort).
869+
*
870+
* @param lngLat - Where to drop the pin, as `[lng, lat]`.
871+
* @param options - Translated labels plus the move/done callbacks.
872+
* @returns A function that removes the pin and its popup.
873+
*/
874+
startManualPlacement(
875+
lngLat: [number, number],
876+
options: {
877+
/** Instruction shown in the pin's popup while it is draggable. */
878+
hint: string;
879+
/** Label for the button that finishes placement. */
880+
doneLabel: string;
881+
/** Called with `[lng, lat]` on every drag of the pin. */
882+
onMove: (lngLat: [number, number]) => void;
883+
/** Called once when the user clicks the "Done" button. */
884+
onDone?: () => void;
885+
},
886+
): () => void {
887+
const map = this.map;
888+
if (!map) return () => {};
889+
890+
const marker = new maplibregl.Marker({ draggable: true, color: "#ef4444" })
891+
.setLngLat(lngLat)
892+
.addTo(map);
893+
894+
const container = document.createElement("div");
895+
container.className = "geolibre-placement-popup";
896+
const hintText = document.createElement("p");
897+
hintText.className = "geolibre-placement-popup-hint";
898+
hintText.textContent = options.hint;
899+
const doneButton = document.createElement("button");
900+
doneButton.type = "button";
901+
doneButton.className = "geolibre-placement-popup-done";
902+
doneButton.textContent = options.doneLabel;
903+
container.append(hintText, doneButton);
904+
905+
const popup = new maplibregl.Popup({
906+
closeButton: false,
907+
closeOnClick: false,
908+
offset: 28,
909+
className: "geolibre-placement-popup-root",
910+
})
911+
.setLngLat(lngLat)
912+
.setDOMContent(container)
913+
.addTo(map);
914+
915+
let disposed = false;
916+
const handleDrag = () => {
917+
const next = marker.getLngLat();
918+
popup.setLngLat(next);
919+
options.onMove([next.lng, next.lat]);
920+
};
921+
const dispose = () => {
922+
if (disposed) return;
923+
disposed = true;
924+
marker.off("drag", handleDrag);
925+
doneButton.removeEventListener("click", handleDone);
926+
popup.remove();
927+
marker.remove();
928+
};
929+
const handleDone = () => {
930+
dispose();
931+
options.onDone?.();
932+
};
933+
934+
marker.on("drag", handleDrag);
935+
doneButton.addEventListener("click", handleDone);
936+
return dispose;
937+
}
938+
859939
/**
860940
* Imperatively animate the camera, for the programmatic scripting API.
861941
*

0 commit comments

Comments
 (0)