@madebylars.com/mbl-fleetmap 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/README.md ADDED
@@ -0,0 +1,301 @@
1
+ # mbl-fleetmap
2
+
3
+ [![npm version][npm-version-src]][npm-version-href]
4
+ [![License][license-src]][license-href]
5
+ [![Nuxt][nuxt-src]][nuxt-href]
6
+
7
+ A Nuxt 4 module that wraps Leaflet with a transport-specific composable API — driver markers, live position updates, route display, and pickup/dropoff pins. No API key required (OpenStreetMap tiles).
8
+
9
+ ---
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install @madebylars.com/mbl-fleetmap
15
+ ```
16
+
17
+ Register the module in `nuxt.config.ts`:
18
+
19
+ ```ts
20
+ export default defineNuxtConfig({
21
+ modules: ['@madebylars.com/mbl-fleetmap'],
22
+
23
+ mblFleetMap: {
24
+ defaultCenter: [59.913, 10.752], // [lat, lng] — Oslo
25
+ defaultZoom: 13,
26
+ // tileUrl and attribution default to OpenStreetMap — no changes needed
27
+ },
28
+ })
29
+ ```
30
+
31
+ That's it. The component and all composables are auto-imported.
32
+
33
+ ---
34
+
35
+ ## Configuration options
36
+
37
+ | Option | Type | Default | Description |
38
+ |---|---|---|---|
39
+ | `tileUrl` | `string` | OpenStreetMap URL | Tile server URL template |
40
+ | `attribution` | `string` | `© OpenStreetMap contributors` | Map attribution text |
41
+ | `defaultZoom` | `number` | `13` | Initial zoom level |
42
+ | `defaultCenter` | `[number, number]` | `[51.505, -0.09]` | Initial `[lat, lng]` |
43
+ | `clusterMarkers` | `boolean` | `true` | Cluster driver markers when zoomed out |
44
+
45
+ ---
46
+
47
+ ## Quick start
48
+
49
+ ### 1. Add the map to a page
50
+
51
+ ```vue
52
+ <template>
53
+ <div class="map-container">
54
+ <MblFleetMap
55
+ map-id="fleet"
56
+ @ready="onMapReady"
57
+ @click="onMapClick"
58
+ />
59
+ </div>
60
+ </template>
61
+
62
+ <style scoped>
63
+ .map-container {
64
+ height: 100vh;
65
+ width: 100%;
66
+ }
67
+ </style>
68
+ ```
69
+
70
+ The component is renderless — it fills whatever container you give it. Apply height via CSS; without a defined height Leaflet has nothing to render into.
71
+
72
+ ### 2. Add drivers and routes on ready
73
+
74
+ ```vue
75
+ <script setup lang="ts">
76
+ const { addDriver, updateDriver } = useDriverMarkers('fleet')
77
+ const { setPickup, setDropoff } = useJobPins('fleet')
78
+ const { drawRoute } = useFleetRoute('fleet')
79
+
80
+ function onMapReady() {
81
+ // Add a driver marker (green circle with initials)
82
+ addDriver({
83
+ driverId: 'd1',
84
+ name: 'Alice Smith',
85
+ lat: 59.913,
86
+ lng: 10.752,
87
+ status: 'active', // 'active' | 'idle' | 'offline'
88
+ jobId: 'job1',
89
+ })
90
+
91
+ // Pickup (green pin) and dropoff (red pin)
92
+ setPickup( 'job1', 59.920, 10.740, 'Oslo S')
93
+ setDropoff('job1', 59.905, 10.765, 'Grønland')
94
+
95
+ // Straight-line route (replace with OSRM/GraphHopper waypoints for real routing)
96
+ drawRoute('job1', [
97
+ [59.920, 10.740],
98
+ [59.913, 10.752],
99
+ [59.905, 10.765],
100
+ ])
101
+ }
102
+
103
+ function onMapClick({ lat, lng }: { lat: number; lng: number }) {
104
+ console.log('clicked', lat, lng)
105
+ }
106
+ </script>
107
+ ```
108
+
109
+ ---
110
+
111
+ ## Composables
112
+
113
+ All composables take a `mapId` string that matches the `map-id` prop on `<MblFleetMap>`. You can have multiple independent maps on one page by using different IDs.
114
+
115
+ ### `useFleetMap(mapId)`
116
+
117
+ Access the raw Leaflet instance and control the viewport.
118
+
119
+ ```ts
120
+ const {
121
+ map, // ComputedRef<L.Map | null> — null until 'ready' fires
122
+ isReady, // ComputedRef<boolean>
123
+ setCenter, // (lat, lng, zoom?) => void
124
+ fitBounds, // (bounds: L.LatLngBounds) => void
125
+ } = useFleetMap('fleet')
126
+ ```
127
+
128
+ ### `useDriverMarkers(mapId)`
129
+
130
+ Manage driver markers. Each marker is a coloured circle with the driver's initials (CSS only, no images).
131
+
132
+ | Status | Colour |
133
+ |---|---|
134
+ | `active` | Green `#16a34a` |
135
+ | `idle` | Amber `#d97706` |
136
+ | `offline` | Grey `#6b7280` |
137
+
138
+ ```ts
139
+ const {
140
+ drivers, // Ref<Map<string, DriverMarker>>
141
+ addDriver, // (driver: DriverMarker) => void
142
+ updateDriver, // (driverId, [lat, lng]) => void — animates smoothly over 500 ms
143
+ removeDriver, // (driverId) => void
144
+ clearDrivers, // () => void
145
+ } = useDriverMarkers('fleet')
146
+ ```
147
+
148
+ **Live position updates** — call `updateDriver` as often as you receive GPS ticks. The marker slides to its new position over 500 ms using a CSS transform transition.
149
+
150
+ ```ts
151
+ // e.g. fed by a WebSocket or polling composable
152
+ watch(livePosition, pos => {
153
+ updateDriver('d1', [pos.lat, pos.lng])
154
+ })
155
+ ```
156
+
157
+ ### `useJobPins(mapId)`
158
+
159
+ Pickup (green teardrop) and dropoff (red teardrop) pins with hover tooltips. CSS only, no images.
160
+
161
+ ```ts
162
+ const {
163
+ setPickup, // (jobId, lat, lng, label?) => void
164
+ setDropoff, // (jobId, lat, lng, label?) => void
165
+ clearJob, // (jobId) => void
166
+ clearAll, // () => void
167
+ } = useJobPins('fleet')
168
+ ```
169
+
170
+ ### `useFleetRoute(mapId)`
171
+
172
+ Draw a polyline between waypoints (solid blue, weight 4).
173
+
174
+ > **Note:** The module draws straight lines between the waypoints you provide. Hook up OSRM, GraphHopper, or any routing API in your own code and pass the snapped waypoints here.
175
+
176
+ ```ts
177
+ const {
178
+ routes, // Ref<Map<string, L.Polyline>>
179
+ drawRoute, // (jobId, waypoints: [lat, lng][]) => void
180
+ clearRoute, // (jobId) => void
181
+ clearAll, // () => void
182
+ } = useFleetRoute('fleet')
183
+ ```
184
+
185
+ ### `useMapEvents(mapId)`
186
+
187
+ Subscribe to map and marker events. Call `offAll()` when the component that registered the listeners unmounts.
188
+
189
+ ```ts
190
+ const { onDriverClick, onMapClick, onDriverHover, offAll } = useMapEvents('fleet')
191
+
192
+ onDriverClick(driver => {
193
+ // driver is the full DriverMarker object at the time of the click
194
+ openDriverPanel(driver.driverId)
195
+ })
196
+
197
+ onDriverHover(driver => {
198
+ // driver is null when the cursor leaves a marker
199
+ hoveredDriver.value = driver
200
+ })
201
+
202
+ onMapClick((lat, lng) => {
203
+ console.log('map click', lat, lng)
204
+ })
205
+
206
+ onUnmounted(offAll)
207
+ ```
208
+
209
+ ---
210
+
211
+ ## Multiple maps on one page
212
+
213
+ Use a different `mapId` for each `<MblFleetMap>` instance. Every composable scopes its state to the ID you pass, so they never interfere.
214
+
215
+ ```vue
216
+ <!-- Dispatcher full-screen map -->
217
+ <MblFleetMap map-id="dispatch" @ready="setupDispatch" />
218
+
219
+ <!-- Small order-detail inset -->
220
+ <MblFleetMap map-id="order-detail" @ready="setupOrderDetail" />
221
+ ```
222
+
223
+ ```ts
224
+ const { addDriver } = useDriverMarkers('dispatch')
225
+ const { setDropoff } = useJobPins('order-detail')
226
+ ```
227
+
228
+ ---
229
+
230
+ ## TypeScript types
231
+
232
+ All types are exported from the package root:
233
+
234
+ ```ts
235
+ import type {
236
+ DriverMarker,
237
+ JobPin,
238
+ RouteOptions,
239
+ MarkerStatus,
240
+ LatLng,
241
+ } from '@madebylars.com/mbl-fleetmap'
242
+ ```
243
+
244
+ ```ts
245
+ interface DriverMarker {
246
+ driverId: string
247
+ name: string
248
+ lat: number
249
+ lng: number
250
+ status: 'active' | 'idle' | 'offline'
251
+ jobId?: string
252
+ }
253
+ ```
254
+
255
+ ---
256
+
257
+ ## SSR
258
+
259
+ Leaflet requires the browser DOM and cannot run server-side. The module handles this automatically:
260
+
261
+ - The plugin is registered as `mode: 'client'` — it never runs during SSR.
262
+ - `<MblFleetMap>` is registered as `mode: 'client'` — it is not rendered on the server.
263
+ - Composable methods are no-ops when called before the client plugin has initialised (which should never happen in normal use, since `ready` only fires client-side).
264
+
265
+ No `<ClientOnly>` wrapper or `process.client` guards are needed in your own code.
266
+
267
+ ---
268
+
269
+ ## Integration with mbl-transport
270
+
271
+ Replace all `mbl-mapman` references:
272
+
273
+ | View | Composables |
274
+ |---|---|
275
+ | Dispatcher fleet | `useDriverMarkers('fleet')` fed by `useFleet()` from mbl-order · `useMapEvents('fleet').onDriverClick()` opens driver panel |
276
+ | Driver job navigation | `useJobPins` + `useFleetRoute` + `useDriverMarkers` with a single self-position marker |
277
+ | Customer order detail | Single driver marker + dropoff pin, read-only (no click handlers) |
278
+
279
+ ---
280
+
281
+ ## Contributing
282
+
283
+ ```bash
284
+ npm install # install dependencies
285
+ npm run dev:prepare # generate type stubs
286
+ npm run dev # start playground at localhost:3000
287
+ npm run lint
288
+ npm run test
289
+ ```
290
+
291
+ ---
292
+
293
+ <!-- Badges -->
294
+ [npm-version-src]: https://img.shields.io/npm/v/@madebylars.com/mbl-fleetmap/latest.svg?style=flat&colorA=020420&colorB=00DC82
295
+ [npm-version-href]: https://npmjs.com/package/@madebylars.com/mbl-fleetmap
296
+
297
+ [license-src]: https://img.shields.io/npm/l/@madebylars.com/mbl-fleetmap.svg?style=flat&colorA=020420&colorB=00DC82
298
+ [license-href]: https://npmjs.com/package/@madebylars.com/mbl-fleetmap
299
+
300
+ [nuxt-src]: https://img.shields.io/badge/Nuxt-020420?logo=nuxt
301
+ [nuxt-href]: https://nuxt.com
@@ -0,0 +1,18 @@
1
+ import * as _nuxt_schema from '@nuxt/schema';
2
+
3
+ interface ModuleOptions {
4
+ tileUrl: string;
5
+ attribution: string;
6
+ defaultZoom: number;
7
+ defaultCenter: [number, number];
8
+ clusterMarkers: boolean;
9
+ }
10
+ declare module 'nuxt/schema' {
11
+ interface PublicRuntimeConfig {
12
+ mblFleetMap: ModuleOptions;
13
+ }
14
+ }
15
+ declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
16
+
17
+ export { _default as default };
18
+ export type { ModuleOptions };
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "mbl-fleetmap",
3
+ "configKey": "mblFleetMap",
4
+ "version": "1.0.0",
5
+ "builder": {
6
+ "@nuxt/module-builder": "1.0.2",
7
+ "unbuild": "3.6.1"
8
+ }
9
+ }
@@ -0,0 +1,32 @@
1
+ import { defineNuxtModule, createResolver, addPlugin, addComponent, addImportsDir } from '@nuxt/kit';
2
+
3
+ const module$1 = defineNuxtModule({
4
+ meta: {
5
+ name: "mbl-fleetmap",
6
+ configKey: "mblFleetMap"
7
+ },
8
+ defaults: {
9
+ tileUrl: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
10
+ attribution: "\xA9 OpenStreetMap contributors",
11
+ defaultZoom: 13,
12
+ defaultCenter: [51.505, -0.09],
13
+ clusterMarkers: true
14
+ },
15
+ setup(options, nuxt) {
16
+ const resolver = createResolver(import.meta.url);
17
+ nuxt.options.css.push("leaflet/dist/leaflet.css");
18
+ nuxt.options.runtimeConfig.public.mblFleetMap = options;
19
+ addPlugin({
20
+ src: resolver.resolve("./runtime/plugin"),
21
+ mode: "client"
22
+ });
23
+ addComponent({
24
+ name: "MblFleetMap",
25
+ filePath: resolver.resolve("./runtime/components/MblFleetMap.vue"),
26
+ mode: "client"
27
+ });
28
+ addImportsDir(resolver.resolve("./runtime/composables"));
29
+ }
30
+ });
31
+
32
+ export { module$1 as default };
@@ -0,0 +1,20 @@
1
+ type __VLS_Props = {
2
+ mapId: string;
3
+ center?: [number, number];
4
+ zoom?: number;
5
+ };
6
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
7
+ ready: () => any;
8
+ click: (args_0: {
9
+ lat: number;
10
+ lng: number;
11
+ }) => any;
12
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
13
+ onReady?: (() => any) | undefined;
14
+ onClick?: ((args_0: {
15
+ lat: number;
16
+ lng: number;
17
+ }) => any) | undefined;
18
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
19
+ declare const _default: typeof __VLS_export;
20
+ export default _default;
@@ -0,0 +1,41 @@
1
+ <script setup>
2
+ import { onMounted, onUnmounted, ref } from "vue";
3
+ import { useRuntimeConfig } from "#app";
4
+ import { registerMap, getMap, removeMap, getLeaflet } from "../utils/registry";
5
+ import { emitMapClick } from "../utils/events";
6
+ const props = defineProps({
7
+ mapId: { type: String, required: true },
8
+ center: { type: Array, required: false },
9
+ zoom: { type: Number, required: false }
10
+ });
11
+ const emit = defineEmits(["ready", "click"]);
12
+ const containerEl = ref(null);
13
+ onMounted(() => {
14
+ const L = getLeaflet();
15
+ if (!L || !containerEl.value) return;
16
+ const config = useRuntimeConfig().public.mblFleetMap;
17
+ const map = L.map(containerEl.value, {
18
+ center: props.center ?? config.defaultCenter,
19
+ zoom: props.zoom ?? config.defaultZoom
20
+ });
21
+ L.tileLayer(config.tileUrl, {
22
+ attribution: config.attribution
23
+ }).addTo(map);
24
+ map.on("click", (e) => {
25
+ const payload = { lat: e.latlng.lat, lng: e.latlng.lng };
26
+ emitMapClick(props.mapId, payload);
27
+ emit("click", payload);
28
+ });
29
+ registerMap(props.mapId, map);
30
+ emit("ready");
31
+ });
32
+ onUnmounted(() => {
33
+ const map = getMap(props.mapId);
34
+ if (map) map.remove();
35
+ removeMap(props.mapId);
36
+ });
37
+ </script>
38
+
39
+ <template>
40
+ <div ref="containerEl" />
41
+ </template>
@@ -0,0 +1,20 @@
1
+ type __VLS_Props = {
2
+ mapId: string;
3
+ center?: [number, number];
4
+ zoom?: number;
5
+ };
6
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
7
+ ready: () => any;
8
+ click: (args_0: {
9
+ lat: number;
10
+ lng: number;
11
+ }) => any;
12
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
13
+ onReady?: (() => any) | undefined;
14
+ onClick?: ((args_0: {
15
+ lat: number;
16
+ lng: number;
17
+ }) => any) | undefined;
18
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
19
+ declare const _default: typeof __VLS_export;
20
+ export default _default;
@@ -0,0 +1,22 @@
1
+ import type { DriverMarker, LatLng } from '../../types.js';
2
+ export declare function useDriverMarkers(mapId: string): {
3
+ addDriver: (driver: DriverMarker) => void;
4
+ updateDriver: (driverId: string, position: LatLng) => void;
5
+ removeDriver: (driverId: string) => void;
6
+ clearDrivers: () => void;
7
+ drivers: import("vue").Ref<Map<string, {
8
+ driverId: string;
9
+ name: string;
10
+ lat: number;
11
+ lng: number;
12
+ status: "active" | "idle" | "offline";
13
+ jobId?: string | undefined;
14
+ }> & Omit<Map<string, DriverMarker>, keyof Map<any, any>>, Map<string, DriverMarker> | (Map<string, {
15
+ driverId: string;
16
+ name: string;
17
+ lat: number;
18
+ lng: number;
19
+ status: "active" | "idle" | "offline";
20
+ jobId?: string | undefined;
21
+ }> & Omit<Map<string, DriverMarker>, keyof Map<any, any>>)>;
22
+ };
@@ -0,0 +1,72 @@
1
+ import { ref } from "vue";
2
+ import { getMap, getLeaflet } from "../utils/registry.js";
3
+ import { emitDriverClick, emitDriverHover } from "../utils/events.js";
4
+ const STATUS_COLORS = {
5
+ active: "#16a34a",
6
+ idle: "#d97706",
7
+ offline: "#6b7280"
8
+ };
9
+ function initials(name) {
10
+ return name.split(" ").slice(0, 2).map((n) => n[0] ?? "").join("").toUpperCase();
11
+ }
12
+ function createDriverIcon(L, driver) {
13
+ const color = STATUS_COLORS[driver.status];
14
+ const label = initials(driver.name);
15
+ return L.divIcon({
16
+ className: "",
17
+ html: `<div style="
18
+ width:36px;height:36px;border-radius:50%;
19
+ background:${color};color:#fff;
20
+ display:flex;align-items:center;justify-content:center;
21
+ font-size:13px;font-weight:600;font-family:sans-serif;
22
+ box-shadow:0 2px 4px rgba(0,0,0,.3);
23
+ cursor:pointer;
24
+ ">${label}</div>`,
25
+ iconSize: [36, 36],
26
+ iconAnchor: [18, 18]
27
+ });
28
+ }
29
+ export function useDriverMarkers(mapId) {
30
+ const drivers = ref(/* @__PURE__ */ new Map());
31
+ const markerInstances = /* @__PURE__ */ new Map();
32
+ const addDriver = (driver) => {
33
+ const L = getLeaflet();
34
+ const map = getMap(mapId);
35
+ if (!L || !map) return;
36
+ const icon = createDriverIcon(L, driver);
37
+ const marker = L.marker([driver.lat, driver.lng], { icon }).addTo(map);
38
+ const el = marker.getElement();
39
+ if (el) el.style.transition = "transform 500ms ease-out";
40
+ marker.on("click", () => {
41
+ const current = drivers.value.get(driver.driverId);
42
+ if (current) emitDriverClick(mapId, current);
43
+ });
44
+ marker.on("mouseover", () => {
45
+ const current = drivers.value.get(driver.driverId);
46
+ if (current) emitDriverHover(mapId, current);
47
+ });
48
+ marker.on("mouseout", () => emitDriverHover(mapId, null));
49
+ markerInstances.set(driver.driverId, marker);
50
+ drivers.value.set(driver.driverId, driver);
51
+ };
52
+ const updateDriver = (driverId, position) => {
53
+ const marker = markerInstances.get(driverId);
54
+ if (!marker) return;
55
+ marker.setLatLng(position);
56
+ const existing = drivers.value.get(driverId);
57
+ if (existing) {
58
+ drivers.value.set(driverId, { ...existing, lat: position[0], lng: position[1] });
59
+ }
60
+ };
61
+ const removeDriver = (driverId) => {
62
+ markerInstances.get(driverId)?.remove();
63
+ markerInstances.delete(driverId);
64
+ drivers.value.delete(driverId);
65
+ };
66
+ const clearDrivers = () => {
67
+ markerInstances.forEach((m) => m.remove());
68
+ markerInstances.clear();
69
+ drivers.value.clear();
70
+ };
71
+ return { addDriver, updateDriver, removeDriver, clearDrivers, drivers };
72
+ }
@@ -0,0 +1,7 @@
1
+ import type L from 'leaflet';
2
+ export declare function useFleetMap(mapId: string): {
3
+ map: import("vue").ComputedRef<L.Map | null>;
4
+ setCenter: (lat: number, lng: number, zoom?: number) => void;
5
+ fitBounds: (bounds: L.LatLngBounds) => void;
6
+ isReady: import("vue").ComputedRef<boolean>;
7
+ };
@@ -0,0 +1,16 @@
1
+ import { computed } from "vue";
2
+ import { getMap } from "../utils/registry.js";
3
+ export function useFleetMap(mapId) {
4
+ const map = computed(() => getMap(mapId));
5
+ const isReady = computed(() => getMap(mapId) !== null);
6
+ const setCenter = (lat, lng, zoom) => {
7
+ const m = getMap(mapId);
8
+ if (!m) return;
9
+ if (zoom !== void 0) m.setView([lat, lng], zoom);
10
+ else m.panTo([lat, lng]);
11
+ };
12
+ const fitBounds = (bounds) => {
13
+ getMap(mapId)?.fitBounds(bounds);
14
+ };
15
+ return { map, setCenter, fitBounds, isReady };
16
+ }