Skip to content

feat(geocoder): add native Photon provider - #1242

Open
TurtIeSocks wants to merge 2 commits into
mainfrom
feat/photon-geocoder
Open

feat(geocoder): add native Photon provider#1242
TurtIeSocks wants to merge 2 commits into
mainfrom
feat/photon-geocoder

Conversation

@TurtIeSocks

Copy link
Copy Markdown
Collaborator

Scope

Adds Photon as a first-class geocoding backend alongside Nominatim.

Photon returns GeoJSON rather than Nominatim's JSON, so it cannot be driven through node-geocoder's openstreetmap provider at all. server/src/services/photonGeocoder.js talks to it directly and returns entries in the same shape that provider produces, so formatter and the webhook resolvers cannot tell which backend answered.

Selected per webhook:

{
  "webhooks": [
    {
      "nominatimUrl": "http://127.0.0.1:2322",
      "geocoderProvider": "photon"
    }
  ]
}

geocoderProvider defaults to nominatim, so existing configs are untouched. nominatimUrl keeps its name and now holds the base URL for whichever provider is selected. Renaming it would break every deployment for no functional gain.

The existing Nominatim path is unchanged in behaviour. It moves into its own nominatimGeocoder function so the two branches read side by side, which is most of the diff in geocoder.js.

Why

Photon is a good fit for a self-hosted geocoder: the prebuilt index is a single download, it needs no PostGIS, and it runs comfortably on ARM. The one thing it cannot do is answer as Nominatim, which until now was the only shape ReactMap could consume.

The two parts of the mapping that are not mechanical

Photon reports a result's own label only in properties.name, and uses the hierarchy fields purely for what contains the result. Nominatim echoes that name into the matching address field, which is why searching "Denver" gives you address.city of Denver. Without reproducing that echo, a city search returns a result with no city in it, which is the commonest query there is. osm_key and osm_value decide which field the name belongs in, and a value Photon already supplied always wins.

formattedAddress is composed rather than read, since Photon has no display_name. Components go most specific first, absent parts are skipped, and no component repeats. Two cases drove that:

  • A postcode search puts the same value in the result name and the postcode field. Nominatim renders 62704, Leland Grove, Sangamon County, Illinois, United States with the value appearing once.
  • Where a component recurs further down the hierarchy, the broader one is kept. The Statue of Liberty sits in city "New York", state "New York", and dropping the state instead would strip the state out of a US address line.

Known gap

address.suburb and address.neighbourhood are always empty for Photon results. Photon's nearest field is district, which is a different OSM concept, and equating them would be an invention rather than a translation. Nominatim users are unaffected.

Unrelated to this change, but noticed while reading formatter: it templates on {{neighborhoods}} while node-geocoder emits neighbourhood, so that placeholder resolves to an empty string for every provider including the existing Nominatim one. Left alone here rather than folded into an unrelated PR.

Testing

server/test/geocoder.test.js, 12 cases, no network required:

  • the entry shape for a city and for a full street address
  • GeoJSON coordinate order, which is [lon, lat]
  • the city / town / village / hamlet echo, and a road taking its name as streetName
  • Photon's own city winning over the echoed name
  • formattedAddress composition, including both duplicate cases above
  • features without usable coordinates being dropped rather than emitted with NaN

The last case is the one worth reviewing. It builds node-geocoder's openstreetmap provider exactly as geocoder.js does, patch included, runs a Nominatim response for the same address through the real _formatResult, and asserts the two providers produce an identical entry. Not merely the same keys, the same values.

Ran locally on Node 22:

  • yarn lint passes
  • yarn build passes
  • yarn prettier passes
  • yarn config:check and yarn config:env produce no changes
  • node --test server/test/geocoder.test.js passes 12/12
  • yarn test also runs server/test/rocketPokemonFiltering.test.js, which fails on this machine with No database selected for React Map Tables. It fails the same way on a clean checkout of main, so it is a local database configuration gap rather than a regression from this change.

Not yet exercised against a live Photon instance through the dev server. Worth doing before merge if you have one to hand.

Photon speaks GeoJSON rather than Nominatim's JSON, so it cannot be driven
through node-geocoder's openstreetmap provider. This adds a small module that
talks to Photon directly and returns entries in the same shape that provider
produces, so formatter and the webhook resolvers cannot tell which backend
answered.

Selected per webhook with geocoderProvider, which defaults to nominatim. The
existing Nominatim path is unchanged in behaviour; it moves into its own
function so the two branches read side by side. nominatimUrl keeps its name and
holds the base URL for either provider, so no existing config needs editing.

Two parts of the mapping are not mechanical.

Photon reports a result's own label only in properties.name and uses the
hierarchy fields purely for what contains the result, while Nominatim echoes
that name into the matching address field. Without reproducing the echo,
searching for a city returns a result with no city in it. osm_key and osm_value
decide which field the name belongs in, and a value Photon already supplied
always wins.

formattedAddress is composed rather than read, since Photon has no display_name.
Components are joined most specific first, absent parts are skipped, and a
component is never repeated: a postcode search puts the same value in both the
name and the postcode field, and Nominatim renders it once. Where a component
recurs further down the hierarchy the broader one is kept, so a city sharing its
state's name does not cost the address line its state.

address.suburb and address.neighbourhood are always empty. Photon's nearest
field is district, which is a different OSM concept, and equating them would be
an invention rather than a translation.

The tests cover the mapping and assert that both providers emit the same entry
for the same address, using node-geocoder's own _formatResult with the same
patch geocoder.js applies.
@Mygod
Mygod requested a balanced review from Copilot August 13, 2026 17:24

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds Photon as a native geocoding backend while preserving Nominatim compatibility.

Changes:

  • Implements Photon GeoJSON requests and response mapping.
  • Adds per-webhook provider selection.
  • Adds provider parity and formatting tests.

Reviewed changes

Copilot reviewed 4 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
server/test/geocoder.test.js Tests Photon mapping and Nominatim parity.
server/src/services/photonGeocoder.js Implements Photon integration.
server/src/services/geocoder.js Dispatches requests by provider.
server/src/graphql/resolvers.js Passes webhook provider selection.
packages/types/lib/config.d.ts Defines the provider configuration option.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread server/src/services/photonGeocoder.js
The length check alone accepted a coordinate pair of [null, null] and emitted
an entry with a null latitude, which contradicts the filtering contract the
function documents. Checking the values rather than the shape also covers NaN,
Infinity, undefined and numeric strings.

The added test fails without the guard.
@Mygod

Mygod commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

The documented Photon configuration never reaches the provider dispatch because the setting is dropped when constructing the webhook service instance, leaving the feature unusable.

Review comment:

  • [P1] Preserve the configured provider on PoracleAPI — server/src/graphql/resolvers.js:200-200
    When a webhook sets geocoderProvider: 'photon', Event.webhookObj contains a PoracleAPI instance whose constructor copies nominatimUrl and addressFormat but never copies this new property. This argument is therefore undefined, so every request takes the Nominatim branch and Photon geocoding fails. Copy the provider onto the instance and cover the Poracle/resolver boundary as required by AGENTS.md:9.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants