Skip to content

Commit c93327f

Browse files
feat(add-data): geocode delimited-text addresses at import time (#1604)
* feat(add-data): geocode delimited-text addresses at import time Adds an "Addresses (geocode)" import mode to the Delimited Text Layer panel alongside the existing coordinate-column mode: pick one or more columns to concatenate into an address and each row is geocoded through the project's configured provider. Matched rows become points stamped with geocode_provider/geocode_status; unmatched rows are kept as null-geometry features (geocode_status: "unmatched") instead of being silently dropped, so they stay visible and fixable in the attribute table. * style: auto-format (ruff + oxfmt) [pre-commit.ci] * fix(add-data): address CSV-geocode review findings - Distinguish a batch cancelled before any row completed from a completed run with zero matches, so the thrown error doesn't misreport "No rows could be geocoded" for a cancellation. - Persist geocodeSkippedEmpty/geocodeCancelled on the layer alongside the existing geocodeMatched/geocodeUnmatched metadata, so a cancelled or partially-skipped run isn't silently indistinguishable from a complete one after the fact. - Give the address-columns empty-state hint its own i18n string instead of reusing the "Retrieve columns" button label as body copy. - Apply the unmatched-rows attribute filter after addAndClose selects the new layer, not before. * fix(add-data): fail fast on a missing API key, style geocoded points - Check geocoderNeedsApiKey before starting the batch, mirroring GeocodeDialog's guard, instead of paying a full paced loop of requests that are all doomed to fail with a keyed provider that has no key configured. - Pass the matched-feature collection as createBaseLayer's vector option so a geocoded address layer gets the as-added palette color and point sizing (#1519) like every other point-producing import path, instead of always rendering flat DEFAULT_LAYER_STYLE. An all-unmatched run (nothing to draw) keeps the flat defaults, mirroring the coordinate-mode attribute-table branch. * fix(add-data): preserve row order and stop stale address columns - Build the geocoded FeatureCollection in original CSV row order instead of grouping every matched point before every unmatched row, so the attribute table lines up with the source file. matchedFeatures now feeds the as-added style inference on its own, not the mixed collection. - Give the address-columns checklist its own field list derived only from the retrieved header, instead of reusing the coordinate-mode Select's field options, which always carries non-blank "longitude"/ "latitude" defaults and both hid the "retrieve columns first" empty state and could offer columns the file doesn't have. - Clear delimitedTextAddressColumns whenever the header resets (new file, delimiter change, mode switch) so a selection can't outlive the header it was picked against, and drop any stale selection that still slips through right before building geocode requests. * style: auto-format (ruff + oxfmt) [pre-commit.ci] * fix(add-data): clear the stale unmatched attribute filter setAttributeFilter was only ever set to "unmatched", never cleared, so a prior geocode run that left the global filter on "unmatched" stayed active and hid every row of a later, fully-matched layer. Always set it (to "unmatched" or "") after addAndClose selects the new layer. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 03907ac commit c93327f

8 files changed

Lines changed: 493 additions & 70 deletions

File tree

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

Lines changed: 396 additions & 66 deletions
Large diffs are not rendered by default.

apps/geolibre-desktop/src/components/layout/add-data/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,5 @@ export type GpxLayerKind = "waypoints" | "tracks" | "trackPoints" | "routes" | "
2828
export type GeoRssMode = FeedMode;
2929
export type DelimitedTextMode = FeedMode;
3030
export type DelimitedTextDelimiter = "comma" | "tab" | "semicolon" | "pipe" | "custom";
31+
/** Whether the delimited-text source builds points from coordinate columns or by geocoding addresses. */
32+
export type DelimitedTextImportMode = "coordinates" | "addresses";

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@
283283
},
284284
"delimitedText": {
285285
"label": "Add Delimited Text Layer",
286-
"description": "Add a delimited text file or URL as a point layer using longitude and latitude fields, or as a non-spatial attribute table when no coordinates are available."
286+
"description": "Add a delimited text file or URL as a point layer using longitude and latitude fields, by geocoding an address column, or as a non-spatial attribute table when no coordinates are available."
287287
},
288288
"cad": {
289289
"label": "Add CAD (DXF/DWG) Layer",
@@ -579,7 +579,15 @@
579579
"crs": "Coordinate system",
580580
"crsPlaceholder": "e.g. EPSG:32643",
581581
"crsPresetLabel": "Common coordinate systems...",
582-
"crsHelp": "Leave blank if the coordinates are already longitude/latitude (WGS84). Otherwise enter the EPSG code of the coordinate columns and the points will be reprojected to WGS84. For a projected CRS, the longitude and latitude fields select the X (easting) and Y (northing) columns."
582+
"crsHelp": "Leave blank if the coordinates are already longitude/latitude (WGS84). Otherwise enter the EPSG code of the coordinate columns and the points will be reprojected to WGS84. For a projected CRS, the longitude and latitude fields select the X (easting) and Y (northing) columns.",
583+
"importMode": "Import as",
584+
"modeCoordinates": "Coordinates",
585+
"modeAddresses": "Addresses (geocode)",
586+
"addressColumns": "Address columns",
587+
"addressColumnsEmptyHint": "Retrieve columns first to select address columns.",
588+
"addressColumnsHint": "Select one or more columns to concatenate into the address sent to the geocoder (e.g. street, city, state).",
589+
"errorNoAddressColumns": "Select at least one address column.",
590+
"errorNoAddressesFound": "No rows had a value in the selected address column(s)."
583591
},
584592
"photos": {
585593
"defaultName": "Photos",
@@ -2743,6 +2751,7 @@
27432751
"summary": "Geocoded {{matched}} of {{total}} row(s); {{failed}} had no match.",
27442752
"failedRows": "Rows with no match: {{rows}}",
27452753
"noMatches": "No rows could be geocoded.",
2754+
"cancelledNoRows": "Cancelled before any rows were geocoded.",
27462755
"cancelled": "Cancelled. Added {{matched}} matched row(s).",
27472756
"error": "Error: {{message}}",
27482757
"reverseLookingUp": "Looking up address...",

docs/user-guide/adding-data.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ The **Add Data** menu is the main way to bring layers into GeoLibre. It groups s
1010
| --- | --- |
1111
| **Vector Layer** | Opens the Add Vector panel (backed by `maplibre-gl-vector`). Loads GeoJSON, GeoParquet, FlatGeobuf, zipped Shapefile, GeoPackage, KML/KMZ, GML, and other vector formats from a file or URL. |
1212
| **Raster Layer** | Opens the Add Raster panel (backed by `maplibre-gl-raster`). Loads GeoTIFF and Cloud-Optimized GeoTIFF (COG) from a file or URL. |
13-
| **Delimited Text Layer** | Loads CSV/TSV from a file or URL, using longitude and latitude columns to build point features. |
13+
| **Delimited Text Layer** | Loads CSV/TSV from a file or URL, using longitude and latitude columns to build point features, or by geocoding one or more address columns (see [Geocoding](data-integrations.md#geocoding)). |
1414
| **GPX Layer** | Loads a GPX file or URL and splits it into separate waypoint, track, and route layers. |
1515
| **MBTiles Layer** | Loads a local MBTiles tile archive (desktop app). |
1616

docs/user-guide/data-integrations.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,10 @@ GeoLibre can turn addresses into points and points into addresses. Both run thro
5656
| Tool | Where | What it does |
5757
| --- | --- | --- |
5858
| **Geocode Addresses** | Processing menu | Pick a CSV with an address column and geocode each row into a point layer. Each matched row keeps its original columns plus `geocode_lat`, `geocode_lon`, `geocode_display_name`, and `geocode_importance` (a match score). A per-run provider picker lets you switch backend for that batch. |
59+
| **Delimited Text Layer → Addresses** | Add Data | Geocode a CSV/TSV at import time instead of a separate step: choose "Addresses" as the import mode, pick one or more columns to concatenate into the address (e.g. street, city, state), and each row is geocoded through the project's configured provider. Matched rows become points; rows with no match are kept (not dropped) with `geocode_status: "unmatched"` so they stay visible and fixable in the attribute table. |
5960
| **Reverse Geocode** | Controls menu | A toggle. While on, click anywhere on the map to look up the address at that point, shown in a popup with a copy button. |
6061

61-
Both send coordinates or addresses to a third-party service, so the first time you enable Reverse Geocode (and whenever you run a batch) your data leaves your device for those requests. Reverse Geocode shows a one-time notice before it is first enabled.
62+
All three send coordinates or addresses to a third-party service, so the first time you enable Reverse Geocode (and whenever you run a batch, including at CSV import time) your data leaves your device for those requests. Reverse Geocode shows a one-time notice before it is first enabled.
6263

6364
### Providers
6465

packages/core/src/geocoding.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ export const GEOCODE_LAT_KEY = "geocode_lat";
4747
export const GEOCODE_LON_KEY = "geocode_lon";
4848
export const GEOCODE_DISPLAY_NAME_KEY = "geocode_display_name";
4949
export const GEOCODE_SCORE_KEY = "geocode_importance";
50+
/** Provider id that produced (or failed to produce) a match, when stamped. */
51+
export const GEOCODE_PROVIDER_KEY = "geocode_provider";
52+
/** Whether a row matched, when stamped. See {@link GeocodeStatus}. */
53+
export const GEOCODE_STATUS_KEY = "geocode_status";
5054

5155
/** Identifier of a selectable geocoding backend. */
5256
export type GeocodingProviderId = "nominatim" | "pelias" | "arcgis" | "mapbox" | "google";
@@ -175,6 +179,9 @@ export interface ReverseGeocodeDisplay {
175179
parts: Record<string, string>;
176180
}
177181

182+
/** Whether a row matched during batch geocoding, stamped as {@link GEOCODE_STATUS_KEY}. */
183+
export type GeocodeStatus = "matched" | "unmatched";
184+
178185
function coerceScore(value: number | string | undefined | null): number | null {
179186
if (value === undefined || value === null || value === "") return null;
180187
const num = Number(value);
@@ -265,10 +272,15 @@ function nominatimForwardResultToMatch(result: NominatimForwardResult): GeocodeM
265272
* against the original columns so an existing `geocode_lat` is not clobbered.
266273
* Geometry coordinates are `[lon, lat]`. Returns null when the match has no
267274
* finite coordinates.
275+
*
276+
* `extra.providerId`, when passed, also stamps `geocode_provider` and
277+
* `geocode_status: "matched"` (dropped when omitted, so the existing
278+
* `GeocodeDialog` call site keeps its current output unchanged).
268279
*/
269280
export function geocodeMatchToFeature(
270281
match: GeocodeMatch,
271282
originalRow: Record<string, string> = {},
283+
extra?: { providerId?: GeocodingProviderId },
272284
): Feature<Point> | null {
273285
if (!Number.isFinite(match.lat) || !Number.isFinite(match.lon)) return null;
274286

@@ -279,6 +291,10 @@ export function geocodeMatchToFeature(
279291
[GEOCODE_DISPLAY_NAME_KEY]: match.displayName ?? "",
280292
[GEOCODE_SCORE_KEY]: match.score,
281293
};
294+
if (extra?.providerId) {
295+
added[GEOCODE_PROVIDER_KEY] = extra.providerId;
296+
added[GEOCODE_STATUS_KEY] = "matched" satisfies GeocodeStatus;
297+
}
282298
for (const [key, value] of Object.entries(added)) {
283299
properties[uniqueKey(key, properties)] = value;
284300
}
@@ -290,6 +306,30 @@ export function geocodeMatchToFeature(
290306
};
291307
}
292308

309+
/**
310+
* Build a null-geometry Feature for a CSV row that could not be geocoded, so
311+
* it is kept (not silently dropped) alongside the matched point features.
312+
* Mirrors the null-geometry shape {@link parseDelimitedTextLayer} already uses
313+
* for a non-spatial attribute table. Stamps `geocode_status: "unmatched"` and
314+
* `geocode_provider`, de-duplicated against the original columns the same way
315+
* {@link geocodeMatchToFeature} does.
316+
*/
317+
export function unmatchedGeocodeFeature(
318+
originalRow: Record<string, string>,
319+
providerId: GeocodingProviderId,
320+
): Feature<null> {
321+
const properties: Record<string, unknown> = { ...originalRow };
322+
const added: Record<string, unknown> = {
323+
[GEOCODE_STATUS_KEY]: "unmatched" satisfies GeocodeStatus,
324+
[GEOCODE_PROVIDER_KEY]: providerId,
325+
};
326+
for (const [key, value] of Object.entries(added)) {
327+
properties[uniqueKey(key, properties)] = value;
328+
}
329+
330+
return { type: "Feature", geometry: null, properties };
331+
}
332+
293333
/**
294334
* Convert a Nominatim forward result into a point Feature. Retained as a thin
295335
* wrapper over {@link geocodeMatchToFeature} for callers and tests that work

packages/core/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ export {
6464
GEOCODE_LON_KEY,
6565
GEOCODE_DISPLAY_NAME_KEY,
6666
GEOCODE_SCORE_KEY,
67+
GEOCODE_PROVIDER_KEY,
68+
GEOCODE_STATUS_KEY,
6769
DEFAULT_GEOCODING_PROVIDER_ID,
6870
GEOCODING_PROVIDERS,
6971
getGeocoderConfig,
@@ -78,6 +80,7 @@ export {
7880
buildForwardGeocodeUrl,
7981
buildReverseGeocodeUrl,
8082
geocodeMatchToFeature,
83+
unmatchedGeocodeFeature,
8184
nominatimResultToFeature,
8285
nominatimReverseResultToDisplay,
8386
csvRowsToGeocodeRequests,
@@ -89,6 +92,7 @@ export {
8992
type GeocodingProviderId,
9093
type GeocodingPreferenceInput,
9194
type GeocodeMatch,
95+
type GeocodeStatus,
9296
type NominatimForwardResult,
9397
type NominatimReverseResult,
9498
type GeocodeRequest,

tests/geocoding.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
resolveGeocoderConfig,
2222
rowCap,
2323
shouldThrottle,
24+
unmatchedGeocodeFeature,
2425
type GeocoderConfig,
2526
type NominatimForwardResult,
2627
} from "@geolibre/core";
@@ -473,6 +474,42 @@ describe("geocodeMatchToFeature", () => {
473474
it("returns null for non-finite coordinates", () => {
474475
assert.equal(geocodeMatchToFeature({ lat: NaN, lon: 0, displayName: "", score: null }), null);
475476
});
477+
478+
it("stamps provider and matched status only when extra.providerId is passed", () => {
479+
const withoutExtra = geocodeMatchToFeature(
480+
{ lat: 48.85, lon: 2.35, displayName: "Paris", score: 0.9 },
481+
{ id: "1" },
482+
);
483+
assert.ok(withoutExtra);
484+
assert.equal(withoutExtra.properties?.geocode_provider, undefined);
485+
assert.equal(withoutExtra.properties?.geocode_status, undefined);
486+
487+
const withExtra = geocodeMatchToFeature(
488+
{ lat: 48.85, lon: 2.35, displayName: "Paris", score: 0.9 },
489+
{ id: "1" },
490+
{ providerId: "nominatim" },
491+
);
492+
assert.ok(withExtra);
493+
assert.equal(withExtra.properties?.geocode_provider, "nominatim");
494+
assert.equal(withExtra.properties?.geocode_status, "matched");
495+
});
496+
});
497+
498+
describe("unmatchedGeocodeFeature", () => {
499+
it("builds a null-geometry feature flagged unmatched, keeping the original row", () => {
500+
const feature = unmatchedGeocodeFeature({ id: "2", address: "nowhere" }, "nominatim");
501+
assert.equal(feature.geometry, null);
502+
assert.equal(feature.properties?.id, "2");
503+
assert.equal(feature.properties?.address, "nowhere");
504+
assert.equal(feature.properties?.geocode_status, "unmatched");
505+
assert.equal(feature.properties?.geocode_provider, "nominatim");
506+
});
507+
508+
it("de-duplicates against an existing geocode_status column instead of clobbering it", () => {
509+
const feature = unmatchedGeocodeFeature({ geocode_status: "original value" }, "mapbox");
510+
assert.equal(feature.properties?.geocode_status, "original value");
511+
assert.equal(feature.properties?.geocode_status_2, "unmatched");
512+
});
476513
});
477514

478515
describe("setGeocodingFetch", () => {

0 commit comments

Comments
 (0)