@nways/maps 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/index.js ADDED
@@ -0,0 +1,154 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright 2026 N-WAYS LIMITED
3
+ // The N-Ways Maps SDK for the web, version 1: a MapLibre GL JS map with
4
+ // N-Ways' hosted style, tiles and fonts and your API key on every request,
5
+ // plus small helpers for the N-Ways APIs (geocoding, routes, places, static
6
+ // maps, an address autocomplete box).
7
+ //
8
+ // import { createMap, createClient } from '@nways/maps'
9
+ // const map = createMap({ container: 'map', key: 'YOUR_API_KEY', center: [3.3792, 6.5244], zoom: 12 })
10
+ //
11
+ // MapLibre GL JS (6 or later) and pmtiles are peer dependencies: bring your
12
+ // own (npm, or an import map), or load the standalone build from
13
+ // https://nways.ng/sdk/v1/nways-maps.standalone.js. See https://nways.ng/developers/maps-sdk.
14
+ import * as maplibregl from 'maplibre-gl'
15
+ import { Protocol } from 'pmtiles'
16
+ import { ATTRIBUTION, NWAYS_ORIGIN, STYLE_IDS, THEMES, TILESETS, buildStyle, styleUrl, withKey } from './style.js'
17
+ import { ApiError, createClient, PATHS, sessionToken, staticMapUrl } from './services.js'
18
+ import { attachAutocomplete, suggestionsOf } from './autocomplete.js'
19
+
20
+ export { ATTRIBUTION, NWAYS_ORIGIN, STYLE_IDS, THEMES, TILESETS, buildStyle, styleUrl, withKey }
21
+ export { ApiError, createClient, PATHS, sessionToken, staticMapUrl, attachAutocomplete, suggestionsOf }
22
+ // MapLibre itself (markers, popups, controls), so the standalone build needs no other import.
23
+ export { maplibregl }
24
+
25
+ export const VERSION = '1.0.0'
26
+
27
+ let registered = false
28
+ // Each map's style options, so setTheme keeps its key, origin, language and source.
29
+ const optionsOf = new WeakMap()
30
+
31
+ /** Lets MapLibre read PMTiles archives (once per page). */
32
+ function registerPMTiles() {
33
+ if (registered) return
34
+ maplibregl.addProtocol('pmtiles', new Protocol().tile)
35
+ registered = true
36
+ }
37
+
38
+ /** The style a map starts with: N-Ways' hosted style (default), the built-in one, or the caller's own. */
39
+ function styleFor({ key, theme, origin, lang, style }) {
40
+ if (style === 'bundled') return buildStyle({ key, theme, origin, lang })
41
+ if (style === undefined || style === 'hosted') return styleUrl({ key, theme, origin, lang })
42
+ return style
43
+ }
44
+
45
+ /**
46
+ * A message over the map when N-Ways refuses it (a key that's missing,
47
+ * restricted elsewhere or over its quota), so the page doesn't stay blank.
48
+ */
49
+ function showRefusal(map, err) {
50
+ const box = map.getContainer()
51
+ if (box.querySelector('.nways-refused')) return
52
+ const note = document.createElement('div')
53
+ note.className = 'nways-refused'
54
+ note.setAttribute('role', 'alert')
55
+ note.style.cssText =
56
+ 'position:absolute;inset:auto 12px 12px 12px;z-index:5;padding:10px 12px;border-radius:8px;font:13px/1.4 system-ui,sans-serif;' +
57
+ 'background:rgba(20,24,28,.92);color:#fff;box-shadow:0 2px 8px rgba(0,0,0,.3)'
58
+ const reason = err.reason ? ` (${err.reason})` : ''
59
+ note.textContent = `This map can’t load: N-Ways refused the API key${reason}. `
60
+ const link = document.createElement('a')
61
+ link.href = 'https://nways.ng/developers/errors#key-errors'
62
+ link.textContent = 'What to do'
63
+ link.style.color = '#9fd3b5'
64
+ note.append(link)
65
+ box.append(note)
66
+ }
67
+
68
+ /** Asks N-Ways why a request was refused (the error's reason), for the console and the note. */
69
+ async function refusalOf(url) {
70
+ try {
71
+ const res = await fetch(url)
72
+ if (res.ok) return null
73
+ const body = await res.json().catch(() => null)
74
+ return { status: res.status, reason: body?.error?.reason, message: body?.error?.message }
75
+ } catch {
76
+ return null
77
+ }
78
+ }
79
+
80
+ /**
81
+ * A map of Nigeria, Ghana, Benin and Togo. Takes MapLibre's map options plus
82
+ * `key` (your N-Ways API key), `theme` ('light' or 'dark'), `lang` ('en' or 'fr'),
83
+ * `style` ('hosted' (default), 'bundled', or your own style or URL) and
84
+ * `showErrors` (default true: a note over the map when the key is refused).
85
+ */
86
+ export function createMap(options) {
87
+ const { key, theme = 'light', lang = 'en', origin = NWAYS_ORIGIN, style, showErrors = true, ...rest } = options ?? {}
88
+ if (!key) console.warn('N-Ways: no API key. Pass createMap({ key }); requests from your website without one are refused.')
89
+ if (!THEMES[theme]) throw new Error(`N-Ways: unknown theme "${theme}" (light or dark)`)
90
+ registerPMTiles()
91
+ const opts = { key, theme, origin, lang, style }
92
+ const map = new maplibregl.Map({
93
+ center: [3.3792, 6.5244], // Lagos
94
+ zoom: 11,
95
+ maxBounds: [
96
+ [-4.5, 2.5],
97
+ [16.5, 15.5],
98
+ ],
99
+ ...rest,
100
+ style: styleFor(opts),
101
+ // Anything the map asks N-Ways for (style, tiles, fonts, sprites) carries the key; nothing else does.
102
+ transformRequest: (url, kind) => {
103
+ const own = rest.transformRequest?.(url, kind)
104
+ return own ?? { url: withKey(url, key, origin) }
105
+ },
106
+ })
107
+ optionsOf.set(map, opts)
108
+ if (rest.attributionControl !== false) {
109
+ // The credit stays even if a custom style forgets it (OpenStreetMap's licence requires it).
110
+ map.on('load', () => {
111
+ const credits = Object.values(map.getStyle()?.sources ?? {}).map((s) => s.attribution ?? '')
112
+ if (!credits.some((c) => c.includes('OpenStreetMap'))) map.addControl(new maplibregl.AttributionControl({ customAttribution: ATTRIBUTION }))
113
+ })
114
+ }
115
+ // A refused key leaves a blank map: say why in the console (and over the map).
116
+ // If the hosted style can't be had for another reason, fall back to the built-in one.
117
+ let told = false
118
+ let fellBack = false
119
+ let styled = false
120
+ map.once('style.load', () => (styled = true))
121
+ map.on('error', async (e) => {
122
+ const err = e?.error ?? {}
123
+ // Tile archives (PMTiles) report only "Bad response code: 403" in the message.
124
+ const status = err.status ?? Number(/\b(401|403|429)\b/.exec(String(err.message ?? ''))?.[1])
125
+ const hosted = (optionsOf.get(map)?.style ?? 'hosted') === 'hosted'
126
+ if ([401, 403, 429].includes(status)) {
127
+ if (told) return
128
+ told = true
129
+ const why = err.url ? await refusalOf(err.url) : null
130
+ const reason = why?.reason ? ` ${why.reason}: ${why.message ?? ''}` : ''
131
+ console.error(`N-Ways: a map request was refused (HTTP ${status}).${reason} Check the key's restrictions: https://nways.ng/developers/errors#key-errors`)
132
+ if (showErrors && typeof document !== 'undefined') showRefusal(map, { status, reason: why?.reason })
133
+ return
134
+ }
135
+ // Any other failure before the hosted style has loaded (unreachable, not JSON): the built-in style.
136
+ if (hosted && !fellBack && !styled) {
137
+ fellBack = true
138
+ optionsOf.get(map).style = 'bundled'
139
+ console.warn('N-Ways: the hosted style is unavailable; using the built-in style.')
140
+ map.setStyle(buildStyle({ key, theme: optionsOf.get(map).theme, origin, lang }), { diff: false })
141
+ }
142
+ })
143
+ return map
144
+ }
145
+
146
+ /** Switches a map made by createMap between light and dark. */
147
+ export function setTheme(map, theme) {
148
+ const opts = optionsOf.get(map)
149
+ if (!opts) throw new Error('N-Ways: setTheme needs a map made by createMap')
150
+ if (!THEMES[theme]) throw new Error(`N-Ways: unknown theme "${theme}" (light or dark)`)
151
+ opts.theme = theme
152
+ if (opts.style !== undefined && opts.style !== 'hosted' && opts.style !== 'bundled') return // the caller's own style
153
+ map.setStyle(styleFor(opts))
154
+ }
package/package.json CHANGED
@@ -1,6 +1,73 @@
1
1
  {
2
2
  "name": "@nways/maps",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "N-Ways Maps SDK for the web: MapLibre GL JS maps of Nigeria, Ghana, Benin and Togo with N-Ways' style, tiles and API key, plus geocoding, routes, places, static maps and an address autocomplete box.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "types": "index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "default": "./index.js"
12
+ },
13
+ "./style": {
14
+ "types": "./style.d.ts",
15
+ "default": "./style.js"
16
+ },
17
+ "./services": {
18
+ "types": "./services.d.ts",
19
+ "default": "./services.js"
20
+ },
21
+ "./autocomplete": {
22
+ "types": "./autocomplete.d.ts",
23
+ "default": "./autocomplete.js"
24
+ },
25
+ "./package.json": "./package.json"
26
+ },
27
+ "files": [
28
+ "index.js",
29
+ "index.d.ts",
30
+ "style.js",
31
+ "style.d.ts",
32
+ "services.js",
33
+ "services.d.ts",
34
+ "autocomplete.js",
35
+ "autocomplete.d.ts",
36
+ "README.md",
37
+ "CHANGELOG.md",
38
+ "LICENSE",
39
+ "NOTICE"
40
+ ],
41
+ "sideEffects": false,
42
+ "dependencies": {
43
+ "@types/geojson": "^7946.0.16"
44
+ },
45
+ "peerDependencies": {
46
+ "maplibre-gl": ">=6 <7",
47
+ "pmtiles": ">=4 <5"
48
+ },
49
+ "keywords": [
50
+ "maps",
51
+ "maplibre",
52
+ "nigeria",
53
+ "ghana",
54
+ "benin",
55
+ "togo",
56
+ "geocoding",
57
+ "routing",
58
+ "postcodes",
59
+ "west africa"
60
+ ],
61
+ "homepage": "https://nways.ng/developers",
62
+ "bugs": {
63
+ "email": "hello@nways.ng"
64
+ },
65
+ "author": "N-Ways <hello@nways.ng>",
66
+ "license": "Apache-2.0",
67
+ "publishConfig": {
68
+ "access": "public"
69
+ },
70
+ "engines": {
71
+ "node": ">=18"
72
+ }
73
+ }
package/services.d.ts ADDED
@@ -0,0 +1,164 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright 2026 N-WAYS LIMITED
3
+ import type { FeatureCollection } from 'geojson'
4
+
5
+ /** A GeoJSON point: coordinates are [lng, lat]. */
6
+ export interface GeoPoint {
7
+ type: 'Point'
8
+ coordinates: [number, number]
9
+ }
10
+
11
+ /** A point: { lat, lng }, [lng, lat] (GeoJSON order) or a GeoJSON Point. */
12
+ export type LatLng = { lat: number; lng: number } | [number, number] | GeoPoint
13
+
14
+ /** Where a route starts, stops or ends: a point, a NIPOST postcode, a GhanaPost GPS address or a Plus Code. */
15
+ export type Waypoint =
16
+ | LatLng
17
+ | { lat: number; lng: number; heading?: number }
18
+ | { postcode: string }
19
+ | { ghanaPost: string }
20
+ | { plusCode: string }
21
+
22
+ export const PATHS: Record<
23
+ | 'geocode' | 'reverse' | 'postcode' | 'ghanapost' | 'autocomplete' | 'validateAddress'
24
+ | 'placesNearby' | 'placesSearch' | 'place' | 'route' | 'matrix' | 'optimize' | 'fares' | 'traffic' | 'staticMap',
25
+ string
26
+ >
27
+
28
+ /** An error answer from N-Ways. */
29
+ export class ApiError extends Error {
30
+ /** HTTP status: 400, 401, 403, 404, 429, 503… */
31
+ status: number
32
+ /** The stable reason: API_KEY_INVALID, RATE_LIMIT_EXCEEDED, no_route… (null if none). */
33
+ reason: string | null
34
+ /** The status word: PERMISSION_DENIED, RESOURCE_EXHAUSTED… (null if none). */
35
+ statusText: string | null
36
+ /** Seconds to wait before trying again (429 and 503). */
37
+ retryAfter?: number
38
+ /** The whole answer. */
39
+ body: unknown
40
+ }
41
+
42
+ export interface Place {
43
+ id: string
44
+ name: string
45
+ type?: string
46
+ category?: string
47
+ location: GeoPoint
48
+ formattedAddress?: string
49
+ postcode?: string
50
+ ghanaPost?: string
51
+ plusCode?: string
52
+ context?: string
53
+ country?: 'NG' | 'GH' | 'BJ' | 'TG' | string
54
+ bbox?: [number, number, number, number]
55
+ attribution?: string
56
+ [field: string]: unknown
57
+ }
58
+
59
+ export interface Suggestion {
60
+ id?: string
61
+ name: string
62
+ context: string
63
+ location?: GeoPoint
64
+ [field: string]: unknown
65
+ }
66
+
67
+ export interface Route {
68
+ distanceM: number
69
+ durationS: number
70
+ /** The line, as a GeoJSON LineString ([lng, lat] pairs). */
71
+ geometry: { type: 'LineString'; coordinates: [number, number][] }
72
+ [field: string]: unknown
73
+ }
74
+
75
+ export interface RouteRequest {
76
+ origin: Waypoint
77
+ destination: Waypoint
78
+ intermediates?: Waypoint[]
79
+ /** drive, van, okada, keke, danfo, walk, bicycle, train, boat… (see the Routes docs). */
80
+ mode?: string
81
+ alternatives?: boolean
82
+ routingPreference?: 'traffic_unaware' | 'traffic_aware'
83
+ departAt?: string
84
+ avoid?: ('tolls' | 'highways' | 'bad_roads')[]
85
+ fares?: boolean
86
+ lang?: 'en' | 'fr'
87
+ [field: string]: unknown
88
+ }
89
+
90
+ type Opts = { signal?: AbortSignal }
91
+
92
+ export interface MatrixElement {
93
+ originIndex: number
94
+ destinationIndex: number
95
+ /** OK, or ROUTE_NOT_FOUND. */
96
+ status: string
97
+ durationS: number
98
+ distanceM: number
99
+ }
100
+
101
+ export interface MatrixResponse {
102
+ elements: MatrixElement[]
103
+ [k: string]: unknown
104
+ }
105
+
106
+ export interface StaticMapOptions {
107
+ key?: string
108
+ origin?: string
109
+ center?: LatLng
110
+ zoom?: number
111
+ /** Width and height in CSS pixels (default 640×400). */
112
+ size?: [number, number]
113
+ scale?: 1 | 2
114
+ theme?: 'light' | 'dark'
115
+ format?: 'png' | 'jpg'
116
+ /** Up to 100. color: a name (red, green, blue, orange…) or #RRGGBB; label: one letter or digit. */
117
+ markers?: { at: LatLng; color?: string; label?: string; size?: 'tiny' | 'small' | 'mid' }[]
118
+ /** Up to 10 lines (or areas, with fillColor). weight: 0 to 30 px. */
119
+ paths?: StaticPath[]
120
+ /** One path (same as paths: [path]). */
121
+ path?: StaticPath
122
+ }
123
+
124
+ export interface StaticPath {
125
+ points: LatLng[]
126
+ color?: string
127
+ weight?: number
128
+ fillColor?: string
129
+ }
130
+
131
+ export interface Client {
132
+ url(name: keyof typeof PATHS, query?: Record<string, unknown>, params?: Record<string, unknown>): string
133
+ geocode(q: string, o?: Opts & { near?: LatLng; country?: string | string[]; limit?: number; fields?: string | string[]; lang?: 'en' | 'fr' }): Promise<{ results: Place[]; [k: string]: unknown }>
134
+ reverse(at: LatLng, o?: Opts & { radius?: number; lang?: 'en' | 'fr' }): Promise<Record<string, unknown> | null>
135
+ postcode(code: string, o?: Opts): Promise<Record<string, unknown>>
136
+ ghanapost(code: string, o?: Opts): Promise<Record<string, unknown>>
137
+ autocomplete(q: string, o?: Opts & { session?: string; near?: LatLng; types?: string | string[]; country?: string | string[]; limit?: number }): Promise<{ suggestions?: Suggestion[]; [k: string]: unknown }>
138
+ validateAddress(address: string, o?: Opts & { country?: string; near?: LatLng }): Promise<Record<string, unknown>>
139
+ placesNearby(at: LatLng, o?: Opts & { category?: string; q?: string; radius?: number; limit?: number; pageToken?: string; fields?: string | string[] }): Promise<{ places: Place[]; nextPageToken?: string; [k: string]: unknown }>
140
+ placesSearch(q: string, o?: Opts & { near?: LatLng; radius?: number; limit?: number; fields?: string | string[] }): Promise<{ places: Place[]; nextPageToken?: string; [k: string]: unknown }>
141
+ place(id: string, o?: Opts & { fields?: string | string[] }): Promise<Place>
142
+ route(req: RouteRequest, o?: Opts): Promise<{ routes: Route[]; [k: string]: unknown }>
143
+ matrix(req: { origins: Waypoint[]; destinations: Waypoint[]; mode?: string; [k: string]: unknown }, o?: Opts): Promise<MatrixResponse>
144
+ optimize(
145
+ req: { stops: { id: string; location: Waypoint; demand?: number; serviceS?: number }[]; vehicles: { id: string; start: Waypoint; end?: Waypoint; capacity?: number }[]; mode?: string; [k: string]: unknown },
146
+ o?: Opts,
147
+ ): Promise<Record<string, unknown>>
148
+ fares(from: LatLng, to: LatLng, o?: Opts & { mode?: string | string[]; at?: string; distanceM?: number; durationS?: number; charter?: boolean; rain?: boolean }): Promise<Record<string, unknown>>
149
+ traffic(bbox: [number, number, number, number], o?: Opts): Promise<FeatureCollection>
150
+ staticMapUrl(o: Omit<StaticMapOptions, 'key' | 'origin'>): string
151
+ }
152
+
153
+ export interface ClientOptions {
154
+ key?: string
155
+ /** Default https://nways.ng. */
156
+ origin?: string
157
+ lang?: 'en' | 'fr'
158
+ fetch?: typeof fetch
159
+ headers?: Record<string, string>
160
+ }
161
+
162
+ export function createClient(options?: ClientOptions): Client
163
+ export function staticMapUrl(o: StaticMapOptions): string
164
+ export function sessionToken(): string
package/services.js ADDED
@@ -0,0 +1,206 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright 2026 N-WAYS LIMITED
3
+ // Small helpers for the N-Ways APIs, for web pages and Node 18+ (anything with
4
+ // fetch): geocoding, reverse geocoding, postcodes, places, routes, the route
5
+ // matrix, fares, autocomplete, address validation and static map addresses.
6
+ // Every call carries your key; errors come back as ApiError with N-Ways'
7
+ // machine-readable reason (API_KEY_HTTP_REFERRER_BLOCKED, RATE_LIMIT_EXCEEDED…).
8
+ //
9
+ // const nways = createClient({ key: 'YOUR_API_KEY' })
10
+ // const { results } = await nways.geocode('Allen Avenue, Ikeja')
11
+ import { NWAYS_ORIGIN, withKey } from './style.js'
12
+
13
+ /**
14
+ * Where each API answers, relative to the origin. One table, so a move (for
15
+ * example from the preview /v1 addresses to /maps/v1) is a one-line change.
16
+ */
17
+ export const PATHS = {
18
+ geocode: '/maps/v1/geocode',
19
+ reverse: '/maps/v1/reverse',
20
+ postcode: '/maps/v1/postcodes/{code}',
21
+ ghanapost: '/maps/v1/ghanapost/{code}',
22
+ autocomplete: '/maps/v1/autocomplete',
23
+ validateAddress: '/maps/v1/addresses/validate',
24
+ placesNearby: '/maps/v1/places/nearby',
25
+ placesSearch: '/maps/v1/places/search',
26
+ place: '/maps/v1/places/{id}',
27
+ route: '/maps/v1/routes/compute',
28
+ matrix: '/maps/v1/routes/matrix',
29
+ optimize: '/maps/v1/routes/optimize',
30
+ fares: '/maps/v1/fares/estimate',
31
+ traffic: '/maps/v1/traffic',
32
+ staticMap: '/maps/v1/staticmap',
33
+ }
34
+
35
+ // The APIs that take a language (lang=en|fr): in the query, or in the JSON body.
36
+ const LANG_QUERY = new Set(['geocode', 'reverse', 'autocomplete', 'placesSearch', 'fares'])
37
+ const LANG_BODY = new Set(['route', 'optimize', 'validateAddress'])
38
+
39
+ /** An error answer from N-Ways: HTTP status, the stable reason, the message, and when to retry (seconds). */
40
+ export class ApiError extends Error {
41
+ constructor(status, body, retryAfter) {
42
+ const e = body?.error
43
+ const env = e && typeof e === 'object' ? e : null
44
+ super(env?.message ?? (typeof e === 'string' ? e : `N-Ways answered HTTP ${status}`))
45
+ this.name = 'ApiError'
46
+ this.status = status
47
+ this.reason = env?.reason ?? body?.code ?? null
48
+ this.statusText = env?.status ?? null
49
+ this.retryAfter = retryAfter
50
+ this.body = body
51
+ }
52
+ }
53
+
54
+ const fill = (path, params) => path.replace(/\{(\w+)\}/g, (_, k) => encodeURIComponent(String(params[k])))
55
+ /** A point as `lat,lng`: from { lat, lng }, [lng, lat] or a GeoJSON Point. */
56
+ const ll = (p) => {
57
+ const c = Array.isArray(p) ? p : p?.type === 'Point' ? p.coordinates : null
58
+ return c ? `${c[1]},${c[0]}` : `${p.lat},${p.lng}`
59
+ }
60
+
61
+ /** A random session token for autocomplete (one per search, ended by the pick). */
62
+ export function sessionToken() {
63
+ if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
64
+ return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join('')
65
+ }
66
+
67
+ /**
68
+ * A client for the N-Ways APIs: `{ key, origin?, lang?, fetch?, headers? }`.
69
+ * `lang: 'fr'` asks for French names and instructions where there are any.
70
+ */
71
+ export function createClient(options = {}) {
72
+ const { key, origin = NWAYS_ORIGIN, lang, headers: extra = {} } = options
73
+ const doFetch = options.fetch ?? globalThis.fetch?.bind(globalThis)
74
+ if (!doFetch) throw new Error('N-Ways: no fetch here; pass createClient({ fetch })')
75
+
76
+ /** The full address of an API with its query (and the key), without fetching it. */
77
+ function url(name, query = {}, params = {}) {
78
+ const u = new URL(origin + fill(PATHS[name], params))
79
+ for (const [k, v] of Object.entries(query)) {
80
+ if (v === undefined || v === null || v === '') continue
81
+ u.searchParams.set(k, Array.isArray(v) ? v.join(',') : String(v))
82
+ }
83
+ if (lang && LANG_QUERY.has(name) && !u.searchParams.has('lang')) u.searchParams.set('lang', lang)
84
+ return withKey(u.toString(), key, origin)
85
+ }
86
+
87
+ async function call(name, { query, params, body, signal } = {}) {
88
+ const init = { method: body === undefined ? 'GET' : 'POST', headers: { Accept: 'application/json', ...extra }, signal }
89
+ if (body !== undefined) {
90
+ init.headers['Content-Type'] = 'application/json'
91
+ init.body = JSON.stringify(lang && LANG_BODY.has(name) && body.lang === undefined ? { ...body, lang } : body)
92
+ }
93
+ const res = await doFetch(url(name, query, params), init)
94
+ if (res.status === 204) return null
95
+ const text = await res.text()
96
+ let data = null
97
+ try {
98
+ data = text ? JSON.parse(text) : null
99
+ } catch {
100
+ data = { error: text }
101
+ }
102
+ if (!res.ok) {
103
+ const ra = Number(res.headers.get('Retry-After'))
104
+ throw new ApiError(res.status, data, Number.isFinite(ra) && ra > 0 ? ra : undefined)
105
+ }
106
+ return data
107
+ }
108
+
109
+ return {
110
+ url,
111
+ /** Places, addresses, postcodes and GhanaPost codes for what someone typed. */
112
+ geocode: (q, o = {}) =>
113
+ call('geocode', { query: { q, near: o.near && ll(o.near), country: o.country, limit: o.limit, fields: o.fields, lang: o.lang }, signal: o.signal }),
114
+ /** What's at a point (null when nothing is known there). */
115
+ reverse: (at, o = {}) => {
116
+ const [lat, lng] = ll(at).split(',')
117
+ return call('reverse', { query: { lat, lng, radius: o.radius, lang: o.lang }, signal: o.signal })
118
+ },
119
+ /** A NIPOST postcode's building, entrances and area. */
120
+ postcode: (code, o = {}) => call('postcode', { params: { code }, signal: o.signal }),
121
+ /** A GhanaPost GPS address. */
122
+ ghanapost: (code, o = {}) => call('ghanapost', { params: { code }, signal: o.signal }),
123
+ /** Suggestions while someone types (bill per session: pass the same session until they pick). */
124
+ autocomplete: (q, o = {}) =>
125
+ call('autocomplete', { query: { q, session: o.session, near: o.near && ll(o.near), types: o.types, country: o.country, limit: o.limit }, signal: o.signal }),
126
+ /** Checks and completes an address: a verdict, its parts, the point and a corrected postcode. */
127
+ validateAddress: (address, o = {}) =>
128
+ call('validateAddress', { body: { address, country: o.country, near: o.near && toLatLng(o.near) }, signal: o.signal }),
129
+ /** Businesses and places near a point, by category or name. */
130
+ placesNearby: (at, o = {}) => {
131
+ const [lat, lng] = ll(at).split(',')
132
+ return call('placesNearby', {
133
+ query: { lat, lng, category: o.category, q: o.q, radius: o.radius, limit: o.limit, pageToken: o.pageToken, fields: o.fields },
134
+ signal: o.signal,
135
+ })
136
+ },
137
+ /** Places by name or text anywhere, nearest first when `near` is given. */
138
+ placesSearch: (q, o = {}) =>
139
+ call('placesSearch', { query: { q, near: o.near && ll(o.near), radius: o.radius, limit: o.limit, fields: o.fields }, signal: o.signal }),
140
+ /** One place's details (`fields` chooses what comes back, and the price tier). */
141
+ place: (id, o = {}) => call('place', { params: { id }, query: { fields: o.fields }, signal: o.signal }),
142
+ /** A route: `{ origin, destination, intermediates?, mode?, alternatives?, routingPreference?, departAt?, fares? }`. */
143
+ route: (req, o = {}) =>
144
+ call('route', {
145
+ body: { ...req, origin: wp(req.origin), destination: wp(req.destination), ...(req.intermediates ? { intermediates: req.intermediates.map(wp) } : {}) },
146
+ signal: o.signal,
147
+ }),
148
+ /** Times and distances from each origin to each destination: `{ origins, destinations, mode? }`. */
149
+ matrix: (req, o = {}) => call('matrix', { body: { ...req, origins: req.origins.map(wp), destinations: req.destinations.map(wp) }, signal: o.signal }),
150
+ /** The best order for a batch of stops: `{ stops: [{ id, location }], vehicles: [{ id, start }], mode? }`. */
151
+ optimize: (req, o = {}) =>
152
+ call('optimize', {
153
+ body: {
154
+ ...req,
155
+ stops: req.stops.map((st) => ({ ...st, location: wp(st.location) })),
156
+ vehicles: req.vehicles.map((v) => ({ ...v, start: wp(v.start), ...(v.end ? { end: wp(v.end) } : {}) })),
157
+ },
158
+ signal: o.signal,
159
+ }),
160
+ /** What a taxi, okada, keke, bus or boat ride should cost. */
161
+ fares: (from, to, o = {}) =>
162
+ call('fares', { query: { from: ll(from), to: ll(to), mode: o.mode, at: o.at, distanceM: o.distanceM, durationS: o.durationS, charter: o.charter, rain: o.rain }, signal: o.signal }),
163
+ /** Live road speeds in a box [west, south, east, north], as GeoJSON. */
164
+ traffic: (bbox, o = {}) => call('traffic', { query: { bbox: bbox.join(',') }, signal: o.signal }),
165
+ /** The address of a map picture (an <img src>), with markers and a path. */
166
+ staticMapUrl: (o) => staticMapUrl({ key, origin, ...o }),
167
+ }
168
+ }
169
+
170
+ /** A waypoint: points ([lng, lat], GeoJSON) become { lat, lng }; postcodes and other codes pass as they are. */
171
+ const wp = (p) => (Array.isArray(p) || p?.type === 'Point' ? toLatLng(p) : p)
172
+
173
+ const toLatLng = (p) => {
174
+ const c = Array.isArray(p) ? p : p?.type === 'Point' ? p.coordinates : null
175
+ return c ? { lat: c[1], lng: c[0] } : { lat: p.lat, lng: p.lng }
176
+ }
177
+
178
+ /** A colour for static maps: a name (red, green, blue…) or #RRGGBB[AA] → 0xRRGGBB[AA]. */
179
+ const colour = (c) => (c.startsWith('#') ? '0x' + c.slice(1) : c)
180
+
181
+ /**
182
+ * A static map picture's address: `{ key, center, zoom, size: [w, h], scale?, theme?,
183
+ * format?, markers?: [{ at, color?, label?, size? }], paths?: [{ points, color?, weight?, fillColor? }] }`.
184
+ * Points are { lat, lng }, [lng, lat] or GeoJSON points. Without center and zoom,
185
+ * the map frames the markers and paths.
186
+ */
187
+ export function staticMapUrl(o) {
188
+ const { key, origin = NWAYS_ORIGIN, center, zoom, size = [640, 400], scale, theme, format, markers = [], path } = o
189
+ const paths = o.paths ?? (path ? [path] : [])
190
+ const u = new URL(origin + PATHS.staticMap)
191
+ if (center) u.searchParams.set('center', ll(center))
192
+ if (zoom !== undefined) u.searchParams.set('zoom', String(zoom))
193
+ u.searchParams.set('size', `${size[0]}x${size[1]}`)
194
+ if (scale) u.searchParams.set('scale', String(scale))
195
+ if (theme) u.searchParams.set('theme', theme)
196
+ if (format) u.searchParams.set('format', format)
197
+ for (const m of markers) {
198
+ const style = [m.color && `color:${colour(m.color)}`, m.label && `label:${m.label}`, m.size && `size:${m.size}`].filter(Boolean)
199
+ u.searchParams.append('markers', [...style, ll(m.at)].join('|'))
200
+ }
201
+ for (const p of paths) {
202
+ const style = [p.color && `color:${colour(p.color)}`, p.weight !== undefined && `weight:${p.weight}`, p.fillColor && `fillcolor:${colour(p.fillColor)}`].filter(Boolean)
203
+ u.searchParams.append('path', [...style, ...p.points.map(ll)].join('|'))
204
+ }
205
+ return withKey(u.toString(), key, origin)
206
+ }
package/style.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright 2026 N-WAYS LIMITED
3
+ import type { StyleSpecification } from 'maplibre-gl'
4
+
5
+ export type Theme = 'light' | 'dark'
6
+
7
+ export interface StyleOptions {
8
+ /** Your N-Ways API key. */
9
+ key?: string
10
+ theme?: Theme
11
+ /** Where the style, tiles and fonts are served (default https://nways.ng). */
12
+ origin?: string
13
+ /** Label language: local names, or French names where there are any. */
14
+ lang?: 'en' | 'fr'
15
+ }
16
+
17
+ export const NWAYS_ORIGIN: string
18
+ export const TILESETS: { base: string }
19
+ export const ATTRIBUTION: string
20
+ export const THEMES: Record<Theme, Record<string, string>>
21
+ /** The hosted styles' ids by theme: streets-light, streets-dark. */
22
+ export const STYLE_IDS: Record<Theme, string>
23
+ /** The address of N-Ways' hosted style (with the key). */
24
+ export function styleUrl(options?: StyleOptions): string
25
+ export function withKey(url: string, key: string | undefined, origin?: string): string
26
+ /** The SDK's built-in style as a plain MapLibre style object. */
27
+ export function buildStyle(options?: StyleOptions): StyleSpecification