import {Destination} from '../destination.js';
import {attractionType, statusType, queueType, tagType, scheduleType, entityType} from '../parkTypes.js';
import moment from 'moment-timezone';
/**
* Plopsaland Deutschland theme park implementation
* @since 2025
*/
export class PlopsalandDeutschland extends Destination {
constructor(options = {}) {
options.timezone = options.timezone || 'Europe/Berlin';
// Base API URL for Plopsaland Deutschland
options.baseURL = options.baseURL || '';
options.parkId = options.parkId || 'plopsaland-deutschland';
options.language = options.language || 'en';
super(options);
if (!this.config.baseURL) throw new Error('Missing baseURL');
if (!this.config.parkId) throw new Error('Missing parkId');
// Setup API injection for the domain
const baseURLHostname = new URL(this.config.baseURL).hostname;
this.http.injectForDomain({
hostname: baseURLHostname,
}, async (method, url, data, options) => {
// Add default headers if needed
// TODO - Add authentication if required
});
}
/**
* Get points of interest data (attractions, restaurants, shops, etc.)
*/
async getPointsOfInterest() {
'@cache|360'; // cache for 6 hours
const resp = await this.http('GET', `${this.config.baseURL}/points-of-interest`, {
language: this.config.language,
park: this.config.parkId,
});
return resp.body;
}
/**
* Get waiting times for attractions
*/
async getWaitingTimes() {
'@cache|1'; // cache for 1 minute
const resp = await this.http('GET', `${this.config.baseURL}/attractions/waiting-times`, {
language: this.config.language,
park: this.config.parkId,
});
return resp.body;
}
/**
* Get park opening hours
*/
async getParkHours() {
'@cache|360'; // cache for 6 hours
const nowInParkTimezone = this.getTimeNowMoment();
const startDate = nowInParkTimezone.clone().format('YYYY-MM-DD');
const endDate = nowInParkTimezone.clone().add(3, 'months').format('YYYY-MM-DD');
const resp = await this.http('GET', `https://www.plopsa.com/en/plopsaland-deutschland/api/opening-hours-calendar`, {
start: startDate,
end: endDate,
});
return resp.body;
}
/**
* Get entertainment/shows schedule
*/
async getEntertainmentProgram() {
'@cache|60'; // cache for 1 hour
const resp = await this.http('GET', `${this.config.baseURL}/entertainments/day_program`, {
language: this.config.language,
park: this.config.parkId,
});
return resp.body;
}
/**
* Convert image coordinates on the park map to geographical coordinates
* This uses a linear transformation based on known control points on the park map image
* to convert pixel coordinates (x, y) to longitude and latitude.
* Gives a good enough approximation for the park map.
* @param {number} x
* @param {number} y
*/
imageToCoordinates(x, y) {
let transformCoefficients = null;
// generate our transform coefficients if not already done
if (transformCoefficients === null) {
// known control points on the park map image
const controlPoints = [
// Sky Scream
{pixel: {x: 1301, y: 457}, geo: {lat: 49.319340577718215, lon: 8.29254336959917}},
// lighthouse tower
{pixel: {x: 1237, y: 315}, geo: {lat: 49.31888886637556, lon: 8.29163056371229}},
// dinosplash
{pixel: {x: 954, y: 1019}, geo: {lat: 49.3187872288459, lon: 8.297126723709829}},
// splash battle
{pixel: {x: 1105, y: 810}, geo: {lat: 49.31921368348008, lon: 8.295472339476438}},
// beach resuce
{pixel: {x: 1103, y: 298}, geo: {lat: 49.31859964484687, lon: 8.29199916066343}},
// smurfs adventure
{pixel: {x: 1131, y: 991}, geo: {lat: 49.319341, lon: 8.296514}},
// red baron
{pixel: {x: 1108, y: 479}, geo: {lat: 49.31838591416893, lon: 8.293468523154518}},
// the frogs
{pixel: {x: 602, y: 1544}, geo: {lat: 49.318147, lon: 8.300963}},
// geforce
{pixel: {x: 678, y: 1022}, geo: {lat: 49.317542883557145, lon: 8.29789694573585}},
];
const transpose = (matrix) => {
return matrix[0].map((_, colIndex) => matrix.map(row => row[colIndex]));
};
const multiply = (a, b) => {
const aNumRows = a.length;
const aNumCols = a[0].length;
const bNumCols = b[0].length;
const m = new Array(aNumRows);
for (let r = 0; r < aNumRows; ++r) {
m[r] = new Array(bNumCols).fill(0);
for (let c = 0; c < bNumCols; ++c) {
for (let i = 0; i < aNumCols; ++i) {
m[r][c] += a[r][i] * b[i][c];
}
}
}
return m;
};
const invert3x3 = (m) => {
const det = m[0][0] * (m[1][1] * m[2][2] - m[2][1] * m[1][2]) -
m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) +
m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
if (det === 0) {
console.error("Matrix is singular and cannot be inverted.");
return null;
}
const invDet = 1 / det;
const inv = [
new Array(3), new Array(3), new Array(3)
];
inv[0][0] = (m[1][1] * m[2][2] - m[2][1] * m[1][2]) * invDet;
inv[0][1] = (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * invDet;
inv[0][2] = (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * invDet;
inv[1][0] = (m[1][2] * m[2][0] - m[1][0] * m[2][2]) * invDet;
inv[1][1] = (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * invDet;
inv[1][2] = (m[1][0] * m[0][2] - m[0][0] * m[1][2]) * invDet;
inv[2][0] = (m[1][0] * m[2][1] - m[2][0] * m[1][1]) * invDet;
inv[2][1] = (m[2][0] * m[0][1] - m[0][0] * m[2][1]) * invDet;
inv[2][2] = (m[0][0] * m[1][1] - m[1][0] * m[0][1]) * invDet;
return inv;
};
const calculateTransformCoefficients = () => {
// A matrix: Each row is [x, y, 1] for a control point.
const A = controlPoints.map(p => [p.pixel.x, p.pixel.y, 1]);
// b vectors: one for longitude, one for latitude.
const b_lon = controlPoints.map(p => [p.geo.lon]);
const b_lat = controlPoints.map(p => [p.geo.lat]);
// Calculate A^T (A transpose)
const AT = transpose(A);
// Calculate A^T * A
const ATA = multiply(AT, A);
// Calculate (A^T * A)^-1
const ATA_inv = invert3x3(ATA);
if (!ATA_inv) return null;
// Calculate A^T * b for both lon and lat
const ATb_lon = multiply(AT, b_lon);
const ATb_lat = multiply(AT, b_lat);
// Finally, calculate the coefficients: x = (A^T * A)^-1 * (A^T * b)
const x_lon = multiply(ATA_inv, ATb_lon);
const x_lat = multiply(ATA_inv, ATb_lat);
// The transformation is:
// lon = a*x + b*y + c
// lat = d*x + e*y + f
return {
a: x_lon[0][0], b: x_lon[1][0], c: x_lon[2][0], // for longitude
d: x_lat[0][0], e: x_lat[1][0], f: x_lat[2][0] // for latitude
};
};
transformCoefficients = calculateTransformCoefficients();
}
const {a, b, c, d, e, f} = transformCoefficients;
const lon = a * x + b * y + c;
const lat = d * x + e * y + f;
return {lon, lat};
}
/**
* Helper function to build a basic entity document
* Useful to avoid copy/pasting
* @param {object} data
* @returns {object}
*/
buildBaseEntityObject(data) {
const entity = Destination.prototype.buildBaseEntityObject.call(this, data);
if (data) {
if (data.map_coordinates && data.map_coordinates.x && data.map_coordinates.y) {
// Convert image coordinates to geographical coordinates
const coords = this.imageToCoordinates(data.map_coordinates.x, data.map_coordinates.y);
entity.location = {
longitude: coords.lon,
latitude: coords.lat,
};
}
}
return entity;
}
/**
* Build the destination entity representing this destination
*/
async buildDestinationEntity() {
const doc = {};
return {
...this.buildBaseEntityObject(doc),
_id: 'plopsalanddeutschland',
slug: 'plopsaland-deutschland',
name: 'Plopsaland Deutschland',
entityType: entityType.destination,
location: {
longitude: 8.300217955490842,
latitude: 49.317914992075146,
},
};
}
/**
* Build the park entities for this destination
*/
async buildParkEntities() {
return [
{
...this.buildBaseEntityObject(null),
_id: 'plopsalanddeutschlandpark',
_destinationId: 'plopsalanddeutschland',
_parentId: 'plopsalanddeutschland',
name: 'Plopsaland Deutschland',
entityType: entityType.park,
location: {
longitude: 8.300217955490842,
latitude: 49.317914992075146,
}
}
];
}
/**
* Build the attraction entities for this destination
*/
async buildAttractionEntities() {
const poisData = await this.getPointsOfInterest();
const attractions = [];
if (poisData?.items) {
for (const poi of poisData.items) {
// Filter for attractions only
if (poi.type?.label === 'Attraction' && poi.contains?.length > 0) {
for (const attraction of poi.contains) {
if (attraction.type === 'attraction') {
const entity = {
...this.buildBaseEntityObject(poi),
_id: attraction.plopsa_id || attraction.id,
_destinationId: 'plopsalanddeutschland',
_parentId: 'plopsalanddeutschlandpark',
_parkId: 'plopsalanddeutschlandpark',
name: attraction.title,
entityType: entityType.attraction,
attractionType: attractionType.ride, // Default to ride type
};
attractions.push(entity);
}
}
}
}
}
return attractions;
}
/**
* Build the show entities for this destination
*/
async buildShowEntities() {
const entertainmentData = await this.getEntertainmentProgram();
const poiData = await this.getPointsOfInterest();
const shows = [];
if (entertainmentData?.items) {
for (const show of entertainmentData.items) {
if (show.type?.label === 'Show') {
// find poidata
const poi = poiData.items.find(p => p.plopsa_id === show.plopsa_id || p.id === show.plopsa_id);
const entity = {
...this.buildBaseEntityObject(poi),
_id: show.plopsa_id || show.id,
_destinationId: 'plopsalanddeutschland',
_parentId: 'plopsalanddeutschlandpark',
_parkId: 'plopsalanddeutschlandpark',
name: show.title,
entityType: entityType.show,
// default to park location
location: {
longitude: 8.300217955490842,
latitude: 49.317914992075146,
},
};
shows.push(entity);
}
}
}
return shows;
}
/**
* Build the restaurant entities for this destination
*/
async buildRestaurantEntities() {
const poisData = await this.getPointsOfInterest();
const restaurants = [];
if (poisData?.items) {
for (const poi of poisData.items) {
// Filter for food and drinks
if (poi.type?.label === 'Food and drinks' && poi.contains?.length > 0) {
for (const restaurant of poi.contains) {
if (restaurant.type === 'foods_and_drinks') {
const entity = {
...this.buildBaseEntityObject(poi),
_id: restaurant.plopsa_id || restaurant.id,
_destinationId: 'plopsalanddeutschland',
_parentId: 'plopsalanddeutschlandpark',
_parkId: 'plopsalanddeutschlandpark',
name: restaurant.title,
entityType: entityType.restaurant,
};
restaurants.push(entity);
}
}
}
}
}
return restaurants;
}
/**
* @inheritdoc
*/
async buildEntityLiveData() {
const waitTimes = await this.getWaitingTimes();
const liveData = [];
// Process waiting times
if (waitTimes) {
for (const [attractionId, waitTime] of Object.entries(waitTimes)) {
const entity = {
_id: attractionId,
status: statusType.operating,
};
// Set queue time
if (waitTime > 0) {
entity.queue = {
[queueType.standBy]: {
waitTime: waitTime,
}
};
} else {
// 0 could mean closed or no wait
entity.queue = {
[queueType.standBy]: {
waitTime: 0,
}
};
}
liveData.push(entity);
}
}
return liveData;
}
/**
* Return schedule data for all scheduled entities in this destination
* @returns {array<object>}
*/
async buildEntityScheduleData() {
const scheduleData = [];
try {
// Get park hours
const parkHours = await this.getParkHours();
if (parkHours) {
const parkSchedule = {
_id: 'plopsalanddeutschlandpark',
schedule: []
};
// Process each month and date
for (const [monthKey, monthData] of Object.entries(parkHours)) {
for (const [dateKey, dayData] of Object.entries(monthData)) {
// Skip if no slots (park closed)
if (!dayData.slots || dayData.slots.length === 0) {
continue;
}
// Process each time slot for this date
for (const slot of dayData.slots) {
if (slot.type === 'open') {
const scheduleEntry = {
date: dateKey,
type: scheduleType.operating,
openingTime: slot.start_time,
closingTime: slot.end_time,
};
parkSchedule.schedule.push(scheduleEntry);
}
}
}
}
if (parkSchedule.schedule.length > 0) {
scheduleData.push(parkSchedule);
}
} else {
console.error('parkHours is null or undefined:', parkHours);
}
} catch (error) {
console.error('Error fetching park hours:', error);
}
return scheduleData;
}
}