Spaces:
Running
Running
Commit ·
66c0efc
1
Parent(s): 531908f
refactor(ui): split field service workspace scripts
Browse filesMove the field-service workspace helpers and rendering logic out of the bootstrap script so the frontend stays under the 300-line file-size limit.
The index now loads dedicated utility and renderer modules before the application bootstrap. The rendered summary also accounts for unassigned visits when reporting hard issues, which keeps the UI truthful while demo plans start empty.
- static/app-render.js +281 -0
- static/app-utils.js +225 -0
- static/app.js +52 -576
- static/index.html +2 -0
static/app-render.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* app-render.js — rendering layer for the Bergamo FSR demo */
|
| 2 |
+
|
| 3 |
+
(function () {
|
| 4 |
+
'use strict';
|
| 5 |
+
|
| 6 |
+
var FSR = window.FSR = window.FSR || {};
|
| 7 |
+
var utils = FSR.utils;
|
| 8 |
+
|
| 9 |
+
FSR.createRenderer = function (options) {
|
| 10 |
+
var SF = options.SF;
|
| 11 |
+
var routeTimeline = null;
|
| 12 |
+
|
| 13 |
+
return {
|
| 14 |
+
buildAnalysisHtml: buildAnalysisHtml,
|
| 15 |
+
destroy: destroy,
|
| 16 |
+
renderAll: renderAll,
|
| 17 |
+
renderApiGuide: renderApiGuide,
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
function renderAll(plan) {
|
| 21 |
+
renderSummary(plan);
|
| 22 |
+
renderMap(plan);
|
| 23 |
+
renderRouteCards(plan);
|
| 24 |
+
renderTimeline(plan);
|
| 25 |
+
renderTables(plan);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function renderSummary(plan) {
|
| 29 |
+
var routes = plan.technician_routes || [];
|
| 30 |
+
var visits = plan.service_visits || [];
|
| 31 |
+
var assigned = utils.assignedVisitSet(routes);
|
| 32 |
+
var assignedCount = Object.keys(assigned).length;
|
| 33 |
+
var routeMetrics = routes.map(function (route) { return utils.routeStats(plan, route); });
|
| 34 |
+
var travelMinutes = routeMetrics.reduce(function (sum, stats) { return sum + stats.travelMinutes; }, 0);
|
| 35 |
+
var serviceMinutes = routeMetrics.reduce(function (sum, stats) { return sum + stats.serviceMinutes; }, 0);
|
| 36 |
+
var routeIssues = routeMetrics.reduce(function (sum, stats) {
|
| 37 |
+
return sum + stats.unreachable + stats.missingSkills + stats.missingParts + stats.lateMinutes + stats.overtimeMinutes;
|
| 38 |
+
}, 0);
|
| 39 |
+
|
| 40 |
+
options.summaryContainer.innerHTML = '';
|
| 41 |
+
options.summaryContainer.appendChild(SF.createTable({
|
| 42 |
+
columns: ['Dataset', 'Visits', 'Assigned', 'Technicians', 'Travel', 'Service', 'Hard issues', 'Score'],
|
| 43 |
+
rows: [[
|
| 44 |
+
options.getDemoCatalog().defaultId || 'STANDARD',
|
| 45 |
+
String(visits.length),
|
| 46 |
+
String(assignedCount),
|
| 47 |
+
String(routes.length),
|
| 48 |
+
utils.formatDuration(travelMinutes),
|
| 49 |
+
utils.formatDuration(serviceMinutes),
|
| 50 |
+
String(routeIssues + Math.max(0, visits.length - assignedCount)),
|
| 51 |
+
String(plan.score || 'unsolved'),
|
| 52 |
+
]],
|
| 53 |
+
}));
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function renderMap(plan) {
|
| 57 |
+
if (!options.routeMap) return;
|
| 58 |
+
options.routeMap.clearAll();
|
| 59 |
+
|
| 60 |
+
var locations = plan.locations || [];
|
| 61 |
+
var visits = plan.service_visits || [];
|
| 62 |
+
var routes = plan.technician_routes || [];
|
| 63 |
+
var assigned = utils.assignedVisitSet(routes);
|
| 64 |
+
|
| 65 |
+
routes.forEach(function (route) {
|
| 66 |
+
var start = locations[route.start_location_idx];
|
| 67 |
+
if (!start) return;
|
| 68 |
+
options.routeMap.addVehicleMarker({
|
| 69 |
+
lat: utils.locationLat(start),
|
| 70 |
+
lng: utils.locationLng(start),
|
| 71 |
+
color: route.color || '#2563eb',
|
| 72 |
+
});
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
visits.forEach(function (visit, idx) {
|
| 76 |
+
var location = locations[visit.location_idx];
|
| 77 |
+
if (!location) return;
|
| 78 |
+
options.routeMap.addVisitMarker({
|
| 79 |
+
lat: utils.locationLat(location),
|
| 80 |
+
lng: utils.locationLng(location),
|
| 81 |
+
color: assigned[idx] ? assigned[idx].color : '#64748b',
|
| 82 |
+
icon: utils.iconForVisit(visit),
|
| 83 |
+
assigned: !!assigned[idx],
|
| 84 |
+
});
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
routes.forEach(function (route) {
|
| 88 |
+
var previous = route.start_location_idx;
|
| 89 |
+
(route.visits || []).forEach(function (visitIdx, sequenceIdx) {
|
| 90 |
+
var visit = visits[visitIdx];
|
| 91 |
+
if (!visit) return;
|
| 92 |
+
drawLeg(plan, previous, visit.location_idx, route.color);
|
| 93 |
+
addStopNumber(locations[visit.location_idx], sequenceIdx + 1, route.color);
|
| 94 |
+
previous = visit.location_idx;
|
| 95 |
+
});
|
| 96 |
+
drawLeg(plan, previous, route.end_location_idx, route.color);
|
| 97 |
+
});
|
| 98 |
+
|
| 99 |
+
options.routeMap.fitBounds();
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function addStopNumber(location, number, color) {
|
| 103 |
+
if (!location) return;
|
| 104 |
+
options.routeMap.addStopNumber({
|
| 105 |
+
lat: utils.locationLat(location),
|
| 106 |
+
lng: utils.locationLng(location),
|
| 107 |
+
number: number,
|
| 108 |
+
color: color || '#2563eb',
|
| 109 |
+
});
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
function renderRouteCards(plan) {
|
| 113 |
+
options.routeCards.innerHTML = '';
|
| 114 |
+
renderUnassignedCard(plan);
|
| 115 |
+
(plan.technician_routes || []).forEach(function (route) {
|
| 116 |
+
var stats = utils.routeStats(plan, route);
|
| 117 |
+
var card = SF.el('div', {
|
| 118 |
+
className: 'sf-section',
|
| 119 |
+
style: { borderLeft: '4px solid ' + (route.color || '#2563eb'), padding: '12px', borderRadius: '8px' },
|
| 120 |
+
});
|
| 121 |
+
card.appendChild(SF.el('h3', { style: { margin: '0 0 8px' } }, route.technician_name || route.id));
|
| 122 |
+
card.appendChild(SF.createTable({
|
| 123 |
+
columns: ['Stops', 'Travel', 'Service'],
|
| 124 |
+
rows: [[String((route.visits || []).length), utils.formatDuration(stats.travelMinutes), utils.formatDuration(stats.serviceMinutes)]],
|
| 125 |
+
}));
|
| 126 |
+
card.appendChild(SF.createTable({
|
| 127 |
+
columns: ['Territory', 'Late', 'Overtime'],
|
| 128 |
+
rows: [[route.territory || '-', utils.formatDuration(stats.lateMinutes), utils.formatDuration(stats.overtimeMinutes)]],
|
| 129 |
+
}));
|
| 130 |
+
options.routeCards.appendChild(card);
|
| 131 |
+
});
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
function renderUnassignedCard(plan) {
|
| 135 |
+
var assigned = utils.assignedVisitSet(plan.technician_routes || []);
|
| 136 |
+
var rows = (plan.service_visits || []).reduce(function (items, visit, idx) {
|
| 137 |
+
if (assigned[idx]) return items;
|
| 138 |
+
items.push([
|
| 139 |
+
visit.customer || visit.name || visit.id,
|
| 140 |
+
utils.timeLabel(visit.earliest_minute) + '-' + utils.timeLabel(visit.latest_minute),
|
| 141 |
+
utils.formatDuration(visit.duration_minutes || 0),
|
| 142 |
+
]);
|
| 143 |
+
return items;
|
| 144 |
+
}, []);
|
| 145 |
+
if (!rows.length) return;
|
| 146 |
+
|
| 147 |
+
var card = SF.el('div', {
|
| 148 |
+
className: 'sf-section',
|
| 149 |
+
style: { borderLeft: '4px solid #64748b', padding: '12px', borderRadius: '8px' },
|
| 150 |
+
});
|
| 151 |
+
card.appendChild(SF.el('h3', { style: { margin: '0 0 8px' } }, 'Unassigned visits'));
|
| 152 |
+
card.appendChild(SF.createTable({ columns: ['Visit', 'Window', 'Duration'], rows: rows }));
|
| 153 |
+
options.routeCards.appendChild(card);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
function renderTimeline(plan) {
|
| 157 |
+
var timelineConfig = buildTimelineConfig(plan);
|
| 158 |
+
options.timelineContainer.innerHTML = '';
|
| 159 |
+
options.timelineContainer.appendChild(SF.el('div', { className: 'sf-section' }, SF.createTable({
|
| 160 |
+
columns: ['Route lanes', 'Window', 'Source'],
|
| 161 |
+
rows: [[String((plan.technician_routes || []).length), '08:00-18:00', 'Latest SolverForge solution payload']],
|
| 162 |
+
})));
|
| 163 |
+
|
| 164 |
+
if (!routeTimeline) routeTimeline = SF.rail.createTimeline(timelineConfig);
|
| 165 |
+
else routeTimeline.setModel(timelineConfig.model);
|
| 166 |
+
options.timelineContainer.appendChild(routeTimeline.el);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
function buildTimelineConfig(plan) {
|
| 170 |
+
return {
|
| 171 |
+
label: 'Technician',
|
| 172 |
+
labelWidth: 280,
|
| 173 |
+
title: 'Bergamo Field Service Routes',
|
| 174 |
+
subtitle: 'Ordered service visits per technician',
|
| 175 |
+
model: {
|
| 176 |
+
axis: buildDayAxis(),
|
| 177 |
+
lanes: (plan.technician_routes || []).map(routeLane(plan)),
|
| 178 |
+
},
|
| 179 |
+
};
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
function routeLane(plan) {
|
| 183 |
+
return function (route, routeIdx) {
|
| 184 |
+
var items = utils.routeSchedule(plan, route).map(function (entry, entryIdx) {
|
| 185 |
+
return {
|
| 186 |
+
id: 'route-' + routeIdx + '-visit-' + entryIdx,
|
| 187 |
+
startMinute: entry.start,
|
| 188 |
+
endMinute: entry.end,
|
| 189 |
+
label: entry.visit.customer || entry.visit.name || entry.visit.id,
|
| 190 |
+
meta: utils.timeLabel(entry.start) + '-' + utils.timeLabel(entry.end),
|
| 191 |
+
tone: utils.toneForRoute(routeIdx),
|
| 192 |
+
};
|
| 193 |
+
});
|
| 194 |
+
var stats = utils.routeStats(plan, route);
|
| 195 |
+
return {
|
| 196 |
+
id: route.id || ('route-' + routeIdx),
|
| 197 |
+
label: route.technician_name || route.id || ('Technician ' + (routeIdx + 1)),
|
| 198 |
+
mode: 'detailed',
|
| 199 |
+
badges: utils.routeBadges(stats),
|
| 200 |
+
stats: [
|
| 201 |
+
{ label: 'Stops', value: (route.visits || []).length },
|
| 202 |
+
{ label: 'Travel', value: utils.formatDuration(stats.travelMinutes) },
|
| 203 |
+
{ label: 'Service', value: utils.formatDuration(stats.serviceMinutes) },
|
| 204 |
+
],
|
| 205 |
+
items: items,
|
| 206 |
+
};
|
| 207 |
+
};
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
function buildDayAxis() {
|
| 211 |
+
var ticks = [];
|
| 212 |
+
for (var minute = options.dayStart; minute <= options.dayEnd; minute += 60) {
|
| 213 |
+
ticks.push({ id: 'tick-' + minute, minute: minute, label: utils.timeLabel(minute) });
|
| 214 |
+
}
|
| 215 |
+
return {
|
| 216 |
+
startMinute: options.dayStart,
|
| 217 |
+
endMinute: options.dayEnd,
|
| 218 |
+
days: [{ id: 'bergamo-day', label: 'Service day', subLabel: '08:00-18:00', startMinute: options.dayStart, endMinute: options.dayEnd }],
|
| 219 |
+
ticks: ticks,
|
| 220 |
+
initialViewport: { startMinute: options.dayStart, endMinute: options.dayEnd },
|
| 221 |
+
};
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
function renderTables(plan) {
|
| 225 |
+
options.tablesContainer.innerHTML = '';
|
| 226 |
+
['technician_routes', 'service_visits', 'locations'].forEach(function (key) {
|
| 227 |
+
var rows = plan[key] || [];
|
| 228 |
+
if (!rows.length) return;
|
| 229 |
+
var columns = Object.keys(rows[0]).filter(function (column) { return column !== 'score' && column !== 'encoded_polyline'; });
|
| 230 |
+
var values = rows.map(function (row) {
|
| 231 |
+
return columns.map(function (column) {
|
| 232 |
+
var value = row[column];
|
| 233 |
+
if (value == null) return '-';
|
| 234 |
+
if (Array.isArray(value)) return value.join(', ');
|
| 235 |
+
return String(value);
|
| 236 |
+
});
|
| 237 |
+
});
|
| 238 |
+
var section = SF.el('div', { className: 'sf-section' });
|
| 239 |
+
section.appendChild(SF.el('h3', null, utils.title(key)));
|
| 240 |
+
section.appendChild(SF.createTable({ columns: columns, rows: values }));
|
| 241 |
+
options.tablesContainer.appendChild(section);
|
| 242 |
+
});
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
function renderApiGuide() {
|
| 246 |
+
var catalog = options.getDemoCatalog();
|
| 247 |
+
options.apiGuideContainer.innerHTML = '';
|
| 248 |
+
options.apiGuideContainer.appendChild(SF.createApiGuide({
|
| 249 |
+
endpoints: [
|
| 250 |
+
{ method: 'GET', path: '/demo-data', description: 'Discover demo datasets', curl: utils.buildCurl('GET', '/demo-data') },
|
| 251 |
+
{ method: 'GET', path: '/demo-data/' + (catalog.defaultId || '{defaultId}'), description: 'Fetch Bergamo OSM-backed demo data', curl: utils.buildCurl('GET', '/demo-data/' + (catalog.defaultId || 'STANDARD')) },
|
| 252 |
+
{ method: 'POST', path: '/jobs', description: 'Create a retained solve job', curl: utils.buildCurl('POST', '/jobs', true) },
|
| 253 |
+
{ method: 'GET', path: '/jobs/{id}/events', description: 'Stream typed SolverForge lifecycle events', curl: utils.buildCurl('GET', '/jobs/{id}/events') },
|
| 254 |
+
{ method: 'GET', path: '/jobs/{id}/snapshot', description: 'Fetch latest route snapshot', curl: utils.buildCurl('GET', '/jobs/{id}/snapshot') },
|
| 255 |
+
{ method: 'GET', path: '/jobs/{id}/analysis', description: 'Analyze the latest retained score', curl: utils.buildCurl('GET', '/jobs/{id}/analysis') },
|
| 256 |
+
],
|
| 257 |
+
}));
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
function drawLeg(plan, from, to, color) {
|
| 261 |
+
var leg = utils.legFor(plan, from, to);
|
| 262 |
+
if (!leg || !leg.reachable || !leg.encoded_polyline) return;
|
| 263 |
+
options.routeMap.drawEncodedRoute({ encoded: leg.encoded_polyline, color: color || '#2563eb' });
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
function buildAnalysisHtml(analysis) {
|
| 267 |
+
if (!analysis || !analysis.constraints) return '<p>No analysis available.</p>';
|
| 268 |
+
var html = '<p><strong>Score:</strong> ' + SF.escHtml(analysis.score) + '</p>';
|
| 269 |
+
html += '<table class="sf-table"><thead><tr><th>Constraint</th><th>Weight</th><th>Score</th><th>Matches</th></tr></thead><tbody>';
|
| 270 |
+
analysis.constraints.forEach(function (constraint) {
|
| 271 |
+
html += '<tr><td>' + SF.escHtml(constraint.name) + '</td><td>' + SF.escHtml(constraint.weight) + '</td><td>' + SF.escHtml(constraint.score) + '</td><td>' + String(constraint.matchCount || 0) + '</td></tr>';
|
| 272 |
+
});
|
| 273 |
+
html += '</tbody></table>';
|
| 274 |
+
return html;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
function destroy() {
|
| 278 |
+
if (routeTimeline) routeTimeline.destroy();
|
| 279 |
+
}
|
| 280 |
+
};
|
| 281 |
+
})();
|
static/app-utils.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* app-utils.js — shared helpers for the Bergamo FSR demo */
|
| 2 |
+
|
| 3 |
+
(function () {
|
| 4 |
+
'use strict';
|
| 5 |
+
|
| 6 |
+
var FSR = window.FSR = window.FSR || {};
|
| 7 |
+
|
| 8 |
+
FSR.utils = {
|
| 9 |
+
assignedVisitSet: assignedVisitSet,
|
| 10 |
+
buildCurl: buildCurl,
|
| 11 |
+
clonePlan: clonePlan,
|
| 12 |
+
fetchDemoCatalog: fetchDemoCatalog,
|
| 13 |
+
fetchDemoPlan: fetchDemoPlan,
|
| 14 |
+
findHeaderButton: findHeaderButton,
|
| 15 |
+
formatDuration: formatDuration,
|
| 16 |
+
iconForVisit: iconForVisit,
|
| 17 |
+
legFor: legFor,
|
| 18 |
+
locationLat: locationLat,
|
| 19 |
+
locationLng: locationLng,
|
| 20 |
+
maskContains: maskContains,
|
| 21 |
+
routeBadges: routeBadges,
|
| 22 |
+
routeSchedule: routeSchedule,
|
| 23 |
+
routeStats: routeStats,
|
| 24 |
+
timeLabel: timeLabel,
|
| 25 |
+
title: title,
|
| 26 |
+
toneForRoute: toneForRoute,
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
function buildCurl(method, path, json) {
|
| 30 |
+
var parts = ['curl'];
|
| 31 |
+
if (method && method !== 'GET') parts.push('-X', method);
|
| 32 |
+
if (json) parts.push('-H', '"Content-Type: application/json"', '-d', '@plan.json');
|
| 33 |
+
parts.push(window.location.origin + path);
|
| 34 |
+
return parts.join(' ');
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function fetchDemoCatalog() {
|
| 38 |
+
return requestJson('/demo-data', 'demo data catalog').then(function (catalog) {
|
| 39 |
+
if (!catalog || typeof catalog.defaultId !== 'string' || !Array.isArray(catalog.availableIds)) {
|
| 40 |
+
throw new Error('demo data catalog is missing defaultId or availableIds');
|
| 41 |
+
}
|
| 42 |
+
return { defaultId: catalog.defaultId, availableIds: catalog.availableIds.slice() };
|
| 43 |
+
});
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
function fetchDemoPlan(demoId) {
|
| 47 |
+
return requestJson('/demo-data/' + encodeURIComponent(demoId), 'demo data "' + demoId + '"');
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function requestJson(path, label) {
|
| 51 |
+
return fetch(path).then(function (response) {
|
| 52 |
+
if (!response.ok) throw new Error(label + ' returned HTTP ' + response.status);
|
| 53 |
+
return response.json();
|
| 54 |
+
});
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function routeSchedule(plan, route) {
|
| 58 |
+
var visits = plan.service_visits || [];
|
| 59 |
+
var entries = [];
|
| 60 |
+
var clock = route.shift_start_minute;
|
| 61 |
+
var previous = route.start_location_idx;
|
| 62 |
+
|
| 63 |
+
(route.visits || []).forEach(function (visitIdx) {
|
| 64 |
+
var visit = visits[visitIdx];
|
| 65 |
+
if (!visit) return;
|
| 66 |
+
var leg = legFor(plan, previous, visit.location_idx);
|
| 67 |
+
clock += leg && leg.reachable ? Math.ceil((leg.duration_seconds || 0) / 60) : 0;
|
| 68 |
+
if (clock < visit.earliest_minute) clock = visit.earliest_minute;
|
| 69 |
+
var start = clock;
|
| 70 |
+
var end = start + Math.max(0, visit.duration_minutes || 0);
|
| 71 |
+
entries.push({ visit: visit, start: start, end: end });
|
| 72 |
+
clock = end;
|
| 73 |
+
previous = visit.location_idx;
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
return entries;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
function routeStats(plan, route) {
|
| 80 |
+
var visits = plan.service_visits || [];
|
| 81 |
+
var travelMinutes = 0;
|
| 82 |
+
var serviceMinutes = 0;
|
| 83 |
+
var lateMinutes = 0;
|
| 84 |
+
var overtimeMinutes = 0;
|
| 85 |
+
var missingSkills = 0;
|
| 86 |
+
var missingParts = 0;
|
| 87 |
+
var unreachable = 0;
|
| 88 |
+
var clock = route.shift_start_minute;
|
| 89 |
+
var previous = route.start_location_idx;
|
| 90 |
+
|
| 91 |
+
(route.visits || []).forEach(function (visitIdx) {
|
| 92 |
+
var visit = visits[visitIdx];
|
| 93 |
+
if (!visit) {
|
| 94 |
+
unreachable += 1;
|
| 95 |
+
return;
|
| 96 |
+
}
|
| 97 |
+
var leg = legFor(plan, previous, visit.location_idx);
|
| 98 |
+
if (!leg || !leg.reachable) {
|
| 99 |
+
unreachable += 1;
|
| 100 |
+
} else {
|
| 101 |
+
var legMinutes = Math.ceil((leg.duration_seconds || 0) / 60);
|
| 102 |
+
travelMinutes += legMinutes;
|
| 103 |
+
clock += legMinutes;
|
| 104 |
+
}
|
| 105 |
+
if (clock < visit.earliest_minute) clock = visit.earliest_minute;
|
| 106 |
+
if (clock > visit.latest_minute) lateMinutes += clock - visit.latest_minute;
|
| 107 |
+
if (!maskContains(route.skill_mask, visit.required_skill_mask)) missingSkills += 1;
|
| 108 |
+
if (!maskContains(route.inventory_mask, visit.required_parts_mask)) missingParts += 1;
|
| 109 |
+
serviceMinutes += Math.max(0, visit.duration_minutes || 0);
|
| 110 |
+
clock += Math.max(0, visit.duration_minutes || 0);
|
| 111 |
+
previous = visit.location_idx;
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
var returnLeg = legFor(plan, previous, route.end_location_idx);
|
| 115 |
+
if (!returnLeg || !returnLeg.reachable) {
|
| 116 |
+
unreachable += 1;
|
| 117 |
+
} else {
|
| 118 |
+
var returnMinutes = Math.ceil((returnLeg.duration_seconds || 0) / 60);
|
| 119 |
+
travelMinutes += returnMinutes;
|
| 120 |
+
clock += returnMinutes;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
var routeMinutes = Math.max(0, clock - route.shift_start_minute);
|
| 124 |
+
overtimeMinutes += Math.max(0, clock - route.shift_end_minute);
|
| 125 |
+
overtimeMinutes += Math.max(0, routeMinutes - route.max_route_minutes);
|
| 126 |
+
|
| 127 |
+
return {
|
| 128 |
+
travelMinutes: travelMinutes,
|
| 129 |
+
serviceMinutes: serviceMinutes,
|
| 130 |
+
lateMinutes: lateMinutes,
|
| 131 |
+
overtimeMinutes: overtimeMinutes,
|
| 132 |
+
missingSkills: missingSkills,
|
| 133 |
+
missingParts: missingParts,
|
| 134 |
+
unreachable: unreachable,
|
| 135 |
+
};
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
function legFor(plan, from, to) {
|
| 139 |
+
var width = (plan.locations || []).length;
|
| 140 |
+
var direct = (plan.travel_legs || [])[from * width + to];
|
| 141 |
+
if (direct && direct.from_location_idx === from && direct.to_location_idx === to) {
|
| 142 |
+
return direct;
|
| 143 |
+
}
|
| 144 |
+
return (plan.travel_legs || []).find(function (leg) {
|
| 145 |
+
return leg.from_location_idx === from && leg.to_location_idx === to;
|
| 146 |
+
});
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
function assignedVisitSet(routes) {
|
| 150 |
+
var assigned = {};
|
| 151 |
+
(routes || []).forEach(function (route) {
|
| 152 |
+
(route.visits || []).forEach(function (visitIdx) {
|
| 153 |
+
assigned[visitIdx] = route;
|
| 154 |
+
});
|
| 155 |
+
});
|
| 156 |
+
return assigned;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
function locationLat(location) {
|
| 160 |
+
if (location.lat != null) return Number(location.lat);
|
| 161 |
+
return Number(location.lat_e6 || 0) / 1000000;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
function locationLng(location) {
|
| 165 |
+
if (location.lng != null) return Number(location.lng);
|
| 166 |
+
return Number(location.lng_e6 || 0) / 1000000;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
function routeBadges(stats) {
|
| 170 |
+
var badges = [];
|
| 171 |
+
if (stats.unreachable) badges.push('Routing');
|
| 172 |
+
if (stats.missingSkills) badges.push('Skills');
|
| 173 |
+
if (stats.missingParts) badges.push('Parts');
|
| 174 |
+
if (stats.lateMinutes) badges.push('Late');
|
| 175 |
+
if (stats.overtimeMinutes) badges.push('Overtime');
|
| 176 |
+
return badges.length ? badges : ['Feasible'];
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
function iconForVisit(visit) {
|
| 180 |
+
if ((visit.required_skill_mask & 8) === 8) return 'fa-elevator';
|
| 181 |
+
if ((visit.required_skill_mask & 4) === 4) return 'fa-faucet';
|
| 182 |
+
if ((visit.required_skill_mask & 2) === 2) return 'fa-bolt';
|
| 183 |
+
return 'fa-screwdriver-wrench';
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
function maskContains(available, required) {
|
| 187 |
+
return ((available || 0) & (required || 0)) === (required || 0);
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
function toneForRoute(index) {
|
| 191 |
+
return ['blue', 'emerald', 'amber', 'rose', 'violet', 'slate'][index % 6];
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
function formatDuration(minutes) {
|
| 195 |
+
var value = Math.max(0, Math.round(minutes || 0));
|
| 196 |
+
var h = Math.floor(value / 60);
|
| 197 |
+
var m = value % 60;
|
| 198 |
+
if (!h) return String(m) + 'm';
|
| 199 |
+
return String(h) + 'h ' + String(m).padStart(2, '0') + 'm';
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
function timeLabel(minute) {
|
| 203 |
+
var h = Math.floor(minute / 60);
|
| 204 |
+
var m = minute % 60;
|
| 205 |
+
return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
function findHeaderButton(header, label) {
|
| 209 |
+
var buttons = header.querySelectorAll('button');
|
| 210 |
+
for (var i = 0; i < buttons.length; i += 1) {
|
| 211 |
+
if ((buttons[i].textContent || '').trim() === label) return buttons[i];
|
| 212 |
+
}
|
| 213 |
+
return null;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
function clonePlan(data) {
|
| 217 |
+
return JSON.parse(JSON.stringify(data));
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
function title(text) {
|
| 221 |
+
return String(text || '')
|
| 222 |
+
.replace(/_/g, ' ')
|
| 223 |
+
.replace(/\b\w/g, function (match) { return match.toUpperCase(); });
|
| 224 |
+
}
|
| 225 |
+
})();
|
static/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
/* app.js — Bergamo field service routing demo
|
| 2 |
|
| 3 |
(async function () {
|
| 4 |
'use strict';
|
|
@@ -6,6 +6,7 @@
|
|
| 6 |
var DAY_START = 8 * 60;
|
| 7 |
var DAY_END = 18 * 60;
|
| 8 |
var DEFAULT_CENTER = [45.698, 9.677];
|
|
|
|
| 9 |
|
| 10 |
var config = await fetch('/sf-config.json').then(function (response) { return response.json(); });
|
| 11 |
var uiModel = await fetch('/generated/ui-model.json').then(function (response) { return response.json(); });
|
|
@@ -18,7 +19,7 @@
|
|
| 18 |
var bootstrapError = null;
|
| 19 |
var lastAnalysis = null;
|
| 20 |
var routeMap = null;
|
| 21 |
-
var
|
| 22 |
|
| 23 |
var panels = {
|
| 24 |
map: SF.el('div', { className: 'sf-content' }),
|
|
@@ -88,13 +89,7 @@
|
|
| 88 |
style: { minHeight: '560px', borderRadius: '8px' },
|
| 89 |
});
|
| 90 |
var routeCards = SF.el('div', {
|
| 91 |
-
style: {
|
| 92 |
-
display: 'grid',
|
| 93 |
-
gap: '10px',
|
| 94 |
-
alignContent: 'start',
|
| 95 |
-
maxHeight: '560px',
|
| 96 |
-
overflow: 'auto',
|
| 97 |
-
},
|
| 98 |
});
|
| 99 |
mapShell.appendChild(mapContainer);
|
| 100 |
mapShell.appendChild(routeCards);
|
|
@@ -102,12 +97,10 @@
|
|
| 102 |
panels.map.appendChild(mapShell);
|
| 103 |
|
| 104 |
var timelineContainer = SF.el('div');
|
| 105 |
-
panels.routes.appendChild(timelineContainer);
|
| 106 |
-
|
| 107 |
var tablesContainer = SF.el('div');
|
| 108 |
-
panels.data.appendChild(tablesContainer);
|
| 109 |
-
|
| 110 |
var apiGuideContainer = SF.el('div');
|
|
|
|
|
|
|
| 111 |
panels.api.appendChild(apiGuideContainer);
|
| 112 |
|
| 113 |
app.appendChild(panels.map);
|
|
@@ -122,51 +115,45 @@
|
|
| 122 |
}));
|
| 123 |
|
| 124 |
routeMap = SF.map.create({ container: 'fsr-map', center: DEFAULT_CENTER, zoom: 13 });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
var analysisModal = SF.createModal({ title: 'Score Analysis', width: '760px' });
|
| 127 |
var solver = SF.createSolver({
|
| 128 |
backend: backend,
|
| 129 |
statusBar: statusBar,
|
| 130 |
-
onProgress: function (meta) {
|
| 131 |
-
|
| 132 |
-
},
|
| 133 |
-
onPauseRequested: function (meta) {
|
| 134 |
-
syncLifecycleMarkers(meta);
|
| 135 |
-
},
|
| 136 |
onSolution: function (snapshot, meta) {
|
| 137 |
-
|
| 138 |
-
renderAll(snapshot.solution);
|
| 139 |
-
}
|
| 140 |
syncLifecycleMarkers(meta);
|
| 141 |
},
|
| 142 |
onPaused: function (snapshot, meta) {
|
| 143 |
-
|
| 144 |
-
renderAll(snapshot.solution);
|
| 145 |
-
}
|
| 146 |
-
syncLifecycleMarkers(meta);
|
| 147 |
-
},
|
| 148 |
-
onResumed: function (meta) {
|
| 149 |
syncLifecycleMarkers(meta);
|
| 150 |
},
|
|
|
|
| 151 |
onCancelled: function (snapshot, meta) {
|
| 152 |
-
|
| 153 |
-
renderAll(snapshot.solution);
|
| 154 |
-
}
|
| 155 |
syncLifecycleMarkers(meta);
|
| 156 |
},
|
| 157 |
onComplete: function (snapshot, meta) {
|
| 158 |
-
|
| 159 |
-
renderAll(snapshot.solution);
|
| 160 |
-
}
|
| 161 |
syncLifecycleMarkers(meta);
|
| 162 |
},
|
| 163 |
onFailure: function (message, meta, snapshot, analysis) {
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
}
|
| 167 |
-
if (analysis) {
|
| 168 |
-
lastAnalysis = analysis;
|
| 169 |
-
}
|
| 170 |
console.error('Solver job failed:', message);
|
| 171 |
syncLifecycleMarkers(meta);
|
| 172 |
},
|
|
@@ -180,15 +167,10 @@
|
|
| 180 |
},
|
| 181 |
});
|
| 182 |
|
| 183 |
-
renderApiGuide();
|
| 184 |
updateSolveActionAvailability();
|
| 185 |
bootstrapDemoData();
|
| 186 |
-
|
| 187 |
-
window.addEventListener('beforeunload', function () {
|
| 188 |
-
if (routeTimeline) {
|
| 189 |
-
routeTimeline.destroy();
|
| 190 |
-
}
|
| 191 |
-
});
|
| 192 |
|
| 193 |
function loadAndSolve() {
|
| 194 |
if (solver.isRunning() || solver.getLifecycleState() === 'PAUSED' || !canSolve()) return;
|
|
@@ -200,21 +182,15 @@
|
|
| 200 |
}
|
| 201 |
|
| 202 |
function pauseSolve() {
|
| 203 |
-
solver.pause()
|
| 204 |
-
.then(function () { syncLifecycleMarkers(); })
|
| 205 |
-
.catch(function (err) { console.error('Pause failed:', err); });
|
| 206 |
}
|
| 207 |
|
| 208 |
function resumeSolve() {
|
| 209 |
-
solver.resume()
|
| 210 |
-
.then(function () { syncLifecycleMarkers(); })
|
| 211 |
-
.catch(function (err) { console.error('Resume failed:', err); });
|
| 212 |
}
|
| 213 |
|
| 214 |
function cancelSolve() {
|
| 215 |
-
solver.cancel()
|
| 216 |
-
.then(function () { syncLifecycleMarkers(); })
|
| 217 |
-
.catch(function (err) { console.error('Cancel failed:', err); });
|
| 218 |
}
|
| 219 |
|
| 220 |
function openAnalysis() {
|
|
@@ -222,7 +198,7 @@
|
|
| 222 |
solver.analyzeSnapshot()
|
| 223 |
.then(function (analysis) {
|
| 224 |
lastAnalysis = analysis;
|
| 225 |
-
analysisModal.setBody(buildAnalysisHtml(analysis));
|
| 226 |
analysisModal.open();
|
| 227 |
})
|
| 228 |
.catch(function (err) { console.error('Analysis failed:', err); });
|
|
@@ -233,511 +209,40 @@
|
|
| 233 |
if (!solver.getJobId() || state === 'IDLE' || state === 'PAUSED' || solver.isRunning()) {
|
| 234 |
return Promise.resolve(null);
|
| 235 |
}
|
| 236 |
-
return solver.delete()
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
});
|
| 241 |
}
|
| 242 |
|
| 243 |
function resolvePlanForSolve() {
|
| 244 |
-
if (currentPlan)
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
if (!demoCatalog.defaultId) {
|
| 248 |
-
return Promise.reject(new Error('demo data catalog is unavailable'));
|
| 249 |
-
}
|
| 250 |
-
return fetchDemoPlan(demoCatalog.defaultId);
|
| 251 |
}
|
| 252 |
|
| 253 |
function bootstrapDemoData() {
|
| 254 |
-
fetchDemoCatalog()
|
| 255 |
.then(function (catalog) {
|
| 256 |
demoCatalog = catalog;
|
| 257 |
clearBootstrapError();
|
| 258 |
-
renderApiGuide();
|
| 259 |
-
return fetchDemoPlan(catalog.defaultId);
|
| 260 |
})
|
| 261 |
.then(function (plan) {
|
| 262 |
-
|
| 263 |
updateSolveActionAvailability();
|
| 264 |
})
|
| 265 |
-
.catch(function (err) {
|
| 266 |
-
reportBootstrapError(err);
|
| 267 |
-
});
|
| 268 |
-
}
|
| 269 |
-
|
| 270 |
-
function fetchDemoCatalog() {
|
| 271 |
-
return requestJson('/demo-data', 'demo data catalog')
|
| 272 |
-
.then(function (catalog) {
|
| 273 |
-
if (!catalog || typeof catalog.defaultId !== 'string' || !Array.isArray(catalog.availableIds)) {
|
| 274 |
-
throw new Error('demo data catalog is missing defaultId or availableIds');
|
| 275 |
-
}
|
| 276 |
-
return {
|
| 277 |
-
defaultId: catalog.defaultId,
|
| 278 |
-
availableIds: catalog.availableIds.slice(),
|
| 279 |
-
};
|
| 280 |
-
});
|
| 281 |
-
}
|
| 282 |
-
|
| 283 |
-
function fetchDemoPlan(demoId) {
|
| 284 |
-
return requestJson('/demo-data/' + encodeURIComponent(demoId), 'demo data "' + demoId + '"');
|
| 285 |
-
}
|
| 286 |
-
|
| 287 |
-
function requestJson(path, label) {
|
| 288 |
-
return fetch(path)
|
| 289 |
-
.then(function (response) {
|
| 290 |
-
if (!response.ok) {
|
| 291 |
-
throw new Error(label + ' returned HTTP ' + response.status);
|
| 292 |
-
}
|
| 293 |
-
return response.json();
|
| 294 |
-
});
|
| 295 |
-
}
|
| 296 |
-
|
| 297 |
-
function renderAll(plan) {
|
| 298 |
-
currentPlan = clonePlan(plan);
|
| 299 |
-
renderSummary(plan);
|
| 300 |
-
renderMap(plan);
|
| 301 |
-
renderRouteCards(plan);
|
| 302 |
-
renderTimeline(plan);
|
| 303 |
-
renderTables(plan);
|
| 304 |
-
}
|
| 305 |
-
|
| 306 |
-
function renderSummary(plan) {
|
| 307 |
-
var routes = plan.technician_routes || [];
|
| 308 |
-
var visits = plan.service_visits || [];
|
| 309 |
-
var assigned = assignedVisitSet(routes);
|
| 310 |
-
var routeMetrics = routes.map(function (route) { return routeStats(plan, route); });
|
| 311 |
-
var travelMinutes = routeMetrics.reduce(function (sum, stats) { return sum + stats.travelMinutes; }, 0);
|
| 312 |
-
var serviceMinutes = routeMetrics.reduce(function (sum, stats) { return sum + stats.serviceMinutes; }, 0);
|
| 313 |
-
var hardIssues = routeMetrics.reduce(function (sum, stats) {
|
| 314 |
-
return sum + stats.unreachable + stats.missingSkills + stats.missingParts + stats.lateMinutes + stats.overtimeMinutes;
|
| 315 |
-
}, 0);
|
| 316 |
-
|
| 317 |
-
summaryContainer.innerHTML = '';
|
| 318 |
-
summaryContainer.appendChild(SF.createTable({
|
| 319 |
-
columns: ['Dataset', 'Visits', 'Assigned', 'Technicians', 'Travel', 'Service', 'Hard issues', 'Score'],
|
| 320 |
-
rows: [[
|
| 321 |
-
demoCatalog.defaultId || 'STANDARD',
|
| 322 |
-
String(visits.length),
|
| 323 |
-
String(Object.keys(assigned).length),
|
| 324 |
-
String(routes.length),
|
| 325 |
-
formatDuration(travelMinutes),
|
| 326 |
-
formatDuration(serviceMinutes),
|
| 327 |
-
String(hardIssues),
|
| 328 |
-
String(plan.score || 'unsolved'),
|
| 329 |
-
]],
|
| 330 |
-
}));
|
| 331 |
-
}
|
| 332 |
-
|
| 333 |
-
function renderMap(plan) {
|
| 334 |
-
if (!routeMap) return;
|
| 335 |
-
routeMap.clearAll();
|
| 336 |
-
|
| 337 |
-
var locations = plan.locations || [];
|
| 338 |
-
var visits = plan.service_visits || [];
|
| 339 |
-
var routes = plan.technician_routes || [];
|
| 340 |
-
var assigned = assignedVisitSet(routes);
|
| 341 |
-
|
| 342 |
-
routes.forEach(function (route) {
|
| 343 |
-
var start = locations[route.start_location_idx];
|
| 344 |
-
if (start) {
|
| 345 |
-
routeMap.addVehicleMarker({
|
| 346 |
-
lat: locationLat(start),
|
| 347 |
-
lng: locationLng(start),
|
| 348 |
-
color: route.color || '#2563eb',
|
| 349 |
-
});
|
| 350 |
-
}
|
| 351 |
-
});
|
| 352 |
-
|
| 353 |
-
visits.forEach(function (visit, idx) {
|
| 354 |
-
var location = locations[visit.location_idx];
|
| 355 |
-
if (!location) return;
|
| 356 |
-
routeMap.addVisitMarker({
|
| 357 |
-
lat: locationLat(location),
|
| 358 |
-
lng: locationLng(location),
|
| 359 |
-
color: assigned[idx] ? assigned[idx].color : '#64748b',
|
| 360 |
-
icon: iconForVisit(visit),
|
| 361 |
-
assigned: !!assigned[idx],
|
| 362 |
-
});
|
| 363 |
-
});
|
| 364 |
-
|
| 365 |
-
routes.forEach(function (route) {
|
| 366 |
-
var previous = route.start_location_idx;
|
| 367 |
-
(route.visits || []).forEach(function (visitIdx, sequenceIdx) {
|
| 368 |
-
var visit = visits[visitIdx];
|
| 369 |
-
if (!visit) return;
|
| 370 |
-
drawLeg(plan, previous, visit.location_idx, route.color);
|
| 371 |
-
var stopLocation = locations[visit.location_idx];
|
| 372 |
-
if (stopLocation) {
|
| 373 |
-
routeMap.addStopNumber({
|
| 374 |
-
lat: locationLat(stopLocation),
|
| 375 |
-
lng: locationLng(stopLocation),
|
| 376 |
-
number: sequenceIdx + 1,
|
| 377 |
-
color: route.color || '#2563eb',
|
| 378 |
-
});
|
| 379 |
-
}
|
| 380 |
-
previous = visit.location_idx;
|
| 381 |
-
});
|
| 382 |
-
drawLeg(plan, previous, route.end_location_idx, route.color);
|
| 383 |
-
});
|
| 384 |
-
|
| 385 |
-
routeMap.fitBounds();
|
| 386 |
-
}
|
| 387 |
-
|
| 388 |
-
function renderRouteCards(plan) {
|
| 389 |
-
routeCards.innerHTML = '';
|
| 390 |
-
renderUnassignedCard(plan);
|
| 391 |
-
(plan.technician_routes || []).forEach(function (route) {
|
| 392 |
-
var stats = routeStats(plan, route);
|
| 393 |
-
var card = SF.el('div', {
|
| 394 |
-
className: 'sf-section',
|
| 395 |
-
style: {
|
| 396 |
-
borderLeft: '4px solid ' + (route.color || '#2563eb'),
|
| 397 |
-
padding: '12px',
|
| 398 |
-
borderRadius: '8px',
|
| 399 |
-
},
|
| 400 |
-
});
|
| 401 |
-
card.appendChild(SF.el('h3', { style: { margin: '0 0 8px' } }, route.technician_name || route.id));
|
| 402 |
-
card.appendChild(SF.createTable({
|
| 403 |
-
columns: ['Stops', 'Travel', 'Service'],
|
| 404 |
-
rows: [[
|
| 405 |
-
String((route.visits || []).length),
|
| 406 |
-
formatDuration(stats.travelMinutes),
|
| 407 |
-
formatDuration(stats.serviceMinutes),
|
| 408 |
-
]],
|
| 409 |
-
}));
|
| 410 |
-
card.appendChild(SF.createTable({
|
| 411 |
-
columns: ['Territory', 'Late', 'Overtime'],
|
| 412 |
-
rows: [[
|
| 413 |
-
route.territory || '-',
|
| 414 |
-
formatDuration(stats.lateMinutes),
|
| 415 |
-
formatDuration(stats.overtimeMinutes),
|
| 416 |
-
]],
|
| 417 |
-
}));
|
| 418 |
-
routeCards.appendChild(card);
|
| 419 |
-
});
|
| 420 |
-
}
|
| 421 |
-
|
| 422 |
-
function renderUnassignedCard(plan) {
|
| 423 |
-
var assigned = assignedVisitSet(plan.technician_routes || []);
|
| 424 |
-
var rows = (plan.service_visits || []).reduce(function (items, visit, idx) {
|
| 425 |
-
if (assigned[idx]) return items;
|
| 426 |
-
items.push([
|
| 427 |
-
visit.customer || visit.name || visit.id,
|
| 428 |
-
timeLabel(visit.earliest_minute) + '-' + timeLabel(visit.latest_minute),
|
| 429 |
-
formatDuration(visit.duration_minutes || 0),
|
| 430 |
-
]);
|
| 431 |
-
return items;
|
| 432 |
-
}, []);
|
| 433 |
-
if (!rows.length) return;
|
| 434 |
-
|
| 435 |
-
var card = SF.el('div', {
|
| 436 |
-
className: 'sf-section',
|
| 437 |
-
style: {
|
| 438 |
-
borderLeft: '4px solid #64748b',
|
| 439 |
-
padding: '12px',
|
| 440 |
-
borderRadius: '8px',
|
| 441 |
-
},
|
| 442 |
-
});
|
| 443 |
-
card.appendChild(SF.el('h3', { style: { margin: '0 0 8px' } }, 'Unassigned visits'));
|
| 444 |
-
card.appendChild(SF.createTable({
|
| 445 |
-
columns: ['Visit', 'Window', 'Duration'],
|
| 446 |
-
rows: rows,
|
| 447 |
-
}));
|
| 448 |
-
routeCards.appendChild(card);
|
| 449 |
}
|
| 450 |
|
| 451 |
-
function
|
| 452 |
-
|
| 453 |
-
timelineContainer.innerHTML = '';
|
| 454 |
-
timelineContainer.appendChild(SF.el('div', { className: 'sf-section' }, SF.createTable({
|
| 455 |
-
columns: ['Route lanes', 'Window', 'Source'],
|
| 456 |
-
rows: [[
|
| 457 |
-
String((plan.technician_routes || []).length),
|
| 458 |
-
'08:00-18:00',
|
| 459 |
-
'Latest SolverForge solution payload',
|
| 460 |
-
]],
|
| 461 |
-
})));
|
| 462 |
-
|
| 463 |
-
if (!routeTimeline) {
|
| 464 |
-
routeTimeline = SF.rail.createTimeline(timelineConfig);
|
| 465 |
-
} else {
|
| 466 |
-
routeTimeline.setModel(timelineConfig.model);
|
| 467 |
-
}
|
| 468 |
-
timelineContainer.appendChild(routeTimeline.el);
|
| 469 |
-
}
|
| 470 |
-
|
| 471 |
-
function buildTimelineConfig(plan) {
|
| 472 |
-
var lanes = (plan.technician_routes || []).map(function (route, routeIdx) {
|
| 473 |
-
var items = routeSchedule(plan, route).map(function (entry, entryIdx) {
|
| 474 |
-
return {
|
| 475 |
-
id: 'route-' + routeIdx + '-visit-' + entryIdx,
|
| 476 |
-
startMinute: entry.start,
|
| 477 |
-
endMinute: entry.end,
|
| 478 |
-
label: entry.visit.customer || entry.visit.name || entry.visit.id,
|
| 479 |
-
meta: timeLabel(entry.start) + '-' + timeLabel(entry.end),
|
| 480 |
-
tone: toneForRoute(routeIdx),
|
| 481 |
-
};
|
| 482 |
-
});
|
| 483 |
-
var stats = routeStats(plan, route);
|
| 484 |
-
return {
|
| 485 |
-
id: route.id || ('route-' + routeIdx),
|
| 486 |
-
label: route.technician_name || route.id || ('Technician ' + (routeIdx + 1)),
|
| 487 |
-
mode: 'detailed',
|
| 488 |
-
badges: routeBadges(stats),
|
| 489 |
-
stats: [
|
| 490 |
-
{ label: 'Stops', value: (route.visits || []).length },
|
| 491 |
-
{ label: 'Travel', value: formatDuration(stats.travelMinutes) },
|
| 492 |
-
{ label: 'Service', value: formatDuration(stats.serviceMinutes) },
|
| 493 |
-
],
|
| 494 |
-
items: items,
|
| 495 |
-
};
|
| 496 |
-
});
|
| 497 |
-
|
| 498 |
-
return {
|
| 499 |
-
label: 'Technician',
|
| 500 |
-
labelWidth: 280,
|
| 501 |
-
title: 'Bergamo Field Service Routes',
|
| 502 |
-
subtitle: 'Ordered service visits per technician',
|
| 503 |
-
model: {
|
| 504 |
-
axis: buildDayAxis(),
|
| 505 |
-
lanes: lanes,
|
| 506 |
-
},
|
| 507 |
-
};
|
| 508 |
-
}
|
| 509 |
-
|
| 510 |
-
function buildDayAxis() {
|
| 511 |
-
var days = [{
|
| 512 |
-
id: 'bergamo-day',
|
| 513 |
-
label: 'Service day',
|
| 514 |
-
subLabel: '08:00-18:00',
|
| 515 |
-
startMinute: DAY_START,
|
| 516 |
-
endMinute: DAY_END,
|
| 517 |
-
}];
|
| 518 |
-
var ticks = [];
|
| 519 |
-
for (var minute = DAY_START; minute <= DAY_END; minute += 60) {
|
| 520 |
-
ticks.push({
|
| 521 |
-
id: 'tick-' + minute,
|
| 522 |
-
minute: minute,
|
| 523 |
-
label: timeLabel(minute),
|
| 524 |
-
});
|
| 525 |
-
}
|
| 526 |
-
return {
|
| 527 |
-
startMinute: DAY_START,
|
| 528 |
-
endMinute: DAY_END,
|
| 529 |
-
days: days,
|
| 530 |
-
ticks: ticks,
|
| 531 |
-
initialViewport: { startMinute: DAY_START, endMinute: DAY_END },
|
| 532 |
-
};
|
| 533 |
-
}
|
| 534 |
-
|
| 535 |
-
function renderTables(plan) {
|
| 536 |
-
tablesContainer.innerHTML = '';
|
| 537 |
-
['technician_routes', 'service_visits', 'locations'].forEach(function (key) {
|
| 538 |
-
var rows = plan[key] || [];
|
| 539 |
-
if (!rows.length) return;
|
| 540 |
-
var columns = Object.keys(rows[0]).filter(function (column) {
|
| 541 |
-
return column !== 'score' && column !== 'encoded_polyline';
|
| 542 |
-
});
|
| 543 |
-
var section = SF.el('div', { className: 'sf-section' });
|
| 544 |
-
section.appendChild(SF.el('h3', null, title(key)));
|
| 545 |
-
section.appendChild(SF.createTable({
|
| 546 |
-
columns: columns,
|
| 547 |
-
rows: rows.map(function (row) {
|
| 548 |
-
return columns.map(function (column) {
|
| 549 |
-
var value = row[column];
|
| 550 |
-
if (value == null) return '-';
|
| 551 |
-
if (Array.isArray(value)) return value.join(', ');
|
| 552 |
-
return String(value);
|
| 553 |
-
});
|
| 554 |
-
}),
|
| 555 |
-
}));
|
| 556 |
-
tablesContainer.appendChild(section);
|
| 557 |
-
});
|
| 558 |
-
}
|
| 559 |
-
|
| 560 |
-
function renderApiGuide() {
|
| 561 |
-
apiGuideContainer.innerHTML = '';
|
| 562 |
-
apiGuideContainer.appendChild(SF.createApiGuide({
|
| 563 |
-
endpoints: [
|
| 564 |
-
{ method: 'GET', path: '/demo-data', description: 'Discover demo datasets', curl: buildCurl('GET', '/demo-data') },
|
| 565 |
-
{ method: 'GET', path: '/demo-data/' + (demoCatalog.defaultId || '{defaultId}'), description: 'Fetch Bergamo OSM-backed demo data', curl: buildCurl('GET', '/demo-data/' + (demoCatalog.defaultId || 'STANDARD')) },
|
| 566 |
-
{ method: 'POST', path: '/jobs', description: 'Create a retained solve job', curl: buildCurl('POST', '/jobs', true) },
|
| 567 |
-
{ method: 'GET', path: '/jobs/{id}/events', description: 'Stream typed SolverForge lifecycle events', curl: buildCurl('GET', '/jobs/{id}/events') },
|
| 568 |
-
{ method: 'GET', path: '/jobs/{id}/snapshot', description: 'Fetch latest route snapshot', curl: buildCurl('GET', '/jobs/{id}/snapshot') },
|
| 569 |
-
{ method: 'GET', path: '/jobs/{id}/analysis', description: 'Analyze the latest retained score', curl: buildCurl('GET', '/jobs/{id}/analysis') },
|
| 570 |
-
],
|
| 571 |
-
}));
|
| 572 |
-
}
|
| 573 |
-
|
| 574 |
-
function buildCurl(method, path, json) {
|
| 575 |
-
var parts = ['curl'];
|
| 576 |
-
if (method && method !== 'GET') parts.push('-X', method);
|
| 577 |
-
if (json) parts.push('-H', '"Content-Type: application/json"', '-d', '@plan.json');
|
| 578 |
-
parts.push(window.location.origin + path);
|
| 579 |
-
return parts.join(' ');
|
| 580 |
-
}
|
| 581 |
-
|
| 582 |
-
function routeSchedule(plan, route) {
|
| 583 |
-
var visits = plan.service_visits || [];
|
| 584 |
-
var entries = [];
|
| 585 |
-
var clock = route.shift_start_minute;
|
| 586 |
-
var previous = route.start_location_idx;
|
| 587 |
-
|
| 588 |
-
(route.visits || []).forEach(function (visitIdx) {
|
| 589 |
-
var visit = visits[visitIdx];
|
| 590 |
-
if (!visit) return;
|
| 591 |
-
var leg = legFor(plan, previous, visit.location_idx);
|
| 592 |
-
clock += leg && leg.reachable ? Math.ceil((leg.duration_seconds || 0) / 60) : 0;
|
| 593 |
-
if (clock < visit.earliest_minute) clock = visit.earliest_minute;
|
| 594 |
-
var start = clock;
|
| 595 |
-
var end = start + Math.max(0, visit.duration_minutes || 0);
|
| 596 |
-
entries.push({ visit: visit, start: start, end: end });
|
| 597 |
-
clock = end;
|
| 598 |
-
previous = visit.location_idx;
|
| 599 |
-
});
|
| 600 |
-
|
| 601 |
-
return entries;
|
| 602 |
-
}
|
| 603 |
-
|
| 604 |
-
function routeStats(plan, route) {
|
| 605 |
-
var visits = plan.service_visits || [];
|
| 606 |
-
var travelMinutes = 0;
|
| 607 |
-
var serviceMinutes = 0;
|
| 608 |
-
var lateMinutes = 0;
|
| 609 |
-
var overtimeMinutes = 0;
|
| 610 |
-
var missingSkills = 0;
|
| 611 |
-
var missingParts = 0;
|
| 612 |
-
var unreachable = 0;
|
| 613 |
-
var clock = route.shift_start_minute;
|
| 614 |
-
var previous = route.start_location_idx;
|
| 615 |
-
|
| 616 |
-
(route.visits || []).forEach(function (visitIdx) {
|
| 617 |
-
var visit = visits[visitIdx];
|
| 618 |
-
if (!visit) {
|
| 619 |
-
unreachable += 1;
|
| 620 |
-
return;
|
| 621 |
-
}
|
| 622 |
-
var leg = legFor(plan, previous, visit.location_idx);
|
| 623 |
-
if (!leg || !leg.reachable) {
|
| 624 |
-
unreachable += 1;
|
| 625 |
-
} else {
|
| 626 |
-
var legMinutes = Math.ceil((leg.duration_seconds || 0) / 60);
|
| 627 |
-
travelMinutes += legMinutes;
|
| 628 |
-
clock += legMinutes;
|
| 629 |
-
}
|
| 630 |
-
if (clock < visit.earliest_minute) clock = visit.earliest_minute;
|
| 631 |
-
if (clock > visit.latest_minute) lateMinutes += clock - visit.latest_minute;
|
| 632 |
-
if (!maskContains(route.skill_mask, visit.required_skill_mask)) missingSkills += 1;
|
| 633 |
-
if (!maskContains(route.inventory_mask, visit.required_parts_mask)) missingParts += 1;
|
| 634 |
-
serviceMinutes += Math.max(0, visit.duration_minutes || 0);
|
| 635 |
-
clock += Math.max(0, visit.duration_minutes || 0);
|
| 636 |
-
previous = visit.location_idx;
|
| 637 |
-
});
|
| 638 |
-
|
| 639 |
-
var returnLeg = legFor(plan, previous, route.end_location_idx);
|
| 640 |
-
if (!returnLeg || !returnLeg.reachable) {
|
| 641 |
-
unreachable += 1;
|
| 642 |
-
} else {
|
| 643 |
-
var returnMinutes = Math.ceil((returnLeg.duration_seconds || 0) / 60);
|
| 644 |
-
travelMinutes += returnMinutes;
|
| 645 |
-
clock += returnMinutes;
|
| 646 |
-
}
|
| 647 |
-
|
| 648 |
-
var routeMinutes = Math.max(0, clock - route.shift_start_minute);
|
| 649 |
-
overtimeMinutes += Math.max(0, clock - route.shift_end_minute);
|
| 650 |
-
overtimeMinutes += Math.max(0, routeMinutes - route.max_route_minutes);
|
| 651 |
-
|
| 652 |
-
return {
|
| 653 |
-
travelMinutes: travelMinutes,
|
| 654 |
-
serviceMinutes: serviceMinutes,
|
| 655 |
-
lateMinutes: lateMinutes,
|
| 656 |
-
overtimeMinutes: overtimeMinutes,
|
| 657 |
-
missingSkills: missingSkills,
|
| 658 |
-
missingParts: missingParts,
|
| 659 |
-
unreachable: unreachable,
|
| 660 |
-
};
|
| 661 |
-
}
|
| 662 |
-
|
| 663 |
-
function drawLeg(plan, from, to, color) {
|
| 664 |
-
var leg = legFor(plan, from, to);
|
| 665 |
-
if (!leg || !leg.reachable || !leg.encoded_polyline) return;
|
| 666 |
-
routeMap.drawEncodedRoute({ encoded: leg.encoded_polyline, color: color || '#2563eb' });
|
| 667 |
-
}
|
| 668 |
-
|
| 669 |
-
function legFor(plan, from, to) {
|
| 670 |
-
var width = (plan.locations || []).length;
|
| 671 |
-
var direct = (plan.travel_legs || [])[from * width + to];
|
| 672 |
-
if (direct && direct.from_location_idx === from && direct.to_location_idx === to) {
|
| 673 |
-
return direct;
|
| 674 |
-
}
|
| 675 |
-
return (plan.travel_legs || []).find(function (leg) {
|
| 676 |
-
return leg.from_location_idx === from && leg.to_location_idx === to;
|
| 677 |
-
});
|
| 678 |
-
}
|
| 679 |
-
|
| 680 |
-
function assignedVisitSet(routes) {
|
| 681 |
-
var assigned = {};
|
| 682 |
-
(routes || []).forEach(function (route) {
|
| 683 |
-
(route.visits || []).forEach(function (visitIdx) {
|
| 684 |
-
assigned[visitIdx] = route;
|
| 685 |
-
});
|
| 686 |
-
});
|
| 687 |
-
return assigned;
|
| 688 |
}
|
| 689 |
|
| 690 |
-
function
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
}
|
| 694 |
-
|
| 695 |
-
function locationLng(location) {
|
| 696 |
-
if (location.lng != null) return Number(location.lng);
|
| 697 |
-
return Number(location.lng_e6 || 0) / 1000000;
|
| 698 |
-
}
|
| 699 |
-
|
| 700 |
-
function routeBadges(stats) {
|
| 701 |
-
var badges = [];
|
| 702 |
-
if (stats.unreachable) badges.push('Routing');
|
| 703 |
-
if (stats.missingSkills) badges.push('Skills');
|
| 704 |
-
if (stats.missingParts) badges.push('Parts');
|
| 705 |
-
if (stats.lateMinutes) badges.push('Late');
|
| 706 |
-
if (stats.overtimeMinutes) badges.push('Overtime');
|
| 707 |
-
return badges.length ? badges : ['Feasible'];
|
| 708 |
-
}
|
| 709 |
-
|
| 710 |
-
function iconForVisit(visit) {
|
| 711 |
-
if ((visit.required_skill_mask & 8) === 8) return 'fa-elevator';
|
| 712 |
-
if ((visit.required_skill_mask & 4) === 4) return 'fa-faucet';
|
| 713 |
-
if ((visit.required_skill_mask & 2) === 2) return 'fa-bolt';
|
| 714 |
-
return 'fa-screwdriver-wrench';
|
| 715 |
-
}
|
| 716 |
-
|
| 717 |
-
function maskContains(available, required) {
|
| 718 |
-
return ((available || 0) & (required || 0)) === (required || 0);
|
| 719 |
-
}
|
| 720 |
-
|
| 721 |
-
function toneForRoute(index) {
|
| 722 |
-
return ['blue', 'emerald', 'amber', 'rose', 'violet', 'slate'][index % 6];
|
| 723 |
-
}
|
| 724 |
-
|
| 725 |
-
function formatDuration(minutes) {
|
| 726 |
-
var value = Math.max(0, Math.round(minutes || 0));
|
| 727 |
-
var h = Math.floor(value / 60);
|
| 728 |
-
var m = value % 60;
|
| 729 |
-
if (!h) return String(m) + 'm';
|
| 730 |
-
return String(h) + 'h ' + String(m).padStart(2, '0') + 'm';
|
| 731 |
-
}
|
| 732 |
-
|
| 733 |
-
function timeLabel(minute) {
|
| 734 |
-
var h = Math.floor(minute / 60);
|
| 735 |
-
var m = minute % 60;
|
| 736 |
-
return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
|
| 737 |
-
}
|
| 738 |
-
|
| 739 |
-
function clonePlan(data) {
|
| 740 |
-
return JSON.parse(JSON.stringify(data));
|
| 741 |
}
|
| 742 |
|
| 743 |
function canSolve() {
|
|
@@ -745,7 +250,7 @@
|
|
| 745 |
}
|
| 746 |
|
| 747 |
function reportBootstrapError(err) {
|
| 748 |
-
bootstrapError =
|
| 749 |
bootstrapNotice.textContent = 'Demo data bootstrap failed: ' + bootstrapError;
|
| 750 |
bootstrapNotice.style.display = '';
|
| 751 |
app.dataset.bootstrapError = 'true';
|
|
@@ -760,12 +265,8 @@
|
|
| 760 |
delete app.dataset.bootstrapError;
|
| 761 |
}
|
| 762 |
|
| 763 |
-
function describeError(err) {
|
| 764 |
-
return err && err.message ? err.message : String(err || 'unknown error');
|
| 765 |
-
}
|
| 766 |
-
|
| 767 |
function updateSolveActionAvailability() {
|
| 768 |
-
var solveButton = findHeaderButton('Solve');
|
| 769 |
if (!solveButton) return;
|
| 770 |
var disabled = !canSolve();
|
| 771 |
solveButton.disabled = disabled;
|
|
@@ -775,14 +276,6 @@
|
|
| 775 |
: '';
|
| 776 |
}
|
| 777 |
|
| 778 |
-
function findHeaderButton(label) {
|
| 779 |
-
var buttons = header.querySelectorAll('button');
|
| 780 |
-
for (var i = 0; i < buttons.length; i += 1) {
|
| 781 |
-
if ((buttons[i].textContent || '').trim() === label) return buttons[i];
|
| 782 |
-
}
|
| 783 |
-
return null;
|
| 784 |
-
}
|
| 785 |
-
|
| 786 |
function syncLifecycleMarkers(meta) {
|
| 787 |
var jobId = solver.getJobId();
|
| 788 |
var snapshotRevision = solver.getSnapshotRevision();
|
|
@@ -796,21 +289,4 @@
|
|
| 796 |
else delete app.dataset.lifecycleState;
|
| 797 |
updateSolveActionAvailability();
|
| 798 |
}
|
| 799 |
-
|
| 800 |
-
function buildAnalysisHtml(analysis) {
|
| 801 |
-
if (!analysis || !analysis.constraints) return '<p>No analysis available.</p>';
|
| 802 |
-
var html = '<p><strong>Score:</strong> ' + SF.escHtml(analysis.score) + '</p>';
|
| 803 |
-
html += '<table class="sf-table"><thead><tr><th>Constraint</th><th>Weight</th><th>Score</th><th>Matches</th></tr></thead><tbody>';
|
| 804 |
-
analysis.constraints.forEach(function (constraint) {
|
| 805 |
-
html += '<tr><td>' + SF.escHtml(constraint.name) + '</td><td>' + SF.escHtml(constraint.weight) + '</td><td>' + SF.escHtml(constraint.score) + '</td><td>' + String(constraint.matchCount || 0) + '</td></tr>';
|
| 806 |
-
});
|
| 807 |
-
html += '</tbody></table>';
|
| 808 |
-
return html;
|
| 809 |
-
}
|
| 810 |
-
|
| 811 |
-
function title(text) {
|
| 812 |
-
return String(text || '')
|
| 813 |
-
.replace(/_/g, ' ')
|
| 814 |
-
.replace(/\b\w/g, function (match) { return match.toUpperCase(); });
|
| 815 |
-
}
|
| 816 |
})();
|
|
|
|
| 1 |
+
/* app.js — lifecycle bootstrap for the Bergamo field service routing demo */
|
| 2 |
|
| 3 |
(async function () {
|
| 4 |
'use strict';
|
|
|
|
| 6 |
var DAY_START = 8 * 60;
|
| 7 |
var DAY_END = 18 * 60;
|
| 8 |
var DEFAULT_CENTER = [45.698, 9.677];
|
| 9 |
+
var utils = window.FSR.utils;
|
| 10 |
|
| 11 |
var config = await fetch('/sf-config.json').then(function (response) { return response.json(); });
|
| 12 |
var uiModel = await fetch('/generated/ui-model.json').then(function (response) { return response.json(); });
|
|
|
|
| 19 |
var bootstrapError = null;
|
| 20 |
var lastAnalysis = null;
|
| 21 |
var routeMap = null;
|
| 22 |
+
var renderer = null;
|
| 23 |
|
| 24 |
var panels = {
|
| 25 |
map: SF.el('div', { className: 'sf-content' }),
|
|
|
|
| 89 |
style: { minHeight: '560px', borderRadius: '8px' },
|
| 90 |
});
|
| 91 |
var routeCards = SF.el('div', {
|
| 92 |
+
style: { display: 'grid', gap: '10px', alignContent: 'start', maxHeight: '560px', overflow: 'auto' },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
});
|
| 94 |
mapShell.appendChild(mapContainer);
|
| 95 |
mapShell.appendChild(routeCards);
|
|
|
|
| 97 |
panels.map.appendChild(mapShell);
|
| 98 |
|
| 99 |
var timelineContainer = SF.el('div');
|
|
|
|
|
|
|
| 100 |
var tablesContainer = SF.el('div');
|
|
|
|
|
|
|
| 101 |
var apiGuideContainer = SF.el('div');
|
| 102 |
+
panels.routes.appendChild(timelineContainer);
|
| 103 |
+
panels.data.appendChild(tablesContainer);
|
| 104 |
panels.api.appendChild(apiGuideContainer);
|
| 105 |
|
| 106 |
app.appendChild(panels.map);
|
|
|
|
| 115 |
}));
|
| 116 |
|
| 117 |
routeMap = SF.map.create({ container: 'fsr-map', center: DEFAULT_CENTER, zoom: 13 });
|
| 118 |
+
renderer = window.FSR.createRenderer({
|
| 119 |
+
SF: SF,
|
| 120 |
+
apiGuideContainer: apiGuideContainer,
|
| 121 |
+
dayEnd: DAY_END,
|
| 122 |
+
dayStart: DAY_START,
|
| 123 |
+
getDemoCatalog: function () { return demoCatalog; },
|
| 124 |
+
routeCards: routeCards,
|
| 125 |
+
routeMap: routeMap,
|
| 126 |
+
summaryContainer: summaryContainer,
|
| 127 |
+
tablesContainer: tablesContainer,
|
| 128 |
+
timelineContainer: timelineContainer,
|
| 129 |
+
});
|
| 130 |
|
| 131 |
var analysisModal = SF.createModal({ title: 'Score Analysis', width: '760px' });
|
| 132 |
var solver = SF.createSolver({
|
| 133 |
backend: backend,
|
| 134 |
statusBar: statusBar,
|
| 135 |
+
onProgress: function (meta) { syncLifecycleMarkers(meta); },
|
| 136 |
+
onPauseRequested: function (meta) { syncLifecycleMarkers(meta); },
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
onSolution: function (snapshot, meta) {
|
| 138 |
+
renderSnapshot(snapshot);
|
|
|
|
|
|
|
| 139 |
syncLifecycleMarkers(meta);
|
| 140 |
},
|
| 141 |
onPaused: function (snapshot, meta) {
|
| 142 |
+
renderSnapshot(snapshot);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
syncLifecycleMarkers(meta);
|
| 144 |
},
|
| 145 |
+
onResumed: function (meta) { syncLifecycleMarkers(meta); },
|
| 146 |
onCancelled: function (snapshot, meta) {
|
| 147 |
+
renderSnapshot(snapshot);
|
|
|
|
|
|
|
| 148 |
syncLifecycleMarkers(meta);
|
| 149 |
},
|
| 150 |
onComplete: function (snapshot, meta) {
|
| 151 |
+
renderSnapshot(snapshot);
|
|
|
|
|
|
|
| 152 |
syncLifecycleMarkers(meta);
|
| 153 |
},
|
| 154 |
onFailure: function (message, meta, snapshot, analysis) {
|
| 155 |
+
renderSnapshot(snapshot);
|
| 156 |
+
if (analysis) lastAnalysis = analysis;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
console.error('Solver job failed:', message);
|
| 158 |
syncLifecycleMarkers(meta);
|
| 159 |
},
|
|
|
|
| 167 |
},
|
| 168 |
});
|
| 169 |
|
| 170 |
+
renderer.renderApiGuide();
|
| 171 |
updateSolveActionAvailability();
|
| 172 |
bootstrapDemoData();
|
| 173 |
+
window.addEventListener('beforeunload', function () { renderer.destroy(); });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
function loadAndSolve() {
|
| 176 |
if (solver.isRunning() || solver.getLifecycleState() === 'PAUSED' || !canSolve()) return;
|
|
|
|
| 182 |
}
|
| 183 |
|
| 184 |
function pauseSolve() {
|
| 185 |
+
solver.pause().then(function () { syncLifecycleMarkers(); }).catch(function (err) { console.error('Pause failed:', err); });
|
|
|
|
|
|
|
| 186 |
}
|
| 187 |
|
| 188 |
function resumeSolve() {
|
| 189 |
+
solver.resume().then(function () { syncLifecycleMarkers(); }).catch(function (err) { console.error('Resume failed:', err); });
|
|
|
|
|
|
|
| 190 |
}
|
| 191 |
|
| 192 |
function cancelSolve() {
|
| 193 |
+
solver.cancel().then(function () { syncLifecycleMarkers(); }).catch(function (err) { console.error('Cancel failed:', err); });
|
|
|
|
|
|
|
| 194 |
}
|
| 195 |
|
| 196 |
function openAnalysis() {
|
|
|
|
| 198 |
solver.analyzeSnapshot()
|
| 199 |
.then(function (analysis) {
|
| 200 |
lastAnalysis = analysis;
|
| 201 |
+
analysisModal.setBody(renderer.buildAnalysisHtml(analysis));
|
| 202 |
analysisModal.open();
|
| 203 |
})
|
| 204 |
.catch(function (err) { console.error('Analysis failed:', err); });
|
|
|
|
| 209 |
if (!solver.getJobId() || state === 'IDLE' || state === 'PAUSED' || solver.isRunning()) {
|
| 210 |
return Promise.resolve(null);
|
| 211 |
}
|
| 212 |
+
return solver.delete().then(function () {
|
| 213 |
+
lastAnalysis = null;
|
| 214 |
+
syncLifecycleMarkers();
|
| 215 |
+
});
|
|
|
|
| 216 |
}
|
| 217 |
|
| 218 |
function resolvePlanForSolve() {
|
| 219 |
+
if (currentPlan) return Promise.resolve(utils.clonePlan(currentPlan));
|
| 220 |
+
if (!demoCatalog.defaultId) return Promise.reject(new Error('demo data catalog is unavailable'));
|
| 221 |
+
return utils.fetchDemoPlan(demoCatalog.defaultId);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
}
|
| 223 |
|
| 224 |
function bootstrapDemoData() {
|
| 225 |
+
utils.fetchDemoCatalog()
|
| 226 |
.then(function (catalog) {
|
| 227 |
demoCatalog = catalog;
|
| 228 |
clearBootstrapError();
|
| 229 |
+
renderer.renderApiGuide();
|
| 230 |
+
return utils.fetchDemoPlan(catalog.defaultId);
|
| 231 |
})
|
| 232 |
.then(function (plan) {
|
| 233 |
+
renderPlan(plan);
|
| 234 |
updateSolveActionAvailability();
|
| 235 |
})
|
| 236 |
+
.catch(function (err) { reportBootstrapError(err); });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
}
|
| 238 |
|
| 239 |
+
function renderSnapshot(snapshot) {
|
| 240 |
+
if (snapshot && snapshot.solution) renderPlan(snapshot.solution);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
}
|
| 242 |
|
| 243 |
+
function renderPlan(plan) {
|
| 244 |
+
currentPlan = utils.clonePlan(plan);
|
| 245 |
+
renderer.renderAll(plan);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
}
|
| 247 |
|
| 248 |
function canSolve() {
|
|
|
|
| 250 |
}
|
| 251 |
|
| 252 |
function reportBootstrapError(err) {
|
| 253 |
+
bootstrapError = err && err.message ? err.message : String(err || 'unknown error');
|
| 254 |
bootstrapNotice.textContent = 'Demo data bootstrap failed: ' + bootstrapError;
|
| 255 |
bootstrapNotice.style.display = '';
|
| 256 |
app.dataset.bootstrapError = 'true';
|
|
|
|
| 265 |
delete app.dataset.bootstrapError;
|
| 266 |
}
|
| 267 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
function updateSolveActionAvailability() {
|
| 269 |
+
var solveButton = utils.findHeaderButton(header, 'Solve');
|
| 270 |
if (!solveButton) return;
|
| 271 |
var disabled = !canSolve();
|
| 272 |
solveButton.disabled = disabled;
|
|
|
|
| 276 |
: '';
|
| 277 |
}
|
| 278 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
function syncLifecycleMarkers(meta) {
|
| 280 |
var jobId = solver.getJobId();
|
| 281 |
var snapshotRevision = solver.getSnapshotRevision();
|
|
|
|
| 289 |
else delete app.dataset.lifecycleState;
|
| 290 |
updateSolveActionAvailability();
|
| 291 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
})();
|
static/index.html
CHANGED
|
@@ -16,6 +16,8 @@
|
|
| 16 |
<script src="/sf/vendor/leaflet/leaflet.js"></script>
|
| 17 |
<script src="/sf/sf.js"></script>
|
| 18 |
<script src="/sf/modules/sf-map.js"></script>
|
|
|
|
|
|
|
| 19 |
<script src="/app.js"></script>
|
| 20 |
</body>
|
| 21 |
</html>
|
|
|
|
| 16 |
<script src="/sf/vendor/leaflet/leaflet.js"></script>
|
| 17 |
<script src="/sf/sf.js"></script>
|
| 18 |
<script src="/sf/modules/sf-map.js"></script>
|
| 19 |
+
<script src="/app-utils.js"></script>
|
| 20 |
+
<script src="/app-render.js"></script>
|
| 21 |
<script src="/app.js"></script>
|
| 22 |
</body>
|
| 23 |
</html>
|