Skip to content
~sami-ullah

Concept project · runs in your browserTypeScript · React.js · Next.js · Leaflet

Location-Based Store Finder with PostGIS Demo

A store finder for 140 fictional Corner Mart branches around Lahore. Click the map, drag the pin or use your location, then search within a radius or for the nearest k stores. Filter by open now (in Pakistan time) and services, watch the row counts after each query stage, and read the PostGIS SQL that would answer the same search.

Read the case study →

Interactive demo

samiullah.tech/lab/store-finder-postgis
Search mode

Click the map or drag the blue pin to search there. Centre 31.5150, 74.3440.

14 stores within 3 km

  1. 140 stores
  2. 20 in bbox (index)
  3. 14 within 3 km
  4. 14 after filters
  5. 14 returned

equivalent PostGIS

SELECT s.id, s.name,
       ST_Distance(s.location, ST_SetSRID(ST_MakePoint(74.34400, 31.51500), 4326)::geography) AS distance_m
FROM stores s
WHERE ST_DWithin(s.location, ST_SetSRID(ST_MakePoint(74.34400, 31.51500), 4326)::geography, 3000)
ORDER BY s.location <-> ST_SetSRID(ST_MakePoint(74.34400, 31.51500), 4326)::geography
LIMIT 50;

ST_DWithin on geography uses the GiST index for the bounding-box step (the orange box), then rechecks the exact distance. The <-> operator orders the survivors.

## How it works

A radius search runs the same stages PostGIS does for ST_DWithin: a bounding box around the circle (the orange dashed box) cheaply discards most stores, then an exact haversine distance check removes the corners of the box, then the filters run and the survivors are sorted by distance.

Nearest-k mode mirrors ORDER BY location <-> point LIMIT k: stores are visited in distance order and the scan stops once k of them pass the filters, so there is no radius to guess.

Opening hours are intervals in minutes from local midnight, and an interval may end after 24:00 for shops open past midnight. "Open now" checks today's intervals and yesterday's late ones, using the time in Pakistan rather than your own time zone. Pick a preset time to test edge cases such as the Friday prayer break.

## View source

The code running in the window above, exactly as deployed.

app/lab/store-finder-postgis/stores.ts249 lines
/**
 * Store finder query engine. Mirrors what PostGIS does for a radius or nearest-store search:
 *   1. index prefilter by bounding box (what the GiST index answers),
 *   2. exact distance recheck (ST_DWithin on geography),
 *   3. attribute filters (services, opening hours in the store's time zone),
 *   4. order by distance and limit.
 * Store data is fictional: an invented brand with generated branches around Lahore.
 */
import { bboxAround, haversineM, inBBox, mulberry32, offsetM, round6, type BBox, type LngLat } from "@/components/lab/geo/geo";

export const SERVICES = ["pharmacy", "parking", "delivery", "atm"] as const;
export type Service = (typeof SERVICES)[number];

/** Minutes from local midnight. `close` may exceed 1440 for shifts that run past midnight. */
export type Interval = { open: number; close: number };
/** Index 0 = Sunday … 6 = Saturday, like PostgreSQL's EXTRACT(dow). */
export type WeekHours = Interval[][];

export type Store = {
  id: number;
  name: string;
  area: string;
  address: string;
  location: LngLat;
  services: Service[];
  hours: WeekHours;
  hoursLabel: string;
};

export const TIME_ZONE = "Asia/Karachi";

/* ------------------------------------------------------------------ */
/* Fixture generation                                                  */
/* ------------------------------------------------------------------ */

const AREAS: Array<{ name: string; at: LngLat; spread: number }> = [
  { name: "Gulberg", at: [74.344, 31.515], spread: 1400 },
  { name: "DHA", at: [74.405, 31.475], spread: 2200 },
  { name: "Model Town", at: [74.325, 31.483], spread: 1300 },
  { name: "Johar Town", at: [74.272, 31.469], spread: 1800 },
  { name: "Iqbal Town", at: [74.29, 31.505], spread: 1400 },
  { name: "Shadman", at: [74.33, 31.54], spread: 1000 },
  { name: "Cantt", at: [74.39, 31.525], spread: 1700 },
  { name: "Walled City", at: [74.315, 31.582], spread: 900 },
  { name: "Township", at: [74.31, 31.448], spread: 1500 },
  { name: "Wapda Town", at: [74.262, 31.432], spread: 1400 },
  { name: "Bahria Town", at: [74.18, 31.37], spread: 2200 },
  { name: "Shahdara", at: [74.285, 31.62], spread: 1500 },
];

const h = (hh: number, mm = 0) => hh * 60 + mm;
const PATTERNS: Array<{ label: string; week: WeekHours; weight: number }> = [
  { label: "Daily 09:00–22:00", week: Array.from({ length: 7 }, () => [{ open: h(9), close: h(22) }]), weight: 5 },
  {
    label: "08:00–02:00, Fri break 13:00–14:30",
    week: Array.from({ length: 7 }, (_, d) =>
      d === 5 ? [{ open: h(8), close: h(13) }, { open: h(14, 30), close: h(26) }] : [{ open: h(8), close: h(26) }],
    ),
    weight: 3,
  },
  { label: "Open 24 hours", week: Array.from({ length: 7 }, () => [{ open: 0, close: h(24) }]), weight: 1 },
  {
    label: "Mon–Sat 10:00–21:00, Sun closed",
    week: Array.from({ length: 7 }, (_, d) => (d === 0 ? [] : [{ open: h(10), close: h(21) }])),
    weight: 2,
  },
];

export function generateStores(count = 140, seed = 42): Store[] {
  const rnd = mulberry32(seed);
  const gauss = () => (rnd() + rnd() + rnd() - 1.5) / 1.5; // cheap bell curve in [-1, 1]
  const perArea = new Map<string, number>();
  const totalWeight = PATTERNS.reduce((s, p) => s + p.weight, 0);
  return Array.from({ length: count }, (_, i) => {
    const area = AREAS[Math.floor(rnd() * AREAS.length)]!;
    const n = (perArea.get(area.name) ?? 0) + 1;
    perArea.set(area.name, n);
    const location = round6(offsetM(area.at, gauss() * area.spread, gauss() * area.spread));
    let pick = rnd() * totalWeight;
    const pattern = PATTERNS.find((p) => (pick -= p.weight) < 0) ?? PATTERNS[0]!;
    const services = SERVICES.filter((_, k) => rnd() < [0.35, 0.55, 0.6, 0.3][k]!);
    return {
      id: 1001 + i,
      name: `Corner Mart ${area.name}${n > 1 ? ` ${n}` : ""}`,
      area: area.name,
      address: `Shop ${1 + Math.floor(rnd() * 80)}, Block ${"ABCDEFGHJK"[Math.floor(rnd() * 10)]}, ${area.name}, Lahore`,
      location,
      services,
      hours: pattern.week,
      hoursLabel: pattern.label,
    };
  });
}

export const AREA_PRESETS = AREAS.map((a) => ({ name: a.name, at: a.at }));
export const LAHORE: LngLat = [74.33, 31.5];

/* ------------------------------------------------------------------ */
/* Opening hours                                                       */
/* ------------------------------------------------------------------ */

export type Clock = { dow: number; minutes: number };

/** Wall-clock time in the stores' time zone, whatever the visitor's own time zone is. */
export function clockIn(date: Date, timeZone = TIME_ZONE): Clock {
  const parts = new Intl.DateTimeFormat("en-US", { timeZone, weekday: "short", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(date);
  const get = (t: string) => parts.find((p) => p.type === t)?.value ?? "0";
  const dow = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(get("weekday"));
  return { dow: Math.max(0, dow), minutes: Number(get("hour")) * 60 + Number(get("minute")) };
}

const fmt = (m: number) => `${String(Math.floor(m / 60) % 24).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;

/** Open now? Checks today's intervals and yesterday's intervals that run past midnight. */
export function openStatus(hours: WeekHours, c: Clock): { open: boolean; label: string } {
  const today = hours[c.dow] ?? [];
  const yesterday = hours[(c.dow + 6) % 7] ?? [];
  const cur = today.find((i) => c.minutes >= i.open && c.minutes < i.close) ?? yesterday.map((i) => ({ open: i.open - 1440, close: i.close - 1440 })).find((i) => c.minutes >= i.open && c.minutes < i.close);
  if (cur) return { open: true, label: cur.close - cur.open >= 1440 ? "Open 24 hours" : `Open · closes ${fmt(cur.close)}` };
  const later = today.find((i) => i.open > c.minutes);
  if (later) return { open: false, label: `Closed · opens ${fmt(later.open)}` };
  for (let k = 1; k <= 7; k++) {
    const next = hours[(c.dow + k) % 7]?.[0];
    if (next) return { open: false, label: `Closed · opens ${k === 1 ? "tomorrow" : ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][(c.dow + k) % 7]} ${fmt(next.open)}` };
  }
  return { open: false, label: "Closed" };
}

/* ------------------------------------------------------------------ */
/* Search                                                              */
/* ------------------------------------------------------------------ */

export type SearchParams = {
  center: LngLat;
  mode: "radius" | "nearest";
  radiusM: number;
  k: number;
  openNow: boolean;
  services: Service[];
  clock: Clock;
};

export type Hit = { store: Store; distanceM: number; status: { open: boolean; label: string } };

export type SearchResult = {
  hits: Hit[];
  bbox: BBox | null;
  /** Row counts after each stage, to show how much work the index saves. */
  stages: Array<{ label: string; rows: number }>;
};

export const RADIUS_LIMIT = 50;

export function search(stores: Store[], p: SearchParams): SearchResult {
  const matches = (s: Store) =>
    p.services.every((x) => s.services.includes(x)) && (!p.openNow || openStatus(s.hours, p.clock).open);
  const toHit = (store: Store): Hit => ({ store, distanceM: haversineM(p.center, store.location), status: openStatus(store.hours, p.clock) });

  if (p.mode === "radius") {
    const bbox = bboxAround(p.center, p.radiusM);
    const inBox = stores.filter((s) => inBBox(s.location, bbox));
    const within = inBox.map(toHit).filter((x) => x.distanceM <= p.radiusM);
    const filtered = within.filter((x) => matches(x.store));
    const hits = filtered.sort((a, b) => a.distanceM - b.distanceM).slice(0, RADIUS_LIMIT);
    return {
      hits,
      bbox,
      stages: [
        { label: "stores", rows: stores.length },
        { label: "in bbox (index)", rows: inBox.length },
        { label: `within ${p.radiusM / 1000} km`, rows: within.length },
        { label: "after filters", rows: filtered.length },
        { label: "returned", rows: hits.length },
      ],
    };
  }

  // KNN: the GiST index returns rows in distance order; the executor stops after k rows pass the filters.
  const ordered = stores.map(toHit).sort((a, b) => a.distanceM - b.distanceM);
  const hits: Hit[] = [];
  let visited = 0;
  for (const x of ordered) {
    visited++;
    if (matches(x.store)) hits.push(x);
    if (hits.length === p.k) break;
  }
  return {
    hits,
    bbox: null,
    stages: [
      { label: "stores", rows: stores.length },
      { label: "visited in distance order", rows: visited },
      { label: "returned", rows: hits.length },
    ],
  };
}

/* ------------------------------------------------------------------ */
/* Equivalent SQL                                                      */
/* ------------------------------------------------------------------ */

export function toSql(p: SearchParams): string {
  const pt = `ST_SetSRID(ST_MakePoint(${p.center[0].toFixed(5)}, ${p.center[1].toFixed(5)}), 4326)::geography`;
  const where: string[] = [];
  if (p.mode === "radius") where.push(`ST_DWithin(s.location, ${pt}, ${p.radiusM})`);
  if (p.services.length) where.push(`s.services @> ARRAY[${p.services.map((x) => `'${x}'`).join(", ")}]::text[]`);
  if (p.openNow)
    where.push(`EXISTS (
    SELECT 1 FROM opening_hours h
    WHERE h.store_id = s.id
      AND ((h.dow = local.dow AND local.t >= h.opens AND local.t < h.closes)
        OR (h.dow = (local.dow + 6) % 7 AND h.closes > interval '24 hours'
            AND local.t + interval '24 hours' < h.closes))
  )`);
  const local = p.openNow
    ? `\nCROSS JOIN LATERAL (
  SELECT EXTRACT(dow FROM now() AT TIME ZONE s.tz)::int AS dow,
         (now() AT TIME ZONE s.tz)::time::interval AS t
) local`
    : "";
  return `SELECT s.id, s.name,
       ST_Distance(s.location, ${pt}) AS distance_m
FROM stores s${local}
${where.length ? `WHERE ${where.join("\n  AND ")}\n` : ""}ORDER BY s.location <-> ${pt}
LIMIT ${p.mode === "radius" ? RADIUS_LIMIT : p.k};`;
}

export const SCHEMA_SQL = `CREATE EXTENSION IF NOT EXISTS postgis;

CREATE TABLE stores (
  id        bigint PRIMARY KEY,
  name      text NOT NULL,
  address   text NOT NULL,
  tz        text NOT NULL DEFAULT 'Asia/Karachi',
  services  text[] NOT NULL DEFAULT '{}',
  location  geography(Point, 4326) NOT NULL
);
CREATE INDEX stores_location_gix ON stores USING gist (location);
CREATE INDEX stores_services_gin ON stores USING gin (services);

-- One row per opening interval. closes may exceed 24h for shifts past midnight.
CREATE TABLE opening_hours (
  store_id  bigint NOT NULL REFERENCES stores(id) ON DELETE CASCADE,
  dow       smallint NOT NULL CHECK (dow BETWEEN 0 AND 6),  -- 0 = Sunday
  opens     interval NOT NULL,
  closes    interval NOT NULL CHECK (closes > opens)
);
CREATE INDEX opening_hours_store ON opening_hours (store_id, dow);`;
components/lab/geo/geo.ts190 lines
/**
 * Small, dependency-free geodesy helpers shared by the geospatial lab demos.
 *
 * Coordinates follow GeoJSON (RFC 7946): [longitude, latitude] in WGS84 degrees.
 * Distances use a spherical Earth. For field-sized polygons (a few hectares to a few
 * km²) the difference from an ellipsoidal calculation is well under 1 percent.
 */

export type LngLat = [lng: number, lat: number];

/** IUGG mean radius, used for distances (haversine). */
export const EARTH_MEAN_RADIUS_M = 6_371_008.8;
/** Authalic radius: the sphere with the same surface area as the WGS84 ellipsoid, used for areas. */
export const EARTH_AUTHALIC_RADIUS_M = 6_371_007.2;

export const M2_PER_HECTARE = 10_000;
export const M2_PER_ACRE = 4_046.856_422_4;

const rad = (d: number) => (d * Math.PI) / 180;
const deg = (r: number) => (r * 180) / Math.PI;

/** Great-circle distance in metres (haversine formula). */
export function haversineM(a: LngLat, b: LngLat): number {
  const dLat = rad(b[1] - a[1]);
  const dLng = rad(b[0] - a[0]);
  const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a[1])) * Math.cos(rad(b[1])) * Math.sin(dLng / 2) ** 2;
  return 2 * EARTH_MEAN_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h)));
}

/** Drop a repeated closing vertex so rings are stored "open" internally. */
export function openRing(ring: LngLat[]): LngLat[] {
  if (ring.length > 1) {
    const f = ring[0]!;
    const l = ring[ring.length - 1]!;
    if (f[0] === l[0] && f[1] === l[1]) return ring.slice(0, -1);
  }
  return ring;
}

/**
 * Signed spherical area of a ring in m² (positive = counter-clockwise).
 * Chamberlain & Duquette, "Some algorithms for polygons on a sphere" (JPL, 2007),
 * the same formula Turf.js and OpenLayers use.
 */
export function ringSignedAreaM2(ringIn: LngLat[]): number {
  const ring = openRing(ringIn);
  const n = ring.length;
  if (n < 3) return 0;
  let sum = 0;
  for (let i = 0; i < n; i++) {
    const lower = ring[i]!;
    const middle = ring[(i + 1) % n]!;
    const upper = ring[(i + 2) % n]!;
    sum += (rad(upper[0]) - rad(lower[0])) * Math.sin(rad(middle[1]));
  }
  return (-sum * EARTH_AUTHALIC_RADIUS_M * EARTH_AUTHALIC_RADIUS_M) / 2;
}

export const ringAreaM2 = (ring: LngLat[]) => Math.abs(ringSignedAreaM2(ring));

/** Perimeter of a closed ring (or length of a line when `closed` is false), in metres. */
export function ringPerimeterM(ringIn: LngLat[], closed = true): number {
  const ring = openRing(ringIn);
  let total = 0;
  const last = closed ? ring.length : ring.length - 1;
  for (let i = 0; i < last; i++) total += haversineM(ring[i]!, ring[(i + 1) % ring.length]!);
  return ring.length < 2 ? 0 : total;
}

/** RFC 7946 §3.1.6: exterior rings are counter-clockwise. */
export function toCounterClockwise(ring: LngLat[]): LngLat[] {
  return ringSignedAreaM2(ring) < 0 ? [...ring].reverse() : ring;
}

/* ------------------------------------------------------------------ */
/* Validity: self-intersection                                         */
/* ------------------------------------------------------------------ */

const orient = (a: LngLat, b: LngLat, c: LngLat) => {
  const v = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
  return Math.abs(v) < 1e-18 ? 0 : Math.sign(v);
};
const onSegment = (a: LngLat, b: LngLat, p: LngLat) =>
  Math.min(a[0], b[0]) <= p[0] && p[0] <= Math.max(a[0], b[0]) && Math.min(a[1], b[1]) <= p[1] && p[1] <= Math.max(a[1], b[1]);

export function segmentsIntersect(p1: LngLat, p2: LngLat, q1: LngLat, q2: LngLat): boolean {
  const o1 = orient(p1, p2, q1);
  const o2 = orient(p1, p2, q2);
  const o3 = orient(q1, q2, p1);
  const o4 = orient(q1, q2, p2);
  if (o1 !== o2 && o3 !== o4) return true;
  if (o1 === 0 && onSegment(p1, p2, q1)) return true;
  if (o2 === 0 && onSegment(p1, p2, q2)) return true;
  if (o3 === 0 && onSegment(q1, q2, p1)) return true;
  if (o4 === 0 && onSegment(q1, q2, p2)) return true;
  return false;
}

/**
 * Pairs of edge indexes that cross each other. Edge i runs from vertex i to vertex i+1.
 * Adjacent edges share a vertex by definition and are skipped. O(n²), which is fine
 * for hand-drawn fields (tens of vertices); a sweep line would be the next step.
 */
export function selfIntersections(ringIn: LngLat[]): Array<[number, number]> {
  const ring = openRing(ringIn);
  const n = ring.length;
  const out: Array<[number, number]> = [];
  if (n < 4) return out;
  for (let i = 0; i < n; i++) {
    for (let j = i + 2; j < n; j++) {
      if (i === 0 && j === n - 1) continue; // first and last edge share vertex 0
      if (segmentsIntersect(ring[i]!, ring[(i + 1) % n]!, ring[j]!, ring[(j + 1) % n]!)) out.push([i, j]);
    }
  }
  return out;
}

/* ------------------------------------------------------------------ */
/* Bounding boxes, centroids, offsets                                  */
/* ------------------------------------------------------------------ */

export type BBox = { minLng: number; minLat: number; maxLng: number; maxLat: number };

/**
 * Lat/lng box that fully contains a circle of `radiusM` around `center`.
 * Cheap prefilter before an exact distance check (what a GiST index does for ST_DWithin).
 */
export function bboxAround(center: LngLat, radiusM: number): BBox {
  const dLat = deg(radiusM / EARTH_MEAN_RADIUS_M);
  const dLng = deg(radiusM / (EARTH_MEAN_RADIUS_M * Math.cos(rad(center[1]))));
  return { minLng: center[0] - dLng, minLat: center[1] - dLat, maxLng: center[0] + dLng, maxLat: center[1] + dLat };
}

export const inBBox = (p: LngLat, b: BBox) => p[0] >= b.minLng && p[0] <= b.maxLng && p[1] >= b.minLat && p[1] <= b.maxLat;

export function ringBBox(ring: LngLat[]): BBox {
  const b: BBox = { minLng: Infinity, minLat: Infinity, maxLng: -Infinity, maxLat: -Infinity };
  for (const [x, y] of ring) {
    b.minLng = Math.min(b.minLng, x);
    b.maxLng = Math.max(b.maxLng, x);
    b.minLat = Math.min(b.minLat, y);
    b.maxLat = Math.max(b.maxLat, y);
  }
  return b;
}

/** Vertex average. Good enough for label placement on convex-ish fields. */
export function ringCenter(ring: LngLat[]): LngLat {
  const r = openRing(ring);
  const s = r.reduce<[number, number]>((a, p) => [a[0] + p[0], a[1] + p[1]], [0, 0]);
  return [s[0] / Math.max(1, r.length), s[1] / Math.max(1, r.length)];
}

/** Move a point by metres east/north (local flat approximation; fine below a few km). */
export function offsetM(p: LngLat, eastM: number, northM: number): LngLat {
  const dLat = deg(northM / EARTH_MEAN_RADIUS_M);
  const dLng = deg(eastM / (EARTH_MEAN_RADIUS_M * Math.cos(rad(p[1]))));
  return [p[0] + dLng, p[1] + dLat];
}

/** Build a ring from metre offsets around an origin. Used to author realistic sample fields. */
export const ringFromMetres = (origin: LngLat, pts: Array<[number, number]>): LngLat[] =>
  pts.map(([e, n]) => round6(offsetM(origin, e, n)));

export const round6 = (p: LngLat): LngLat => [Math.round(p[0] * 1e6) / 1e6, Math.round(p[1] * 1e6) / 1e6];

/* ------------------------------------------------------------------ */
/* Formatting                                                          */
/* ------------------------------------------------------------------ */

export function formatDistance(m: number): string {
  if (m < 1000) return `${Math.round(m)} m`;
  return `${(m / 1000).toFixed(m < 10_000 ? 2 : 1)} km`;
}

export const hectares = (m2: number) => m2 / M2_PER_HECTARE;
export const acres = (m2: number) => m2 / M2_PER_ACRE;

/** Deterministic PRNG (mulberry32) so seeded sample data is identical on server and client. */
export function mulberry32(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;
  };
}
app/lab/store-finder-postgis/Demo.tsx419 lines
"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import type * as Leaflet from "leaflet";
import { MAP_CLASS, useLeafletMap } from "@/components/lab/geo/useLeafletMap";
import { formatDistance, haversineM, type LngLat } from "@/components/lab/geo/geo";
import {
  AREA_PRESETS,
  LAHORE,
  SCHEMA_SQL,
  SERVICES,
  clockIn,
  generateStores,
  search,
  toSql,
  type Clock,
  type SearchParams,
  type Service,
} from "./stores";

const KEY = "lab:store-finder-postgis:v1";
const STORES = generateStores();
const toLatLng = (p: LngLat): [number, number] => [p[1], p[0]];

type Saved = Omit<SearchParams, "clock"> & { time: string };
const GULBERG = AREA_PRESETS[0]!.at;
const DEFAULTS: Saved = { center: GULBERG, mode: "radius", radiusM: 3000, k: 10, openNow: false, services: [], time: "now" };

const TIME_PRESETS: Array<{ id: string; label: string; clock?: Clock }> = [
  { id: "now", label: "Now (Pakistan time)" },
  { id: "fri1345", label: "Friday 13:45", clock: { dow: 5, minutes: 13 * 60 + 45 } },
  { id: "sun1100", label: "Sunday 11:00", clock: { dow: 0, minutes: 11 * 60 } },
  { id: "tue0130", label: "Tuesday 01:30", clock: { dow: 2, minutes: 90 } },
  { id: "wed2230", label: "Wednesday 22:30", clock: { dow: 3, minutes: 22 * 60 + 30 } },
];

const btn =
  "inline-flex items-center gap-1.5 rounded-md border border-border bg-bg-elev px-3 py-1.5 font-mono text-xs hover:border-accent hover:text-accent focus-visible:outline-2 focus-visible:outline-accent disabled:opacity-40";
const SERVICE_LABEL: Record<Service, string> = { pharmacy: "Pharmacy", parking: "Parking", delivery: "Home delivery", atm: "ATM" };

export default function Demo() {
  const { ref, ctx } = useLeafletMap({ center: [GULBERG[1], GULBERG[0]], zoom: 13 });
  const [p, setP] = useState<Saved>(DEFAULTS);
  const [loaded, setLoaded] = useState(false);
  const [nowClock, setNowClock] = useState<Clock>({ dow: 1, minutes: 12 * 60 });
  const [active, setActive] = useState<number | null>(null);
  const [tab, setTab] = useState<"query" | "schema">("query");
  const [showBox, setShowBox] = useState(true);
  const [geoMsg, setGeoMsg] = useState<string | null>(null);
  const [locating, setLocating] = useState(false);
  const markers = useRef(new Map<number, Leaflet.CircleMarker>());

  useEffect(() => {
    setNowClock(clockIn(new Date()));
    const t = setInterval(() => setNowClock(clockIn(new Date())), 60_000);
    try {
      const raw = localStorage.getItem(KEY);
      if (raw) setP({ ...DEFAULTS, ...(JSON.parse(raw) as Partial<Saved>) });
    } catch {
      /* ignore */
    }
    setLoaded(true);
    return () => clearInterval(t);
  }, []);

  useEffect(() => {
    if (!loaded) return;
    try {
      localStorage.setItem(KEY, JSON.stringify(p));
    } catch {
      /* ignore */
    }
  }, [p, loaded]);

  const clock = TIME_PRESETS.find((t) => t.id === p.time)?.clock ?? nowClock;
  const params: SearchParams = useMemo(() => ({ ...p, clock }), [p, clock]);
  const result = useMemo(() => search(STORES, params), [params]);
  const sql = useMemo(() => toSql(params), [params]);
  const hitIds = useMemo(() => new Set(result.hits.map((h) => h.store.id)), [result]);

  const setCenter = (c: LngLat, msg: string | null = null) => {
    setP((v) => ({ ...v, center: [Math.round(c[0] * 1e5) / 1e5, Math.round(c[1] * 1e5) / 1e5] }));
    setGeoMsg(msg);
    setActive(null);
  };

  /* ---------------- map: click to search ---------------- */
  useEffect(() => {
    if (!ctx) return;
    const onClick = (e: Leaflet.LeafletMouseEvent) => setCenter([e.latlng.lng, e.latlng.lat]);
    ctx.map.on("click", onClick);
    return () => {
      ctx.map.off("click", onClick);
    };
  }, [ctx]);

  /* ---------------- map: stores, centre, radius, bbox ---------------- */
  useEffect(() => {
    if (!ctx) return;
    const { L, map } = ctx;
    const g = L.layerGroup().addTo(map);
    markers.current.clear();
    for (const s of STORES) {
      const hit = hitIds.has(s.id);
      const open = result.hits.find((h) => h.store.id === s.id)?.status.open;
      const m = L.circleMarker(toLatLng(s.location), {
        radius: hit ? 7 : 3.5,
        color: "#111",
        weight: hit ? 1.5 : 0.5,
        fillColor: hit ? (open ? "#16a34a" : "#a1a1aa") : "#71717a",
        fillOpacity: hit ? 0.95 : 0.45,
        bubblingMouseEvents: false,
      }).addTo(g);
      m.bindTooltip(`${s.name}${hit ? "" : " (not in results)"}`);
      m.on("click", () => setActive(s.id));
      markers.current.set(s.id, m);
    }
    const pin = L.marker(toLatLng(p.center), {
      icon: L.divIcon({
        className: "",
        html: `<div style="width:18px;height:18px;border-radius:9999px;background:#2563eb;border:3px solid #fff;box-shadow:0 0 0 2px #2563eb55,0 1px 4px #0006"></div>`,
        iconSize: [18, 18],
        iconAnchor: [9, 9],
      }),
      draggable: true,
      title: "Search centre. Drag to move.",
      alt: "Search centre",
    }).addTo(g);
    pin.on("dragend", () => setCenter([pin.getLatLng().lng, pin.getLatLng().lat]));
    if (p.mode === "radius") {
      L.circle(toLatLng(p.center), { radius: p.radiusM, color: "#2563eb", weight: 1.5, fillOpacity: 0.06, interactive: false }).addTo(g);
      if (showBox && result.bbox)
        L.rectangle(
          [
            [result.bbox.minLat, result.bbox.minLng],
            [result.bbox.maxLat, result.bbox.maxLng],
          ],
          { color: "#f59e0b", weight: 1.5, dashArray: "6 5", fill: false, interactive: false },
        ).addTo(g);
    } else if (result.hits.length) {
      const far = result.hits[result.hits.length - 1]!.distanceM;
      L.circle(toLatLng(p.center), { radius: far, color: "#2563eb", weight: 1, dashArray: "3 5", fillOpacity: 0.03, interactive: false }).addTo(g);
    }
    return () => {
      g.remove();
    };
  }, [ctx, result, hitIds, p.center, p.mode, p.radiusM, showBox]);

  /* Highlight the active store. */
  useEffect(() => {
    if (!ctx || active === null) return;
    const m = markers.current.get(active);
    if (!m) return;
    m.setStyle({ color: "#2563eb", weight: 4 }).bringToFront();
    m.openTooltip();
    return () => {
      m.setStyle({ color: "#111", weight: 1.5 });
      m.closeTooltip();
    };
  }, [ctx, active, result]);

  /* Keep the search area in view when the centre or radius changes from the controls. */
  useEffect(() => {
    if (!ctx) return;
    if (p.mode === "radius") {
      const L = ctx.L;
      const bounds = L.latLng(toLatLng(p.center)).toBounds(p.radiusM * 2.2);
      if (!ctx.map.getBounds().contains(bounds)) ctx.map.fitBounds(bounds);
    }
  }, [ctx, p.center, p.radiusM, p.mode]);

  function locate() {
    if (!("geolocation" in navigator)) {
      setGeoMsg("This browser has no geolocation. Click the map or pick an area instead.");
      return;
    }
    setLocating(true);
    navigator.geolocation.getCurrentPosition(
      (pos) => {
        setLocating(false);
        const here: LngLat = [pos.coords.longitude, pos.coords.latitude];
        const d = haversineM(here, LAHORE);
        if (d > 60_000) {
          setP((v) => ({ ...v, mode: "nearest", center: here }));
          setGeoMsg(`You are ${formatDistance(d)} from Lahore, where all demo stores are. Switched to "nearest" so you still get results.`);
          ctx?.map.setView(toLatLng(here), 6);
        } else {
          setCenter(here, `Using your location (±${Math.round(pos.coords.accuracy)} m).`);
          ctx?.map.setView(toLatLng(here), 14);
        }
      },
      (err) => {
        setLocating(false);
        setGeoMsg(
          err.code === err.PERMISSION_DENIED
            ? "Location permission denied. Click the map or pick an area to search instead."
            : "Could not get your location. Click the map or pick an area instead.",
        );
      },
      { enableHighAccuracy: false, timeout: 8000, maximumAge: 300_000 },
    );
  }

  function focusStore(id: number) {
    setActive(id);
    const s = STORES.find((x) => x.id === id);
    if (s && ctx) ctx.map.panTo(toLatLng(s.location));
  }

  const toggleService = (s: Service) =>
    setP((v) => ({ ...v, services: v.services.includes(s) ? v.services.filter((x) => x !== s) : [...v.services, s] }));

  return (
    <div>
      <div className="flex flex-wrap items-end gap-3 border-b border-border p-3 text-xs">
        <button type="button" className={`${btn} border-accent text-accent`} onClick={locate} disabled={locating}>
          {locating ? "Locating…" : "◎ Near me"}
        </button>
        <label className="block">
          <span className="text-fg-muted">Jump to area</span>
          <select
            className="mt-1 block rounded-md border border-border bg-bg-elev px-2 py-1.5"
            value=""
            onChange={(e) => {
              const a = AREA_PRESETS.find((x) => x.name === e.target.value);
              if (a) {
                setCenter(a.at, `Searching around ${a.name}.`);
                ctx?.map.setView(toLatLng(a.at), 13);
              }
            }}
          >
            <option value="">Choose…</option>
            {AREA_PRESETS.map((a) => (
              <option key={a.name}>{a.name}</option>
            ))}
          </select>
        </label>
        <fieldset className="flex rounded-md border border-border">
          <legend className="sr-only">Search mode</legend>
          {(["radius", "nearest"] as const).map((m) => (
            <label key={m} className={`cursor-pointer px-3 py-1.5 font-mono ${p.mode === m ? "bg-accent text-accent-fg" : "hover:text-accent"}`}>
              <input type="radio" name="mode" className="sr-only" checked={p.mode === m} onChange={() => setP((v) => ({ ...v, mode: m }))} />
              {m === "radius" ? "within radius" : "nearest k"}
            </label>
          ))}
        </fieldset>
        {p.mode === "radius" ? (
          <label className="block">
            <span className="text-fg-muted">Radius</span>
            <select
              className="mt-1 block rounded-md border border-border bg-bg-elev px-2 py-1.5"
              value={p.radiusM}
              onChange={(e) => setP((v) => ({ ...v, radiusM: Number(e.target.value) }))}
            >
              {[1000, 2000, 3000, 5000, 10000, 25000].map((r) => (
                <option key={r} value={r}>
                  {r / 1000} km
                </option>
              ))}
            </select>
          </label>
        ) : (
          <label className="block">
            <span className="text-fg-muted">k</span>
            <select className="mt-1 block rounded-md border border-border bg-bg-elev px-2 py-1.5" value={p.k} onChange={(e) => setP((v) => ({ ...v, k: Number(e.target.value) }))}>
              {[5, 10, 20].map((k) => (
                <option key={k}>{k}</option>
              ))}
            </select>
          </label>
        )}
        <label className="block">
          <span className="text-fg-muted">Time</span>
          <select className="mt-1 block rounded-md border border-border bg-bg-elev px-2 py-1.5" value={p.time} onChange={(e) => setP((v) => ({ ...v, time: e.target.value }))}>
            {TIME_PRESETS.map((t) => (
              <option key={t.id} value={t.id}>
                {t.label}
              </option>
            ))}
          </select>
        </label>
        <button type="button" className={`${btn} ml-auto`} onClick={() => { setP(DEFAULTS); setActive(null); setGeoMsg(null); ctx?.map.setView(toLatLng(GULBERG), 13); }}>
          Reset demo
        </button>
      </div>

      <div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-border px-3 py-2 text-xs">
        <label className="flex items-center gap-1.5">
          <input type="checkbox" checked={p.openNow} onChange={(e) => setP((v) => ({ ...v, openNow: e.target.checked }))} className="accent-[var(--accent)]" />
          Open now
        </label>
        {SERVICES.map((s) => (
          <label key={s} className="flex items-center gap-1.5">
            <input type="checkbox" checked={p.services.includes(s)} onChange={() => toggleService(s)} className="accent-[var(--accent)]" />
            {SERVICE_LABEL[s]}
          </label>
        ))}
        {p.mode === "radius" && (
          <label className="ml-auto flex items-center gap-1.5 text-fg-muted">
            <input type="checkbox" checked={showBox} onChange={(e) => setShowBox(e.target.checked)} className="accent-[var(--accent)]" />
            show bbox prefilter
          </label>
        )}
      </div>

      <div className="grid lg:grid-cols-[1fr_24rem]">
        <div className="min-w-0">
          <div ref={ref} className={`${MAP_CLASS} h-[380px] sm:h-[480px]`} role="application" aria-label="Store map. Click to set the search centre. The result list is a text alternative." />
          <p className="border-t border-border px-3 py-2 font-mono text-[11px] text-fg-muted">
            Click the map or drag the blue pin to search there. Centre {p.center[1].toFixed(4)}, {p.center[0].toFixed(4)}.
          </p>
        </div>

        <section aria-labelledby="res-h" className="border-t border-border bg-bg-elev lg:border-t-0 lg:border-l">
          <div className="border-b border-border p-3">
            <h3 id="res-h" className="font-mono text-sm font-semibold">
              {result.hits.length} store{result.hits.length === 1 ? "" : "s"}{" "}
              <span className="font-normal text-fg-muted">{p.mode === "radius" ? `within ${p.radiusM / 1000} km` : `nearest`}</span>
            </h3>
            <ol className="mt-2 flex flex-wrap items-center gap-1 font-mono text-[10px] text-fg-muted" aria-label="Rows after each query stage">
              {result.stages.map((s, i) => (
                <li key={s.label} className="flex items-center gap-1">
                  {i > 0 && <span aria-hidden="true">→</span>}
                  <span className="rounded border border-border bg-bg px-1.5 py-0.5">
                    <span className="text-fg">{s.rows}</span> {s.label}
                  </span>
                </li>
              ))}
            </ol>
            {geoMsg && (
              <p role="status" className="mt-2 text-xs text-warn-fg">
                {geoMsg}
              </p>
            )}
          </div>
          <ul className="max-h-[440px] divide-y divide-border overflow-y-auto" aria-live="polite">
            {result.hits.length === 0 && (
              <li className="p-4 text-sm text-fg-muted">
                No stores match. Try a bigger radius, fewer filters, or switch to <strong>nearest k</strong>.
              </li>
            )}
            {result.hits.map((h, i) => (
              <li key={h.store.id} className={active === h.store.id ? "bg-accent-soft/60" : ""}>
                <button
                  type="button"
                  onClick={() => focusStore(h.store.id)}
                  onMouseEnter={() => setActive(h.store.id)}
                  className="block w-full px-3 py-2.5 text-left hover:bg-bg-muted"
                >
                  <span className="flex items-baseline gap-2">
                    <span className="font-mono text-[10px] text-fg-muted">{i + 1}.</span>
                    <span className="min-w-0 flex-1 truncate text-sm font-medium">{h.store.name}</span>
                    <span className="font-mono text-xs">{formatDistance(h.distanceM)}</span>
                  </span>
                  <span className="block pl-5 text-xs text-fg-muted">{h.store.address}</span>
                  <span className={`block pl-5 text-xs ${h.status.open ? "text-accent" : "text-fg-muted"}`}>
                    {h.status.open ? "● " : "○ "}
                    {h.status.label}
                    <span className="text-fg-muted"> · {h.store.hoursLabel}</span>
                  </span>
                  {h.store.services.length > 0 && (
                    <span className="mt-1 flex flex-wrap gap-1 pl-5">
                      {h.store.services.map((s) => (
                        <span key={s} className="rounded bg-bg-muted px-1.5 py-0.5 font-mono text-[10px] text-fg-muted">
                          {s}
                        </span>
                      ))}
                    </span>
                  )}
                </button>
                {active === h.store.id && (
                  <a
                    className="mb-2 ml-8 inline-block text-xs text-accent-2 underline"
                    href={`https://www.openstreetmap.org/directions?route=${p.center[1]}%2C${p.center[0]}%3B${h.store.location[1]}%2C${h.store.location[0]}`}
                    target="_blank"
                    rel="noopener noreferrer"
                  >
                    Directions on OpenStreetMap ↗
                  </a>
                )}
              </li>
            ))}
          </ul>
        </section>
      </div>

      <section aria-labelledby="sql-h" className="border-t border-border p-3">
        <div className="flex flex-wrap items-center gap-2">
          <h3 id="sql-h" className="font-mono text-sm font-semibold">
            equivalent PostGIS
          </h3>
          <div role="tablist" aria-label="SQL view" className="ml-auto flex gap-1">
            {(["query", "schema"] as const).map((t) => (
              <button
                key={t}
                type="button"
                role="tab"
                aria-selected={tab === t}
                onClick={() => setTab(t)}
                className={`rounded px-2.5 py-1 font-mono text-xs ${tab === t ? "bg-accent text-accent-fg" : "border border-border hover:text-accent"}`}
              >
                {t}
              </button>
            ))}
          </div>
        </div>
        <pre className="mt-2 max-h-80 overflow-auto rounded-md border border-[#27272a] bg-[#0b0b0d] p-3 font-mono text-[12px] leading-relaxed text-[#d4d4d8]" role="tabpanel" aria-live="polite">
          <code>{tab === "query" ? sql : SCHEMA_SQL}</code>
        </pre>
        <p className="mt-2 text-xs text-fg-muted">
          {p.mode === "radius"
            ? "ST_DWithin on geography uses the GiST index for the bounding-box step (the orange box), then rechecks the exact distance. The <-> operator orders the survivors."
            : "ORDER BY location <-> point with LIMIT is an index-assisted nearest-neighbour scan: PostgreSQL walks the GiST index in distance order and stops after k rows pass the filters."}
        </p>
      </section>
    </div>
  );
}