Skip to content

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

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

feat(geocoder): add native Photon provider#1242
TurtIeSocks wants to merge 4 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.

Event.webhookObj holds PoracleAPI instances rather than the raw webhook config,
and the constructor copies fields across one at a time. geocoderProvider was
not among them, so it was undefined by the time the resolvers read it and every
request took the Nominatim branch. Configuring Photon did nothing at all.

The mapping tests all passed because they exercised the response mapping and
never the config to instance to resolver path. Two tests now cover that
boundary: one asserting a configured provider survives construction alongside
nominatimUrl and addressFormat, and one asserting it stays undefined when
unset, which is what keeps existing configs on Nominatim.

Both fail without the constructor change.
@TurtIeSocks

Copy link
Copy Markdown
Collaborator Author

You are right, and the feature was dead on arrival. Fixed in f33185d.

PoracleAPI's constructor copies webhook fields across one at a time, and I never checked what Event.webhookObj actually held. It is Record<string, PoracleAPI> built at EventManager.js:53, not the raw config, so geocoderProvider was undefined by the time the resolvers read it and every request took the Nominatim branch.

The constructor now carries it alongside nominatimUrl and addressFormat.

On the boundary coverage: server/test/geocoder.test.js gains two cases that construct a PoracleAPI from a webhook config. One asserts a configured provider survives construction together with the other two values the resolvers pass to geocoder(), and one asserts it stays undefined when unset, which is what keeps every existing config on Nominatim. Both fail without the constructor change; I removed the line and re-ran to confirm rather than assuming.

Worth naming why the existing tests missed this: they all exercised the response mapping, so they were green while the config never reached it. Mapping correctness and plumbing are different claims and I had only tested one.

Still outstanding from the PR description: this has not been exercised against a live Photon instance through the dev server.

🤖 Addressed by Claude Code

@Mygod

Mygod commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

The provider plumbing works, but valid Photon locality hierarchy data is discarded. This produces incomplete formatted locations for addresses outside Photon's city layer.

Review comment:

  • [P2] Include Photon locality in the city fallback — server/src/services/photonGeocoder.js:152-155
    When Photon returns an address whose containing settlement is in its locality layer, such as a house in a hamlet, the response carries that name in properties.locality, not properties.city. This fallback never reads that documented Photon field, so city and formattedAddress omit the settlement and formats using {{city}} render a blank component in both forward searches and gym reverse geocoding. Include properties.locality in the fallback and add a Photon-shaped regression fixture.

Photon's address hierarchy runs city, district, locality, street. An address
whose containing settlement sits below the city layer, such as a house in a
hamlet, carries that name in properties.locality and has no city at all. The
fallback only read city, so those addresses lost their settlement from both the
city field and formattedAddress, and any format using {{city}} rendered a blank
component in forward search and in gym reverse geocoding alike.

city still wins where Photon sends both, matching its own hierarchy.

The local variable is now called settlement rather than locality, so it is not
mistaken for the Photon field it falls back to.

Two fixtures cover it: a house in a hamlet, and a response carrying both city
and locality. The first fails without the change.
@TurtIeSocks

Copy link
Copy Markdown
Collaborator Author

Correct, fixed in 38654d1. I had mapped Photon's hierarchy as though it stopped at city, so anything below that layer lost its settlement.

The fallback now reads properties.locality after the city, town, village and hamlet sources. city still wins where Photon sends both, matching Photon's own city > district > locality > street ordering. Since the field feeds both the entry and the composed address, a rural address gets its settlement back in city, in formattedAddress, and in any {{city}} format, on forward search and gym reverse geocoding alike.

The local variable is now settlement rather than locality, so it cannot be misread as the Photon field it falls back to.

Two fixtures, both Photon-shaped: a house in a hamlet carrying locality and no city, asserting the full formatted address rather than just the field; and a response carrying both, asserting the city is not demoted. The first fails without the change, which I confirmed by removing the fallback and re-running.

Still deliberately unmapped, so it is a decision rather than an oversight: properties.district. It is a subdivision within a city rather than a settlement, so it is not a fallback for an absent city, and mapping it onto suburb would equate two different OSM concepts. Happy to revisit if you read it differently.

🤖 Addressed by Claude Code

@Mygod

Mygod commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Photon provider plumbing works, but valid administrative and top-level results lose their own address-layer value during conversion, producing incomplete formatted searches.

Review comment:

  • [P2] Use Photon's address layer for self-reference — ReactMap/server/src/services/photonGeocoder.js:149-152
    For state/country results, or cities represented by administrative boundaries, Photon exposes the result's level in properties.type while osm_key/osm_value may be boundary/administrative. This gate therefore never copies the result's own name into state, country, or city; because Photon omits the result's own level from its containing hierarchy, formats such as {{state}}, {{country}} render with missing components. Use properties.type as a fallback for the corresponding entry field while retaining osm_value to distinguish town, village, and hamlet.

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