@xiboplayer/schedule 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/docs/README.md +102 -0
- package/docs/XIBO_CAMPAIGNS_AND_PRIORITY.md +600 -0
- package/docs/advanced-features.md +425 -0
- package/docs/integration.md +284 -0
- package/docs/interrupts-implementation.md +357 -0
- package/package.json +41 -0
- package/src/criteria.js +135 -0
- package/src/criteria.test.js +376 -0
- package/src/index.js +20 -0
- package/src/integration.test.js +351 -0
- package/src/interrupts.js +298 -0
- package/src/interrupts.test.js +482 -0
- package/src/overlays.js +174 -0
- package/src/schedule.dayparting.test.js +390 -0
- package/src/schedule.js +509 -0
- package/src/schedule.test.js +505 -0
- package/vitest.config.js +8 -0
package/src/schedule.js
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedule manager - determines which layouts to show
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { evaluateCriteria } from './criteria.js';
|
|
6
|
+
|
|
7
|
+
export class ScheduleManager {
|
|
8
|
+
constructor(options = {}) {
|
|
9
|
+
this.schedule = null;
|
|
10
|
+
this.playHistory = new Map(); // Track plays per layout: layoutId -> [timestamps]
|
|
11
|
+
this.interruptScheduler = options.interruptScheduler || null; // Optional interrupt scheduler
|
|
12
|
+
this.displayProperties = options.displayProperties || {}; // CMS display custom properties
|
|
13
|
+
this.playerLocation = null; // { latitude, longitude } from Geolocation API
|
|
14
|
+
this._layoutMetadata = new Map(); // layoutFile → { syncEvent, shareOfVoice, ... }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Update schedule from XMDS
|
|
19
|
+
*/
|
|
20
|
+
setSchedule(schedule) {
|
|
21
|
+
this.schedule = schedule;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get data connectors from current schedule
|
|
26
|
+
* @returns {Array} Data connector configurations, or empty array
|
|
27
|
+
*/
|
|
28
|
+
getDataConnectors() {
|
|
29
|
+
return this.schedule?.dataConnectors || [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if a schedule item is active based on recurrence rules
|
|
34
|
+
* Supports weekly dayparting (recurring schedules on specific days/times)
|
|
35
|
+
*/
|
|
36
|
+
isRecurringScheduleActive(item, now) {
|
|
37
|
+
// If no recurrence, it's not a recurring schedule
|
|
38
|
+
if (!item.recurrenceType) {
|
|
39
|
+
return true; // Not a recurring schedule, use date/time checks instead
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Currently only support Weekly recurrence (dayparting)
|
|
43
|
+
if (item.recurrenceType !== 'Week') {
|
|
44
|
+
return true; // Unsupported recurrence type, fallback to date/time checks
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Check if current day of week matches recurrenceRepeatsOn
|
|
48
|
+
// recurrenceRepeatsOn format: "1,2,3,4,5" (1=Monday, 7=Sunday, ISO format)
|
|
49
|
+
if (item.recurrenceRepeatsOn) {
|
|
50
|
+
const currentDayOfWeek = this.getIsoDayOfWeek(now);
|
|
51
|
+
const allowedDays = item.recurrenceRepeatsOn.split(',').map(d => parseInt(d.trim()));
|
|
52
|
+
|
|
53
|
+
if (!allowedDays.includes(currentDayOfWeek)) {
|
|
54
|
+
return false; // Today is not in the allowed days
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Check recurrence range if specified
|
|
59
|
+
if (item.recurrenceRange) {
|
|
60
|
+
const rangeEnd = new Date(item.recurrenceRange);
|
|
61
|
+
if (now > rangeEnd) {
|
|
62
|
+
return false; // Recurrence has ended
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get ISO day of week (1=Monday, 7=Sunday)
|
|
71
|
+
*/
|
|
72
|
+
getIsoDayOfWeek(date) {
|
|
73
|
+
const day = date.getDay(); // 0=Sunday, 6=Saturday
|
|
74
|
+
return day === 0 ? 7 : day; // Convert to ISO (1=Monday, 7=Sunday)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Check if current time is within the schedule's time window
|
|
79
|
+
* Handles both date ranges and time-of-day for dayparting
|
|
80
|
+
*/
|
|
81
|
+
isTimeActive(item, now) {
|
|
82
|
+
const from = item.fromdt ? new Date(item.fromdt) : null;
|
|
83
|
+
const to = item.todt ? new Date(item.todt) : null;
|
|
84
|
+
|
|
85
|
+
// For recurring schedules, check time-of-day instead of full datetime
|
|
86
|
+
if (item.recurrenceType === 'Week') {
|
|
87
|
+
// Extract time from fromdt/todt and compare with current time
|
|
88
|
+
if (from && to) {
|
|
89
|
+
const currentTime = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds();
|
|
90
|
+
const fromTime = from.getHours() * 3600 + from.getMinutes() * 60 + from.getSeconds();
|
|
91
|
+
const toTime = to.getHours() * 3600 + to.getMinutes() * 60 + to.getSeconds();
|
|
92
|
+
|
|
93
|
+
// Handle midnight crossing
|
|
94
|
+
if (fromTime <= toTime) {
|
|
95
|
+
// Normal case: 09:00 - 17:00
|
|
96
|
+
return currentTime >= fromTime && currentTime <= toTime;
|
|
97
|
+
} else {
|
|
98
|
+
// Midnight crossing: 22:00 - 02:00
|
|
99
|
+
return currentTime >= fromTime || currentTime <= toTime;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// For non-recurring schedules, use full date/time comparison
|
|
106
|
+
if (from && now < from) return false;
|
|
107
|
+
if (to && now > to) return false;
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get current layouts to display
|
|
113
|
+
* Returns array of layout files, prioritized
|
|
114
|
+
*
|
|
115
|
+
* Campaign behavior:
|
|
116
|
+
* - Priority applies at campaign level, not individual layout level
|
|
117
|
+
* - All layouts in a campaign share the campaign's priority
|
|
118
|
+
* - Layouts within a campaign are returned in order for cycling
|
|
119
|
+
* - Standalone layouts compete with campaigns at their own priority
|
|
120
|
+
*
|
|
121
|
+
* Dayparting behavior:
|
|
122
|
+
* - Schedules can recur weekly on specific days (recurrenceType='Week')
|
|
123
|
+
* - recurrenceRepeatsOn specifies days: "1,2,3,4,5" (Mon-Fri, ISO format)
|
|
124
|
+
* - Time matching uses time-of-day for recurring schedules
|
|
125
|
+
* - Non-recurring schedules use full date/time ranges
|
|
126
|
+
*
|
|
127
|
+
* Interrupt behavior (shareOfVoice):
|
|
128
|
+
* - Layouts with shareOfVoice > 0 are interrupts
|
|
129
|
+
* - They must play for a percentage of each hour
|
|
130
|
+
* - Normal layouts fill remaining time
|
|
131
|
+
* - Interrupts are interleaved with normal layouts
|
|
132
|
+
*/
|
|
133
|
+
getCurrentLayouts() {
|
|
134
|
+
if (!this.schedule) {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const now = new Date();
|
|
139
|
+
const activeItems = []; // Mix of campaign objects and standalone layouts
|
|
140
|
+
|
|
141
|
+
// Track the highest priority of any time-active layout BEFORE rate-limit
|
|
142
|
+
// filtering. Used by advanceToNextLayout() to detect when only lower-
|
|
143
|
+
// priority layouts remain (all high-priority ones are rate-limited) and
|
|
144
|
+
// replay the current layout instead of downgrading.
|
|
145
|
+
this._maxActivePriority = 0;
|
|
146
|
+
|
|
147
|
+
// Find all active campaigns
|
|
148
|
+
if (this.schedule.campaigns) {
|
|
149
|
+
for (const campaign of this.schedule.campaigns) {
|
|
150
|
+
// Check recurrence and time window
|
|
151
|
+
if (!this.isRecurringScheduleActive(campaign, now)) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (!this.isTimeActive(campaign, now)) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
this._maxActivePriority = Math.max(this._maxActivePriority, campaign.priority || 0);
|
|
159
|
+
|
|
160
|
+
// Campaign is active - add it as a single item with its priority
|
|
161
|
+
activeItems.push({
|
|
162
|
+
type: 'campaign',
|
|
163
|
+
priority: campaign.priority,
|
|
164
|
+
layouts: campaign.layouts, // Keep full layout objects for interrupt processing
|
|
165
|
+
campaignId: campaign.id
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Find all active standalone layouts
|
|
171
|
+
if (this.schedule.layouts) {
|
|
172
|
+
for (const layout of this.schedule.layouts) {
|
|
173
|
+
// Check recurrence and time window
|
|
174
|
+
if (!this.isRecurringScheduleActive(layout, now)) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (!this.isTimeActive(layout, now)) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Check criteria conditions (date/time, display properties)
|
|
182
|
+
if (layout.criteria && layout.criteria.length > 0) {
|
|
183
|
+
if (!evaluateCriteria(layout.criteria, { now, displayProperties: this.displayProperties })) {
|
|
184
|
+
console.log('[Schedule] Layout', layout.id, 'filtered by criteria');
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Check geo-fencing
|
|
190
|
+
if (layout.isGeoAware && layout.geoLocation) {
|
|
191
|
+
if (!this.isWithinGeoFence(layout.geoLocation)) {
|
|
192
|
+
console.log('[Schedule] Layout', layout.id, 'filtered by geofence');
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Track priority before rate-limit filtering
|
|
198
|
+
this._maxActivePriority = Math.max(this._maxActivePriority, layout.priority || 0);
|
|
199
|
+
|
|
200
|
+
// Check max plays per hour - but track that we filtered it
|
|
201
|
+
if (!this.canPlayLayout(layout.id, layout.maxPlaysPerHour)) {
|
|
202
|
+
console.log('[Schedule] Layout', layout.id, 'filtered by maxPlaysPerHour (limit:', layout.maxPlaysPerHour, ')');
|
|
203
|
+
// Continue to check other layouts, but don't add this one
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
activeItems.push({
|
|
208
|
+
type: 'layout',
|
|
209
|
+
priority: layout.priority || 0,
|
|
210
|
+
layouts: [layout], // Keep full layout object for interrupt processing
|
|
211
|
+
layoutId: layout.id
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// If no active schedules, return default
|
|
217
|
+
if (activeItems.length === 0) {
|
|
218
|
+
return this.schedule.default ? [this.schedule.default] : [];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Find maximum priority across all items (campaigns and layouts)
|
|
222
|
+
let maxPriority = Math.max(...activeItems.map(item => item.priority));
|
|
223
|
+
console.log('[Schedule] Max priority:', maxPriority, 'from', activeItems.length, 'active items');
|
|
224
|
+
|
|
225
|
+
// Collect all layouts from items with max priority
|
|
226
|
+
let allLayouts = [];
|
|
227
|
+
for (const item of activeItems) {
|
|
228
|
+
if (item.priority === maxPriority) {
|
|
229
|
+
console.log('[Schedule] Including priority', item.priority, 'layouts:', item.layouts.map(l => l.file));
|
|
230
|
+
// Add all layouts from this campaign or standalone layout
|
|
231
|
+
allLayouts.push(...item.layouts);
|
|
232
|
+
} else {
|
|
233
|
+
console.log('[Schedule] Skipping priority', item.priority, '< max', maxPriority);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Build layout metadata map (syncEvent, shareOfVoice, etc.)
|
|
238
|
+
this._layoutMetadata.clear();
|
|
239
|
+
for (const layout of allLayouts) {
|
|
240
|
+
this._layoutMetadata.set(layout.file, {
|
|
241
|
+
syncEvent: layout.syncEvent || false,
|
|
242
|
+
shareOfVoice: layout.shareOfVoice || 0,
|
|
243
|
+
scheduleid: layout.scheduleid,
|
|
244
|
+
priority: layout.priority || 0,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Process interrupts if interrupt scheduler is available
|
|
249
|
+
if (this.interruptScheduler) {
|
|
250
|
+
const { normalLayouts, interruptLayouts } = this.interruptScheduler.separateLayouts(allLayouts);
|
|
251
|
+
|
|
252
|
+
if (interruptLayouts.length > 0) {
|
|
253
|
+
console.log('[Schedule] Found', interruptLayouts.length, 'interrupt layouts with shareOfVoice');
|
|
254
|
+
const processedLayouts = this.interruptScheduler.processInterrupts(normalLayouts, interruptLayouts);
|
|
255
|
+
// Extract file IDs from processed layouts
|
|
256
|
+
const result = processedLayouts.map(l => l.file);
|
|
257
|
+
console.log('[Schedule] Final layouts (with interrupts):', result);
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// No interrupts, return layout files
|
|
263
|
+
const result = allLayouts.map(l => l.file);
|
|
264
|
+
console.log('[Schedule] Final layouts:', result);
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Check if schedule needs update (every minute)
|
|
270
|
+
*/
|
|
271
|
+
shouldCheckSchedule(lastCheck) {
|
|
272
|
+
if (!lastCheck) return true;
|
|
273
|
+
const elapsed = Date.now() - lastCheck;
|
|
274
|
+
return elapsed >= 60000; // 1 minute
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Check if layout can play based on maxPlaysPerHour with even distribution.
|
|
279
|
+
*
|
|
280
|
+
* Instead of allowing bursts (3 plays back-to-back then nothing for 50 min),
|
|
281
|
+
* plays are distributed evenly across the hour:
|
|
282
|
+
* maxPlaysPerHour=3 → minimum 20 min gap between plays
|
|
283
|
+
* maxPlaysPerHour=6 → minimum 10 min gap between plays
|
|
284
|
+
*
|
|
285
|
+
* Two checks:
|
|
286
|
+
* 1. Total plays in sliding 1-hour window < maxPlaysPerHour
|
|
287
|
+
* 2. Time since last play >= (60 / maxPlaysPerHour) minutes
|
|
288
|
+
*
|
|
289
|
+
* @param {string} layoutId - Layout ID to check
|
|
290
|
+
* @param {number} maxPlaysPerHour - Maximum plays allowed per hour (0 = unlimited)
|
|
291
|
+
* @returns {boolean} True if layout can play, false if exceeded limit
|
|
292
|
+
*/
|
|
293
|
+
canPlayLayout(layoutId, maxPlaysPerHour) {
|
|
294
|
+
// If maxPlaysPerHour is 0 or undefined, unlimited plays
|
|
295
|
+
if (!maxPlaysPerHour || maxPlaysPerHour === 0) {
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const now = Date.now();
|
|
300
|
+
const oneHourAgo = now - (60 * 60 * 1000);
|
|
301
|
+
|
|
302
|
+
// Get play history for this layout
|
|
303
|
+
const history = this.playHistory.get(layoutId) || [];
|
|
304
|
+
|
|
305
|
+
// Filter to plays within the last hour
|
|
306
|
+
const playsInLastHour = history.filter(timestamp => timestamp > oneHourAgo);
|
|
307
|
+
|
|
308
|
+
// Check 1: Total plays in last hour must be under limit
|
|
309
|
+
if (playsInLastHour.length >= maxPlaysPerHour) {
|
|
310
|
+
console.log(`[Schedule] Layout ${layoutId} has reached max plays per hour (${playsInLastHour.length}/${maxPlaysPerHour})`);
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Check 2: Minimum gap between plays for even distribution
|
|
315
|
+
// e.g., 3/hour → 1 every 20 min, 6/hour → 1 every 10 min
|
|
316
|
+
if (playsInLastHour.length > 0) {
|
|
317
|
+
const minGapMs = (60 * 60 * 1000) / maxPlaysPerHour;
|
|
318
|
+
const lastPlayTime = Math.max(...playsInLastHour);
|
|
319
|
+
const elapsed = now - lastPlayTime;
|
|
320
|
+
|
|
321
|
+
if (elapsed < minGapMs) {
|
|
322
|
+
const remainingMin = ((minGapMs - elapsed) / 60000).toFixed(1);
|
|
323
|
+
console.log(`[Schedule] Layout ${layoutId} spacing: next play in ${remainingMin} min (${playsInLastHour.length}/${maxPlaysPerHour} plays, ${Math.round(minGapMs/60000)} min gap)`);
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Record that a layout was played
|
|
333
|
+
* @param {string} layoutId - Layout ID that was played
|
|
334
|
+
*/
|
|
335
|
+
recordPlay(layoutId) {
|
|
336
|
+
if (!this.playHistory.has(layoutId)) {
|
|
337
|
+
this.playHistory.set(layoutId, []);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const history = this.playHistory.get(layoutId);
|
|
341
|
+
history.push(Date.now());
|
|
342
|
+
|
|
343
|
+
// Clean up old entries (older than 1 hour)
|
|
344
|
+
const oneHourAgo = Date.now() - (60 * 60 * 1000);
|
|
345
|
+
const cleaned = history.filter(timestamp => timestamp > oneHourAgo);
|
|
346
|
+
this.playHistory.set(layoutId, cleaned);
|
|
347
|
+
|
|
348
|
+
console.log(`[Schedule] Recorded play for layout ${layoutId} (${cleaned.length} plays in last hour)`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Get the max priority of any time-active layout (ignoring rate-limit filtering).
|
|
353
|
+
* Returns 0 if no layouts are active or if getCurrentLayouts() hasn't been called.
|
|
354
|
+
* @returns {number}
|
|
355
|
+
*/
|
|
356
|
+
getMaxActivePriority() {
|
|
357
|
+
return this._maxActivePriority || 0;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Check if a layout file is a sync event (part of multi-display sync group)
|
|
362
|
+
* @param {string} layoutFile - Layout file identifier (e.g., '123')
|
|
363
|
+
* @returns {boolean}
|
|
364
|
+
*/
|
|
365
|
+
isSyncEvent(layoutFile) {
|
|
366
|
+
const meta = this._layoutMetadata.get(layoutFile);
|
|
367
|
+
return meta?.syncEvent === true;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Get metadata for a layout file (syncEvent, shareOfVoice, etc.)
|
|
372
|
+
* @param {string} layoutFile - Layout file identifier
|
|
373
|
+
* @returns {Object|null} Metadata or null if not found
|
|
374
|
+
*/
|
|
375
|
+
getLayoutMetadata(layoutFile) {
|
|
376
|
+
return this._layoutMetadata.get(layoutFile) || null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Check if any current layouts are sync events
|
|
381
|
+
* @returns {boolean}
|
|
382
|
+
*/
|
|
383
|
+
hasSyncEvents() {
|
|
384
|
+
for (const meta of this._layoutMetadata.values()) {
|
|
385
|
+
if (meta.syncEvent) return true;
|
|
386
|
+
}
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Get currently active actions (within their time window)
|
|
392
|
+
* @returns {Array} Active action objects
|
|
393
|
+
*/
|
|
394
|
+
getActiveActions() {
|
|
395
|
+
if (!this.schedule?.actions) return [];
|
|
396
|
+
|
|
397
|
+
const now = new Date();
|
|
398
|
+
return this.schedule.actions.filter(action => this.isTimeActive(action, now));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Get scheduled commands
|
|
403
|
+
* @returns {Array} Command objects
|
|
404
|
+
*/
|
|
405
|
+
getCommands() {
|
|
406
|
+
return this.schedule?.commands || [];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Find action by trigger code
|
|
411
|
+
* @param {string} triggerCode - The trigger code to match
|
|
412
|
+
* @returns {Object|null} Matching action or null
|
|
413
|
+
*/
|
|
414
|
+
findActionByTrigger(triggerCode) {
|
|
415
|
+
const activeActions = this.getActiveActions();
|
|
416
|
+
return activeActions.find(a => a.triggerCode === triggerCode) || null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Clear play history (useful for testing or reset)
|
|
421
|
+
*/
|
|
422
|
+
clearPlayHistory() {
|
|
423
|
+
this.playHistory.clear();
|
|
424
|
+
console.log('[Schedule] Play history cleared');
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Set player's current GPS location (from Geolocation API or XMR command)
|
|
429
|
+
* @param {number} latitude
|
|
430
|
+
* @param {number} longitude
|
|
431
|
+
*/
|
|
432
|
+
setLocation(latitude, longitude) {
|
|
433
|
+
this.playerLocation = { latitude, longitude };
|
|
434
|
+
console.log(`[Schedule] Location set: ${latitude}, ${longitude}`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Set display properties from CMS (custom fields for criteria evaluation)
|
|
439
|
+
* @param {Object} properties - Key-value map of display properties
|
|
440
|
+
*/
|
|
441
|
+
setDisplayProperties(properties) {
|
|
442
|
+
this.displayProperties = properties || {};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Check if player is within a geo-fence.
|
|
447
|
+
* geoLocation format from CMS: "lat,lng" (point + default radius)
|
|
448
|
+
* or "lat1,lng1;lat2,lng2;..." (polygon — future)
|
|
449
|
+
*
|
|
450
|
+
* Default radius: 500 meters (Xibo default for point geofences)
|
|
451
|
+
*
|
|
452
|
+
* @param {string} geoLocation - Geo-fence specification from CMS
|
|
453
|
+
* @param {number} [defaultRadius=500] - Default radius in meters for point geofences
|
|
454
|
+
* @returns {boolean} True if within geofence or no location available
|
|
455
|
+
*/
|
|
456
|
+
isWithinGeoFence(geoLocation, defaultRadius = 500) {
|
|
457
|
+
if (!this.playerLocation) {
|
|
458
|
+
// No location available — be permissive, show the content
|
|
459
|
+
console.log('[Schedule] No player location, skipping geofence check');
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (!geoLocation) return true;
|
|
464
|
+
|
|
465
|
+
// Parse "lat,lng" format
|
|
466
|
+
const parts = geoLocation.split(',').map(s => parseFloat(s.trim()));
|
|
467
|
+
if (parts.length < 2 || isNaN(parts[0]) || isNaN(parts[1])) {
|
|
468
|
+
console.log('[Schedule] Invalid geoLocation format:', geoLocation);
|
|
469
|
+
return true; // Invalid format, be permissive
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const fenceLat = parts[0];
|
|
473
|
+
const fenceLng = parts[1];
|
|
474
|
+
const radius = parts[2] || defaultRadius; // Optional 3rd param: radius in meters
|
|
475
|
+
|
|
476
|
+
const distance = this.haversineDistance(
|
|
477
|
+
this.playerLocation.latitude, this.playerLocation.longitude,
|
|
478
|
+
fenceLat, fenceLng
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
const within = distance <= radius;
|
|
482
|
+
console.log(`[Schedule] Geofence: ${distance.toFixed(0)}m from (${fenceLat},${fenceLng}), radius ${radius}m → ${within ? 'WITHIN' : 'OUTSIDE'}`);
|
|
483
|
+
return within;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Haversine formula: calculate distance between two GPS coordinates
|
|
488
|
+
* @param {number} lat1 - Latitude 1 (degrees)
|
|
489
|
+
* @param {number} lon1 - Longitude 1 (degrees)
|
|
490
|
+
* @param {number} lat2 - Latitude 2 (degrees)
|
|
491
|
+
* @param {number} lon2 - Longitude 2 (degrees)
|
|
492
|
+
* @returns {number} Distance in meters
|
|
493
|
+
*/
|
|
494
|
+
haversineDistance(lat1, lon1, lat2, lon2) {
|
|
495
|
+
const R = 6371000; // Earth radius in meters
|
|
496
|
+
const toRad = deg => deg * Math.PI / 180;
|
|
497
|
+
|
|
498
|
+
const dLat = toRad(lat2 - lat1);
|
|
499
|
+
const dLon = toRad(lon2 - lon1);
|
|
500
|
+
|
|
501
|
+
const a = Math.sin(dLat / 2) ** 2 +
|
|
502
|
+
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
|
|
503
|
+
Math.sin(dLon / 2) ** 2;
|
|
504
|
+
|
|
505
|
+
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export const scheduleManager = new ScheduleManager();
|