zet-api 0.1.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/LICENSE +674 -0
- package/README.md +211 -0
- package/dist/index.d.mts +561 -0
- package/dist/index.d.ts +561 -0
- package/dist/index.js +864 -0
- package/dist/index.mjs +803 -0
- package/package.json +43 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
MemoryStorage: () => MemoryStorage,
|
|
34
|
+
ZETApiError: () => ZETApiError,
|
|
35
|
+
ZETAuthError: () => ZETAuthError,
|
|
36
|
+
ZETClient: () => ZETClient,
|
|
37
|
+
ZETTicketStatus: () => ZETTicketStatus,
|
|
38
|
+
ZETTripStatus: () => ZETTripStatus,
|
|
39
|
+
buildTimetable: () => buildTimetable,
|
|
40
|
+
collectAll: () => collectAll,
|
|
41
|
+
fetchGtfsRtVehicles: () => fetchGtfsRtVehicles,
|
|
42
|
+
formatCountdown: () => formatCountdown,
|
|
43
|
+
getArticleLabel: () => getArticleLabel,
|
|
44
|
+
getCurrentStopIndex: () => getCurrentStopIndex,
|
|
45
|
+
getLastArrivedStop: () => getLastArrivedStop,
|
|
46
|
+
getNearbyVehicles: () => getNearbyVehicles,
|
|
47
|
+
getNextStop: () => getNextStop,
|
|
48
|
+
getStandardArticles: () => getStandardArticles,
|
|
49
|
+
getTicketRemainingSeconds: () => getTicketRemainingSeconds,
|
|
50
|
+
getTicketVehicleNumber: () => getTicketVehicleNumber,
|
|
51
|
+
haversineMeters: () => haversineMeters,
|
|
52
|
+
isBus: () => isBus,
|
|
53
|
+
isTicketActive: () => isTicketActive,
|
|
54
|
+
isTram: () => isTram,
|
|
55
|
+
isTripLive: () => isTripLive,
|
|
56
|
+
normalizeString: () => normalizeString,
|
|
57
|
+
paginate: () => paginate
|
|
58
|
+
});
|
|
59
|
+
module.exports = __toCommonJS(index_exports);
|
|
60
|
+
|
|
61
|
+
// src/types/api.ts
|
|
62
|
+
var ZETTicketStatus = /* @__PURE__ */ ((ZETTicketStatus2) => {
|
|
63
|
+
ZETTicketStatus2[ZETTicketStatus2["Pending"] = 0] = "Pending";
|
|
64
|
+
ZETTicketStatus2[ZETTicketStatus2["Active"] = 1] = "Active";
|
|
65
|
+
return ZETTicketStatus2;
|
|
66
|
+
})(ZETTicketStatus || {});
|
|
67
|
+
var ZETTripStatus = /* @__PURE__ */ ((ZETTripStatus2) => {
|
|
68
|
+
ZETTripStatus2[ZETTripStatus2["Running"] = 0] = "Running";
|
|
69
|
+
ZETTripStatus2[ZETTripStatus2["Scheduled"] = 3] = "Scheduled";
|
|
70
|
+
return ZETTripStatus2;
|
|
71
|
+
})(ZETTripStatus || {});
|
|
72
|
+
|
|
73
|
+
// src/types/errors.ts
|
|
74
|
+
var ZETApiError = class extends Error {
|
|
75
|
+
constructor(message, status, endpoint) {
|
|
76
|
+
super(message);
|
|
77
|
+
this.status = status;
|
|
78
|
+
this.endpoint = endpoint;
|
|
79
|
+
this.name = "ZETApiError";
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
var ZETAuthError = class extends ZETApiError {
|
|
83
|
+
constructor(endpoint) {
|
|
84
|
+
super("Authentication failed: token expired or invalid", 401, endpoint);
|
|
85
|
+
this.name = "ZETAuthError";
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// src/core/constants.ts
|
|
90
|
+
var baseUrl = "https://api.zet.hr";
|
|
91
|
+
var gtfsRtUrl = "https://www.zet.hr/gtfs-rt-protobuf";
|
|
92
|
+
var appUid = "ZET.Mobile";
|
|
93
|
+
var userAgent = "okhttp/4.9.2";
|
|
94
|
+
var tenant = "KingICT_ZET_Public";
|
|
95
|
+
var endpoints = {
|
|
96
|
+
login: "/AuthService.Api/api/auth/login",
|
|
97
|
+
refreshTokens: "/AuthService.Api/api/auth/refreshTokens",
|
|
98
|
+
account: "/AccountService.Api/api/account",
|
|
99
|
+
newsFeed: "/NewsProxyService.Api/api/newsfeed",
|
|
100
|
+
orders: "/OrderService.Api/api/v1/open/orders/order",
|
|
101
|
+
orderRedirect: "/OrderService.MVC/order/redirect",
|
|
102
|
+
orderCancel: "/OrderService.MVC/Order/Cancel",
|
|
103
|
+
paginatedTickets: "/TicketService.Api/api/v1/open/tickets/ticket/paginatedTickets",
|
|
104
|
+
filteredTickets: "/TicketService.Api/api/v1/open/tickets/ticket/filteredTickets",
|
|
105
|
+
ticket: "/TicketService.Api/api/v1/open/tickets/ticket",
|
|
106
|
+
articles: "/TicketService.Api/api/v1/open/tickets/article",
|
|
107
|
+
routes: "/TimetableService.Api/api/gtfs/routes",
|
|
108
|
+
stops: "/TimetableService.Api/api/gtfs/stops",
|
|
109
|
+
shapes: "/TimetableService.Api/api/gtfs/shapes",
|
|
110
|
+
routeTrips: "/TimetableService.Api/api/gtfs/routeTrips",
|
|
111
|
+
tripStopTimes: "/TimetableService.Api/api/gtfs/tripStopTimes",
|
|
112
|
+
stopIncomingTrips: "/TimetableService.Api/api/gtfs/stopIncomingTrips"
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// src/client/storage.ts
|
|
116
|
+
var MemoryStorage = class {
|
|
117
|
+
store = /* @__PURE__ */ new Map();
|
|
118
|
+
get(key) {
|
|
119
|
+
return this.store.get(key) ?? null;
|
|
120
|
+
}
|
|
121
|
+
set(key, value) {
|
|
122
|
+
this.store.set(key, value);
|
|
123
|
+
}
|
|
124
|
+
delete(key) {
|
|
125
|
+
this.store.delete(key);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// src/helpers/utils.ts
|
|
130
|
+
function isTripLive(trip) {
|
|
131
|
+
return trip.tripStatus === 0 /* Running */ && trip.hasLiveTracking;
|
|
132
|
+
}
|
|
133
|
+
function getCurrentStopIndex(stops) {
|
|
134
|
+
return stops.findIndex((stop) => !stop.isArrived);
|
|
135
|
+
}
|
|
136
|
+
function getLastArrivedStop(stops) {
|
|
137
|
+
const arrivedStops = stops.filter((stop) => stop.isArrived);
|
|
138
|
+
return arrivedStops[arrivedStops.length - 1] ?? null;
|
|
139
|
+
}
|
|
140
|
+
function getNextStop(stops) {
|
|
141
|
+
return stops.find((stop) => !stop.isArrived) ?? null;
|
|
142
|
+
}
|
|
143
|
+
function isTram(route) {
|
|
144
|
+
return route.routeType === 0;
|
|
145
|
+
}
|
|
146
|
+
function isBus(route) {
|
|
147
|
+
return route.routeType === 3;
|
|
148
|
+
}
|
|
149
|
+
function buildTimetable(trips, direction) {
|
|
150
|
+
const now = /* @__PURE__ */ new Date();
|
|
151
|
+
const filtered = trips.filter((trip) => trip.direction === direction).map((trip) => ({
|
|
152
|
+
trip,
|
|
153
|
+
departureTime: new Date(trip.departureDateTime),
|
|
154
|
+
arrivalTime: new Date(trip.arrivalDateTime)
|
|
155
|
+
})).sort((a, b) => a.departureTime.getTime() - b.departureTime.getTime());
|
|
156
|
+
const nextIndex = filtered.findIndex((entry) => entry.departureTime > now || entry.trip.tripStatus === 0 /* Running */);
|
|
157
|
+
return filtered.map((entry, index) => ({
|
|
158
|
+
...entry,
|
|
159
|
+
isPast: entry.departureTime < now && entry.trip.tripStatus !== 0 /* Running */,
|
|
160
|
+
isLive: isTripLive(entry.trip),
|
|
161
|
+
isNext: index === nextIndex
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
function getArticleLabel(article) {
|
|
165
|
+
const caption = article.articleCaption.toLowerCase();
|
|
166
|
+
if (caption.includes("30")) return "30 min";
|
|
167
|
+
if (caption.includes("60")) return "60 min";
|
|
168
|
+
if (caption.includes("90")) return "90 min";
|
|
169
|
+
if (caption.includes("180")) return "180 min";
|
|
170
|
+
if (caption.includes("dnevna") || caption.includes("daily")) return "Dnevna";
|
|
171
|
+
if (caption.includes("uspinja") || caption.includes("funicular")) return "Uspinja\u010Da";
|
|
172
|
+
if (article.availableInZones.length > 0 && article.availableInZones.every((zone) => zone.value === 4)) return "Uspinja\u010Da";
|
|
173
|
+
return article.articleCaption;
|
|
174
|
+
}
|
|
175
|
+
function getStandardArticles(articles) {
|
|
176
|
+
return articles.filter((article) => article.availableInZones.some((zone) => zone.value === 1 || zone.value === 2));
|
|
177
|
+
}
|
|
178
|
+
function isTicketActive(ticket) {
|
|
179
|
+
return ticket.statusType === 1 /* Active */ && new Date(ticket.validTo) > /* @__PURE__ */ new Date();
|
|
180
|
+
}
|
|
181
|
+
function getTicketRemainingSeconds(ticket) {
|
|
182
|
+
return Math.floor((new Date(ticket.validTo).getTime() - Date.now()) / 1e3);
|
|
183
|
+
}
|
|
184
|
+
function formatCountdown(seconds) {
|
|
185
|
+
const safeSeconds = Math.max(0, seconds);
|
|
186
|
+
const minutes = Math.floor(safeSeconds / 60);
|
|
187
|
+
const secondsPart = safeSeconds % 60;
|
|
188
|
+
return `${minutes}:${String(secondsPart).padStart(2, "0")}`;
|
|
189
|
+
}
|
|
190
|
+
function normalizeString(value) {
|
|
191
|
+
return value.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/č/g, "c").replace(/ć/g, "c").replace(/š/g, "s").replace(/ž/g, "z").trim();
|
|
192
|
+
}
|
|
193
|
+
function getTicketVehicleNumber(ticket) {
|
|
194
|
+
return ticket.validations?.[0]?.vehicleNumber ?? null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// src/client/client.ts
|
|
198
|
+
var ZETClient = class {
|
|
199
|
+
baseUrl;
|
|
200
|
+
appKeys;
|
|
201
|
+
storage;
|
|
202
|
+
cacheTTL;
|
|
203
|
+
newsCacheTTL;
|
|
204
|
+
isRefreshing = false;
|
|
205
|
+
refreshQueue = [];
|
|
206
|
+
opts;
|
|
207
|
+
cachedRoutes = null;
|
|
208
|
+
cachedStops = null;
|
|
209
|
+
cachedShapes = null;
|
|
210
|
+
cachedNews = null;
|
|
211
|
+
routesCachedAt = null;
|
|
212
|
+
stopsCachedAt = null;
|
|
213
|
+
shapesCachedAt = null;
|
|
214
|
+
newsCachedAt = null;
|
|
215
|
+
routesLoading = null;
|
|
216
|
+
stopsLoading = null;
|
|
217
|
+
shapesLoading = null;
|
|
218
|
+
newsLoading = null;
|
|
219
|
+
shapeToRouteTypeMapLoading = null;
|
|
220
|
+
shapeToRouteTypeMap = /* @__PURE__ */ new Map();
|
|
221
|
+
constructor(options) {
|
|
222
|
+
if (!options?.appKeys?.ticket || !options.appKeys.order) throw new Error("appKeys.ticket and appKeys.order are required");
|
|
223
|
+
if ((options.cacheTTL ?? -1) < -1) throw new Error("cacheTTL must be -1 (no expiry) or a non-negative number in milliseconds");
|
|
224
|
+
this.baseUrl = options.baseUrl ?? baseUrl;
|
|
225
|
+
this.appKeys = options.appKeys;
|
|
226
|
+
this.storage = options.storage ?? new MemoryStorage();
|
|
227
|
+
this.cacheTTL = options.cacheTTL ?? -1;
|
|
228
|
+
this.newsCacheTTL = this.resolveNewsCacheTTL(this.cacheTTL);
|
|
229
|
+
this.opts = {
|
|
230
|
+
onTokenRefresh: options.onTokenRefresh ?? (() => {
|
|
231
|
+
}),
|
|
232
|
+
onAuthFailure: options.onAuthFailure ?? (() => {
|
|
233
|
+
}),
|
|
234
|
+
timeout: options.timeout ?? 1e4
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
resolveNewsCacheTTL(cacheTtl) {
|
|
238
|
+
if (cacheTtl === 0) return 0;
|
|
239
|
+
if (cacheTtl < 0) return 5 * 60 * 1e3;
|
|
240
|
+
return Math.min(cacheTtl, 5 * 60 * 1e3);
|
|
241
|
+
}
|
|
242
|
+
createBaseHeaders() {
|
|
243
|
+
const headers = new Headers();
|
|
244
|
+
headers.set("Accept", "application/json, text/plain, */*");
|
|
245
|
+
headers.set("appuid", appUid);
|
|
246
|
+
headers.set("x-tenant", tenant);
|
|
247
|
+
headers.set("language", "hr");
|
|
248
|
+
headers.set("User-Agent", userAgent);
|
|
249
|
+
return headers;
|
|
250
|
+
}
|
|
251
|
+
async createHeaders(extraHeaders, includeAuth = true) {
|
|
252
|
+
const headers = this.createBaseHeaders();
|
|
253
|
+
if (extraHeaders) for (const [key, value] of Object.entries(extraHeaders)) headers.set(key, value);
|
|
254
|
+
if (includeAuth) {
|
|
255
|
+
const accessToken = await this.storage.get("accessToken");
|
|
256
|
+
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
|
|
257
|
+
}
|
|
258
|
+
return headers;
|
|
259
|
+
}
|
|
260
|
+
buildUrl(endpoint, query) {
|
|
261
|
+
const normalizedBaseUrl = this.baseUrl.endsWith("/") ? this.baseUrl : `${this.baseUrl}/`;
|
|
262
|
+
const url = new URL(endpoint, normalizedBaseUrl);
|
|
263
|
+
if (query) {
|
|
264
|
+
for (const [key, value] of Object.entries(query)) if (value !== void 0 && value !== null) url.searchParams.set(key, String(value));
|
|
265
|
+
}
|
|
266
|
+
return url.toString();
|
|
267
|
+
}
|
|
268
|
+
async fetchWithTimeout(url, init) {
|
|
269
|
+
const controller = new AbortController();
|
|
270
|
+
const timeoutId = setTimeout(() => controller.abort(), this.opts.timeout);
|
|
271
|
+
try {
|
|
272
|
+
return await fetch(url, {
|
|
273
|
+
...init,
|
|
274
|
+
signal: controller.signal
|
|
275
|
+
});
|
|
276
|
+
} catch (error) {
|
|
277
|
+
if (error instanceof Error && error.name === "AbortError") throw new ZETApiError(`Request timed out after ${this.opts.timeout}ms`, 0, url);
|
|
278
|
+
throw new ZETApiError(error instanceof Error ? error.message : "Network request failed", 0, url);
|
|
279
|
+
} finally {
|
|
280
|
+
clearTimeout(timeoutId);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async readErrorMessage(response) {
|
|
284
|
+
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
285
|
+
try {
|
|
286
|
+
if (contentType.includes("application/json")) {
|
|
287
|
+
const data = await response.json();
|
|
288
|
+
if (typeof data === "object" && data !== null && "message" in data) {
|
|
289
|
+
const maybeMessage = data.message;
|
|
290
|
+
if (typeof maybeMessage === "string" && maybeMessage.length > 0) return maybeMessage;
|
|
291
|
+
}
|
|
292
|
+
const serialized = JSON.stringify(data);
|
|
293
|
+
if (serialized.length > 0) return serialized;
|
|
294
|
+
}
|
|
295
|
+
const text = await response.text();
|
|
296
|
+
if (text.length > 0) return text;
|
|
297
|
+
} catch {
|
|
298
|
+
}
|
|
299
|
+
return `Request failed with status ${response.status}`;
|
|
300
|
+
}
|
|
301
|
+
async toApiError(response, endpoint) {
|
|
302
|
+
const message = await this.readErrorMessage(response);
|
|
303
|
+
return new ZETApiError(message, response.status, endpoint);
|
|
304
|
+
}
|
|
305
|
+
async sendRequest(endpoint, options = {}, allowAuthRetry = true) {
|
|
306
|
+
const url = this.buildUrl(endpoint, options.query);
|
|
307
|
+
const includeAuth = options.auth !== false;
|
|
308
|
+
const headers = await this.createHeaders(options.headers, includeAuth);
|
|
309
|
+
const method = options.method ?? "GET";
|
|
310
|
+
let body = options.body;
|
|
311
|
+
if (options.jsonBody !== void 0) {
|
|
312
|
+
headers.set("Content-Type", "application/json");
|
|
313
|
+
body = JSON.stringify(options.jsonBody);
|
|
314
|
+
}
|
|
315
|
+
const response = await this.fetchWithTimeout(url, {
|
|
316
|
+
method,
|
|
317
|
+
headers,
|
|
318
|
+
body
|
|
319
|
+
});
|
|
320
|
+
if (response.status === 401 && includeAuth && allowAuthRetry) {
|
|
321
|
+
const refreshedAccessToken = await this.handleUnauthorized(endpoint);
|
|
322
|
+
const retryHeaders = await this.createHeaders(options.headers, includeAuth);
|
|
323
|
+
retryHeaders.set("Authorization", `Bearer ${refreshedAccessToken}`);
|
|
324
|
+
if (options.jsonBody !== void 0) retryHeaders.set("Content-Type", "application/json");
|
|
325
|
+
const retryResponse = await this.fetchWithTimeout(url, {
|
|
326
|
+
method,
|
|
327
|
+
headers: retryHeaders,
|
|
328
|
+
body
|
|
329
|
+
});
|
|
330
|
+
if (!retryResponse.ok) throw await this.toApiError(retryResponse, endpoint);
|
|
331
|
+
return retryResponse;
|
|
332
|
+
}
|
|
333
|
+
if (!response.ok) throw await this.toApiError(response, endpoint);
|
|
334
|
+
return response;
|
|
335
|
+
}
|
|
336
|
+
async requestJson(endpoint, options = {}, allowAuthRetry = true) {
|
|
337
|
+
const response = await this.sendRequest(endpoint, options, allowAuthRetry);
|
|
338
|
+
return await response.json();
|
|
339
|
+
}
|
|
340
|
+
async requestText(endpoint, options = {}, allowAuthRetry = true) {
|
|
341
|
+
const response = await this.sendRequest(endpoint, options, allowAuthRetry);
|
|
342
|
+
return await response.text();
|
|
343
|
+
}
|
|
344
|
+
async saveTokens(tokens) {
|
|
345
|
+
await this.storage.set("accessToken", tokens.accessToken);
|
|
346
|
+
await this.storage.set("refreshToken", tokens.refreshToken);
|
|
347
|
+
}
|
|
348
|
+
async refreshTokens(refreshToken) {
|
|
349
|
+
return await this.requestJson(endpoints.refreshTokens, {
|
|
350
|
+
method: "POST",
|
|
351
|
+
jsonBody: { refreshToken }
|
|
352
|
+
}, false);
|
|
353
|
+
}
|
|
354
|
+
resolveRefreshQueue(token) {
|
|
355
|
+
for (const waiter of this.refreshQueue) waiter.resolve(token);
|
|
356
|
+
this.refreshQueue = [];
|
|
357
|
+
}
|
|
358
|
+
rejectRefreshQueue(error) {
|
|
359
|
+
for (const waiter of this.refreshQueue) waiter.reject(error);
|
|
360
|
+
this.refreshQueue = [];
|
|
361
|
+
}
|
|
362
|
+
async handleUnauthorized(endpoint) {
|
|
363
|
+
if (this.isRefreshing) return await new Promise((resolve, reject) => this.refreshQueue.push({ resolve, reject }));
|
|
364
|
+
this.isRefreshing = true;
|
|
365
|
+
try {
|
|
366
|
+
const refreshToken = await this.storage.get("refreshToken");
|
|
367
|
+
if (!refreshToken) throw new ZETAuthError(endpoint);
|
|
368
|
+
const tokens = await this.refreshTokens(refreshToken);
|
|
369
|
+
await this.saveTokens(tokens);
|
|
370
|
+
this.opts.onTokenRefresh(tokens);
|
|
371
|
+
this.resolveRefreshQueue(tokens.accessToken);
|
|
372
|
+
return tokens.accessToken;
|
|
373
|
+
} catch {
|
|
374
|
+
const authError = new ZETAuthError(endpoint);
|
|
375
|
+
this.rejectRefreshQueue(authError);
|
|
376
|
+
this.opts.onAuthFailure();
|
|
377
|
+
throw authError;
|
|
378
|
+
} finally {
|
|
379
|
+
this.isRefreshing = false;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
ticketHeaders() {
|
|
383
|
+
return { appkey: this.appKeys.ticket };
|
|
384
|
+
}
|
|
385
|
+
orderHeaders() {
|
|
386
|
+
return { appkey: this.appKeys.order };
|
|
387
|
+
}
|
|
388
|
+
isExpired(cachedAt, ttl) {
|
|
389
|
+
if (ttl === 0) return true;
|
|
390
|
+
if (ttl < 0) return false;
|
|
391
|
+
if (cachedAt === null) return true;
|
|
392
|
+
return Date.now() - cachedAt > ttl;
|
|
393
|
+
}
|
|
394
|
+
async fetchRoutesAndCache() {
|
|
395
|
+
const routes = await this.requestJson(endpoints.routes);
|
|
396
|
+
this.cachedRoutes = routes;
|
|
397
|
+
this.routesCachedAt = Date.now();
|
|
398
|
+
this.invalidateShapeToRouteTypeMap();
|
|
399
|
+
return routes;
|
|
400
|
+
}
|
|
401
|
+
async fetchStopsAndCache() {
|
|
402
|
+
const stops = await this.requestJson(endpoints.stops);
|
|
403
|
+
this.cachedStops = stops;
|
|
404
|
+
this.stopsCachedAt = Date.now();
|
|
405
|
+
return stops;
|
|
406
|
+
}
|
|
407
|
+
async fetchShapesAndCache() {
|
|
408
|
+
const shapesResponse = await this.requestJson(endpoints.shapes);
|
|
409
|
+
const shapes = Object.entries(shapesResponse).map(([id, points]) => ({
|
|
410
|
+
id,
|
|
411
|
+
points,
|
|
412
|
+
routeType: this.shapeToRouteTypeMap.get(id)
|
|
413
|
+
}));
|
|
414
|
+
this.cachedShapes = shapes;
|
|
415
|
+
this.shapesCachedAt = Date.now();
|
|
416
|
+
this.invalidateShapeToRouteTypeMap();
|
|
417
|
+
return shapes;
|
|
418
|
+
}
|
|
419
|
+
async fetchNewsAndCache() {
|
|
420
|
+
const news = await this.requestJson(endpoints.newsFeed);
|
|
421
|
+
this.cachedNews = news;
|
|
422
|
+
this.newsCachedAt = Date.now();
|
|
423
|
+
return news;
|
|
424
|
+
}
|
|
425
|
+
async getRoutesCached() {
|
|
426
|
+
const needsRefresh = !this.cachedRoutes || this.isExpired(this.routesCachedAt, this.cacheTTL);
|
|
427
|
+
if (!needsRefresh && this.cachedRoutes) return this.cachedRoutes;
|
|
428
|
+
if (!this.routesLoading) this.routesLoading = this.fetchRoutesAndCache().finally(() => {
|
|
429
|
+
this.routesLoading = null;
|
|
430
|
+
});
|
|
431
|
+
return await this.routesLoading;
|
|
432
|
+
}
|
|
433
|
+
async getStopsCached() {
|
|
434
|
+
const needsRefresh = !this.cachedStops || this.isExpired(this.stopsCachedAt, this.cacheTTL);
|
|
435
|
+
if (!needsRefresh && this.cachedStops) return this.cachedStops;
|
|
436
|
+
if (!this.stopsLoading) this.stopsLoading = this.fetchStopsAndCache().finally(() => {
|
|
437
|
+
this.stopsLoading = null;
|
|
438
|
+
});
|
|
439
|
+
return await this.stopsLoading;
|
|
440
|
+
}
|
|
441
|
+
async getShapesCached() {
|
|
442
|
+
const needsRefresh = !this.cachedShapes || this.isExpired(this.shapesCachedAt, this.cacheTTL);
|
|
443
|
+
if (!needsRefresh && this.cachedShapes) return this.cachedShapes;
|
|
444
|
+
if (!this.shapesLoading) this.shapesLoading = this.fetchShapesAndCache().finally(() => {
|
|
445
|
+
this.shapesLoading = null;
|
|
446
|
+
});
|
|
447
|
+
return await this.shapesLoading;
|
|
448
|
+
}
|
|
449
|
+
async getNewsCached() {
|
|
450
|
+
const needsRefresh = !this.cachedNews || this.isExpired(this.newsCachedAt, this.newsCacheTTL);
|
|
451
|
+
if (!needsRefresh && this.cachedNews) return this.cachedNews;
|
|
452
|
+
if (!this.newsLoading) this.newsLoading = this.fetchNewsAndCache().finally(() => {
|
|
453
|
+
this.newsLoading = null;
|
|
454
|
+
});
|
|
455
|
+
return await this.newsLoading;
|
|
456
|
+
}
|
|
457
|
+
async ensureShapeToRouteTypeMap() {
|
|
458
|
+
if (this.shapeToRouteTypeMap.size > 0) return;
|
|
459
|
+
if (this.shapeToRouteTypeMapLoading) return await this.shapeToRouteTypeMapLoading;
|
|
460
|
+
this.shapeToRouteTypeMapLoading = (async () => {
|
|
461
|
+
const [routes, shapes] = await Promise.all([
|
|
462
|
+
this.getRoutes(),
|
|
463
|
+
this.getShapesCached()
|
|
464
|
+
]);
|
|
465
|
+
const shapeIds = new Set(shapes.map((shape) => shape.id));
|
|
466
|
+
const nextMap = /* @__PURE__ */ new Map();
|
|
467
|
+
for (const route of routes) {
|
|
468
|
+
const trips = await this.getRouteTrips(route.id, { daysFromToday: 0 });
|
|
469
|
+
for (const trip of trips) {
|
|
470
|
+
if (!nextMap.has(trip.shapeId) && shapeIds.has(trip.shapeId)) nextMap.set(trip.shapeId, route.routeType);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
this.shapeToRouteTypeMap = nextMap;
|
|
474
|
+
if (this.cachedShapes) this.cachedShapes = this.cachedShapes.map((shape) => ({
|
|
475
|
+
...shape,
|
|
476
|
+
routeType: nextMap.get(shape.id)
|
|
477
|
+
}));
|
|
478
|
+
})();
|
|
479
|
+
try {
|
|
480
|
+
await this.shapeToRouteTypeMapLoading;
|
|
481
|
+
} finally {
|
|
482
|
+
this.shapeToRouteTypeMapLoading = null;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
invalidateShapeToRouteTypeMap() {
|
|
486
|
+
this.shapeToRouteTypeMap.clear();
|
|
487
|
+
if (this.cachedShapes) this.cachedShapes = this.cachedShapes.map((shape) => ({
|
|
488
|
+
...shape,
|
|
489
|
+
routeType: void 0
|
|
490
|
+
}));
|
|
491
|
+
}
|
|
492
|
+
extractHtmlAttribute(tag, attribute) {
|
|
493
|
+
const regex = new RegExp(`${attribute}\\s*=\\s*"([^"]*)"`, "i");
|
|
494
|
+
const match = tag.match(regex);
|
|
495
|
+
return match?.[1] ?? null;
|
|
496
|
+
}
|
|
497
|
+
parsePaymentForm(html) {
|
|
498
|
+
const formTag = html.match(/<form\b[^>]*>/i)?.[0];
|
|
499
|
+
if (!formTag) throw new Error("Unable to find payment form in redirect HTML");
|
|
500
|
+
const action = this.extractHtmlAttribute(formTag, "action") ?? "";
|
|
501
|
+
const method = this.extractHtmlAttribute(formTag, "method") ?? "post";
|
|
502
|
+
if (!action) throw new Error("Unable to parse payment form action URL");
|
|
503
|
+
const inputs = {};
|
|
504
|
+
const inputTags = html.match(/<input\b[^>]*>/gi) ?? [];
|
|
505
|
+
for (const inputTag of inputTags) {
|
|
506
|
+
const name = this.extractHtmlAttribute(inputTag, "name");
|
|
507
|
+
if (!name) continue;
|
|
508
|
+
inputs[name] = this.extractHtmlAttribute(inputTag, "value") ?? "";
|
|
509
|
+
}
|
|
510
|
+
return {
|
|
511
|
+
action,
|
|
512
|
+
method,
|
|
513
|
+
inputs,
|
|
514
|
+
html
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
/** Logs in and stores returned tokens. */
|
|
518
|
+
async login(credentials) {
|
|
519
|
+
const tokens = await this.requestJson(endpoints.login, {
|
|
520
|
+
method: "POST",
|
|
521
|
+
jsonBody: {
|
|
522
|
+
username: credentials.username,
|
|
523
|
+
password: credentials.password,
|
|
524
|
+
revokeOtherTokens: credentials.revokeOtherTokens ?? false,
|
|
525
|
+
fcmToken: credentials.fcmToken ?? "zet-api-client"
|
|
526
|
+
},
|
|
527
|
+
auth: false
|
|
528
|
+
});
|
|
529
|
+
await this.saveTokens(tokens);
|
|
530
|
+
return tokens;
|
|
531
|
+
}
|
|
532
|
+
/** Stores externally obtained access and refresh tokens. */
|
|
533
|
+
async setTokens(tokens) {
|
|
534
|
+
await this.saveTokens(tokens);
|
|
535
|
+
}
|
|
536
|
+
/** Clears locally stored tokens. */
|
|
537
|
+
async clearTokens() {
|
|
538
|
+
await this.storage.delete("accessToken");
|
|
539
|
+
await this.storage.delete("refreshToken");
|
|
540
|
+
}
|
|
541
|
+
/** Returns currently stored access token or `null`. */
|
|
542
|
+
async getAccessToken() {
|
|
543
|
+
return await this.storage.get("accessToken");
|
|
544
|
+
}
|
|
545
|
+
/** Fetches current account details. */
|
|
546
|
+
async getAccount() {
|
|
547
|
+
return await this.requestJson(endpoints.account);
|
|
548
|
+
}
|
|
549
|
+
/** Fetches traffic/service news feed with internal cache. */
|
|
550
|
+
async getNewsFeed() {
|
|
551
|
+
return await this.getNewsCached();
|
|
552
|
+
}
|
|
553
|
+
/** Backward-compatible alias of `getNewsFeed`. */
|
|
554
|
+
async getNewsfeed() {
|
|
555
|
+
return await this.getNewsFeed();
|
|
556
|
+
}
|
|
557
|
+
/** Fetches news filtered by route line id. */
|
|
558
|
+
async getNewsByRoute(routeId) {
|
|
559
|
+
const news = await this.getNewsCached();
|
|
560
|
+
return news.filter((item) => item.lines.includes(routeId));
|
|
561
|
+
}
|
|
562
|
+
/** Fetches top-up/order history. */
|
|
563
|
+
async getOrders(params = {}) {
|
|
564
|
+
return await this.requestJson(endpoints.orders, {
|
|
565
|
+
headers: this.orderHeaders(),
|
|
566
|
+
query: {
|
|
567
|
+
completedOnly: params.completedOnly ?? true,
|
|
568
|
+
PageSize: params.pageSize ?? 20,
|
|
569
|
+
PageNumber: params.pageNumber ?? 1
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Opens mPurse top-up redirect and returns raw HTML that auto-submits to CorvusPay.
|
|
575
|
+
*/
|
|
576
|
+
async getTopUpPaymentHtml(params = {}) {
|
|
577
|
+
return await this.requestText(endpoints.orderRedirect, {
|
|
578
|
+
headers: {
|
|
579
|
+
...this.orderHeaders(),
|
|
580
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
581
|
+
language: "0"
|
|
582
|
+
},
|
|
583
|
+
query: {
|
|
584
|
+
mPurse: params.mPurse ?? 1
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Opens mPurse top-up redirect and parses the generated CorvusPay form.
|
|
590
|
+
* The returned `action` URL is where the user should be redirected.
|
|
591
|
+
*/
|
|
592
|
+
async getTopUpPaymentForm(params = {}) {
|
|
593
|
+
const html = await this.getTopUpPaymentHtml(params);
|
|
594
|
+
return this.parsePaymentForm(html);
|
|
595
|
+
}
|
|
596
|
+
/** Returns only top-up payment URL and order number for cancel flow. */
|
|
597
|
+
async getTopUpPaymentInfo(params = {}) {
|
|
598
|
+
const form = await this.getTopUpPaymentForm(params);
|
|
599
|
+
return {
|
|
600
|
+
url: form.action,
|
|
601
|
+
orderNumber: form.inputs.order_number ?? null
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Calls `OrderService.MVC/Order/Cancel` with the Corvus order number.
|
|
606
|
+
* This is the same endpoint the WebView flow calls when payment is canceled.
|
|
607
|
+
*/
|
|
608
|
+
async cancelTopUpPayment(request) {
|
|
609
|
+
const formData = new URLSearchParams();
|
|
610
|
+
formData.set("order_number", request.orderNumber);
|
|
611
|
+
formData.set("language", request.language ?? "hr");
|
|
612
|
+
return await this.requestText(endpoints.orderCancel, {
|
|
613
|
+
method: "POST",
|
|
614
|
+
headers: {
|
|
615
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
616
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
617
|
+
},
|
|
618
|
+
body: formData.toString()
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
/** Returns cached routes, optionally filtered by route type. */
|
|
622
|
+
async getRoutes(routeType) {
|
|
623
|
+
const routes = await this.getRoutesCached();
|
|
624
|
+
if (routeType === void 0) return routes;
|
|
625
|
+
return routes.filter((route) => route.routeType === routeType);
|
|
626
|
+
}
|
|
627
|
+
/** Returns one route by route id using cached routes. */
|
|
628
|
+
async getRouteById(routeId) {
|
|
629
|
+
const routes = await this.getRoutesCached();
|
|
630
|
+
return routes.find((route) => route.id === routeId) ?? null;
|
|
631
|
+
}
|
|
632
|
+
/** Fuzzy-search routes by normalized name and short route code. */
|
|
633
|
+
async searchRoutes(query, options = {}) {
|
|
634
|
+
const normalizedQuery = normalizeString(query);
|
|
635
|
+
const limit = Math.max(1, options.limit ?? 10);
|
|
636
|
+
const routes = await this.getRoutes(options.routeType);
|
|
637
|
+
return routes.filter((route) => {
|
|
638
|
+
const normalizedName = normalizeString(route.normalizedSearchName);
|
|
639
|
+
return normalizedName.includes(normalizedQuery) || route.shortName.toLowerCase().includes(normalizedQuery);
|
|
640
|
+
}).slice(0, limit);
|
|
641
|
+
}
|
|
642
|
+
/** Returns cached stops, optionally filtered by route type. */
|
|
643
|
+
async getStops(routeType) {
|
|
644
|
+
const stops = await this.getStopsCached();
|
|
645
|
+
if (routeType === void 0) return stops;
|
|
646
|
+
return stops.filter((stop) => stop.routeType === routeType);
|
|
647
|
+
}
|
|
648
|
+
/** Returns one stop by id from cached stops. */
|
|
649
|
+
async getStopById(stopId) {
|
|
650
|
+
const stops = await this.getStopsCached();
|
|
651
|
+
return stops.find((stop) => stop.id === stopId) ?? null;
|
|
652
|
+
}
|
|
653
|
+
/** Fuzzy-search stops by normalized stop name. */
|
|
654
|
+
async searchStops(query, options = {}) {
|
|
655
|
+
const normalizedQuery = normalizeString(query);
|
|
656
|
+
const limit = Math.max(1, options.limit ?? 10);
|
|
657
|
+
const stops = await this.getStops(options.routeType);
|
|
658
|
+
return stops.filter((stop) => normalizeString(stop.normalizedSearchName).includes(normalizedQuery)).slice(0, limit);
|
|
659
|
+
}
|
|
660
|
+
/** Returns cached shapes. */
|
|
661
|
+
async getShapes() {
|
|
662
|
+
const shapes = await this.getShapesCached();
|
|
663
|
+
await this.ensureShapeToRouteTypeMap();
|
|
664
|
+
return this.cachedShapes ?? shapes;
|
|
665
|
+
}
|
|
666
|
+
/** Returns shapes used by trips of the specified route id. */
|
|
667
|
+
async getShapesByRouteId(routeId) {
|
|
668
|
+
const trips = await this.getRouteTrips(routeId, { daysFromToday: 0 });
|
|
669
|
+
const shapeIds = new Set(trips.map((trip) => trip.shapeId));
|
|
670
|
+
const shapes = await this.getShapes();
|
|
671
|
+
return shapes.filter((shape) => shapeIds.has(shape.id));
|
|
672
|
+
}
|
|
673
|
+
/** Fetches paginated ticket purchase history. */
|
|
674
|
+
async getTicketHistory(params = {}) {
|
|
675
|
+
return await this.requestJson(endpoints.paginatedTickets, {
|
|
676
|
+
headers: this.ticketHeaders(),
|
|
677
|
+
query: {
|
|
678
|
+
PageSize: params.pageSize ?? 20,
|
|
679
|
+
PageNumber: params.pageNumber ?? 1
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
/** Fetches filtered/active tickets. */
|
|
684
|
+
async getActiveTickets(params = {}) {
|
|
685
|
+
return await this.requestJson(endpoints.filteredTickets, {
|
|
686
|
+
headers: this.ticketHeaders(),
|
|
687
|
+
query: {
|
|
688
|
+
isControlTicket: params.isControlTicket ?? true,
|
|
689
|
+
validOnly: params.validOnly ?? true,
|
|
690
|
+
includeValidations: params.includeValidations ?? true
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Fetches ticket articles.
|
|
696
|
+
* If `lat` and `lng` are provided, zone-specific data is requested.
|
|
697
|
+
*/
|
|
698
|
+
async getArticles(params = {}) {
|
|
699
|
+
const query = params.lat !== void 0 && params.lng !== void 0 ? {
|
|
700
|
+
precision: params.precision ?? 5,
|
|
701
|
+
wkt: `${params.lat},${params.lng}`
|
|
702
|
+
} : void 0;
|
|
703
|
+
return await this.requestJson(endpoints.articles, {
|
|
704
|
+
headers: this.ticketHeaders(),
|
|
705
|
+
query
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
/** Purchases a ticket. */
|
|
709
|
+
async buyTicket(request) {
|
|
710
|
+
return await this.requestJson(endpoints.ticket, {
|
|
711
|
+
method: "POST",
|
|
712
|
+
headers: this.ticketHeaders(),
|
|
713
|
+
jsonBody: {
|
|
714
|
+
trafficZone: request.trafficZone,
|
|
715
|
+
ticketType: request.ticketType ?? 2,
|
|
716
|
+
passengers: request.passengers ?? 1,
|
|
717
|
+
articleId: request.articleId,
|
|
718
|
+
vehicleNumber: request.vehicleNumber,
|
|
719
|
+
wkt: request.wkt ?? null,
|
|
720
|
+
precision: request.precision ?? null,
|
|
721
|
+
ticketStations: request.ticketStations ?? []
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Updates active ticket validation by changing the vehicle number.
|
|
727
|
+
* Mirrors `PUT /TicketService.Api/api/v1/open/tickets/ticket` in `zet.txt`.
|
|
728
|
+
*/
|
|
729
|
+
async changeTicketVehicle(request) {
|
|
730
|
+
return await this.requestJson(endpoints.ticket, {
|
|
731
|
+
method: "PUT",
|
|
732
|
+
headers: this.ticketHeaders(),
|
|
733
|
+
jsonBody: {
|
|
734
|
+
accountId: request.accountId,
|
|
735
|
+
vehicleNumber: request.vehicleNumber,
|
|
736
|
+
precision: request.precision ?? null,
|
|
737
|
+
wkt: request.wkt ?? null
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
/** Fetches all trips for one route and relative day offset. */
|
|
742
|
+
async getRouteTrips(routeId, params = {}) {
|
|
743
|
+
return await this.requestJson(endpoints.routeTrips, {
|
|
744
|
+
query: {
|
|
745
|
+
routeId,
|
|
746
|
+
daysFromToday: params.daysFromToday ?? 0
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
/** Fetches stop times and live state for one trip. */
|
|
751
|
+
async getTripStopTimes(tripId, params = {}) {
|
|
752
|
+
return await this.requestJson(endpoints.tripStopTimes, {
|
|
753
|
+
query: {
|
|
754
|
+
tripId,
|
|
755
|
+
daysFromToday: params.daysFromToday ?? 0
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
/** Fetches upcoming trips for one stop. */
|
|
760
|
+
async getStopIncomingTrips(stopId) {
|
|
761
|
+
return await this.requestJson(endpoints.stopIncomingTrips, {
|
|
762
|
+
query: {
|
|
763
|
+
stopId,
|
|
764
|
+
isMapView: false
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
// src/helpers/gtfs.ts
|
|
771
|
+
var import_gtfs_realtime_bindings = __toESM(require("gtfs-realtime-bindings"));
|
|
772
|
+
async function fetchGtfsRtVehicles(url = gtfsRtUrl, fetchFn = fetch) {
|
|
773
|
+
const transitRealtime = import_gtfs_realtime_bindings.default.transit_realtime ?? import_gtfs_realtime_bindings.default.default?.transit_realtime;
|
|
774
|
+
if (!transitRealtime?.FeedMessage) throw new Error("[zet-api] Unable to initialize gtfs-realtime-bindings FeedMessage parser");
|
|
775
|
+
const response = await fetchFn(url);
|
|
776
|
+
if (!response.ok) throw new Error(`GTFS-RT fetch failed: ${response.status}`);
|
|
777
|
+
const buffer = await response.arrayBuffer();
|
|
778
|
+
const decoded = transitRealtime.FeedMessage.decode(new Uint8Array(buffer));
|
|
779
|
+
const feed = transitRealtime.FeedMessage.toObject(decoded, {
|
|
780
|
+
enums: String,
|
|
781
|
+
longs: Number,
|
|
782
|
+
bytes: String,
|
|
783
|
+
defaults: false,
|
|
784
|
+
arrays: true,
|
|
785
|
+
objects: true,
|
|
786
|
+
oneofs: true
|
|
787
|
+
});
|
|
788
|
+
const entityMap = /* @__PURE__ */ new Map();
|
|
789
|
+
for (const entity of feed.entity ?? []) {
|
|
790
|
+
const typedEntity = entity;
|
|
791
|
+
const baseId = String(typedEntity.id).split("_")[0] ?? "";
|
|
792
|
+
const existing = entityMap.get(baseId) ?? {};
|
|
793
|
+
entityMap.set(baseId, {
|
|
794
|
+
vehicle: typedEntity.vehicle ?? existing.vehicle
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
const vehicles = [];
|
|
798
|
+
for (const [baseId, entity] of entityMap) {
|
|
799
|
+
const vehicle = entity.vehicle;
|
|
800
|
+
if (vehicle?.position && vehicle.trip) vehicles.push({ vehicleId: vehicle.vehicle?.id ?? baseId, routeId: vehicle.trip.routeId ?? "", latitude: vehicle.position.latitude, longitude: vehicle.position.longitude, timestamp: vehicle.timestamp ?? 0 });
|
|
801
|
+
}
|
|
802
|
+
return vehicles;
|
|
803
|
+
}
|
|
804
|
+
function haversineMeters(lat1, lng1, lat2, lng2) {
|
|
805
|
+
const earthRadiusMeters = 6371e3;
|
|
806
|
+
const phi1 = lat1 * Math.PI / 180;
|
|
807
|
+
const phi2 = lat2 * Math.PI / 180;
|
|
808
|
+
const deltaPhi = (lat2 - lat1) * Math.PI / 180;
|
|
809
|
+
const deltaLambda = (lng2 - lng1) * Math.PI / 180;
|
|
810
|
+
const a = Math.sin(deltaPhi / 2) ** 2 + Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
|
|
811
|
+
return earthRadiusMeters * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
812
|
+
}
|
|
813
|
+
function getNearbyVehicles(vehicles, userLat, userLng, radiusMeters = 150) {
|
|
814
|
+
return vehicles.map((vehicle) => ({
|
|
815
|
+
...vehicle,
|
|
816
|
+
distanceMeters: haversineMeters(userLat, userLng, vehicle.latitude, vehicle.longitude),
|
|
817
|
+
vehicleType: vehicle.vehicleId.startsWith("T") ? "tram" : vehicle.vehicleId.startsWith("B") ? "bus" : "unknown"
|
|
818
|
+
})).filter((vehicle) => vehicle.distanceMeters <= radiusMeters).sort((a, b) => a.distanceMeters - b.distanceMeters);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// src/helpers/pagination.ts
|
|
822
|
+
async function* paginate(fetcher, pageSize = 20) {
|
|
823
|
+
let page = 1;
|
|
824
|
+
while (true) {
|
|
825
|
+
const results = await fetcher(page);
|
|
826
|
+
if (results.length === 0) break;
|
|
827
|
+
yield results;
|
|
828
|
+
if (results.length < pageSize) break;
|
|
829
|
+
page++;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
async function collectAll(fetcher, pageSize = 20) {
|
|
833
|
+
const all = [];
|
|
834
|
+
for await (const page of paginate(fetcher, pageSize)) all.push(...page);
|
|
835
|
+
return all;
|
|
836
|
+
}
|
|
837
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
838
|
+
0 && (module.exports = {
|
|
839
|
+
MemoryStorage,
|
|
840
|
+
ZETApiError,
|
|
841
|
+
ZETAuthError,
|
|
842
|
+
ZETClient,
|
|
843
|
+
ZETTicketStatus,
|
|
844
|
+
ZETTripStatus,
|
|
845
|
+
buildTimetable,
|
|
846
|
+
collectAll,
|
|
847
|
+
fetchGtfsRtVehicles,
|
|
848
|
+
formatCountdown,
|
|
849
|
+
getArticleLabel,
|
|
850
|
+
getCurrentStopIndex,
|
|
851
|
+
getLastArrivedStop,
|
|
852
|
+
getNearbyVehicles,
|
|
853
|
+
getNextStop,
|
|
854
|
+
getStandardArticles,
|
|
855
|
+
getTicketRemainingSeconds,
|
|
856
|
+
getTicketVehicleNumber,
|
|
857
|
+
haversineMeters,
|
|
858
|
+
isBus,
|
|
859
|
+
isTicketActive,
|
|
860
|
+
isTram,
|
|
861
|
+
isTripLive,
|
|
862
|
+
normalizeString,
|
|
863
|
+
paginate
|
|
864
|
+
});
|