@12-apps/routing 0.0.0-stage → 1.1.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 +126 -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 +209 -0
- package/src/react/map-geometry.ts +82 -0
- package/src/react/maplibre-types.ts +43 -0
- package/src/react/pt-BR.ts +12 -0
- package/src/react/route-map.tsx +177 -0
- package/src/react/tag-placement.ts +221 -0
- package/src/react/types.ts +142 -0
- package/src/react/use-map.ts +107 -0
- package/src/react/use-overlays.ts +227 -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,209 @@
|
|
|
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
|
+
/** Which side of its pin a marker's tag (pill and tail) is drawn on. */
|
|
55
|
+
export type TagSide = "above" | "below" | "right" | "left";
|
|
56
|
+
|
|
57
|
+
/** How far the tail reaches from the pill to the pin. */
|
|
58
|
+
export const TAG_TAIL_PX = 9;
|
|
59
|
+
|
|
60
|
+
/** Each tail's colour, so a side change can redraw it pointing elsewhere. */
|
|
61
|
+
const tailColours = new WeakMap<HTMLElement, string>();
|
|
62
|
+
|
|
63
|
+
/** The tail under a pill, pointing down at the pin (the `above` side). */
|
|
64
|
+
function tailElement(colour: string): HTMLElement {
|
|
65
|
+
const tail = span("", { width: "0", height: "0", borderLeft: "7px solid transparent", borderRight: "7px solid transparent", borderTop: `${TAG_TAIL_PX}px solid ${colour}` });
|
|
66
|
+
tailColours.set(tail, colour);
|
|
67
|
+
return tail;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Per side: how the frame stacks pill and tail (the tail always at the edge
|
|
72
|
+
* that meets the pin), and which tail border is filled (the triangle points
|
|
73
|
+
* the opposite way) versus transparent (its two flanks).
|
|
74
|
+
*/
|
|
75
|
+
const SIDE_LAYOUT: Record<TagSide, { flexDirection: string; filled: Edge; flanks: readonly Edge[] }> = {
|
|
76
|
+
above: { flexDirection: "column", filled: "top", flanks: ["left", "right"] },
|
|
77
|
+
below: { flexDirection: "column-reverse", filled: "bottom", flanks: ["left", "right"] },
|
|
78
|
+
right: { flexDirection: "row-reverse", filled: "right", flanks: ["top", "bottom"] },
|
|
79
|
+
left: { flexDirection: "row", filled: "left", flanks: ["top", "bottom"] },
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
type Edge = "top" | "bottom" | "left" | "right";
|
|
83
|
+
|
|
84
|
+
function tailBorder(edge: Edge, side: TagSide, colour: string): string {
|
|
85
|
+
const layout = SIDE_LAYOUT[side];
|
|
86
|
+
if (edge === layout.filled) return `${TAG_TAIL_PX}px solid ${colour}`;
|
|
87
|
+
return layout.flanks.includes(edge) ? "7px solid transparent" : "0";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Lay a marker's (or group's) tag out on `side` of its pin, in place — the
|
|
92
|
+
* element is never rebuilt, so keyboard focus survives. The caller re-offsets
|
|
93
|
+
* the MapLibre marker so the tail's tip stays on the point. `false` when the
|
|
94
|
+
* element is not a tag this module built (nothing changed).
|
|
95
|
+
*/
|
|
96
|
+
export function setTagSide(element: HTMLElement, side: TagSide): boolean {
|
|
97
|
+
const tail = element.children[1] as HTMLElement | undefined;
|
|
98
|
+
const colour = tail ? tailColours.get(tail) : undefined;
|
|
99
|
+
if (!tail || colour === undefined) return false;
|
|
100
|
+
element.style.flexDirection = SIDE_LAYOUT[side].flexDirection;
|
|
101
|
+
for (const edge of ["top", "bottom", "left", "right"] as const) tail.style.setProperty(`border-${edge}`, tailBorder(edge, side, colour));
|
|
102
|
+
element.dataset.tagSide = side;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A marker the viewer can act on is a `<button>` with `aria-pressed` (it
|
|
108
|
+
* selects); one they can only read is an `img` with a label — never an empty
|
|
109
|
+
* tab stop that does nothing.
|
|
110
|
+
*/
|
|
111
|
+
export function markerElement(marker: RouteMapMarker, theme: RouteMapTheme, onSelect: (() => void) | null): HTMLElement {
|
|
112
|
+
const frame = { display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "flex-end", minWidth: "44px", minHeight: "44px", zIndex: marker.emphasized ? "3" : "2" };
|
|
113
|
+
let element: HTMLElement;
|
|
114
|
+
if (onSelect) {
|
|
115
|
+
element = button(marker.ariaLabel, { ...frame, background: "none", border: "0", padding: "0" }, onSelect);
|
|
116
|
+
element.setAttribute("aria-pressed", marker.emphasized ? "true" : "false");
|
|
117
|
+
} else {
|
|
118
|
+
element = span("", frame);
|
|
119
|
+
element.setAttribute("role", "img");
|
|
120
|
+
element.setAttribute("aria-label", marker.ariaLabel);
|
|
121
|
+
}
|
|
122
|
+
const bubble = span("", {
|
|
123
|
+
display: "inline-flex",
|
|
124
|
+
alignItems: "center",
|
|
125
|
+
gap: "6px",
|
|
126
|
+
padding: marker.emphasized ? "6px 12px" : "5px 9px",
|
|
127
|
+
borderRadius: "999px",
|
|
128
|
+
fontSize: "13px",
|
|
129
|
+
fontWeight: "700",
|
|
130
|
+
whiteSpace: "nowrap",
|
|
131
|
+
color: theme.paper,
|
|
132
|
+
background: marker.color,
|
|
133
|
+
border: `2px ${marker.faded ? "dashed" : "solid"} ${theme.paper}`,
|
|
134
|
+
boxShadow: SHADOW,
|
|
135
|
+
opacity: marker.faded ? "0.75" : "1",
|
|
136
|
+
});
|
|
137
|
+
if (marker.icon) bubble.appendChild(glyph(marker.icon));
|
|
138
|
+
bubble.appendChild(document.createTextNode(marker.text));
|
|
139
|
+
element.append(bubble, tailElement(marker.color));
|
|
140
|
+
element.dataset.tagSide = "above";
|
|
141
|
+
return element;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A group of overlapping markers keeps the MARKER's shape (a pill with a
|
|
146
|
+
* tail), so it never reads as a numbered stop badge drawn nearby.
|
|
147
|
+
*/
|
|
148
|
+
export function groupElement(count: number, copy: RouteMapCopy, theme: RouteMapTheme, onClick: () => void): HTMLElement {
|
|
149
|
+
const frame = { display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "flex-end" };
|
|
150
|
+
const element = button(copy.group(count), { ...frame, background: "none", border: "0", padding: "0", minWidth: "44px", minHeight: "44px" }, onClick);
|
|
151
|
+
const bubble = span(`+${count}`, {
|
|
152
|
+
padding: "5px 10px",
|
|
153
|
+
borderRadius: "999px",
|
|
154
|
+
fontSize: "13px",
|
|
155
|
+
fontWeight: "700",
|
|
156
|
+
color: theme.paper,
|
|
157
|
+
background: theme.ink,
|
|
158
|
+
border: `2px solid ${theme.paper}`,
|
|
159
|
+
boxShadow: SHADOW,
|
|
160
|
+
});
|
|
161
|
+
element.append(bubble, tailElement(theme.ink));
|
|
162
|
+
element.dataset.tagSide = "above";
|
|
163
|
+
return element;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function stopElement(stop: RouteMapStop, theme: RouteMapTheme): HTMLElement {
|
|
167
|
+
const fill = { done: theme.done, next: theme.next, pending: theme.pending }[stop.variant];
|
|
168
|
+
const element = span(stop.mark, {
|
|
169
|
+
width: "28px",
|
|
170
|
+
height: "28px",
|
|
171
|
+
boxSizing: "border-box",
|
|
172
|
+
borderRadius: "50%",
|
|
173
|
+
display: "flex",
|
|
174
|
+
alignItems: "center",
|
|
175
|
+
justifyContent: "center",
|
|
176
|
+
fontSize: "13px",
|
|
177
|
+
fontWeight: "700",
|
|
178
|
+
background: fill,
|
|
179
|
+
color: stop.variant === "pending" ? theme.ink : theme.paper,
|
|
180
|
+
border: `2px solid ${stop.variant === "done" ? theme.paper : theme.ink}`,
|
|
181
|
+
boxShadow: SHADOW,
|
|
182
|
+
// Pins are z 2–3; an emphasised stop sits above them, the rest beneath.
|
|
183
|
+
zIndex: stop.emphasized ? "4" : "1",
|
|
184
|
+
});
|
|
185
|
+
element.setAttribute("role", "img");
|
|
186
|
+
element.setAttribute("aria-label", stop.title);
|
|
187
|
+
element.title = stop.title;
|
|
188
|
+
return element;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** How far above its point a place's label sits: label (~26) + stem (46) + dot (8). */
|
|
192
|
+
export const PLACE_LIFT = 80;
|
|
193
|
+
|
|
194
|
+
export function placeElement(spot: RouteMapPlace, theme: RouteMapTheme, lifted = true): HTMLElement {
|
|
195
|
+
// The label rides ABOVE a pin-high stem, so a courier standing at the place
|
|
196
|
+
// (a pin anchored on the same point) never covers it. It stays UNDER the
|
|
197
|
+
// pins (z 1 against their 2–3), so the stem never crosses a pin's name, and
|
|
198
|
+
// under any host overlay; clicks pass through to whatever is beneath.
|
|
199
|
+
const element = span("", { display: "flex", flexDirection: "column", alignItems: "center", pointerEvents: "none", zIndex: "1" });
|
|
200
|
+
const label = 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" });
|
|
201
|
+
if (spot.icon) label.appendChild(glyph(spot.icon));
|
|
202
|
+
label.appendChild(document.createTextNode(spot.label));
|
|
203
|
+
if (!lifted) {
|
|
204
|
+
element.append(label);
|
|
205
|
+
return element;
|
|
206
|
+
}
|
|
207
|
+
element.append(label, span("", { width: "2px", height: "46px", background: theme.place }), span("", { width: "8px", height: "8px", borderRadius: "50%", background: theme.place }));
|
|
208
|
+
return element;
|
|
209
|
+
}
|