The UmimaClean Stack, Part 7: Leaflet, Pinpointing, and Geospatial Rules

Building an address picker people can actually use on a phone - why the marker is fixed and the map moves, react-leaflet hooks, the tile layer licensing corner, Haversine distance for route ordering, and a service area that is not a circle.

A 13-part series on the UmimaClean stack. 1 — AdonisJS · 2 — VineJS · 3 — Lucid · 4 — Inertia · 5 — Transformers · 6 — Transmit · 7 — Leaflet · 8 — Midtrans · 9 — Fonnte · 10 — ExcelJS · 11 — Frontend · 12 — Security · 13 — Testing

Two map problems in this app.

A customer has to tell the shop where their house is, on a phone, accurately enough for a van to find it. And staff need their stops in an order that is not arbitrary.

Neither needs a mapping platform. Both need one or two decisions to be right.

Why Leaflet

The reflex is Google Maps JavaScript API. It is excellent, and it comes with an API key, a billing account, and a per-load cost.

Leaflet is ~40 KB, MIT licensed, and needs neither. react-leaflet wraps it in components and hooks that behave like React instead of like an imperative library bolted onto it.

For “put a pin on a map and give me coordinates”, the Maps Platform is a large amount of capability, ongoing cost, and vendor account management for a feature that is a coordinate picker.

The Interaction Decision

This is the part worth taking from this post even if you never touch Leaflet.

The documented way to do a draggable pin is <Marker draggable>. It is the first example in every tutorial, it works, and it is the wrong interaction on a phone.

Dragging a small pin with your thumb means your thumb is on top of the exact thing you are trying to place. You lift your finger to check, discover you are one house off, and try again.

Every mapping app on your phone solves this the same way: the marker is fixed and the map moves. The pin sits dead centre, the map pans underneath, and the target is always visible because your thumb is somewhere else.

Implemented, the marker is not a Leaflet marker at all — it is a div outside the map:

{
	/* Fixed center marker */
}
;<div
	className="pointer-events-none absolute top-1/2 left-1/2 z-999"
	style={{ transform: 'translate(-50%, -100%)' }}
>
	<img
		src={markerIcon}
		srcSet={`${markerIcon2x} 2x`}
		alt="location pin"
		style={{ width: 25, height: 41 }}
	/>
</div>

Three details in there.

translate(-50%, -100%) places the pin’s tip at the centre, not its middle. A map pin points at something; centring the image would put the point about 20 pixels below the location it claims.

pointer-events-none lets drags pass through to the map. Without it, the pin is a dead zone in the middle of the map, which is precisely where people grab.

srcSet with the 2x asset keeps it sharp on the phones this is used on. Leaflet ships both files; using only the 1x is a blurry pin on every modern device.

Reading the position back is a react-leaflet hook:

function CenterWatcher({ onChange }: { onChange: (position: LatLng) => void }) {
	const map = useMapEvents({
		moveend: () => {
			onChange(map.getCenter())
		}
	})

	return null
}

A component that renders null and exists to subscribe to a map event. That is the react-leaflet idiom: useMap and useMapEvents only work inside <MapContainer>, so behaviour that needs the map instance becomes a child component with no output. It reads oddly the first time and it is the correct pattern.

moveend, not move. move fires continuously during a drag — dozens of state updates a second, each re-rendering the form. moveend fires once when the map settles. The position is only interesting when the user has stopped choosing it.

The reverse direction is a second null component:

function ChangeView({ center }: { center: LatLng }) {
	const map = useMap()

	useEffect(() => {
		map.setView(center)
	}, [map, center])

	return null
}

Which is what makes the GPS button work: it sets React state, and the map follows.

The GPS Button

const locateUser = useCallback(() => {
	if (!navigator.geolocation) {
		toast.error('Geolocation is not supported')
		return
	}

	navigator.geolocation.getCurrentPosition(
		({ coords }) => {
			onChange(latLng(coords.latitude, coords.longitude))
		},
		() => {
			toast.error('Tidak dapat mengambil lokasi Anda. Pastikan izin lokasi diaktifkan.')
		},
		{
			enableHighAccuracy: true,
			timeout: 10000
		}
	)
}, [onChange])

enableHighAccuracy: true asks for GPS rather than wifi triangulation. Slower and hungrier, and the difference between “your neighbourhood” and “your house” — which is the entire point of the feature.

timeout: 10000 because indoors, high accuracy sometimes never resolves. Ten seconds then an error the user can act on beats a spinner that never stops.

The error handler tells them what to do — check location permissions — rather than reporting that something failed.

Auto-locating Once

const autoLocated = useRef(false)

useEffect(() => {
	if (disableAutoLocation) return
	if (autoLocated.current) return

	autoLocated.current = true

	locateUser()
}, [disableAutoLocation, locateUser])

New address: locate automatically, because the user is almost certainly standing in the place they are pinning. That is the single biggest accuracy win available.

The useRef guard is the important bit. Without it, React 18+ Strict Mode’s double-effect in development fires two permission prompts, and any dependency change fires another. A ref survives re-renders and does not trigger one.

disableAutoLocation covers editing an existing address, where the saved position is what the user wants to see — jumping the map to wherever they happen to be standing would be actively wrong.

The Tile Layer, and a Licensing Corner

<LayersControl position="topright">
	<LayersControl.BaseLayer name="Default">
		<TileLayer attribution="Google Maps" url="https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}" />
	</LayersControl.BaseLayer>

	<LayersControl.BaseLayer checked name="Satellite">
		<TileLayer
			attribution="Google Maps Satellite"
			url="https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}"
		/>
	</LayersControl.BaseLayer>
</LayersControl>

Leaflet is the renderer; the imagery comes from a tile server, and that is a separate choice.

Why not OpenStreetMap. OSM coverage of Bandung’s residential side streets is patchy. The people using this are pinning a house on a small road in a dense neighbourhood, and a map missing their street is a map they cannot use.

Why satellite is checked. People recognise their own roof, their own carport, the shape of the block. They do not reliably recognise a street name on a road that may not be labelled. Defaulting to satellite measurably reduces “the driver could not find it” — and defaults are the whole design here, because most users never open the layer switcher.

The honest caveat: mt1.google.com/vt is Google’s internal tile endpoint, not the licensed Maps Platform. It works and it is not a licence. A deployment that cares about terms of service should be on a proper Maps key or a paid provider like Mapbox or MapTiler. I am flagging it rather than presenting it as a clever trick, because that is what it is — a corner cut.

Leaflet’s CSS is imported globally, which is easy to forget and produces a spectacularly broken map:

@import 'leaflet/dist/leaflet.css';

Miss it and the tiles render as a scattered mosaic. It looks like a data problem and it is a stylesheet.

Distance: Haversine

Server side, the geospatial work is small and deliberately unclever.

calculateDistanceInKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
  const earthRadiusKm = 6371
  const dLat = this.toRadians(lat2 - lat1)
  const dLon = this.toRadians(lng2 - lng1)

  const a =
    Math.sin(dLat / 2) ** 2 +
    Math.cos(this.toRadians(lat1)) * Math.cos(this.toRadians(lat2)) * Math.sin(dLon / 2) ** 2

  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))

  return Math.round(earthRadiusKm * c * 100) / 100
}

The Haversine formula: great-circle distance between two points on a sphere. No PostGIS, no external service, no dependency.

No PostGIS because the entire geospatial requirement is “how far is this from the shop” over a few dozen rows. PostGIS is the right answer for polygons, spatial joins, and indexed proximity queries at scale. Here it would be an extension to install, a column type to manage, and a deployment dependency, to replace fifteen lines of trigonometry.

Rounding to two decimals is presentational — staff see 3.42 km, and false precision on a straight-line estimate would be misleading.

Route ordering is a sort:

buildRoutePlanForOrders(orders: RouteOrder[], input: RoutePlanInput): RouteItem[] {
  return orders
    .map((order) => ({
      ...order,
      distanceKm: order.address
        ? this.calculateDistanceInKm(
            input.originLat,
            input.originLng,
            order.address.latitude,
            order.address.longitude
          )
        : Number.POSITIVE_INFINITY,
    }))
    .sort((a, b) => a.distanceKm - b.distanceKm)
}

POSITIVE_INFINITY for an order with no address is a small nice thing: walk-ins have no address, and rather than filtering them out or crashing, they sort to the bottom. The list stays complete and the sort stays total.

The docblock is honest about what this is not:

/**
 * The route is currently distance-based and does not consider
 * traffic, delivery priority, or route optimization algorithms.
 */

Two kilometres across a river with one bridge is not two kilometres. This is a useful ordering, not a correct one. A routing API would fix it, at the cost of a dependency and a per-call bill — and for one van in one city, “nearest first as the crow flies” beats “the order they were booked in” by enough to be worth having and not enough to pay for.

The Service Area Is Not a Circle

The obvious way to answer “do we deliver there?” is a radius. It is also wrong in a way that costs the business money in both directions.

Real coverage follows roads. The team goes much further north and east — the good roads out of the city — than south or west. A circle either refuses profitable work along the good roads, or accepts work down a road that takes 90 minutes.

So the limits are directional:

/**
 * Maximum distance in kilometres the team travels in each direction.
 * The limits are deliberately asymmetric: coverage reaches much further
 * north and east than south and west.
 */
const DIRECTIONAL_LIMITS_KM = {
	north: 30,
	south: 10,
	east: 30,
	west: 20
}

And the boundary between two directions is blended rather than stepped:

validateRadius(latitude: number, longitude: number): boolean {
  const latitudeOffset = latitude - shop.latitude
  const longitudeOffset = longitude - shop.longitude
  const totalOffset = Math.abs(latitudeOffset) + Math.abs(longitudeOffset)

  // The service center itself is always inside the area.
  if (totalOffset === 0) {
    return true
  }

  const verticalWeight = Math.abs(latitudeOffset) / totalOffset
  const horizontalWeight = Math.abs(longitudeOffset) / totalOffset

  const verticalLimit =
    latitudeOffset >= 0 ? DIRECTIONAL_LIMITS_KM.north : DIRECTIONAL_LIMITS_KM.south
  const horizontalLimit =
    longitudeOffset >= 0 ? DIRECTIONAL_LIMITS_KM.east : DIRECTIONAL_LIMITS_KM.west

  const maxAllowedDistanceKm = Math.sqrt(
    (verticalLimit * verticalWeight) ** 2 + (horizontalLimit * horizontalWeight) ** 2
  )

  const distanceKm = this.routeService.calculateDistanceInKm(
    shop.latitude, shop.longitude, latitude, longitude
  )

  return distanceKm <= maxAllowedDistanceKm
}

Reading it as a shape: due north gets the full 30 km. Due south gets 10. North-east gets a blend of the northern and eastern limits, weighted by how much of the offset is vertical versus horizontal. Roughly a quadrant-wise ellipse.

The blending is what makes it usable. Without it, two neighbours on the same street could get different answers because one is fractionally more north than east — a hard quadrant boundary produces a cliff, and a customer on the wrong side of it has been refused for no reason they can see.

The totalOffset === 0 guard is a division-by-zero check that also happens to be semantically right: the shop is always in its own service area.

One Source for the Shop’s Location

/**
 * Where the shop physically stands.
 *
 * Every distance the product measures starts here: the service-area check a
 * customer's address is tested against, and the route staff drive when they
 * collect and deliver. Both used to carry their own copy of these numbers,
 * which meant moving the shop was two edits in two unrelated files and a
 * silent bug if only one of them happened.
 */
export const shop = {
	latitude: -6.9555305,
	longitude: 107.6540353
} as const

Two duplicated constants, discovered because they were duplicated. The failure that would have produced — service area computed from the old address, routes from the new one — is silent: every distance is plausible, every ordering looks reasonable, and addresses near the edge get the wrong answer.

A constant used by two subsystems belongs to neither of them. It goes in config.

What I Would Change

The tile URL. The honest version is a licensed provider.

Haversine. A real routing API would produce a genuinely better van route. It is the one place a paid dependency would clearly pay for itself.

The service area is invisible to the customer. They pin an address and get told yes or no. Drawing the boundary on the map — a polygon they can see before choosing — would turn a rejection into information. That is a Leaflet <Polygon> and a shape derived from the same constants.

No reverse geocoding. Customers type a street address and pin a location, and nothing checks that the two agree. A reverse geocode could pre-fill the text from the pin, which is both less typing and less disagreement between the two fields the driver reads.

What I would keep without hesitation: the fixed centre marker, satellite by default, and directional limits. All three came from thinking about the person holding the phone rather than about the library’s documented API — which, on this part of the stack, is where nearly all of the value was.


Next: Part 8 — Midtrans: QRIS Payments End to End, where a four-line signature check is the entire security of the payment flow.

© 2026 r3p.dev. All rights reserved.