@geoapify/route-planner-sdk
Version:
TypeScript SDK for the Geoapify Route Planner API. Supports route optimization, delivery planning, and timeline visualization in browser and Node.js
395 lines (389 loc) • 19.8 kB
JavaScript
export class AgentTimelineGenerator {
constructor(container) {
this.timelineTemplate = (timelineData, timeline, index, timelineType, storageColor, agentIcon, timeLabels, distanceLabels, solution) => `
<div class="geoapify-rp-sdk-timeline-item flex-container items-center ${index % 2 === 0 ? 'geoapify-rp-sdk-even' : ''}">
<div class="geoapify-rp-sdk-timeline-item-agent flex-container items-center padding-top-5 padding-bottom-5 ${timelineData.hasLargeDescription ? 'geoapify-rp-sdk-wider' : ''}">
<div>
<button class="geoapify-rp-sdk-icon-button geoapify-rp-sdk-toggle-route-btn" data-agent-index="${timeline.agentIndex}" ${timeline.timelineLength ? '' : 'disabled'}>
${timeline.routeVisible && timeline.timelineLength ? `<i class="geoapify-rp-sdk-visibility-icon fas fa-eye black-06"></i>` : ''}
${!timeline.routeVisible && timeline.timelineLength ? `<i class="geoapify-rp-sdk-visibility-icon fas fa-eye-slash black-06"></i>` : ''}
${solution && !timeline.timelineLength ? `<i class="geoapify-rp-sdk-visibility-icon fas fa-exclamation-triangle" style="color: #ff6666;" title="Unassigned agent"></i>` : ''}
</button>
</div>
<div style="color: ${timeline.color}" class="flex-main geoapify-rp-sdk-agent-info margin-right-10 flex-container column">
<span class="geoapify-rp-sdk-mat-subtitle-2">${timeline.label}</span>
<span class="geoapify-rp-sdk-mat-caption description">${timeline.description}</span>
</div>
<div style="color: ${timeline.color}" class="geoapify-rp-sdk-icon">
<i class="fas fa-${agentIcon}"></i>
</div>
</div>
<div class="geoapify-rp-sdk-timeline flex-main" style="margin-left: 10px; position: relative; height: 100%; min-height: 45px;">
<div class="geoapify-rp-sdk-line"></div>
${timeline.timelineLength && timelineType === 'time' ? `<div class="geoapify-rp-sdk-value-line"
style="background-color: ${timeline.color}; width: ${timeline.timelineLength}; left: ${timeline.timelineLeft};"></div>
` : ''}
${timeline.distanceLineLength && timelineType === 'distance' ? `<div class="geoapify-rp-sdk-value-line"
style="background-color: ${timeline.color}; width: ${timeline.distanceLineLength};"></div>` : ''}
${timelineType === 'time' ? `
${(timeline.itemsByTime || []).map((item) => `
<div class="geoapify-rp-sdk-solution-item"
style="left: ${item.position}; width: ${item.minWidth || ''}"
data-tooltip="${item.description}">
${item.form === 'full' ? `<div class="geoapify-rp-sdk-solution-item-full" style="width: 100%; background-color: ${item.type === 'storage' ? storageColor : timeline.color};"></div>` : ''}
${item.form === 'standard' ? `<div class="geoapify-rp-sdk-solution-item-standard" style="background-color: ${item.type === 'storage' ? storageColor : timeline.color};"></div>` : ''}
${item.form === 'minimal' ? `<div class="geoapify-rp-sdk-solution-item-minimal" style="background-color: ${item.type === 'storage' ? storageColor : timeline.color};"></div>` : ''}
</div>`).join('')}
` : ''}
${timelineType === 'distance' ? `
${(timeline.itemsByDistance || []).map((item) => `<div class="geoapify-rp-sdk-solution-item" style="left: ${item.position};" data-tooltip="${item.description}">
<div class="geoapify-rp-sdk-solution-item-minimal" style="background-color: ${item.type === 'storage' ? storageColor : timeline.color};"></div>
</div> `).join('')}
` : ''}
${timeLabels && timelineType === 'time' ? `
<div class="geoapify-rp-sdk-label-vertical-lines">
${(timeLabels || []).map(label => `
<div class="geoapify-rp-sdk-label-vertical-line" style="left: ${label.position};"></div>
`).join('')}
</div>
` : ''}
${distanceLabels && timelineType === 'distance' ? `
<div class="geoapify-rp-sdk-label-vertical-lines">
${(distanceLabels || []).map(label => `<div class="geoapify-rp-sdk-label-vertical-line" style="left: ${label.position};"></div>`).join('')}
</div>
` : ''}
<div id="global-tooltip" class="geoapify-rp-sdk-custom-tooltip" style="display: none;"></div>
</div>
</div>
`;
this.storageColor = '#ff9933';
this.container = container;
}
static getAgentColorByIndex(index) {
return this.colors[(index % this.colors.length + this.colors.length) % this.colors.length];
}
generateAgentTimeline(timelineType, hasLargeDescription, task, scenario, timeLabels, distanceLabels, onToggleRoute, solution) {
hasLargeDescription = false;
let agentIcon;
let timelines;
if (!task && solution) {
agentIcon = this.getAgentIconByMode(solution.inputData.mode);
const maxIndex = Math.max(...(solution.unassignedAgents || []), ...solution.agents.map(agentPlan => agentPlan.agentIndex));
// create timelines based on result
timelines = [];
for (let i = 0; i <= maxIndex; i++) {
timelines.push({
label: `agent ${i + 1}`,
mode: solution.inputData.mode,
color: AgentTimelineGenerator.getAgentColorByIndex(i),
description: '',
routeVisible: true,
agentIndex: i,
timelineLength: "",
distanceLineLength: "",
itemsByDistance: [],
itemsByTime: [],
timelineLeft: "100%"
});
}
let result = {
agentIcon: agentIcon,
hasLargeDescription: hasLargeDescription,
timelines: timelines
};
this.drawTimelines(result, timelineType, timeLabels, distanceLabels, scenario, solution);
return result;
}
else {
agentIcon = scenario.agentIcon || this.getAgentIconByMode(scenario.mode);
timelines = task.agents.map((agent, index) => {
const label = `${scenario.agentLabel} ${index + 1}`;
if (label.length >= 10) {
hasLargeDescription = true;
}
return {
label: label,
mode: scenario.mode,
color: AgentTimelineGenerator.getAgentColorByIndex(index),
description: this.generateAgentDescription(agent, scenario, hasLargeDescription).description,
routeVisible: true,
agentIndex: index,
timelineLength: "",
distanceLineLength: "",
itemsByDistance: [],
itemsByTime: [],
timelineLeft: "100%"
};
});
let result = {
agentIcon: agentIcon,
hasLargeDescription: hasLargeDescription,
timelines: timelines
};
this.drawTimelines(result, timelineType, timeLabels, distanceLabels, scenario, solution);
this.attachToggleRouteHandler(result.timelines, this.container, onToggleRoute);
return result;
}
}
drawTimelines(result, timelineType, timeLabels, distanceLabels, scenario, solution) {
var _a;
if (result && solution) {
this.generateTimelinesData(result, solution, scenario);
}
this.container.innerHTML = ''; // clear
this.loadFontAwesome();
(_a = result.timelines) === null || _a === void 0 ? void 0 : _a.forEach((timeline, index) => {
const html = this.timelineTemplate(result, timeline, index, timelineType, this.storageColor, result.agentIcon, timeLabels, distanceLabels, solution);
this.container.insertAdjacentHTML('beforeend', html);
});
this.initializeGlobalTooltip();
}
generateTimelinesData(result, solution, scenario) {
const unit = (scenario === null || scenario === void 0 ? void 0 : scenario.capacityUnit) || 'items';
let maxDistance = Math.max.apply(Math, solution.agents.map((agentPlan) => {
return agentPlan.distance;
}));
let maxTime = Math.max.apply(Math, solution.agents.map((agentPlan) => {
return agentPlan.time + agentPlan.start_time;
}));
solution.agents.forEach((agentPlan) => {
var _a, _b;
const timeline = result.timelines[agentPlan.agentIndex];
timeline.timelineLength = ((agentPlan.time - (((_a = agentPlan.waypoints) === null || _a === void 0 ? void 0 : _a.length) ? agentPlan.waypoints[0].start_time : 0)) / maxTime * 100) + '%';
timeline.distanceLineLength = (agentPlan.distance / maxDistance * 100) + '%';
timeline.itemsByDistance = [];
timeline.timelineLeft = ((agentPlan.start_time || (((_b = agentPlan.waypoints) === null || _b === void 0 ? void 0 : _b.length) ? agentPlan.waypoints[0].start_time : 0)) / maxTime * 100) + '%';
this.generateItemsByTime(timeline, agentPlan, maxTime, solution, unit);
this.generateItemsByDistance(timeline, agentPlan, maxDistance);
});
}
generateItemsByTime(timeline, agentPlan, maxTime, solution, unit) {
timeline.itemsByTime = [];
agentPlan.waypoints.forEach((waypoint, index) => {
const duration = (waypoint.duration || 0);
const actualWidth = (duration / maxTime);
const descriptionItems = [];
const title = [...new Set(waypoint.actions.map(action => action.type.charAt(0).toUpperCase() + action.type.slice(1)))].join(' / ');
descriptionItems.push(`${index + 1}: ${title}`);
if (duration) {
descriptionItems.push(`Duration: ${this.toPrettyTime(waypoint.duration)}`);
}
descriptionItems.push(`Time before: ${this.toPrettyTime(waypoint.start_time)}`);
descriptionItems.push(`Time after: ${this.toPrettyTime(waypoint.start_time + duration)}`);
const actionsData = waypoint.actions.map(action => {
const description = [];
if (action) {
let job = action.job_index && action.job_index >= 0 ? solution.inputData.jobs[action.job_index] : undefined;
if (job === null || job === void 0 ? void 0 : job.pickup_amount) {
description.push(`pickup ${job.pickup_amount} ${unit}`);
}
if (job === null || job === void 0 ? void 0 : job.delivery_amount) {
description.push(`deliver ${job.delivery_amount} ${unit}`);
}
}
let shipment = action.shipment_index && action.shipment_index >= 0 ? solution.inputData.shipments[action.shipment_index] : undefined;
if (shipment) {
if (shipment.amount) {
description.push(action.type === 'pickup' ? `pickup ${shipment.amount} ${unit}` : `deliver ${shipment.amount} ${unit}`);
}
}
return description.join(', ');
}).filter((actionDescription) => actionDescription).join('; ');
if (actionsData) {
descriptionItems.push(`Actions: ${actionsData}`);
}
let isStorage = this.isWaypointStorage(waypoint);
const timeItem = {
type: isStorage ? 'storage' : 'job',
actualWidth: 100 * actualWidth + '%',
position: (100 * (waypoint.start_time + duration / 2) / maxTime) + '%',
form: 'full',
minWidth: 100 * actualWidth + '%',
description: descriptionItems.join('\n')
};
timeline.itemsByTime.push(timeItem);
});
}
isWaypointStorage(waypoint) {
return waypoint.actions.some(action => action.location_index && action.location_index >= 0);
}
generateItemsByDistance(timeline, agentPlan, maxDistance) {
let distance = 0;
agentPlan.legs.forEach((leg, index) => {
const from = agentPlan.waypoints[leg.from_waypoint_index];
const to = agentPlan.waypoints[leg.to_waypoint_index];
if (index === 0) {
const descriptionItems = [];
const title = [...new Set(from.actions.map(action => action.type.charAt(0).toUpperCase() + action.type.slice(1)))].join(' / ');
descriptionItems.push(title);
descriptionItems.push('Distance traveled: 0');
let isFromStorage = this.isWaypointStorage(from);
const distanceItem = {
type: isFromStorage ? 'storage' : 'job',
actualWidth: "0",
position: "0",
form: 'minimal',
minWidth: "10px",
description: descriptionItems.join('\n')
};
timeline.itemsByDistance.push(distanceItem);
}
distance += leg.distance;
const descriptionItems = [];
const title = [...new Set(to.actions.map(action => action.type.charAt(0).toUpperCase() + action.type.slice(1)))].join(' / ');
descriptionItems.push(title);
descriptionItems.push(`Distance traveled: ${this.toPrettyDistance(distance)}`);
let isToStorage = this.isWaypointStorage(from);
const distanceItem = {
type: isToStorage ? 'storage' : 'job',
actualWidth: '0',
position: (distance / maxDistance * 100) + '%',
form: 'minimal',
minWidth: "10px",
description: descriptionItems.join('\n')
};
timeline.itemsByDistance.push(distanceItem);
});
}
getAgentIconByMode(mode) {
const iconsMap = {
'drive': 'car',
'truck': 'truck',
'bicycle': 'biking',
'walk': 'walking'
};
return iconsMap[mode];
}
generateAgentDescription(agent, scenario, hasLargeDescription) {
const descriptionItems = [];
if (agent.pickup_capacity && agent.delivery_capacity) {
descriptionItems.push(`${agent.pickup_capacity} ${scenario.capacityUnit} / ${agent.delivery_capacity} ${scenario.capacityUnit}`);
}
else if (agent.pickup_capacity || agent.delivery_capacity) {
descriptionItems.push(`${agent.pickup_capacity || agent.delivery_capacity} ${scenario.capacityUnit}`);
}
if (agent.time_windows) {
descriptionItems.push(agent.time_windows.map((timeFrame) => `${this.toPrettyTime(timeFrame[0])}-${this.toPrettyTime(timeFrame[1])}`).join(', '));
}
if (agent.capabilities) {
descriptionItems.push(...agent.capabilities);
}
if (descriptionItems.join(", ").length > 20) {
hasLargeDescription = true;
}
return {
description: descriptionItems.join(', '),
hasLargeDescription: hasLargeDescription
};
}
toPrettyTime(sec_num) {
let hours = Math.floor(sec_num / 3600);
let minutes = Math.floor((sec_num - (hours * 3600)) / 60);
if (sec_num === 0) {
return '0';
}
if (!hours) {
return minutes + 'min';
}
if (!minutes) {
return hours + 'h';
}
return hours + 'h ' + minutes + 'm';
}
loadFontAwesome() {
const id = 'font-awesome-stylesheet';
if (document.getElementById(id))
return; // already added
const link = document.createElement('link');
link.id = id;
link.rel = 'stylesheet';
link.href = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css';
link.crossOrigin = 'anonymous';
document.head.appendChild(link);
}
initializeGlobalTooltip() {
if (document.getElementById('global-tooltip-listener'))
return; // already added
const idMarker = document.createElement('div');
idMarker.id = 'global-tooltip-listener';
document.body.appendChild(idMarker);
// Create global tooltip
const tooltip = document.createElement('div');
tooltip.id = 'global-tooltip';
tooltip.className = 'geoapify-rp-sdk-custom-tooltip';
tooltip.style.display = 'none';
document.body.appendChild(tooltip);
// Mouse over handler
document.addEventListener('mouseover', (e) => {
var _a;
const target = e.target;
const tooltipText = (_a = target.closest('.geoapify-rp-sdk-solution-item')) === null || _a === void 0 ? void 0 : _a.getAttribute('data-tooltip');
if (tooltipText) {
const rect = target.getBoundingClientRect();
tooltip.innerText = tooltipText;
tooltip.style.display = 'block';
tooltip.style.left = `${e.clientX}px`;
tooltip.style.top = `${rect.bottom + 6}px`;
tooltip.classList.add('geoapify-rp-sdk-show');
}
});
// Mouse out handler
document.addEventListener('mouseout', (e) => {
const target = e.target;
if (target.closest('.geoapify-rp-sdk-solution-item')) {
tooltip.classList.remove('geoapify-rp-sdk-show');
tooltip.style.display = 'none';
}
});
}
attachToggleRouteHandler(timelines, container, onToggleRoute) {
this.injectHoverStyle();
if (container.dataset.clickListenerAttached === 'true')
return;
container.addEventListener('click', (e) => {
const button = e.target.closest('.geoapify-rp-sdk-toggle-route-btn');
if (!button)
return;
const index = Number(button.getAttribute('data-agent-index'));
const timeline = timelines.find(t => t.agentIndex === index);
if (!timeline || !timeline.timelineLength)
return;
const icon = button.querySelector('i');
if (icon) {
icon.classList.toggle('fa-eye');
icon.classList.toggle('fa-eye-slash');
}
if (onToggleRoute) {
onToggleRoute(timeline);
}
});
container.dataset.clickListenerAttached = 'true';
}
injectHoverStyle() {
const styleId = 'eye-button-hover-style';
if (document.getElementById(styleId))
return; // avoid duplicates
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
.geoapify-rp-sdk-timeline-item-agent .toggle-route-btn:active {
background-color: #e0e0e0;
}
.geoapify-rp-sdk-timeline-item-agent .toggle-route-btn:hover {
background-color: buttonface;
}
`;
document.head.appendChild(style);
}
toPrettyDistance(meters) {
if (meters > 10000) {
return `${(meters / 1000).toFixed(1)} km`;
}
if (meters > 5000) {
return `${(meters / 1000).toFixed(2)} km`;
}
return `${meters} m`;
}
}
AgentTimelineGenerator.colors = ["#ff4d4d", "#1a8cff", "#00cc66", "#b300b3", "#e6b800", "#ff3385",
"#0039e6", "#408000", "#ffa31a", "#990073", "#cccc00", "#cc5200", "#6666ff", "#009999"];