@12-apps/routing 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -2
- package/package.json +76 -4
- package/src/core/fallback.ts +16 -0
- package/src/core/geo.ts +97 -0
- package/src/core/planner.ts +106 -0
- package/src/core/provider.ts +67 -0
- package/src/core/types.ts +77 -0
- package/src/index.ts +13 -0
- package/src/manifest/index.ts +18 -0
- package/src/manifest/server.ts +14 -0
- package/src/manifest/web.ts +14 -0
- package/src/providers/google.ts +85 -0
- package/src/providers/http.ts +90 -0
- package/src/providers/openrouteservice.ts +71 -0
- package/src/providers/osrm.ts +58 -0
- package/src/react/copy.ts +24 -0
- package/src/react/en-US.ts +12 -0
- package/src/react/index.ts +28 -0
- package/src/react/locales.ts +12 -0
- package/src/react/map-css.ts +20 -0
- package/src/react/map-elements.ts +142 -0
- package/src/react/map-geometry.ts +82 -0
- package/src/react/maplibre-types.ts +37 -0
- package/src/react/pt-BR.ts +12 -0
- package/src/react/route-map.tsx +149 -0
- package/src/react/types.ts +100 -0
- package/src/react/use-map.ts +104 -0
- package/src/react/use-overlays.ts +162 -0
- package/src/server/index.ts +108 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Routes API (`directions/v2:computeRoutes`).
|
|
3
|
+
*
|
|
4
|
+
* **Licence note for hosts:** Google's Maps Platform terms do not allow
|
|
5
|
+
* content from its services (this route line included) to be displayed on a
|
|
6
|
+
* non-Google map. A host that draws with `RouteMap` over an OpenStreetMap
|
|
7
|
+
* basemap must not list this provider; it exists for hosts that render on a
|
|
8
|
+
* Google basemap, or that use only the durations.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { decodePolyline } from "../core/geo";
|
|
12
|
+
import type { ProviderOutcome, RoutingProvider } from "../core/provider";
|
|
13
|
+
import type { LngLat, RouteLeg, RouteRequest } from "../core/types";
|
|
14
|
+
|
|
15
|
+
import { fetchJson, legsOf, numberOr, routeOutcome, trimBase } from "./http";
|
|
16
|
+
|
|
17
|
+
export interface GoogleRoutesOptions {
|
|
18
|
+
apiKey: string | undefined;
|
|
19
|
+
/** `TWO_WHEELER` (default) or `DRIVE`. */
|
|
20
|
+
travelMode?: "TWO_WHEELER" | "DRIVE";
|
|
21
|
+
/** Ask for a traffic-aware duration (billed at the Pro tier). */
|
|
22
|
+
trafficAware?: boolean;
|
|
23
|
+
baseUrl?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_BASE_URL = "https://routes.googleapis.com";
|
|
27
|
+
|
|
28
|
+
interface GoogleLeg {
|
|
29
|
+
distanceMeters?: unknown;
|
|
30
|
+
duration?: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface GoogleBody {
|
|
34
|
+
routes?: { polyline?: { encodedPolyline?: unknown }; legs?: GoogleLeg[] }[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const waypoint = (point: LngLat) => ({ location: { latLng: { latitude: point.lat, longitude: point.lng } } });
|
|
38
|
+
|
|
39
|
+
function requestBody(request: RouteRequest, options: GoogleRoutesOptions): string {
|
|
40
|
+
const last = request.returnTo ?? request.stops[request.stops.length - 1]!;
|
|
41
|
+
const middle = request.returnTo ? request.stops : request.stops.slice(0, -1);
|
|
42
|
+
return JSON.stringify({
|
|
43
|
+
origin: waypoint(request.origin),
|
|
44
|
+
destination: waypoint(last),
|
|
45
|
+
intermediates: middle.map(waypoint),
|
|
46
|
+
travelMode: options.travelMode ?? "TWO_WHEELER",
|
|
47
|
+
...(options.trafficAware ? { routingPreference: "TRAFFIC_AWARE" } : {}),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function googleRoutesProvider(options: GoogleRoutesOptions): RoutingProvider {
|
|
52
|
+
const base = trimBase(options.baseUrl ?? DEFAULT_BASE_URL);
|
|
53
|
+
return {
|
|
54
|
+
name: "google-routes",
|
|
55
|
+
async route(request, { fetch, signal }): Promise<ProviderOutcome> {
|
|
56
|
+
if (!options.apiKey) return { ok: false, kind: "unconfigured" };
|
|
57
|
+
const answer = await fetchJson({
|
|
58
|
+
fetch,
|
|
59
|
+
signal,
|
|
60
|
+
url: `${base}/directions/v2:computeRoutes`,
|
|
61
|
+
init: {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: {
|
|
64
|
+
"content-type": "application/json",
|
|
65
|
+
"x-goog-api-key": options.apiKey,
|
|
66
|
+
"x-goog-fieldmask": "routes.polyline.encodedPolyline,routes.legs.distanceMeters,routes.legs.duration",
|
|
67
|
+
},
|
|
68
|
+
body: requestBody(request, options),
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
if (!answer.ok) return answer;
|
|
72
|
+
const route = (answer.body as GoogleBody).routes?.[0];
|
|
73
|
+
if (!route) return { ok: false, kind: "no-route" };
|
|
74
|
+
const encoded = route.polyline?.encodedPolyline;
|
|
75
|
+
return routeOutcome(typeof encoded === "string" ? decodePolyline(encoded) : null, legsOf(route.legs, legOf));
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `duration` is a protobuf Duration string, `"123s"` or `"12.5s"`. */
|
|
81
|
+
function legOf(leg: GoogleLeg): RouteLeg | null {
|
|
82
|
+
const distanceM = numberOr(leg.distanceMeters, 0);
|
|
83
|
+
const match = typeof leg.duration === "string" ? /^(\d+(?:\.\d+)?)s$/.exec(leg.duration) : null;
|
|
84
|
+
return distanceM === null || !match ? null : { distanceM, durationS: Number(match[1]) };
|
|
85
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one HTTP step every adapter shares: call, classify the failure, parse
|
|
3
|
+
* JSON. An adapter only knows its URL, its body and how to read its answer.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { failureOfResponse, failureOfThrown, type ProviderOutcome } from "../core/provider";
|
|
7
|
+
import type { Position, RouteLeg } from "../core/types";
|
|
8
|
+
|
|
9
|
+
type Failure = Extract<ProviderOutcome, { ok: false }>;
|
|
10
|
+
|
|
11
|
+
interface JsonCall {
|
|
12
|
+
fetch: typeof fetch;
|
|
13
|
+
signal: AbortSignal;
|
|
14
|
+
url: string;
|
|
15
|
+
init?: RequestInit;
|
|
16
|
+
/**
|
|
17
|
+
* Whether a non-2xx answer means "there is no route between these points"
|
|
18
|
+
* rather than a failed request — read from the status AND the error body,
|
|
19
|
+
* because services reuse a generic status for it (OSRM answers 400).
|
|
20
|
+
*/
|
|
21
|
+
isNoRoute?: (status: number, body: unknown) => boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function fetchJson(call: JsonCall): Promise<{ ok: true; body: unknown } | Failure> {
|
|
25
|
+
let response: Response;
|
|
26
|
+
try {
|
|
27
|
+
response = await call.fetch(call.url, { ...call.init, signal: call.signal });
|
|
28
|
+
} catch (error) {
|
|
29
|
+
return failureOfThrown(error, call.signal) as Failure;
|
|
30
|
+
}
|
|
31
|
+
if (!response.ok && call.isNoRoute) {
|
|
32
|
+
const text = await response.text().catch(() => "");
|
|
33
|
+
if (call.isNoRoute(response.status, parseJson(text))) return { ok: false, kind: "no-route", status: response.status };
|
|
34
|
+
return { ok: false, kind: "http", status: response.status, detail: text.slice(0, 200) };
|
|
35
|
+
}
|
|
36
|
+
const failed = await failureOfResponse(response);
|
|
37
|
+
if (failed) return failed as Failure;
|
|
38
|
+
try {
|
|
39
|
+
return { ok: true, body: (await response.json()) as unknown };
|
|
40
|
+
} catch (error) {
|
|
41
|
+
// An abort while the body streams is the planner's timeout, not a bad body.
|
|
42
|
+
return call.signal.aborted ? (failureOfThrown(error, call.signal) as Failure) : { ok: false, kind: "body" };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseJson(text: string): unknown {
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(text) as unknown;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `[lng, lat][]` from an untrusted value, or `null`. */
|
|
55
|
+
export function positionsOf(value: unknown): Position[] | null {
|
|
56
|
+
if (!Array.isArray(value)) return null;
|
|
57
|
+
const out: Position[] = [];
|
|
58
|
+
for (const item of value) {
|
|
59
|
+
if (!Array.isArray(item) || typeof item[0] !== "number" || typeof item[1] !== "number") return null;
|
|
60
|
+
out.push([item[0], item[1]]);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every leg read, or `null` when any one is unreadable. */
|
|
66
|
+
export function legsOf<T>(items: readonly T[] | undefined, read: (item: T) => RouteLeg | null): RouteLeg[] | null {
|
|
67
|
+
if (!items) return null;
|
|
68
|
+
const legs = items.map(read);
|
|
69
|
+
return legs.every((leg): leg is RouteLeg => leg !== null) ? legs : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The success outcome, or an unreadable-body failure when either part is missing. */
|
|
73
|
+
export function routeOutcome(geometry: Position[] | null, legs: RouteLeg[] | null): ProviderOutcome {
|
|
74
|
+
return geometry && legs ? { ok: true, geometry, legs } : { ok: false, kind: "body" };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A number, with a missing value read as `fallback` (services omit zero-length legs). */
|
|
78
|
+
export const numberOr = (value: unknown, fallback: number): number | null =>
|
|
79
|
+
value === undefined ? fallback : typeof value === "number" ? value : null;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A base URL without its trailing slashes. A loop rather than `/\/+$/`: that
|
|
83
|
+
* pattern backtracks polynomially on a long run of slashes, and a base URL is
|
|
84
|
+
* host config this package does not control.
|
|
85
|
+
*/
|
|
86
|
+
export function trimBase(url: string): string {
|
|
87
|
+
let end = url.length;
|
|
88
|
+
while (end > 0 && url[end - 1] === "/") end -= 1;
|
|
89
|
+
return url.slice(0, end);
|
|
90
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* openrouteservice (OpenStreetMap data, the same map the default basemap
|
|
3
|
+
* draws). `POST /v2/directions/{profile}/geojson` with every waypoint in one
|
|
4
|
+
* call; the answer's single feature carries the line and one segment per leg.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { toPosition } from "../core/geo";
|
|
8
|
+
import { waypointsOf, type ProviderOutcome, type RoutingProvider } from "../core/provider";
|
|
9
|
+
import type { RouteLeg } from "../core/types";
|
|
10
|
+
|
|
11
|
+
import { fetchJson, legsOf, numberOr, positionsOf, routeOutcome, trimBase } from "./http";
|
|
12
|
+
|
|
13
|
+
export interface OpenRouteServiceOptions {
|
|
14
|
+
/** The account's API key. Empty/absent = every attempt is `unconfigured`. */
|
|
15
|
+
apiKey: string | undefined;
|
|
16
|
+
/** ORS profile. `driving-car` is the closest public profile to a motorbike. */
|
|
17
|
+
profile?: string;
|
|
18
|
+
/** Override for a self-hosted instance. */
|
|
19
|
+
baseUrl?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_BASE_URL = "https://api.openrouteservice.org";
|
|
23
|
+
|
|
24
|
+
interface OrsSegment {
|
|
25
|
+
distance?: unknown;
|
|
26
|
+
duration?: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface OrsBody {
|
|
30
|
+
features?: { geometry?: { coordinates?: unknown }; properties?: { segments?: OrsSegment[] } }[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function openRouteServiceProvider(options: OpenRouteServiceOptions): RoutingProvider {
|
|
34
|
+
const base = trimBase(options.baseUrl ?? DEFAULT_BASE_URL);
|
|
35
|
+
const profile = options.profile ?? "driving-car";
|
|
36
|
+
return {
|
|
37
|
+
name: "openrouteservice",
|
|
38
|
+
async route(request, { fetch, signal }): Promise<ProviderOutcome> {
|
|
39
|
+
if (!options.apiKey) return { ok: false, kind: "unconfigured" };
|
|
40
|
+
const answer = await fetchJson({
|
|
41
|
+
fetch,
|
|
42
|
+
signal,
|
|
43
|
+
url: `${base}/v2/directions/${encodeURIComponent(profile)}/geojson`,
|
|
44
|
+
isNoRoute: orsNoRoute,
|
|
45
|
+
init: {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: { authorization: options.apiKey, "content-type": "application/json", accept: "application/geo+json" },
|
|
48
|
+
body: JSON.stringify({ coordinates: waypointsOf(request).map(toPosition) }),
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
if (!answer.ok) return answer;
|
|
52
|
+
const feature = (answer.body as OrsBody).features?.[0];
|
|
53
|
+
return routeOutcome(positionsOf(feature?.geometry?.coordinates), legsOf(feature?.properties?.segments, legOf));
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* ORS answers "no route" (2009) and "no routable point near a waypoint" (2010)
|
|
60
|
+
* with 404 — the same status as a wrong profile or URL, so the body decides.
|
|
61
|
+
*/
|
|
62
|
+
function orsNoRoute(status: number, body: unknown): boolean {
|
|
63
|
+
const code = (body as { error?: { code?: unknown } } | null)?.error?.code;
|
|
64
|
+
return status === 404 && (code === 2009 || code === 2010);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function legOf(segment: OrsSegment): RouteLeg | null {
|
|
68
|
+
const distanceM = numberOr(segment.distance, 0);
|
|
69
|
+
const durationS = numberOr(segment.duration, 0);
|
|
70
|
+
return distanceM === null || durationS === null ? null : { distanceM, durationS };
|
|
71
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OSRM (`/route/v1/{profile}/{coords}`), self-hosted or a public instance.
|
|
3
|
+
* The public demo server is for testing only — a host that lists it in
|
|
4
|
+
* production should expect to land on the fallback when it rate-limits.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { waypointsOf, type ProviderOutcome, type RoutingProvider } from "../core/provider";
|
|
8
|
+
import type { RouteLeg } from "../core/types";
|
|
9
|
+
|
|
10
|
+
import { fetchJson, legsOf, positionsOf, routeOutcome, trimBase } from "./http";
|
|
11
|
+
|
|
12
|
+
export interface OsrmOptions {
|
|
13
|
+
/** e.g. `https://osrm.example.net`. Empty/absent = `unconfigured`. */
|
|
14
|
+
baseUrl: string | undefined;
|
|
15
|
+
/** OSRM profile segment, `driving` by default. */
|
|
16
|
+
profile?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface OsrmLeg {
|
|
20
|
+
distance?: unknown;
|
|
21
|
+
duration?: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface OsrmBody {
|
|
25
|
+
code?: unknown;
|
|
26
|
+
routes?: { geometry?: { coordinates?: unknown }; legs?: OsrmLeg[] }[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function osrmProvider(options: OsrmOptions): RoutingProvider {
|
|
30
|
+
return {
|
|
31
|
+
name: "osrm",
|
|
32
|
+
async route(request, { fetch, signal }): Promise<ProviderOutcome> {
|
|
33
|
+
if (!options.baseUrl) return { ok: false, kind: "unconfigured" };
|
|
34
|
+
const coords = waypointsOf(request)
|
|
35
|
+
.map((point) => `${point.lng},${point.lat}`)
|
|
36
|
+
.join(";");
|
|
37
|
+
const profile = encodeURIComponent(options.profile ?? "driving");
|
|
38
|
+
const url = `${trimBase(options.baseUrl)}/route/v1/${profile}/${coords}?overview=full&geometries=geojson&steps=false`;
|
|
39
|
+
const answer = await fetchJson({ fetch, signal, url, init: { headers: { accept: "application/json" } }, isNoRoute: osrmNoRoute });
|
|
40
|
+
if (!answer.ok) return answer;
|
|
41
|
+
const body = answer.body as OsrmBody;
|
|
42
|
+
if (body.code === "NoRoute") return { ok: false, kind: "no-route" };
|
|
43
|
+
if (body.code !== "Ok") return { ok: false, kind: "body" };
|
|
44
|
+
const route = body.routes?.[0];
|
|
45
|
+
return routeOutcome(positionsOf(route?.geometry?.coordinates), legsOf(route?.legs, legOf));
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** OSRM answers an impossible route with HTTP 400 and a `code` in the body. */
|
|
51
|
+
function osrmNoRoute(status: number, body: unknown): boolean {
|
|
52
|
+
const code = (body as { code?: unknown } | null)?.code;
|
|
53
|
+
return status === 400 && (code === "NoRoute" || code === "NoSegment");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function legOf(leg: OsrmLeg): RouteLeg | null {
|
|
57
|
+
return typeof leg.distance === "number" && typeof leg.duration === "number" ? { distanceM: leg.distance, durationS: leg.duration } : null;
|
|
58
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every word the map draws. Required host config — there are no defaults in
|
|
3
|
+
* the component; the named packs (`./pt-BR`, `./en-US`, `./locales`) are what a
|
|
4
|
+
* host passes when it has no reason to say anything else.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface RouteMapCopy {
|
|
8
|
+
/** Zoom-in control's accessible name. */
|
|
9
|
+
zoomIn: string;
|
|
10
|
+
/** Zoom-out control's accessible name. */
|
|
11
|
+
zoomOut: string;
|
|
12
|
+
/** Fit-everything control's accessible name. */
|
|
13
|
+
fitAll: string;
|
|
14
|
+
/** The map area's own accessible name. */
|
|
15
|
+
mapLabel: string;
|
|
16
|
+
/** Shown in place of the map when it cannot render (no WebGL, style failed). */
|
|
17
|
+
mapError: string;
|
|
18
|
+
/** The retry button under `mapError`. */
|
|
19
|
+
retry: string;
|
|
20
|
+
/** A group of overlapping markers, by how many it holds. */
|
|
21
|
+
group: (count: number) => string;
|
|
22
|
+
/** The basemap's required attribution line. */
|
|
23
|
+
attribution: string;
|
|
24
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RouteMapCopy } from "./copy";
|
|
2
|
+
|
|
3
|
+
export const EN_US_ROUTE_MAP_COPY: RouteMapCopy = {
|
|
4
|
+
zoomIn: "Zoom in",
|
|
5
|
+
zoomOut: "Zoom out",
|
|
6
|
+
fitAll: "Show all",
|
|
7
|
+
mapLabel: "Map",
|
|
8
|
+
mapError: "The map could not be loaded.",
|
|
9
|
+
retry: "Try again",
|
|
10
|
+
group: (count) => `${count} here — tap to choose`,
|
|
11
|
+
attribution: "© OpenFreeMap © OpenMapTiles © OpenStreetMap contributors",
|
|
12
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/routing/react` — the web host's surface:
|
|
3
|
+
* `createWebRouting(config) → { RouteMap }`. Build once per config (the wiring
|
|
4
|
+
* binder does); `RouteMap` is a component TYPE the host places wherever a
|
|
5
|
+
* route belongs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { buildRouteMap } from "./route-map";
|
|
9
|
+
import type { RouteMapConfig, RouteMapProps } from "./types";
|
|
10
|
+
import type { JSX } from "react";
|
|
11
|
+
|
|
12
|
+
export interface RoutingSurface {
|
|
13
|
+
RouteMap: (props: RouteMapProps) => JSX.Element;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createWebRouting(config: RouteMapConfig): RoutingSurface {
|
|
17
|
+
if (!config?.copy || !config.theme) throw new TypeError("createWebRouting: `copy` and `theme` are required");
|
|
18
|
+
return { RouteMap: buildRouteMap(config) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export { DEFAULT_STYLE_URL } from "./route-map";
|
|
22
|
+
export { boundsOf, groupMarkers } from "./map-geometry";
|
|
23
|
+
export type { RouteMapConfig, RouteMapMarker, RouteMapPlace, RouteMapProps, RouteMapStop, RouteMapTheme } from "./types";
|
|
24
|
+
export type { RouteMapCopy } from "./copy";
|
|
25
|
+
export type { MapLibreLike, MapLike, MarkerLike } from "./maplibre-types";
|
|
26
|
+
export { PT_BR_ROUTE_MAP_COPY } from "./pt-BR";
|
|
27
|
+
export { EN_US_ROUTE_MAP_COPY } from "./en-US";
|
|
28
|
+
export { ROUTE_MAP_COPY } from "./locales";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RouteMapCopy } from "./copy";
|
|
2
|
+
import { EN_US_ROUTE_MAP_COPY } from "./en-US";
|
|
3
|
+
import { PT_BR_ROUTE_MAP_COPY } from "./pt-BR";
|
|
4
|
+
|
|
5
|
+
/** Mirrored rather than imported, so the package stays liftable into any repo. */
|
|
6
|
+
type LocalePack<T> = { readonly "pt-BR": T; readonly "en-US": T };
|
|
7
|
+
|
|
8
|
+
/** Both languages, keyed by tag — what a host hands its i18n resolver. */
|
|
9
|
+
export const ROUTE_MAP_COPY = {
|
|
10
|
+
"pt-BR": PT_BR_ROUTE_MAP_COPY,
|
|
11
|
+
"en-US": EN_US_ROUTE_MAP_COPY,
|
|
12
|
+
} as const satisfies LocalePack<RouteMapCopy>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The few rules MapLibre's own stylesheet supplies that the map cannot work
|
|
3
|
+
* without (canvas and marker positioning). Injected once at mount rather than
|
|
4
|
+
* imported, so the package keeps `sideEffects: false` and a host needs no CSS
|
|
5
|
+
* loader for it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const MAP_CSS_ID = "routing-map-css";
|
|
9
|
+
|
|
10
|
+
export const MAP_CSS = [
|
|
11
|
+
".maplibregl-map{position:relative;overflow:hidden;-webkit-tap-highlight-color:transparent}",
|
|
12
|
+
".maplibregl-canvas{position:absolute;left:0;top:0}",
|
|
13
|
+
".maplibregl-canvas-container.maplibregl-interactive{cursor:grab}",
|
|
14
|
+
".maplibregl-marker{position:absolute;top:0;left:0;will-change:transform}",
|
|
15
|
+
// Phone-width maps keep only the fit control: pinch already zooms, and a
|
|
16
|
+
// 140 px column of buttons would sit on top of the markers.
|
|
17
|
+
".routing-map{container-type:inline-size;container-name:routing-map}",
|
|
18
|
+
"@container routing-map (max-width: 599px){.routing-zoom{display:none!important}}",
|
|
19
|
+
".maplibregl-ctrl-bottom-left,.maplibregl-ctrl-bottom-right,.maplibregl-ctrl-top-left,.maplibregl-ctrl-top-right{position:absolute;pointer-events:none;z-index:2}",
|
|
20
|
+
].join("");
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DOM each MapLibre marker carries. MapLibre positions plain elements, so
|
|
3
|
+
* these are built directly — real `<button>`s with accessible names where the
|
|
4
|
+
* viewer can act, an `img` role with a title where they only read.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { RouteMapCopy } from "./copy";
|
|
8
|
+
import type { RouteMapMarker, RouteMapPlace, RouteMapStop, RouteMapTheme } from "./types";
|
|
9
|
+
|
|
10
|
+
const SHADOW = "0 2px 6px rgba(0,0,0,.25)";
|
|
11
|
+
|
|
12
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
13
|
+
|
|
14
|
+
/** Stroke glyphs, drawn in the element's own colour (no remote asset). */
|
|
15
|
+
const GLYPHS = {
|
|
16
|
+
motorbike: ["M5.5 21a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z", "M18.5 21a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z", "M15 6h3l3 6", "M5.5 17.5L9 10h6l3.5 7.5"],
|
|
17
|
+
store: ["M3 9l1-5h16l1 5", "M4 9v11h16V9", "M9 20v-6h6v6"],
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
function glyph(name: keyof typeof GLYPHS): SVGElement {
|
|
21
|
+
const svg = document.createElementNS(SVG_NS, "svg");
|
|
22
|
+
for (const [key, value] of Object.entries({ width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true" })) {
|
|
23
|
+
svg.setAttribute(key, value);
|
|
24
|
+
}
|
|
25
|
+
for (const d of GLYPHS[name]) {
|
|
26
|
+
const path = document.createElementNS(SVG_NS, "path");
|
|
27
|
+
path.setAttribute("d", d);
|
|
28
|
+
svg.appendChild(path);
|
|
29
|
+
}
|
|
30
|
+
return svg;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function button(ariaLabel: string, style: Partial<CSSStyleDeclaration>, onClick?: () => void): HTMLButtonElement {
|
|
34
|
+
const element = document.createElement("button");
|
|
35
|
+
element.type = "button";
|
|
36
|
+
element.setAttribute("aria-label", ariaLabel);
|
|
37
|
+
Object.assign(element.style, { cursor: onClick ? "pointer" : "default", font: "inherit" }, style);
|
|
38
|
+
if (onClick) {
|
|
39
|
+
element.addEventListener("click", (event) => {
|
|
40
|
+
event.stopPropagation();
|
|
41
|
+
onClick();
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return element;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function span(text: string, style: Partial<CSSStyleDeclaration>): HTMLSpanElement {
|
|
48
|
+
const element = document.createElement("span");
|
|
49
|
+
element.textContent = text;
|
|
50
|
+
Object.assign(element.style, style);
|
|
51
|
+
return element;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A marker the viewer can act on is a `<button>` with `aria-pressed` (it
|
|
56
|
+
* selects); one they can only read is an `img` with a label — never an empty
|
|
57
|
+
* tab stop that does nothing.
|
|
58
|
+
*/
|
|
59
|
+
export function markerElement(marker: RouteMapMarker, theme: RouteMapTheme, onSelect: (() => void) | null): HTMLElement {
|
|
60
|
+
const frame = { display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "flex-end", minWidth: "44px", minHeight: "44px", zIndex: marker.emphasized ? "3" : "2" };
|
|
61
|
+
let element: HTMLElement;
|
|
62
|
+
if (onSelect) {
|
|
63
|
+
element = button(marker.ariaLabel, { ...frame, background: "none", border: "0", padding: "0" }, onSelect);
|
|
64
|
+
element.setAttribute("aria-pressed", marker.emphasized ? "true" : "false");
|
|
65
|
+
} else {
|
|
66
|
+
element = span("", frame);
|
|
67
|
+
element.setAttribute("role", "img");
|
|
68
|
+
element.setAttribute("aria-label", marker.ariaLabel);
|
|
69
|
+
}
|
|
70
|
+
const bubble = span("", {
|
|
71
|
+
display: "inline-flex",
|
|
72
|
+
alignItems: "center",
|
|
73
|
+
gap: "6px",
|
|
74
|
+
padding: marker.emphasized ? "6px 12px" : "5px 9px",
|
|
75
|
+
borderRadius: "999px",
|
|
76
|
+
fontSize: "13px",
|
|
77
|
+
fontWeight: "700",
|
|
78
|
+
whiteSpace: "nowrap",
|
|
79
|
+
color: theme.paper,
|
|
80
|
+
background: marker.color,
|
|
81
|
+
border: `2px ${marker.faded ? "dashed" : "solid"} ${theme.paper}`,
|
|
82
|
+
boxShadow: SHADOW,
|
|
83
|
+
opacity: marker.faded ? "0.75" : "1",
|
|
84
|
+
});
|
|
85
|
+
if (marker.icon) bubble.appendChild(glyph(marker.icon));
|
|
86
|
+
bubble.appendChild(document.createTextNode(marker.text));
|
|
87
|
+
const tail = span("", { width: "0", height: "0", borderLeft: "7px solid transparent", borderRight: "7px solid transparent", borderTop: `9px solid ${marker.color}` });
|
|
88
|
+
element.append(bubble, tail);
|
|
89
|
+
return element;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A group of overlapping markers keeps the MARKER's shape (a pill with a
|
|
94
|
+
* tail), so it never reads as a numbered stop badge drawn nearby.
|
|
95
|
+
*/
|
|
96
|
+
export function groupElement(count: number, copy: RouteMapCopy, theme: RouteMapTheme, onClick: () => void): HTMLElement {
|
|
97
|
+
const frame = { display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "flex-end" };
|
|
98
|
+
const element = button(copy.group(count), { ...frame, background: "none", border: "0", padding: "0", minWidth: "44px", minHeight: "44px" }, onClick);
|
|
99
|
+
const bubble = span(`+${count}`, {
|
|
100
|
+
padding: "5px 10px",
|
|
101
|
+
borderRadius: "999px",
|
|
102
|
+
fontSize: "13px",
|
|
103
|
+
fontWeight: "700",
|
|
104
|
+
color: theme.paper,
|
|
105
|
+
background: theme.ink,
|
|
106
|
+
border: `2px solid ${theme.paper}`,
|
|
107
|
+
boxShadow: SHADOW,
|
|
108
|
+
});
|
|
109
|
+
const tail = span("", { width: "0", height: "0", borderLeft: "7px solid transparent", borderRight: "7px solid transparent", borderTop: `9px solid ${theme.ink}` });
|
|
110
|
+
element.append(bubble, tail);
|
|
111
|
+
return element;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function stopElement(stop: RouteMapStop, theme: RouteMapTheme): HTMLElement {
|
|
115
|
+
const fill = { done: theme.done, next: theme.next, pending: theme.pending }[stop.variant];
|
|
116
|
+
const element = span(stop.mark, {
|
|
117
|
+
width: "28px",
|
|
118
|
+
height: "28px",
|
|
119
|
+
boxSizing: "border-box",
|
|
120
|
+
borderRadius: "50%",
|
|
121
|
+
display: "flex",
|
|
122
|
+
alignItems: "center",
|
|
123
|
+
justifyContent: "center",
|
|
124
|
+
fontSize: "13px",
|
|
125
|
+
fontWeight: "700",
|
|
126
|
+
background: fill,
|
|
127
|
+
color: stop.variant === "pending" ? theme.ink : theme.paper,
|
|
128
|
+
border: `2px solid ${stop.variant === "done" ? theme.paper : theme.ink}`,
|
|
129
|
+
boxShadow: SHADOW,
|
|
130
|
+
});
|
|
131
|
+
element.setAttribute("role", "img");
|
|
132
|
+
element.setAttribute("aria-label", stop.title);
|
|
133
|
+
element.title = stop.title;
|
|
134
|
+
return element;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function placeElement(spot: RouteMapPlace, theme: RouteMapTheme): HTMLElement {
|
|
138
|
+
const element = span("", { display: "inline-flex", alignItems: "center", gap: "6px", background: theme.place, color: theme.paper, padding: "4px 10px", borderRadius: "10px", fontSize: "13px", fontWeight: "600", whiteSpace: "nowrap" });
|
|
139
|
+
if (spot.icon) element.appendChild(glyph(spot.icon));
|
|
140
|
+
element.appendChild(document.createTextNode(spot.label));
|
|
141
|
+
return element;
|
|
142
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers over what `RouteMap` draws: the box that holds it all, the
|
|
3
|
+
* screen-space grouping of overlapping markers, and the two line layers.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { isValidPoint } from "../core/geo";
|
|
7
|
+
import type { LngLat, Position } from "../core/types";
|
|
8
|
+
|
|
9
|
+
import type { MapLike } from "./maplibre-types";
|
|
10
|
+
import type { RouteMapMarker, RouteMapProps, RouteMapTheme } from "./types";
|
|
11
|
+
|
|
12
|
+
/** Below this many pixels apart, two markers are one tap target. */
|
|
13
|
+
const GROUP_RADIUS_PX = 32;
|
|
14
|
+
|
|
15
|
+
export const PLANNED_LAYER = "routing-planned";
|
|
16
|
+
export const TRAVELLED_LAYER = "routing-travelled";
|
|
17
|
+
|
|
18
|
+
export function pointsOf(props: RouteMapProps): LngLat[] {
|
|
19
|
+
const points: LngLat[] = [];
|
|
20
|
+
for (const marker of props.markers ?? []) points.push(marker.position);
|
|
21
|
+
for (const stop of props.stops ?? []) points.push(stop.position);
|
|
22
|
+
for (const spot of props.places ?? []) points.push(spot.position);
|
|
23
|
+
for (const [lng, lat] of props.planned ?? []) points.push({ lng, lat });
|
|
24
|
+
return points.filter((point) => isValidPoint(point));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function centreOf(props: RouteMapProps): [number, number] {
|
|
28
|
+
const first = pointsOf(props)[0];
|
|
29
|
+
return first ? [first.lng, first.lat] : [0, 0];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The box around every valid point; a lone point still gets a street-sized box. */
|
|
33
|
+
export function boundsOf(points: readonly LngLat[]): [[number, number], [number, number]] | null {
|
|
34
|
+
const valid = points.filter((point) => isValidPoint(point));
|
|
35
|
+
if (valid.length === 0) return null;
|
|
36
|
+
const lngs = valid.map((point) => point.lng);
|
|
37
|
+
const lats = valid.map((point) => point.lat);
|
|
38
|
+
let [west, east] = [Math.min(...lngs), Math.max(...lngs)];
|
|
39
|
+
let [south, north] = [Math.min(...lats), Math.max(...lats)];
|
|
40
|
+
const pad = 0.0025;
|
|
41
|
+
if (east - west < pad) [west, east] = [west - pad, east + pad];
|
|
42
|
+
if (north - south < pad) [south, north] = [south - pad, north + pad];
|
|
43
|
+
return [
|
|
44
|
+
[west, south],
|
|
45
|
+
[east, north],
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Greedy screen-space grouping: each marker joins the first group whose anchor
|
|
51
|
+
* is within `GROUP_RADIUS_PX`. An emphasised marker always stands alone.
|
|
52
|
+
*/
|
|
53
|
+
export function groupMarkers(markers: readonly RouteMapMarker[], map: Pick<MapLike, "project">): RouteMapMarker[][] {
|
|
54
|
+
const groups: { anchor: { x: number; y: number }; members: RouteMapMarker[] }[] = [];
|
|
55
|
+
const alone: RouteMapMarker[][] = [];
|
|
56
|
+
for (const marker of markers) {
|
|
57
|
+
if (marker.emphasized) {
|
|
58
|
+
alone.push([marker]);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const at = map.project([marker.position.lng, marker.position.lat]);
|
|
62
|
+
const hit = groups.find((group) => Math.hypot(group.anchor.x - at.x, group.anchor.y - at.y) < GROUP_RADIUS_PX);
|
|
63
|
+
if (hit) hit.members.push(marker);
|
|
64
|
+
else groups.push({ anchor: at, members: [marker] });
|
|
65
|
+
}
|
|
66
|
+
return [...groups.map((group) => group.members), ...alone];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function lineFeature(positions: readonly Position[]) {
|
|
70
|
+
return { type: "Feature" as const, properties: {}, geometry: { type: "LineString" as const, coordinates: positions.map((p) => [p[0], p[1]]) } };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function addLines(map: MapLike, theme: RouteMapTheme): void {
|
|
74
|
+
for (const id of [PLANNED_LAYER, TRAVELLED_LAYER]) map.addSource(id, { type: "geojson", data: lineFeature([]) });
|
|
75
|
+
const layout = { "line-cap": "round", "line-join": "round" };
|
|
76
|
+
map.addLayer({ id: PLANNED_LAYER, type: "line", source: PLANNED_LAYER, layout, paint: { "line-color": theme.planned, "line-width": 4, "line-dasharray": [2, 1.6] } });
|
|
77
|
+
map.addLayer({ id: TRAVELLED_LAYER, type: "line", source: TRAVELLED_LAYER, layout, paint: { "line-color": theme.travelled, "line-width": 5 } });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function setLine(map: MapLike, id: string, positions: readonly Position[] | undefined): void {
|
|
81
|
+
map.getSource(id)?.setData(lineFeature(positions ?? []));
|
|
82
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slice of MapLibre GL this package calls — declared here so the
|
|
3
|
+
* component compiles and tests run without the library's WebGL runtime, and
|
|
4
|
+
* so a test can hand in a fake. `maplibre-gl` satisfies it structurally.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface MapLike {
|
|
8
|
+
on(event: "load" | "style.load" | "error" | "moveend", listener: () => void): unknown;
|
|
9
|
+
remove(): void;
|
|
10
|
+
addSource(id: string, source: { type: "geojson"; data: unknown }): unknown;
|
|
11
|
+
getSource(id: string): { setData(data: unknown): unknown } | undefined;
|
|
12
|
+
addLayer(layer: Record<string, unknown>): unknown;
|
|
13
|
+
setPaintProperty(layer: string, property: string, value: unknown): unknown;
|
|
14
|
+
fitBounds(bounds: [[number, number], [number, number]], options?: Record<string, unknown>): unknown;
|
|
15
|
+
zoomIn(): unknown;
|
|
16
|
+
zoomOut(): unknown;
|
|
17
|
+
project(lngLat: [number, number]): { x: number; y: number };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MarkerLike {
|
|
21
|
+
setLngLat(lngLat: [number, number]): MarkerLike;
|
|
22
|
+
addTo(map: MapLike): MarkerLike;
|
|
23
|
+
remove(): unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MapLibreLike {
|
|
27
|
+
Map: new (options: {
|
|
28
|
+
container: HTMLElement;
|
|
29
|
+
style: string;
|
|
30
|
+
attributionControl: boolean;
|
|
31
|
+
center: [number, number];
|
|
32
|
+
zoom: number;
|
|
33
|
+
}) => MapLike;
|
|
34
|
+
Marker: new (options: { element: HTMLElement; anchor: "bottom" | "center" }) => MarkerLike;
|
|
35
|
+
/** MapLibre 6 loads its worker from a separate file; the host says where. */
|
|
36
|
+
setWorkerUrl?(url: string): void;
|
|
37
|
+
}
|