# `@mappedin/blue-dot`

An extension for [Mappedin JS](https://www.npmjs.com/package/@mappedin/mappedin-js) to track the device position on the map.

## Usage

### Installation

With NPM:

```zsh
npm install @mappedin/blue-dot
```

With Yarn:

```zsh
yarn add @mappedin/blue-dot
```

### Getting Started

```ts
import { show3dMap } from '@mappedin/mappedin-js';
import { BlueDot } from '@mappedin/blue-dot';

const mapView = await show3dMap(...);

// Create a new BlueDot
const blueDot = new BlueDot(mapView);
blueDot.enable();

// Listening to device position updates from the browser
blueDot.watchDevicePosition(true);

// Report a position from an external source (e.g., custom IPS)
blueDot.reportPosition({
  latitude: 43.6532,
  longitude: -79.3832,
  confidence: 0.8, // 0-1 confidence score
  heading: 90, // Optional heading in degrees
  floorLevel: 0, // Optional floor level
});

// Force position to a known location (overrides all other sources)
blueDot.forcePosition(
  {
    latitude: 43.6532,
    longitude: -79.3832,
    heading: 90,
    floorLevel: 0,
  },
  30000, // How long to hold this position in ms
);

// Attach the camera to the BlueDot
blueDot.follow('position-only');
```

## Permissions

### Geolocation Permission

`watchDevicePosition(true)` requires the `geolocation` permission. The browser will automatically prompt the user to allow location access when called.

```ts
// Browser will prompt for permission
blueDot.watchDevicePosition(true);

// Listen for errors (including permission denied)
blueDot.on('error', error => {
	if (error.code === error.PERMISSION_DENIED) {
		console.log('User denied geolocation permission');
	}
});
```

### Device Orientation Permission (iOS)

`watchDeviceOrientation(true)` requires the `deviceorientation` permission on iOS. **This method must be called in response to a user gesture** (click, tap) on iOS devices.

```ts
// Must be called from a user gesture handler on iOS
button.addEventListener('click', async () => {
	await blueDot.watchDeviceOrientation(true);
});
```

On Android, the `deviceorientationabsolute` event is used and does not require explicit permission.

## API Reference

### Constructor

```ts
const blueDot = new BlueDot(mapView: MapView);
```

Creates a new BlueDot instance attached to the given MapView.

### Methods

#### `enable(options?: BlueDotUpdateState)`

Enable the BlueDot. Must be called before other methods.

```ts
blueDot.enable({
	color: '#2266ff',
	radius: 10,
	timeout: 30000,
	watchDevicePosition: true,
});
```

#### `disable()`

Disable and hide the BlueDot. Stops all position tracking.

```ts
blueDot.disable();
```

#### `getState(): ReadonlyDeep<BlueDotState>`

Returns the current BlueDot configuration (radius, colors, timeout, etc.).

```ts
const state = blueDot.getState();
console.log(state.radius, state.color);
```

#### `updateState(options: BlueDotUpdateState): void`

Update BlueDot options after it has been enabled (e.g. colors, timeout, accuracy threshold).

```ts
blueDot.updateState({ color: '#ff0000', timeout: 60000 });
```

#### `watchDevicePosition(watch: boolean): void`

Start or stop listening to device GPS position.

- Requires geolocation permission (browser prompts automatically)
- Emits `position-update` events when position changes
- Emits `error` events on geolocation errors

```ts
blueDot.watchDevicePosition(true); // Start tracking
blueDot.watchDevicePosition(false); // Stop tracking
```

#### `watchDeviceOrientation(watch: boolean): Promise<void>`

Start or stop listening to device compass heading.

- On iOS: Requires permission, must call from user gesture
- On Android: Uses `deviceorientationabsolute` event (no permission needed)
- Emits `device-orientation-update` events when heading changes

```ts
// Call from a click handler on iOS
button.addEventListener('click', async () => {
	await blueDot.watchDeviceOrientation(true);
});
```

#### `reportPosition(options)`

Report a position from an external source (e.g., custom Indoor Positioning System). The position is fed into the fusion engine with a confidence score that influences how much weight it receives relative to other sources like GPS.

```ts
blueDot.reportPosition({
	latitude: 43.6532,
	longitude: -79.3832,
	confidence: 0.8, // 0-1: higher = more influence on fused position
	heading: 90, // Optional: degrees from north
	floorLevel: 0, // Optional: floor level
});
```

Parameters:

- `latitude: number` - Latitude coordinate (required)
- `longitude: number` - Longitude coordinate (required)
- `confidence?: number` - Confidence score from 0 to 1 (default: 0.5)
- `accuracy?: number` - Accuracy in meters
- `heading?: number` - Heading in degrees from north
- `floorLevel?: number` - Floor level
- `timestamp?: number` - Timestamp in milliseconds (default: Date.now())

#### `forcePosition(position, durationMs?)`

Force the BlueDot to a specific position, overriding all other data sources for a specified duration. Use this when you have an authoritative position from a calibration source like Visual Positioning (VPS) or AI Localizer.

```ts
blueDot.forcePosition(
	{
		latitude: 43.6532,
		longitude: -79.3832,
		heading: 90, // Optional: degrees from north
		floorLevel: 0, // Optional: floor level
	},
	30000, // How long to hold (default: 30 seconds)
);
```

Parameters:

- `position.latitude: number` - Latitude coordinate (required)
- `position.longitude: number` - Longitude coordinate (required)
- `position.heading?: number` - Heading in degrees from north
- `position.floorLevel?: number` - Floor level
- `durationMs?: number` - Duration in milliseconds (default: 30000)

During the forced period, GPS and other position sources are ignored. After expiration, the BlueDot transitions back to using fused position data. Emits `anchor-set` when activated and `anchor-expired` when the duration ends.

#### `update(position, options?)` _(deprecated)_

> **Deprecated**: Use `reportPosition()` for feeding positions into the fusion engine, or `forcePosition()` to set an authoritative position anchor.

Legacy method to manually set or override position properties.

```ts
// Set full position
blueDot.update({
	latitude: 43.6532,
	longitude: -79.3832,
	accuracy: 5,
	heading: 90,
	floorOrFloorId: floor,
});

// Clear manual position
blueDot.update(undefined);
```

Options:

- `silent?: boolean` - If true, don't trigger status transitions or events
- `animate?: boolean` - If false, skip position animation (default: true)

#### `follow(mode, options?)`

Make the camera follow the BlueDot.

```ts
// Follow position only
blueDot.follow('position-only');

// Follow position and rotate camera with heading
blueDot.follow('position-and-heading');

// Follow position and align camera with navigation path direction
blueDot.follow('position-and-path-direction');

// Stop following
blueDot.follow(false);
```

Camera options (`FollowCameraOptions`):

- `zoomLevel?: number` - Target zoom level (default: 21)
- `pitch?: number` - Camera pitch angle (default: 45)
- `bearing?: number` - Camera bearing in degrees (position-only mode only)
- `duration?: number` - Animation duration in ms (default: 1000)
- `easing?: 'ease-in' | 'ease-out' | 'ease-in-out' | 'linear'` - Animation easing (default: 'ease-in-out')

#### `setPositionProcessor(processor?)`

Set a callback to process/filter incoming geolocation position updates.

```ts
blueDot.setPositionProcessor((currentState, incomingUpdate) => {
	// Filter out positions outside venue bounds
	if (!isWithinBounds(incomingUpdate)) {
		return undefined; // Discard update
	}

	// Modify position
	return {
		...incomingUpdate,
		accuracy: Math.min(incomingUpdate.accuracy, 50),
	};
});

// Clear processor
blueDot.setPositionProcessor(undefined);
```

#### `on(eventName, callback)`

Subscribe to BlueDot events.

```ts
const unsubscribe = blueDot.on('position-update', event => {
	console.log(event.coordinate, event.floor);
});

// Later: unsubscribe
unsubscribe();
```

#### `off(eventName, callback)`

Unsubscribe from BlueDot events.

```ts
blueDot.off('position-update', handler);
```

#### `destroy()`

Clean up all resources. Call when done with the BlueDot.

```ts
blueDot.destroy();
```

## Events

### `position-update`

Fired when position changes (from device or manual update).

```ts
blueDot.on('position-update', event => {
	console.log(event.coordinate); // Coordinate object
	console.log(event.floor); // Floor object or undefined
	console.log(event.heading); // Heading in degrees
	console.log(event.accuracy); // Accuracy in meters
});
```

### `device-orientation-update`

Fired when device heading changes.

```ts
blueDot.on('device-orientation-update', event => {
	console.log(event.heading); // Heading in degrees from north
});
```

### `status-change`

Fired when BlueDot status changes.

```ts
blueDot.on('status-change', event => {
	console.log(event.status); // New status: 'hidden' | 'active' | 'inactive' | 'disabled'
	console.log(event.action); // Action that caused the change: 'timeout' | 'error' | 'position-update' | 'enable' | 'disable' | 'initialize'
});
```

### `error`

Fired on geolocation errors.

```ts
blueDot.on('error', (error: GeolocationPositionError) => {
	switch (error.code) {
		case error.PERMISSION_DENIED:
			console.log('User denied geolocation permission');
			break;
		case error.POSITION_UNAVAILABLE:
			console.log('Position unavailable');
			break;
		case error.TIMEOUT:
			console.log('Position request timed out');
			break;
	}
});
```

### `follow-change`

Fired when follow mode changes.

```ts
blueDot.on('follow-change', event => {
	console.log(event.following); // Whether the camera is following the BlueDot
	console.log(event.mode); // Follow mode when following: 'position-only' | 'position-and-heading' | 'position-and-path-direction'
});
```

### `click`

Fired when the BlueDot is clicked.

```ts
blueDot.on('click', event => {
	console.log('BlueDot clicked at', event.coordinate);
});
```

### `hover`

Fired when the BlueDot is hovered.

```ts
blueDot.on('hover', event => {
	console.log('BlueDot hovered at', event.coordinate);
});
```

### `anchor-set`

Fired when a position anchor is set via `forcePosition()` or a custom sensor.

```ts
blueDot.on('anchor-set', event => {
	console.log('Anchor set:', event.anchor);
	console.log('Position:', event.anchor.latitude, event.anchor.longitude);
	console.log('From sensor:', event.anchor.sensorId);
});
```

### `anchor-expired`

Fired when a position anchor expires after its duration.

```ts
blueDot.on('anchor-expired', event => {
	console.log('Anchor expired:', event.anchor.sensorId);
	// BlueDot will now use fused position data again
});
```

## Options

```ts
blueDot.on('anchor-set', event => {
	console.log('Anchor set:', event.anchor);
	console.log('Position:', event.anchor.latitude, event.anchor.longitude);
	console.log('From sensor:', event.anchor.sensorId);
});
```

### `anchor-expired`

Fired when a position anchor expires after its duration.

```ts
blueDot.on('anchor-expired', event => {
	console.log('Anchor expired:', event.anchor.sensorId);
	// BlueDot will now use fused position data again
});
```

## Options

Options for `enable()` and `updateState()`. All properties are optional. The type is exported as `BlueDotUpdateState` from `@mappedin/blue-dot`.

```ts
import type { BlueDotUpdateState } from '@mappedin/blue-dot';

// BlueDotUpdateState structure (all properties optional)
interface Options {
	/**
	 * The radius of the BlueDot in pixels. The BlueDot will maintain this size clamped to a minimum of 0.35 metres.
	 * @default 10
	 */
	radius?: number;
	/**
	 * The color of the BlueDot core element.
	 * @default #2266ff
	 */
	color?: string;
	/**
	 * The color of the BlueDot when it has timed out and gone inactive.
	 * @default #808080
	 */
	inactiveColor?: string;
	/**
	 * Options for the accuracy ring around the BlueDot.
	 */
	accuracyRing?: {
		/**
		 * Whether the accuracy ring is visible.
		 * @default true
		 */
		visible?: boolean;
		/**
		 * The color of the accuracy ring.
		 * @default #2266ff
		 */
		color?: string;
		/**
		 * The opacity of the accuracy ring.
		 * @default 0.3
		 */
		opacity?: number;
	};
	/**
	 * Options for the heading directional indicator.
	 */
	heading?: {
		/**
		 * The color of the heading cone.
		 * @default #2266ff
		 */
		color?: string;
		/**
		 * The opacity of the heading cone.
		 * @default 0.7
		 */
		opacity?: number;
		/**
		 * Whether to display the heading cone when the BlueDot is inactive.
		 * @default false
		 */
		displayWhenInactive?: boolean;
	};
	/**
	 * The duration of the timeout in milliseconds.
	 * If the BlueDot does not receive a position update within this time, it will grey out until a position is received.
	 * @default 30000
	 */
	timeout?: number;
	/**
	 * Whether to watch the device's position automatically when enabled.
	 * @default true
	 */
	watchDevicePosition?: boolean;
	/**
	 * The initial state of the BlueDot. Use 'inactive' to show the BlueDot immediately in grey.
	 * @default 'hidden'
	 */
	initialState?: 'hidden' | 'inactive';
	/**
	 * Whether to prevent position updates outside the map bounds.
	 * @default true
	 */
	preventOutOfBounds?: boolean;
	/**
	 * Maximum accuracy (in meters) to accept. Updates exceeding this value are dropped.
	 * @default 50
	 */
	accuracyThreshold?: number;
	/**
	 * Whether to log debug messages.
	 * @default false
	 */
	debug?: boolean;
}
```

## Custom Sensors

You can create custom sensors to provide position data from your own sources (e.g., BLE beacons, Wi-Fi triangulation, visual positioning). Custom sensors extend `BaseSensor` and can publish position updates or set authoritative position anchors.

### Creating a Custom Sensor

```ts
import { BaseSensor } from '@mappedin/blue-dot';

class MyPositioningSensor extends BaseSensor {
	readonly id = 'my-ips';
	readonly requiresPermission = false;

	protected async start(): Promise<void> {
		// Start your positioning system
		this.myIps.onPosition((lat, lng, confidence) => {
			// Publish absolute position updates via the internal event system
			this.publish('absolute-update', {
				sensorId: this.id,
				update: {
					latitude: lat,
					longitude: lng,
					confidence, // 0-1
					timestamp: Date.now(),
				},
			});
		});
	}

	protected stop(): void {
		// Clean up your positioning system
		this.myIps.stop();
	}

	async checkPermission(): Promise<'granted' | 'denied' | 'prompt' | 'unavailable'> {
		return 'granted';
	}

	async requestPermission(): Promise<'granted' | 'denied' | 'prompt' | 'unavailable'> {
		return 'granted';
	}
}
```

### Setting Position Anchors from Custom Sensors

Custom sensors can set authoritative position anchors using `this.setAnchor()`. This is useful for calibration sources like Visual Positioning (VPS) or AI localization.

```ts
class MyLocalizerSensor extends BaseSensor {
	readonly id = 'my-localizer';
	readonly requiresPermission = false;

	async localize(imageData: Blob): Promise<void> {
		const result = await this.callLocalizerApi(imageData);

		// Set an anchor - this overrides other position sources
		this.setAnchor({
			latitude: result.latitude,
			longitude: result.longitude,
			heading: result.bearing,
			floorLevel: result.floor,
			ttl: 60000, // Anchor valid for 60 seconds
			confidence: 0.95, // High confidence
			anchorOnlyPeriodMs: 5000, // Ignore GPS for first 5 seconds
		});
	}

	clearLocalization(): void {
		this.clearAnchor();
	}

	// ... other required methods
}
```

### Registering Custom Sensors

```ts
const blueDot = new BlueDot(mapView);

// Create and register your sensor
const mySensor = new MyPositioningSensor();
blueDot.Sensors.register(mySensor);

// Enable the sensor
mySensor.enable();

// Access built-in sensors via .get()
blueDot.Sensors.get('geolocation').enable();
blueDot.Sensors.get('deviceorientation').enable();
```

### Anchor Options

When calling `setAnchor()`, you can configure:

- `latitude: number` - Latitude coordinate (required)
- `longitude: number` - Longitude coordinate (required)
- `heading?: number` - Heading in degrees from north
- `floorLevel?: number` - Floor level
- `ttl?: number` - Time-to-live in milliseconds (default: 30000)
- `confidence?: number` - Confidence score 0-1 (default: 1.0)
- `anchorOnlyPeriodMs?: number` - Duration to ignore other sources (default: 5000)

## React Native

```tsx
import React, { useEffect, useCallback } from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import { MapView, useMapData } from '@mappedin/react-native-sdk';
import { useBlueDot, useBlueDotEvent } from '@mappedin/blue-dot/rn';

function MyComponent() {
	const { mapData } = useMapData({
		key: 'your-api-key',
		secret: 'your-api-secret',
		mapId: 'your-map-id',
	});

	return (
		<MapView mapData={mapData}>
			<BlueDotDisplay />
		</MapView>
	);
}

function BlueDotDisplay() {
	// All methods are async and return Promises
	const {
		isReady,
		isEnabled,
		status,
		coordinate,
		floor,
		isFollowing,
		accuracy,
		heading,
		enable,
		disable,
		update, // Deprecated: use reportPosition or forcePosition
		reportPosition,
		forcePosition,
		follow,
	} = useBlueDot();

	// Listen for position updates
	useBlueDotEvent(
		'position-update',
		useCallback(event => {
			console.log('Position updated:', event.coordinate, event.floor);
		}, []),
	);

	// Listen for status changes
	useBlueDotEvent(
		'status-change',
		useCallback(event => {
			console.log('Status changed:', event.status);
		}, []),
	);

	// Listen for follow mode changes
	useBlueDotEvent(
		'follow-change',
		useCallback(event => {
			console.log('Follow mode:', event.following);
		}, []),
	);

	// Listen for anchor events (from forcePosition or custom sensors)
	useBlueDotEvent(
		'anchor-set',
		useCallback(event => {
			console.log('Anchor set from:', event.anchor.sensorId);
		}, []),
	);

	useBlueDotEvent(
		'anchor-expired',
		useCallback(event => {
			console.log('Anchor expired:', event.anchor.sensorId);
		}, []),
	);

	useEffect(() => {
		if (isReady && !isEnabled) {
			// All methods are async - use await or .then()
			enable({
				radius: 15,
				color: '#ff0000',
				watchDevicePosition: false,
			});
		}
	}, [isReady, isEnabled, enable]);

	const handleReportPosition = useCallback(async () => {
		try {
			// Report position from external IPS
			await reportPosition({
				latitude: 43.6532,
				longitude: -79.3832,
				confidence: 0.8,
				heading: 90,
			});

			// Enable follow mode
			await follow('position-and-heading', {
				zoomLevel: 19,
			});
		} catch (error) {
			console.error('Failed to report position:', error);
		}
	}, [reportPosition, follow]);

	const handleForcePosition = useCallback(async () => {
		try {
			// Force position from calibration source
			await forcePosition(
				{
					latitude: 43.6532,
					longitude: -79.3832,
					heading: 90,
					floorLevel: 0,
				},
				30000,
			);
		} catch (error) {
			console.error('Failed to force position:', error);
		}
	}, [forcePosition]);

	return (
		<View>
			<Text>Is Ready: {isReady ? 'Yes' : 'No'}</Text>
			<Text>Is Enabled: {isEnabled ? 'Yes' : 'No'}</Text>
			<Text>Status: {status}</Text>
			<Text>Following: {isFollowing ? 'Yes' : 'No'}</Text>
			{coordinate && (
				<Text>
					Position: {coordinate.latitude.toFixed(4)}, {coordinate.longitude.toFixed(4)}
				</Text>
			)}
			{accuracy && <Text>Accuracy: {accuracy.toFixed(1)}m</Text>}
			{heading && <Text>Heading: {heading.toFixed(0)}°</Text>}
			<TouchableOpacity onPress={handleReportPosition}>
				<Text>Report Position & Follow</Text>
			</TouchableOpacity>
			<TouchableOpacity onPress={handleForcePosition}>
				<Text>Force Position (Calibration)</Text>
			</TouchableOpacity>
		</View>
	);
}
```

**Key Differences from Vanilla JS:**

- **All methods are async**: `enable()`, `disable()`, `reportPosition()`, `forcePosition()`, and `follow()` return Promises
- **Rich state**: Hook returns `isReady`, `state`, `position`, `floor`, `isFollowing`, `accuracy`, `heading` for real-time updates
- **Separate event hook**: Use `useBlueDotEvent` for listening to position-update, state-change, follow-change, anchor-set, and anchor-expired events
