@bindimaps/hyperlocal-web-sdk 0.0.3 → 0.0.4

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@bindimaps/landseer is a devDependency because tsup bundles it into dist — it must NOT be a runtime dependency in the published package",
3
3
  "name": "@bindimaps/hyperlocal-web-sdk",
4
- "version": "0.0.3",
4
+ "version": "0.0.4",
5
5
  "type": "module",
6
6
  "description": "BindiMaps Hyperlocal Web SDK for indoor localisation",
7
7
  "main": "dist/index.js",
@@ -9,13 +9,12 @@
9
9
  "exports": {
10
10
  ".": {
11
11
  "types": "./dist/index.d.ts",
12
- "development": "./src/index.ts",
12
+ "source": "./src/index.ts",
13
13
  "import": "./dist/index.js"
14
14
  }
15
15
  },
16
16
  "files": [
17
17
  "dist",
18
- "src",
19
18
  "README.md"
20
19
  ],
21
20
  "scripts": {
@@ -1,75 +0,0 @@
1
- import type { CapturedFrame } from "./index"
2
-
3
- /**
4
- * Downsamples an image to a small grayscale thumbnail for diversity comparison.
5
- */
6
- const getImageThumbnail = async (blob: Blob, size = 16): Promise<Uint8Array> => {
7
- const bitmap = await createImageBitmap(blob, { resizeWidth: size, resizeHeight: size, resizeQuality: "low" })
8
- const canvas = document.createElement("canvas")
9
- canvas.width = size
10
- canvas.height = size
11
- const ctx = canvas.getContext("2d")
12
- if (!ctx) throw new Error("Could not get canvas context")
13
- ctx.drawImage(bitmap, 0, 0)
14
- bitmap.close()
15
- const imageData = ctx.getImageData(0, 0, size, size).data
16
- const grayscale = new Uint8Array(size * size)
17
- for (let i = 0; i < imageData.length; i += 4) {
18
- grayscale[i / 4] = (imageData[i] + imageData[i + 1] + imageData[i + 2]) / 3
19
- }
20
- return grayscale
21
- }
22
-
23
- /**
24
- * Calculates the Euclidean distance between two thumbnails.
25
- */
26
- const calculateDistance = (a: Uint8Array, b: Uint8Array): number => {
27
- let sum = 0
28
- for (let i = 0; i < a.length; i++) {
29
- const diff = a[i] - b[i]
30
- sum += diff * diff
31
- }
32
- return Math.sqrt(sum)
33
- }
34
-
35
- /**
36
- * Selects the most diverse frames from a set of candidates.
37
- * Uses a greedy "max-min" distance algorithm.
38
- * O(n·k) greedy, suitable for <100 candidates. For larger inputs consider a spatial index.
39
- */
40
- export const selectDiverseFrames = async (
41
- frames: CapturedFrame[],
42
- count: number
43
- ): Promise<CapturedFrame[]> => {
44
- if (frames.length <= count) return frames
45
-
46
- const thumbnails = await Promise.all(frames.map(f => getImageThumbnail(f.imageData)))
47
- const selectedIndices = new Set<number>([0])
48
-
49
- while (selectedIndices.size < count) {
50
- let maxMinDist = -1
51
- let bestCandidateIdx = -1
52
-
53
- for (let i = 0; i < frames.length; i++) {
54
- if (selectedIndices.has(i)) continue
55
-
56
- let minDist = Infinity
57
- for (const selectedIdx of selectedIndices) {
58
- const dist = calculateDistance(thumbnails[i], thumbnails[selectedIdx])
59
- if (dist < minDist) minDist = dist
60
- }
61
-
62
- if (minDist > maxMinDist) {
63
- maxMinDist = minDist
64
- bestCandidateIdx = i
65
- }
66
- }
67
-
68
- if (bestCandidateIdx === -1) break
69
- selectedIndices.add(bestCandidateIdx)
70
- }
71
-
72
- return [...selectedIndices]
73
- .sort((a, b) => a - b)
74
- .map(idx => frames[idx])
75
- }
package/src/index.ts DELETED
@@ -1,299 +0,0 @@
1
- /**
2
- * @bindimaps/hyperlocal-web-sdk
3
- *
4
- * SDK for image-based indoor localisation using the Landseer API.
5
- */
6
-
7
- import type {
8
- GpsLogEntry,
9
- LocationEstimateImage,
10
- LocationEstimateRequest,
11
- LocationEstimateResponse,
12
- } from "@bindimaps/landseer/bmproto/landseer/landseer_pb"
13
- import {
14
- LandseerAPIService,
15
- Environment as LandseerEnvironment,
16
- LocaliseType,
17
- } from "@bindimaps/landseer/bmproto/landseer/landseer_pb"
18
- import { createClient } from "@connectrpc/connect"
19
- import { createGrpcWebTransport } from "@connectrpc/connect-web"
20
- import { selectDiverseFrames } from "./image-selection"
21
-
22
- // ============================================================================
23
- // Types
24
- // ============================================================================
25
-
26
- export type GeoCoordinate = {
27
- latitude: number
28
- longitude: number
29
- }
30
-
31
- export type CapturedFrame = {
32
- timestamp: Date
33
- imageData: Blob
34
- }
35
-
36
- export type PositionEstimate = {
37
- type: "success"
38
- position: GeoCoordinate
39
- floorId: string
40
- headingDegrees: number
41
- }
42
-
43
- export type PositionErrorCode =
44
- | "invalid_input"
45
- | "insufficient_frames"
46
- | "no_match"
47
- | "location_mismatch"
48
- | "api_error"
49
-
50
- export type PositionError = {
51
- type: "error"
52
- code: PositionErrorCode
53
- message: string
54
- details?: string
55
- }
56
-
57
- export type PositionResult = PositionEstimate | PositionError
58
-
59
- export type MockPositionConfig =
60
- | true
61
- | {
62
- position?: GeoCoordinate
63
- floorId?: string
64
- headingDegrees?: number
65
- }
66
-
67
- export type EstimatePositionOptions = {
68
- environment?: HyperlocalEnvironment
69
- mock?: MockPositionConfig
70
- }
71
-
72
- // ============================================================================
73
- // Constants
74
- // ============================================================================
75
-
76
- export const MIN_LOCALISATION_IMAGE_COUNT = 6
77
- const USE_DIVERSE_FRAME_SELECTION = false
78
-
79
- // Prefixed to avoid clashing with other `Environment` enums of the same name
80
- export enum HyperlocalEnvironment {
81
- UNSPECIFIED = 0,
82
- DEV_PREVIEW = 1,
83
- DEV_PUBLIC = 2,
84
- PROD_PREVIEW = 3,
85
- PROD_PUBLIC = 4,
86
- }
87
-
88
- // ============================================================================
89
- // Internal Helpers
90
- // ============================================================================
91
-
92
- const toLandseerEnv = (env: HyperlocalEnvironment): LandseerEnvironment => {
93
- const map: Record<HyperlocalEnvironment, LandseerEnvironment> = {
94
- [HyperlocalEnvironment.UNSPECIFIED]: LandseerEnvironment.UNSPECIFIED,
95
- [HyperlocalEnvironment.DEV_PREVIEW]: LandseerEnvironment.DEV_PREVIEW,
96
- [HyperlocalEnvironment.DEV_PUBLIC]: LandseerEnvironment.DEV_PUBLIC,
97
- [HyperlocalEnvironment.PROD_PREVIEW]: LandseerEnvironment.PROD_PREVIEW,
98
- [HyperlocalEnvironment.PROD_PUBLIC]: LandseerEnvironment.PROD_PUBLIC,
99
- }
100
- return map[env]
101
- }
102
-
103
- const blobToUint8Array = async (blob: Blob): Promise<Uint8Array> => {
104
- const arrayBuffer = await blob.arrayBuffer()
105
- return new Uint8Array(arrayBuffer)
106
- }
107
-
108
- const getBaseUrl = (environment: HyperlocalEnvironment): string =>
109
- (environment === HyperlocalEnvironment.PROD_PUBLIC || environment === HyperlocalEnvironment.PROD_PREVIEW)
110
- ? "https://landseer.bindimaps.app"
111
- : "https://landseer.bindi.dev"
112
-
113
- const isValidGpsPosition = (gpsPosition: unknown): gpsPosition is GeoCoordinate => {
114
- if (!gpsPosition || typeof gpsPosition !== "object") return false
115
- const maybeGps = gpsPosition as { latitude?: unknown, longitude?: unknown }
116
-
117
- return (
118
- Number.isFinite(maybeGps.latitude) &&
119
- Number.isFinite(maybeGps.longitude) &&
120
- Number(maybeGps.latitude) >= -90 && Number(maybeGps.latitude) <= 90 &&
121
- Number(maybeGps.longitude) >= -180 && Number(maybeGps.longitude) <= 180
122
- )
123
- }
124
-
125
- const normalizeHeadingDegrees = (headingDegrees: unknown): number =>
126
- Number.isFinite(headingDegrees) ? Number(headingDegrees) : 0
127
-
128
- const validateInputs = (
129
- frames: CapturedFrame[],
130
- locationId: string,
131
- gpsPosition: GeoCoordinate,
132
- ): PositionError | null => {
133
- if (typeof locationId !== "string" || locationId.trim().length === 0) {
134
- return {
135
- type: "error",
136
- code: "invalid_input",
137
- message: "locationId must be a non-empty string.",
138
- }
139
- }
140
-
141
- if (!isValidGpsPosition(gpsPosition)) {
142
- return {
143
- type: "error",
144
- code: "invalid_input",
145
- message: "gpsPosition coordinates are out of range (lat: -90..90, lng: -180..180).",
146
- }
147
- }
148
-
149
- if (!Array.isArray(frames)) {
150
- return {
151
- type: "error",
152
- code: "invalid_input",
153
- message: "frames must be an array.",
154
- }
155
- }
156
-
157
- if (frames.length < MIN_LOCALISATION_IMAGE_COUNT) {
158
- return {
159
- type: "error",
160
- code: "insufficient_frames",
161
- message: `At least ${MIN_LOCALISATION_IMAGE_COUNT} frames are required for localisation.`,
162
- details: `Received ${frames.length} frames, required ${MIN_LOCALISATION_IMAGE_COUNT}.`,
163
- }
164
- }
165
-
166
- return null
167
- }
168
-
169
- const buildRequest = async (
170
- frames: CapturedFrame[],
171
- gpsPosition: GeoCoordinate,
172
- environment: HyperlocalEnvironment,
173
- ): Promise<LocationEstimateRequest> => {
174
- const selectedFrames = USE_DIVERSE_FRAME_SELECTION
175
- ? await selectDiverseFrames(frames, MIN_LOCALISATION_IMAGE_COUNT)
176
- : frames
177
-
178
- const now = new Date()
179
- const ts = BigInt(now.getTime())
180
-
181
- const gps: GpsLogEntry = {
182
- gpsLocation: {
183
- lat: gpsPosition.latitude,
184
- lon: gpsPosition.longitude,
185
- horizontalAccuracyMeters: 0,
186
- mslAltitudeMeters: 0,
187
- verticalAccuracyMeters: 0,
188
- $typeName: "bmproto.landseer.LatLon",
189
- },
190
- ts,
191
- $typeName: "bmproto.landseer.GpsLogEntry",
192
- }
193
-
194
- const images: LocationEstimateImage[] = await Promise.all(
195
- selectedFrames.map(async (img) => ({
196
- ts: BigInt(img.timestamp.getTime()),
197
- imageData: await blobToUint8Array(img.imageData),
198
- $typeName: "bmproto.landseer.LocationEstimateImage",
199
- }))
200
- )
201
-
202
- return {
203
- ts,
204
- gps,
205
- images,
206
- env: toLandseerEnv(environment),
207
- $typeName: "bmproto.landseer.LocationEstimateRequest",
208
- }
209
- }
210
-
211
- const interpretResponse = (response: LocationEstimateResponse, locationId: string): PositionResult => {
212
- if (
213
- response.errors.length > 0 ||
214
- response.localiseType === LocaliseType.FAILED ||
215
- !response.locationEstimate ||
216
- !response.locationEstimate.floorId ||
217
- !Number.isFinite(response.locationEstimate.latitude) ||
218
- !Number.isFinite(response.locationEstimate.longitude)
219
- ) {
220
- return {
221
- type: "error",
222
- code: "no_match",
223
- message: response.errors.length > 0 ? response.errors.join(", ") : "Localisation failed — no match found.",
224
- details: response.errors.length > 0 ? response.errors.join("; ") : `localiseType=${response.localiseType}`,
225
- }
226
- }
227
-
228
- if (!response.locationEstimate.locationId || response.locationEstimate.locationId !== locationId) {
229
- return {
230
- type: "error",
231
- code: "location_mismatch",
232
- message: "Matched a different location than requested.",
233
- details: `expected=${locationId}, got=${response.locationEstimate.locationId ?? "undefined"}`,
234
- }
235
- }
236
-
237
- return {
238
- type: "success",
239
- position: {
240
- latitude: response.locationEstimate.latitude,
241
- longitude: response.locationEstimate.longitude,
242
- },
243
- floorId: response.locationEstimate.floorId,
244
- headingDegrees: normalizeHeadingDegrees(response.locationEstimate.headingDegrees),
245
- }
246
- }
247
-
248
- // ============================================================================
249
- // API
250
- // ============================================================================
251
-
252
- /**
253
- * Estimate a user's indoor position from captured camera frames and GPS.
254
- *
255
- * @param frames - Captured camera frames (minimum 6)
256
- * @param locationId - The Bindi location ID to localise within
257
- * @param gpsPosition - Approximate GPS coordinates
258
- * @param options - Optional configuration (environment, mock mode)
259
- */
260
- export const estimatePosition = async (
261
- frames: CapturedFrame[],
262
- locationId: string,
263
- gpsPosition: GeoCoordinate,
264
- options?: EstimatePositionOptions,
265
- ): Promise<PositionResult> => {
266
- // Mock mode — early return before any validation/protobuf/gRPC
267
- if (options?.mock) {
268
- await new Promise(resolve => setTimeout(resolve, 500))
269
- const config = options.mock === true ? {} : options.mock
270
- return {
271
- type: "success",
272
- position: config.position ?? gpsPosition,
273
- floorId: config.floorId ?? "mock-floor-1",
274
- headingDegrees: config.headingDegrees ?? 45,
275
- }
276
- }
277
-
278
- const validationError = validateInputs(frames, locationId, gpsPosition)
279
- if (validationError) return validationError
280
-
281
- const environment = options?.environment ?? HyperlocalEnvironment.PROD_PUBLIC
282
-
283
- try {
284
- const req = await buildRequest(frames, gpsPosition, environment)
285
- const transport = createGrpcWebTransport({ baseUrl: getBaseUrl(environment) })
286
- const client = createClient(LandseerAPIService, transport)
287
- const response = await client.locationEstimate(req, {
288
- signal: AbortSignal.timeout(30_000),
289
- })
290
- return interpretResponse(response, locationId)
291
- } catch (err) {
292
- return {
293
- type: "error",
294
- code: "api_error",
295
- message: "Network or transport error during localisation.",
296
- details: err instanceof Error ? err.message : String(err),
297
- }
298
- }
299
- }