@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/CHANGELOG.md +21 -0
- package/LICENSE +202 -0
- package/NOTICE +29 -0
- package/README.md +78 -2
- package/autocomplete.d.ts +31 -0
- package/autocomplete.js +185 -0
- package/index.d.ts +30 -0
- package/index.js +154 -0
- package/package.json +71 -4
- package/services.d.ts +164 -0
- package/services.js +206 -0
- package/style.d.ts +27 -0
- package/style.js +228 -0
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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|