Skip to content

Repository files navigation

react-map-input

A form input whose UI is a map.

Render a country with its provinces and districts, hold a province to take all of it, tap to zoom in and pick individual districts. You get back a small, canonical value that submits like any other form field.

Ships with Türkiye — 81 provinces (il), 973 districts (ilçe).

npm install react-map-input @react-map-input/data-tr
import { GeoInput } from 'react-map-input';
import { turkey } from '@react-map-input/data-tr';

<GeoInput pack={turkey} name="serviceAreas" onChange={(value, summary) => console.log(value, summary)} />

The value

type GeoSelection = {
  regions: string[];     // fully-selected provinces — implies every district
  subregions: string[];  // individual districts inside partially-selected provinces
};

It is canonical by construction. Select every district of a province and it collapses to { regions: ['TR-34'], subregions: [] }; deselect one and it expands back into the other 38. One selection state always serialises to exactly one string, so equality is a string compare and form dirty-checking behaves.

onChange(value, summary) also hands you a derived view — per-province full / partial / none, resolved names, and counts — so you never recompute it.

Gestures

Gesture Result
Hold 500 ms on a province Select the whole province (a ring fills at the pointer)
Release before 500 ms Zoom into that province's districts
Drag more than 10 px Cancel the hold
Esc, or tap the sea Zoom back out
Tab / Enter / Space / arrows Move / open / select from the keyboard

Remap any of it with gestures={{ longPressMs, longPress, tap, moveTolerance }}.

Hovering names whatever is under the pointer — a province at country level, a district once you have zoomed in. Districts are labelled with their province too, because district names are not unique: Merkez alone identifies 51 different places, Merkez · Kırıkkale identifies one. During a hold the label names the shape being held, which on touch is the only label there is.

Touch is handled properly: the OS context menu is suppressed, text selection and page panning are disabled over the map, and the synthetic click after a completed hold is swallowed so it cannot fire the tap action as well.

Border levels

Three levels are drawn, each as an independent stroke-only layer painted over the fills rather than as a stroke on the shapes themselves — so each level's weight, colour and dash pattern is independent of the others and of the selection colour underneath.

Level Default style Shown
Group — geographic region (bölge) thick, slate always
Region — province (il) medium, solid always
Subregion — district (ilçe) thin, dashed once zoomed in

Groups are purely visual and never selectable. Their outlines are dissolved from the same topology as the provinces, so a geographic-region border always follows province borders exactly — it can never cut through the middle of one. The build refuses to emit a pack where a province belongs to no group or to two.

<GeoInput pack={turkey} borders={{ group: false }} />

Restyle any level through CSS custom properties:

.rmi-root {
  --rmi-group-border: #6b7a8d;
  --rmi-group-border-width: 2.25;
  --rmi-group-border-dash: none;
  --rmi-subregion-border-dash: 2.5 2;   /* and -region-* likewise */
}

Every stroke uses vector-effect: non-scaling-stroke, so weights stay constant as the map zooms.

Zoomed in

While a region is open, everything outside it switches to a muted palette — backdrop, fills and both border levels — and the opened region gets its own outline drawn last, so it reads as "you are here". Neighbouring regions stay clickable, so you can jump straight from one detail view to another.

Recessing them has to change the fill, not just the opacity: the land backdrop is painted in --rmi-land, the same colour as an unselected region, so fading one over the other composites --rmi-land onto --rmi-land and changes nothing. Opacity is left to do the one job it is good at — fading the selection colours, which genuinely differ from what is behind them. The result is three distinguishable states at once: the opened region at full strength, selected neighbours as faded blue, everything else recessive.

.rmi-root {
  --rmi-land-muted: #e8ecf1;
  --rmi-region-border-muted: #dfe5ec;
  --rmi-group-border-muted: #b9c4d0;
  --rmi-focus-outline: #3d4a5c;
  --rmi-focus-outline-width: 2.75;
}

Forms

Pass name and a hidden input carries the serialised value — plain HTML forms and FormData work with no adapter:

<form onSubmit={(e) => { e.preventDefault(); new FormData(e.currentTarget).get('serviceAreas'); }}>
  <GeoInput pack={turkey} name="serviceAreas" />
</form>

Controlled (value + onChange) and uncontrolled (defaultValue) both work. For a completely custom UI, useGeoSelection() exposes the whole model with no DOM.

Loading a saved value back

Pass a stored value straight back in — this is the edit-an-existing-record path, and it works on first paint:

<GeoInput pack={turkey} defaultValue={JSON.parse(row.serviceAreas)} />

Whole provinces render selected immediately, and a province with individually-picked districts renders as partial, both before any district data has loaded. Two details make that hold:

  • Codes are never discarded just because they cannot yet be verified. Normalisation keeps a district code whose province has not loaded — it can only be disproved once the real list arrives. A value restored from a database survives the first render intact.
  • The districts a value references are fetched automatically, one small chunk per province actually named, so summary reports Kadıköy rather than TR-34-kadikoy. Provinces selected whole need no fetch at all, since their count is already known.

Talking to a backend

Your backend has its own identifiers, or keys on names, or wants the parent stored beside the child. A codec is that translation, and it stays out of the component:

import { GeoInput, nestedCodec } from 'react-map-input';

<GeoInput pack={turkey} name="areas" codec={nestedCodec} />
[
  { "code": "TR-34", "name": "İstanbul", "all": true, "subregions": [] },
  { "code": "TR-06", "name": "Ankara", "all": false,
    "subregions": [{ "code": "TR-06-cankaya", "name": "Çankaya" }] }
]

Reading one back is the mirror image:

<GeoInput pack={turkey} defaultValue={nestedCodec.decode(fromBackend, { pack: turkey, index })} />

Prefer the nested shape for anything that leaves the browser. It is not just tidier — it is the only one that survives the trip:

  • Names stop being ambiguous. Merkez names 51 different districts in Türkiye. A flat list of district names is unusable; nested under its province, each one is unique again. decode falls back to names when codes are missing, so a payload that lost its codes still resolves — and resolves to the right Merkez.
  • Decoding needs no global scan. The parent travels with the child, so a stored value reads back without loading all 81 provinces to discover who owns a district.
  • A partially-selected province still appears, carrying only the districts actually chosen — usually exactly what a join table wants.
Codec Shape Use it for
compactCodec (default) { regions: [], subregions: [] } Smallest; needs no lookups
nestedCodec array of regions with nested subregions Anything crossing the wire
namesCodec { "İstanbul": "*", "Ankara": ["Çankaya"] } Backends keyed on names

Write your own by implementing SelectionCodec<T> — two functions, encode and decode.

Your own IDs

Packs carry arbitrary attributes, so a backend key is just another meta field. The Türkiye pack already exposes plateCode on provinces and districtId on districts:

const rows = (await resolveSelection(value, turkey)).subregions
  .map((d) => ({ districtId: d.meta.districtId, name: d.name }));

Getting the districts back out

A value is deliberately compact — one fully-selected province stands in for all of its districts — so value alone cannot tell you which 39 districts the user picked. Persisting to a join table or calling an API usually needs the flat list, and getting there means loading provinces that were never opened:

import { resolveSelection } from 'react-map-input';

const { regions, subregions, codes } = await resolveSelection(value, turkey);
// codes → ['TR-34-adalar', 'TR-34-arnavutkoy', …]

describeSelection(value, pack) gives a short human label instead (İstanbul, Ankara +3 more).

Validation

<GeoInput pack={turkey} name="areas" required limits={{ maxRegions: 3 }}
          onLimitExceeded={(attempted, limits) => toast(`At most ${limits.maxRegions}`)} />

required blocks submission while nothing is selected, through the browser's own constraint validation — no adapter. (It renders a visually-hidden text input rather than type="hidden", because hidden inputs are barred from validation entirely.)

Internationalisation

A pack localises its nouns through meta.labels — province/district, il/ilçe. The sentences around them come from messages:

import { GeoInput, trMessages } from 'react-map-input';

<GeoInput pack={turkey} messages={trMessages} />
// or override just one
<GeoInput pack={turkey} messages={{ back: () => 'Geri' }} />

The map itself has no visible text; this covers what screen readers announce.

Bulk selection

The pack carries the source data's attributes, so no extra lookup table is needed:

import { regionsWhere } from 'react-map-input';

regionsWhere(turkey, (r) => r.meta.region.en === 'Aegean');   // 8
regionsWhere(turkey, (r) => r.meta.isCoastal);                // 28
regionsWhere(turkey, (r) => r.meta.population > 1_000_000);   // 24

Props

Prop Description
pack The country data pack
value / defaultValue / onChange Controlled or uncontrolled selection
name / form / required Hidden-input form integration
codec / serialize Shape written to the hidden input (see backend section)
mode 'multi' (default) or 'single'
limits { maxRegions, maxSubregions }
disabled / readOnly / disabledRegions / disabledSubregions Constraints
gestures Gesture table (see above)
drilledRegion / onDrillIn / onDrillOut Control the drill-down externally
borders { group, region, subregion } — switch each border level on or off
messages Override the strings the accessible layer speaks (see i18n below)
onLimitExceeded Fires with the attempted value when limits refuses a change
showTooltip / renderTooltip Name popover on hover and during a hold (on by default)
theme 'light' / 'dark'; otherwise follows an ancestor data-theme, then the OS
zoomDurationMs / zoomPadding Zoom animation

How it works

Geometry is projected at build time, not in the browser, so the runtime ships with zero dependencies — no d3, no topojson, no projection maths. It renders <path d="…"> and animates a viewBox.

Provinces and districts live in one shared coordinate space, which is what makes drilling in a plain viewBox interpolation rather than a re-projection. Province outlines are produced by dissolving districts after simplification, so a province border is exactly the union of its districts' borders at every zoom level.

Districts load per province — the full country's districts are 527 KB, but you only ever fetch the one province you opened (median 5.6 KB, ~2 KB gzipped). The provinces layer is 59 KB gzipped.

Accessibility does not rely on interactive SVG, which screen readers handle inconsistently. The map is aria-hidden and a visually-hidden parallel list of real <button role="checkbox"> elements carries the semantics, focus order and keyboard interaction from the same state.

Other countries

Country packs live in this repo, one package per country, and a new one is a single pull request. packages/data/tr is the worked reference and @react-map-input/geopack is the toolkit its build is written against. See CONTRIBUTING.md.

packages/
  core/       react-map-input             MIT, zero runtime deps, no geodata
  geopack/    @react-map-input/geopack    MIT build-time toolkit
  data-tr/    @react-map-input/data-tr    ODbL, Türkiye
  data-xx/    …one package per country

Data licensing

Boundary coordinates are facts, and facts are thin ground for copyright — in the US, Feist means a pure list of them attracts none. But a database of them is different: the EU and UK grant a sui generis database right over the investment in compiling one, regardless of whether the individual facts are protectable. That right is the reason OpenStreetMap uses ODbL at all.

What ODbL actually asks for is modest:

  • Attribute. Credit the source wherever the map is shown. Each pack exposes the exact string as pack.meta.attribution.
  • Share alike the database. Publicly distributing a modified version of the data means offering that modified data under ODbL too.

It does not reach your application. Rendering a map from the data produces what ODbL calls a Produced Work, which needs the credit and nothing more — your own code stays yours. Nor does it reach this library: the core package is MIT and contains no geodata, and each country's data sits in its own package with its own LICENSE. Mixed licences inside one repository are ordinary; it is per-package licensing that matters, not per-repository.

Development

npm install
npm run build:data   # build the Türkiye pack (downloads geodata on first run)
npm run dev          # playground at http://localhost:5173
npm test

Licence

Code is MIT. The Türkiye geodata is a separate package under ODbL 1.0 — see @react-map-input/data-tr.

About

No description, website, or topics provided.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages